diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 0d49a78c37..1dfcf0d824 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -238,6 +238,8 @@ func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.T props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) props.CountSSHConn = conncontroller.GetNumSSHHasConnected() props.CountWSLConn = wslconn.GetNumWSLHasConnected() + props.CountJobs = jobcontroller.GetNumJobsRunning() + props.CountJobsConnected = jobcontroller.GetNumJobsConnected() props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) fullConfig := wconfig.GetWatcher().GetFullConfig() diff --git a/cmd/wsh/cmd/wshcmd-jobdebug.go b/cmd/wsh/cmd/wshcmd-jobdebug.go index 08587f6744..27a7b74772 100644 --- a/cmd/wsh/cmd/wshcmd-jobdebug.go +++ b/cmd/wsh/cmd/wshcmd-jobdebug.go @@ -386,6 +386,7 @@ func jobDebugStartRun(cmd *cobra.Command, args []string) error { data := wshrpc.CommandJobControllerStartJobData{ ConnName: jobConnFlag, + JobKind: "task", Cmd: cmdToRun, Args: cmdArgs, Env: make(map[string]string), diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts index f4a728aff5..17dde466ae 100644 --- a/emain/emain-activity.ts +++ b/emain/emain-activity.ts @@ -10,6 +10,9 @@ let globalIsRelaunching = false; let forceQuit = false; let userConfirmedQuit = false; let termCommandsRun = 0; +let termCommandsRemote = 0; +let termCommandsWsl = 0; +let termCommandsDurable = 0; export function setWasActive(val: boolean) { wasActive = val; @@ -72,3 +75,33 @@ export function getAndClearTermCommandsRun(): number { termCommandsRun = 0; return count; } + +export function incrementTermCommandsRemote() { + termCommandsRemote++; +} + +export function getAndClearTermCommandsRemote(): number { + const count = termCommandsRemote; + termCommandsRemote = 0; + return count; +} + +export function incrementTermCommandsWsl() { + termCommandsWsl++; +} + +export function getAndClearTermCommandsWsl(): number { + const count = termCommandsWsl; + termCommandsWsl = 0; + return count; +} + +export function incrementTermCommandsDurable() { + termCommandsDurable++; +} + +export function getAndClearTermCommandsDurable(): number { + const count = termCommandsDurable; + termCommandsDurable = 0; + return count; +} diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 09ba69d1eb..dcf0f4d083 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -12,7 +12,12 @@ import { RpcApi } from "../frontend/app/store/wshclientapi"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; -import { incrementTermCommandsRun } from "./emain-activity"; +import { + incrementTermCommandsDurable, + incrementTermCommandsRemote, + incrementTermCommandsRun, + incrementTermCommandsWsl, +} from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; @@ -407,9 +412,21 @@ export function initIpcHandlers() { console.log("fe-log", logStr); }); - electron.ipcMain.on("increment-term-commands", () => { - incrementTermCommandsRun(); - }); + electron.ipcMain.on( + "increment-term-commands", + (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { + incrementTermCommandsRun(); + if (opts?.isRemote) { + incrementTermCommandsRemote(); + } + if (opts?.isWsl) { + incrementTermCommandsWsl(); + } + if (opts?.isDurable) { + incrementTermCommandsDurable(); + } + } + ); electron.ipcMain.on("native-paste", (event) => { event.sender.paste(); diff --git a/emain/emain.ts b/emain/emain.ts index 960a3d4d2b..79b0c2d0ff 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -12,7 +12,10 @@ import { fireAndForget, sleep } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { getActivityState, + getAndClearTermCommandsDurable, + getAndClearTermCommandsRemote, getAndClearTermCommandsRun, + getAndClearTermCommandsWsl, getForceQuit, getGlobalIsRelaunching, getUserConfirmedQuit, @@ -182,6 +185,9 @@ function logActiveState() { if (termCmdCount > 0) { activity.termcommandsrun = termCmdCount; } + const termCmdRemoteCount = getAndClearTermCommandsRemote(); + const termCmdWslCount = getAndClearTermCommandsWsl(); + const termCmdDurableCount = getAndClearTermCommandsDurable(); const props: TEventProps = { "activity:activeminutes": activity.activeminutes, @@ -191,6 +197,15 @@ function logActiveState() { if (termCmdCount > 0) { props["activity:termcommandsrun"] = termCmdCount; } + if (termCmdRemoteCount > 0) { + props["activity:termcommands:remote"] = termCmdRemoteCount; + } + if (termCmdWslCount > 0) { + props["activity:termcommands:wsl"] = termCmdWslCount; + } + if (termCmdDurableCount > 0) { + props["activity:termcommands:durable"] = termCmdDurableCount; + } if (astate.wasActive && isWaveAIOpen) { props["activity:waveaiactiveminutes"] = 1; } diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..3ad8d25828 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -63,7 +63,8 @@ contextBridge.exposeInMainWorld("api", { clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), - incrementTermCommands: () => ipcRenderer.send("increment-term-commands"), + incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => + ipcRenderer.send("increment-term-commands", opts), nativePaste: () => ipcRenderer.send("native-paste"), openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts new file mode 100644 index 0000000000..e066faafa0 --- /dev/null +++ b/frontend/app/view/term/osc-handlers.ts @@ -0,0 +1,341 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { BlockNodeModel } from "@/app/block/blocktypes"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { + getApi, + getBlockMetaKeyAtom, + getBlockTermDurableAtom, + globalStore, + recordTEvent, + WOS, +} from "@/store/global"; +import * as services from "@/store/services"; +import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; +import debug from "debug"; +import type { TermWrap } from "./termwrap"; + +const dlog = debug("wave:termwrap"); + +const Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations) +const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check) + +// OSC 16162 - Shell Integration Commands +// See aiprompts/wave-osc-16162.md for full documentation +export type ShellIntegrationStatus = "ready" | "running-command"; + +type Osc16162Command = + | { command: "A"; data: {} } + | { command: "C"; data: { cmd64?: string } } + | { command: "M"; data: { shell?: string; shellversion?: string; uname?: string; integration?: boolean } } + | { command: "D"; data: { exitcode?: number } } + | { command: "I"; data: { inputempty?: boolean } } + | { command: "R"; data: {} }; + +function checkCommandForTelemetry(decodedCmd: string) { + if (!decodedCmd) { + return; + } + + if (decodedCmd.startsWith("ssh ")) { + recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); + return; + } + + const editorsRegex = /^(vim|vi|nano|nvim)\b/; + if (editorsRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-edit" }); + return; + } + + const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; + if (tailFollowRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-tailf" }); + return; + } + + const claudeRegex = /^claude\b/; + if (claudeRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "claude" }); + return; + } + + const opencodeRegex = /^opencode\b/; + if (opencodeRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "opencode" }); + return; + } +} + +function handleShellIntegrationCommandStart( + termWrap: TermWrap, + blockId: string, + cmd: { command: "C"; data: { cmd64?: string } }, + rtInfo: ObjRTInfo // this is passed by reference and modified inside of this function +): void { + rtInfo["shell:state"] = "running-command"; + globalStore.set(termWrap.shellIntegrationStatusAtom, "running-command"); + const connName = globalStore.get(getBlockMetaKeyAtom(blockId, "connection")) ?? ""; + const isRemote = isSshConnName(connName); + const isWsl = isWslConnName(connName); + const isDurable = globalStore.get(getBlockTermDurableAtom(blockId)) ?? false; + getApi().incrementTermCommands({ isRemote, isWsl, isDurable }); + if (cmd.data.cmd64) { + const decodedLen = Math.ceil(cmd.data.cmd64.length * 0.75); + if (decodedLen > 8192) { + rtInfo["shell:lastcmd"] = `# command too large (${decodedLen} bytes)`; + globalStore.set(termWrap.lastCommandAtom, rtInfo["shell:lastcmd"]); + } else { + try { + const decodedCmd = base64ToString(cmd.data.cmd64); + rtInfo["shell:lastcmd"] = decodedCmd; + globalStore.set(termWrap.lastCommandAtom, decodedCmd); + checkCommandForTelemetry(decodedCmd); + } catch (e) { + console.error("Error decoding cmd64:", e); + rtInfo["shell:lastcmd"] = null; + globalStore.set(termWrap.lastCommandAtom, null); + } + } + } else { + rtInfo["shell:lastcmd"] = null; + globalStore.set(termWrap.lastCommandAtom, null); + } + rtInfo["shell:lastcmdexitcode"] = null; +} + +// for xterm OSC handlers, we return true always because we "own" the OSC number. +// even if data is invalid we don't want to propagate to other handlers. +export function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { + if (!loaded) { + return true; + } + const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; + if (!document.hasFocus() || !isBlockFocused) { + console.log("OSC 52: rejected, window or block not focused"); + return true; + } + if (!data || data.length === 0) { + console.log("OSC 52: empty data received"); + return true; + } + if (data.length > Osc52MaxRawLength) { + console.log("OSC 52: raw data too large", data.length); + return true; + } + + const semicolonIndex = data.indexOf(";"); + if (semicolonIndex === -1) { + console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50)); + return true; + } + + const clipboardSelection = data.substring(0, semicolonIndex); + const base64Data = data.substring(semicolonIndex + 1); + + // clipboard query ("?") is not supported for security (prevents clipboard theft) + if (base64Data === "?") { + console.log("OSC 52: clipboard query not supported"); + return true; + } + + if (base64Data.length === 0) { + return true; + } + + if (clipboardSelection.length > 10) { + console.log("OSC 52: clipboard selection too long", clipboardSelection); + return true; + } + + const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75); + if (estimatedDecodedSize > Osc52MaxDecodedSize) { + console.log("OSC 52: data too large", estimatedDecodedSize, "bytes"); + return true; + } + + try { + // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648) + const cleanBase64Data = base64Data.replace(/\s+/g, ""); + const decodedText = base64ToString(cleanBase64Data); + + // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8) + const actualByteSize = new TextEncoder().encode(decodedText).length; + if (actualByteSize > Osc52MaxDecodedSize) { + console.log("OSC 52: decoded text too large", actualByteSize, "bytes"); + return true; + } + + fireAndForget(async () => { + try { + await navigator.clipboard.writeText(decodedText); + dlog("OSC 52: copied", decodedText.length, "characters to clipboard"); + } catch (err) { + console.error("OSC 52: clipboard write failed:", err); + } + }); + } catch (e) { + console.error("OSC 52: base64 decode error:", e); + } + + return true; +} + +// for xterm handlers, we return true always because we "own" OSC 7. +// even if it is invalid we dont want to propagate to other handlers +export function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { + if (!loaded) { + return true; + } + if (data == null || data.length == 0) { + console.log("Invalid OSC 7 command received (empty)"); + return true; + } + if (data.length > 1024) { + console.log("Invalid OSC 7, data length too long", data.length); + return true; + } + + let pathPart: string; + try { + const url = new URL(data); + if (url.protocol !== "file:") { + console.log("Invalid OSC 7 command received (non-file protocol)", data); + return true; + } + pathPart = decodeURIComponent(url.pathname); + + // Normalize double slashes at the beginning to single slash + if (pathPart.startsWith("//")) { + pathPart = pathPart.substring(1); + } + + // Handle Windows paths (e.g., /C:/... or /D:\...) + if (/^\/[a-zA-Z]:[\\/]/.test(pathPart)) { + // Strip leading slash and normalize to forward slashes + pathPart = pathPart.substring(1).replace(/\\/g, "/"); + } + + // Handle UNC paths (e.g., /\\server\share) + if (pathPart.startsWith("/\\\\")) { + // Strip leading slash but keep backslashes for UNC + pathPart = pathPart.substring(1); + } + } catch (e) { + console.log("Invalid OSC 7 command received (parse error)", data, e); + return true; + } + + setTimeout(() => { + fireAndForget(async () => { + await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { + "cmd:cwd": pathPart, + }); + + const rtInfo = { "shell:hascurcwd": true }; + const rtInfoData: CommandSetRTInfoData = { + oref: WOS.makeORef("block", blockId), + data: rtInfo, + }; + await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => + console.log("error setting RT info", e) + ); + }); + }, 0); + return true; +} + +export function handleOsc16162Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { + const terminal = termWrap.terminal; + if (!loaded) { + return true; + } + if (!data || data.length === 0) { + return true; + } + + const parts = data.split(";"); + const commandStr = parts[0]; + const jsonDataStr = parts.length > 1 ? parts.slice(1).join(";") : null; + let parsedData: Record = {}; + if (jsonDataStr) { + try { + parsedData = JSON.parse(jsonDataStr); + } catch (e) { + console.error("Error parsing OSC 16162 JSON data:", e); + } + } + + const cmd: Osc16162Command = { command: commandStr, data: parsedData } as Osc16162Command; + const rtInfo: ObjRTInfo = {}; + switch (cmd.command) { + case "A": + rtInfo["shell:state"] = "ready"; + globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); + const marker = terminal.registerMarker(0); + if (marker) { + termWrap.promptMarkers.push(marker); + // addTestMarkerDecoration(terminal, marker, termWrap); + marker.onDispose(() => { + const idx = termWrap.promptMarkers.indexOf(marker); + if (idx !== -1) { + termWrap.promptMarkers.splice(idx, 1); + } + }); + } + break; + case "C": + handleShellIntegrationCommandStart(termWrap, blockId, cmd, rtInfo); + break; + case "M": + if (cmd.data.shell) { + rtInfo["shell:type"] = cmd.data.shell; + } + if (cmd.data.shellversion) { + rtInfo["shell:version"] = cmd.data.shellversion; + } + if (cmd.data.uname) { + rtInfo["shell:uname"] = cmd.data.uname; + } + if (cmd.data.integration != null) { + rtInfo["shell:integration"] = cmd.data.integration; + } + break; + case "D": + if (cmd.data.exitcode != null) { + rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; + } else { + rtInfo["shell:lastcmdexitcode"] = null; + } + break; + case "I": + if (cmd.data.inputempty != null) { + rtInfo["shell:inputempty"] = cmd.data.inputempty; + } + break; + case "R": + globalStore.set(termWrap.shellIntegrationStatusAtom, null); + if (terminal.buffer.active.type === "alternate") { + terminal.write("\x1b[?1049l"); + } + break; + } + + if (Object.keys(rtInfo).length > 0) { + setTimeout(() => { + fireAndForget(async () => { + const rtInfoData: CommandSetRTInfoData = { + oref: WOS.makeORef("block", blockId), + data: rtInfo, + }; + await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => + console.log("error setting RT info (OSC 16162)", e) + ); + }); + }, 0); + } + + return true; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 498356b8c9..45ba48351c 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,18 +8,16 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, fetchWaveFile, - getApi, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, openLink, - recordTEvent, setTabIndicator, WOS, } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; +import { base64ToArray, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -30,6 +28,12 @@ import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; import { FitAddon } from "./fitaddon"; +import { + handleOsc16162Command, + handleOsc52Command, + handleOsc7Command, + type ShellIntegrationStatus, +} from "./osc-handlers"; import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); @@ -37,8 +41,6 @@ const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; -const Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations) -const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check) export const SupportsImageInput = true; // detect webgl support @@ -62,338 +64,6 @@ type TermWrapOptions = { nodeModel?: BlockNodeModel; }; -// for xterm OSC handlers, we return true always because we "own" the OSC number. -// even if data is invalid we don't want to propagate to other handlers. -function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { - if (!loaded) { - return true; - } - const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; - if (!document.hasFocus() || !isBlockFocused) { - console.log("OSC 52: rejected, window or block not focused"); - return true; - } - if (!data || data.length === 0) { - console.log("OSC 52: empty data received"); - return true; - } - if (data.length > Osc52MaxRawLength) { - console.log("OSC 52: raw data too large", data.length); - return true; - } - - const semicolonIndex = data.indexOf(";"); - if (semicolonIndex === -1) { - console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50)); - return true; - } - - const clipboardSelection = data.substring(0, semicolonIndex); - const base64Data = data.substring(semicolonIndex + 1); - - // clipboard query ("?") is not supported for security (prevents clipboard theft) - if (base64Data === "?") { - console.log("OSC 52: clipboard query not supported"); - return true; - } - - if (base64Data.length === 0) { - return true; - } - - if (clipboardSelection.length > 10) { - console.log("OSC 52: clipboard selection too long", clipboardSelection); - return true; - } - - const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75); - if (estimatedDecodedSize > Osc52MaxDecodedSize) { - console.log("OSC 52: data too large", estimatedDecodedSize, "bytes"); - return true; - } - - try { - // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648) - const cleanBase64Data = base64Data.replace(/\s+/g, ""); - const decodedText = base64ToString(cleanBase64Data); - - // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8) - const actualByteSize = new TextEncoder().encode(decodedText).length; - if (actualByteSize > Osc52MaxDecodedSize) { - console.log("OSC 52: decoded text too large", actualByteSize, "bytes"); - return true; - } - - fireAndForget(async () => { - try { - await navigator.clipboard.writeText(decodedText); - dlog("OSC 52: copied", decodedText.length, "characters to clipboard"); - } catch (err) { - console.error("OSC 52: clipboard write failed:", err); - } - }); - } catch (e) { - console.error("OSC 52: base64 decode error:", e); - } - - return true; -} - -// for xterm handlers, we return true always because we "own" OSC 7. -// even if it is invalid we dont want to propagate to other handlers -function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { - if (!loaded) { - return true; - } - if (data == null || data.length == 0) { - console.log("Invalid OSC 7 command received (empty)"); - return true; - } - if (data.length > 1024) { - console.log("Invalid OSC 7, data length too long", data.length); - return true; - } - - let pathPart: string; - try { - const url = new URL(data); - if (url.protocol !== "file:") { - console.log("Invalid OSC 7 command received (non-file protocol)", data); - return true; - } - pathPart = decodeURIComponent(url.pathname); - - // Normalize double slashes at the beginning to single slash - if (pathPart.startsWith("//")) { - pathPart = pathPart.substring(1); - } - - // Handle Windows paths (e.g., /C:/... or /D:\...) - if (/^\/[a-zA-Z]:[\\/]/.test(pathPart)) { - // Strip leading slash and normalize to forward slashes - pathPart = pathPart.substring(1).replace(/\\/g, "/"); - } - - // Handle UNC paths (e.g., /\\server\share) - if (pathPart.startsWith("/\\\\")) { - // Strip leading slash but keep backslashes for UNC - pathPart = pathPart.substring(1); - } - } catch (e) { - console.log("Invalid OSC 7 command received (parse error)", data, e); - return true; - } - - setTimeout(() => { - fireAndForget(async () => { - await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { - "cmd:cwd": pathPart, - }); - - const rtInfo = { "shell:hascurcwd": true }; - const rtInfoData: CommandSetRTInfoData = { - oref: WOS.makeORef("block", blockId), - data: rtInfo, - }; - await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => - console.log("error setting RT info", e) - ); - }); - }, 0); - return true; -} - -// some POC concept code for adding a decoration to a marker -function addTestMarkerDecoration(terminal: Terminal, marker: TermTypes.IMarker, termWrap: TermWrap): void { - const decoration = terminal.registerDecoration({ - marker: marker, - layer: "top", - }); - if (!decoration) { - return; - } - decoration.onRender((el) => { - el.classList.add("wave-decoration"); - el.classList.add("bg-ansi-white"); - el.dataset.markerline = String(marker.line); - if (!el.querySelector(".wave-deco-line")) { - const line = document.createElement("div"); - line.classList.add("wave-deco-line", "bg-accent/20"); - line.style.position = "absolute"; - line.style.top = "0"; - line.style.left = "0"; - line.style.width = "500px"; - line.style.height = "1px"; - el.appendChild(line); - } - }); -} - -function checkCommandForTelemetry(decodedCmd: string) { - if (!decodedCmd) { - return; - } - - if (decodedCmd.startsWith("ssh ")) { - recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); - return; - } - - const editorsRegex = /^(vim|vi|nano|nvim)\b/; - if (editorsRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "cli-edit" }); - return; - } - - const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; - if (tailFollowRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "cli-tailf" }); - return; - } - - const claudeRegex = /^claude\b/; - if (claudeRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "claude" }); - return; - } - - const opencodeRegex = /^opencode\b/; - if (opencodeRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "opencode" }); - return; - } -} - -// OSC 16162 - Shell Integration Commands -// See aiprompts/wave-osc-16162.md for full documentation -type ShellIntegrationStatus = "ready" | "running-command"; - -type Osc16162Command = - | { command: "A"; data: {} } - | { command: "C"; data: { cmd64?: string } } - | { command: "M"; data: { shell?: string; shellversion?: string; uname?: string; integration?: boolean } } - | { command: "D"; data: { exitcode?: number } } - | { command: "I"; data: { inputempty?: boolean } } - | { command: "R"; data: {} }; - -function handleOsc16162Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { - const terminal = termWrap.terminal; - if (!loaded) { - return true; - } - if (!data || data.length === 0) { - return true; - } - - const parts = data.split(";"); - const commandStr = parts[0]; - const jsonDataStr = parts.length > 1 ? parts.slice(1).join(";") : null; - let parsedData: Record = {}; - if (jsonDataStr) { - try { - parsedData = JSON.parse(jsonDataStr); - } catch (e) { - console.error("Error parsing OSC 16162 JSON data:", e); - } - } - - const cmd: Osc16162Command = { command: commandStr, data: parsedData } as Osc16162Command; - const rtInfo: ObjRTInfo = {}; - switch (cmd.command) { - case "A": - rtInfo["shell:state"] = "ready"; - globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); - const marker = terminal.registerMarker(0); - if (marker) { - termWrap.promptMarkers.push(marker); - // addTestMarkerDecoration(terminal, marker, termWrap); - marker.onDispose(() => { - const idx = termWrap.promptMarkers.indexOf(marker); - if (idx !== -1) { - termWrap.promptMarkers.splice(idx, 1); - } - }); - } - break; - case "C": - rtInfo["shell:state"] = "running-command"; - globalStore.set(termWrap.shellIntegrationStatusAtom, "running-command"); - getApi().incrementTermCommands(); - if (cmd.data.cmd64) { - const decodedLen = Math.ceil(cmd.data.cmd64.length * 0.75); - if (decodedLen > 8192) { - rtInfo["shell:lastcmd"] = `# command too large (${decodedLen} bytes)`; - globalStore.set(termWrap.lastCommandAtom, rtInfo["shell:lastcmd"]); - } else { - try { - const decodedCmd = base64ToString(cmd.data.cmd64); - rtInfo["shell:lastcmd"] = decodedCmd; - globalStore.set(termWrap.lastCommandAtom, decodedCmd); - checkCommandForTelemetry(decodedCmd); - } catch (e) { - console.error("Error decoding cmd64:", e); - rtInfo["shell:lastcmd"] = null; - globalStore.set(termWrap.lastCommandAtom, null); - } - } - } else { - rtInfo["shell:lastcmd"] = null; - globalStore.set(termWrap.lastCommandAtom, null); - } - // also clear lastcmdexitcode (since we've now started a new command) - rtInfo["shell:lastcmdexitcode"] = null; - break; - case "M": - if (cmd.data.shell) { - rtInfo["shell:type"] = cmd.data.shell; - } - if (cmd.data.shellversion) { - rtInfo["shell:version"] = cmd.data.shellversion; - } - if (cmd.data.uname) { - rtInfo["shell:uname"] = cmd.data.uname; - } - if (cmd.data.integration != null) { - rtInfo["shell:integration"] = cmd.data.integration; - } - break; - case "D": - if (cmd.data.exitcode != null) { - rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; - } else { - rtInfo["shell:lastcmdexitcode"] = null; - } - break; - case "I": - if (cmd.data.inputempty != null) { - rtInfo["shell:inputempty"] = cmd.data.inputempty; - } - break; - case "R": - globalStore.set(termWrap.shellIntegrationStatusAtom, null); - if (terminal.buffer.active.type === "alternate") { - terminal.write("\x1b[?1049l"); - } - break; - } - - if (Object.keys(rtInfo).length > 0) { - setTimeout(() => { - fireAndForget(async () => { - const rtInfoData: CommandSetRTInfoData = { - oref: WOS.makeORef("block", blockId), - data: rtInfo, - }; - await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => - console.log("error setting RT info (OSC 16162)", e) - ); - }); - }, 0); - } - - return true; -} - export class TermWrap { tabId: string; blockId: string; @@ -416,7 +86,7 @@ export class TermWrap { pasteActive: boolean = false; lastUpdated: number; promptMarkers: TermTypes.IMarker[] = []; - shellIntegrationStatusAtom: jotai.PrimitiveAtom<"ready" | "running-command" | null>; + shellIntegrationStatusAtom: jotai.PrimitiveAtom; lastCommandAtom: jotai.PrimitiveAtom; nodeModel: BlockNodeModel; // this can be null @@ -451,7 +121,7 @@ export class TermWrap { this.hasResized = false; this.lastUpdated = Date.now(); this.promptMarkers = []; - this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<"ready" | "running-command" | null>; + this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 94747faa8a..d6d2d98f01 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -130,7 +130,7 @@ declare global { clearWebviewStorage: (webContentsId: number) => Promise; // clear-webview-storage setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open closeBuilderWindow: () => void; // close-builder-window - incrementTermCommands: () => void; // increment-term-commands + incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => void; // increment-term-commands nativePaste: () => void; // native-paste openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 24be158c62..dada3a248b 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -391,6 +391,7 @@ declare global { // wshrpc.CommandJobControllerStartJobData type CommandJobControllerStartJobData = { connname: string; + jobkind: string; cmd: string; args: string[]; env: {[key: string]: string}; @@ -1434,6 +1435,9 @@ declare global { "activity:waveaiactiveminutes"?: number; "activity:waveaifgminutes"?: number; "activity:termcommandsrun"?: number; + "activity:termcommands:remote"?: number; + "activity:termcommands:durable"?: number; + "activity:termcommands:wsl"?: number; "app:firstday"?: boolean; "app:firstlaunch"?: boolean; "action:initiator"?: "keyboard" | "mouse"; @@ -1447,6 +1451,7 @@ declare global { "wsh:haderror"?: boolean; "conn:conntype"?: string; "conn:wsherrorcode"?: string; + "conn:errorcode"?: string; "onboarding:feature"?: "waveai" | "durable" | "magnify" | "wsh"; "onboarding:version"?: string; "onboarding:githubstar"?: "already" | "star" | "later"; @@ -1461,6 +1466,8 @@ declare global { "count:workspaces"?: number; "count:sshconn"?: number; "count:wslconn"?: number; + "count:jobs"?: number; + "count:jobsconnected"?: number; "count:views"?: {[key: string]: number}; "waveai:apitype"?: string; "waveai:model"?: string; @@ -1489,6 +1496,8 @@ declare global { "waveai:islocal"?: boolean; "waveai:feedback"?: "good" | "bad"; "waveai:action"?: string; + "job:donereason"?: string; + "job:kind"?: string; $set?: TEventUserProps; $set_once?: TEventUserProps; }; diff --git a/frontend/util/util.ts b/frontend/util/util.ts index cf535163c7..2eea82f82e 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -23,6 +23,10 @@ function isWslConnName(connName: string): boolean { return connName != null && connName.startsWith("wsl://"); } +function isSshConnName(connName: string): boolean { + return !isLocalConnName(connName) && !isWslConnName(connName); +} + function base64ToString(b64: string): string { if (b64 == null) { return null; @@ -521,6 +525,7 @@ export { getPromiseValue, isBlank, isLocalConnName, + isSshConnName, isWslConnName, jotaiLoadableValue, jsonDeepEqual, diff --git a/package-lock.json b/package-lock.json index 25cedfeee5..28662b7ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.13.2-alpha.1", + "version": "0.13.2-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.13.2-alpha.1", + "version": "0.13.2-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index d1e17449dc..13a0be88d2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.13.2-alpha.1", + "version": "0.13.2-alpha.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 59f88c100a..24d25814a4 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -20,6 +20,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/streamclient" + "github.com/wavetermdev/waveterm/pkg/telemetry" + "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" @@ -58,6 +60,11 @@ const ( JobConnStatus_Connected = "connected" ) +const ( + JobKind_Shell = "shell" + JobKind_Task = "task" +) + const DefaultStreamRwnd = 64 * 1024 const MetaKey_TotalGap = "totalgap" const JobOutputFileName = "term" @@ -543,6 +550,34 @@ func GetConnectedJobIds() []string { return connectedJobIds } +func GetNumJobsRunning() int { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) + if err != nil { + return 0 + } + count := 0 + for _, job := range allJobs { + if job.JobManagerStatus == JobManagerStatus_Running { + count++ + } + } + return count +} + +func GetNumJobsConnected() int { + jobControllerLock.Lock() + defer jobControllerLock.Unlock() + count := 0 + for _, status := range jobConnStates { + if status == JobConnStatus_Connected { + count++ + } + } + return count +} + func CheckJobConnected(ctx context.Context, jobId string) (*waveobj.Job, error) { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { @@ -567,6 +602,7 @@ func CheckJobConnected(ctx context.Context, jobId string) (*waveobj.Job, error) type StartJobParams struct { ConnName string + JobKind string Cmd string Args []string Env map[string]string @@ -578,6 +614,9 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { if params.ConnName == "" { return "", fmt.Errorf("connection name is required") } + if params.JobKind != JobKind_Shell && params.JobKind != JobKind_Task { + return "", fmt.Errorf("jobkind must be %q or %q", JobKind_Shell, JobKind_Task) + } if params.Cmd == "" { return "", fmt.Errorf("command is required") } @@ -611,6 +650,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { job := &waveobj.Job{ OID: jobId, Connection: params.ConnName, + JobKind: params.JobKind, Cmd: params.Cmd, CmdArgs: params.Args, CmdEnv: params.Env, @@ -687,6 +727,13 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { updatedJob = job }) sendBlockJobStatusEventByJob(ctx, updatedJob) + telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ + Event: "job:done", + Props: telemetrydata.TEventProps{ + JobDoneReason: JobDoneReason_StartupError, + JobKind: params.JobKind, + }, + }) return "", fmt.Errorf("failed to start remote job: %w", err) } @@ -707,6 +754,13 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { sendBlockJobStatusEventByJob(ctx, updatedJob) } + telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ + Event: "job:start", + Props: telemetrydata.TEventProps{ + JobKind: params.JobKind, + }, + }) + go func() { defer func() { panichandler.PanicHandler("jobcontroller:runOutputLoop", recover()) @@ -943,6 +997,11 @@ func remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error { writeMutedMessageToTerminal(job.AttachedBlockId, "[shell terminated]") } + if job.JobManagerStatus == JobManagerStatus_Done { + log.Printf("[job:%s] job manager already marked as done, skipping termination", job.OID) + return nil + } + bareRpc := wshclient.GetBareRpcClient() terminateData := wshrpc.CommandRemoteTerminateJobManagerData{ JobId: job.OID, @@ -978,6 +1037,14 @@ func remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error { sendBlockJobStatusEventByJob(ctx, updatedJob) } + telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ + Event: "job:done", + Props: telemetrydata.TEventProps{ + JobDoneReason: JobDoneReason_Terminated, + JobKind: job.JobKind, + }, + }) + log.Printf("[job:%s] job manager terminated successfully", job.OID) return nil } @@ -1065,6 +1132,13 @@ func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOp } else { sendBlockJobStatusEventByJob(ctx, updatedJob) } + telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ + Event: "job:done", + Props: telemetrydata.TEventProps{ + JobDoneReason: JobDoneReason_Gone, + JobKind: job.JobKind, + }, + }) writeJobTerminationMessage(ctx, jobId, updatedJob, "[session gone]") return fmt.Errorf("job manager has exited: %s", rtnData.Error) } @@ -1082,6 +1156,13 @@ func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOp } SetJobConnStatus(jobId, JobConnStatus_Connected) + telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ + Event: "job:reconnect", + Props: telemetrydata.TEventProps{ + JobKind: job.JobKind, + }, + }) + log.Printf("[job:%s] route established, restarting streaming", jobId) return restartStreaming(ctx, jobId, true, rtOpts) } diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index 16a41809f2..94229a070e 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -749,7 +749,8 @@ func (conn *SSHConn) Connect(ctx context.Context, connFlags *wconfig.ConnKeyword conn.FireConnChangeEvent() err := conn.connectInternal(ctx, connFlags) if err != nil { - conn.Infof(ctx, "ERROR %v\n\n", err) + errorCode := remote.ClassifyConnError(err) + conn.Infof(ctx, "ERROR [%s] %v\n\n", errorCode, err) conn.WithLock(func() { conn.Status = Status_Error conn.Error = err.Error() @@ -761,7 +762,8 @@ func (conn *SSHConn) Connect(ctx context.Context, connFlags *wconfig.ConnKeyword telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:connecterror", Props: telemetrydata.TEventProps{ - ConnType: "ssh", + ConnType: "ssh", + ConnErrorCode: errorCode, }, }) } else { diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 50a5b9dbfb..d23186d569 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -9,6 +9,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/base64" + "errors" "fmt" "log" "math" @@ -31,6 +32,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "golang.org/x/crypto/ssh" @@ -40,6 +42,25 @@ import ( const SshProxyJumpMaxDepth = 10 +const ( + ConnErrCode_ConfigParse = "config-parse" + ConnErrCode_ConfigDefault = "config-default" + ConnErrCode_ProxyDepth = "proxy-depth" + ConnErrCode_ProxyParse = "proxy-parse" + ConnErrCode_SecretStore = "secret-error" + ConnErrCode_SecretNotFound = "secret-notfound" + ConnErrCode_KnownHostsNone = "knownhosts-none" + ConnErrCode_KnownHostsFmt = "knownhosts-format" + ConnErrCode_Dial = "dial-error" + ConnErrCode_HostKeyRevoked = "hostkey-revoked" + ConnErrCode_HostKeyChanged = "hostkey-changed" + ConnErrCode_HostKeyVerify = "hostkey-verify" + ConnErrCode_UserCancelled = "user-cancelled" + ConnErrCode_UserTimeout = "user-timeout" + ConnErrCode_AuthFailed = "auth-failed" + ConnErrCode_Unknown = "unknown" +) + var waveSshConfigUserSettingsInternal *ssh_config.UserSettings var configUserSettingsOnce = &sync.Once{} @@ -61,6 +82,10 @@ func (uice UserInputCancelError) Error() string { return uice.Err.Error() } +func (uice UserInputCancelError) Unwrap() error { + return uice.Err +} + type ConnectionDebugInfo struct { CurrentClient *ssh.Client NextOpts *SSHOpts @@ -79,6 +104,10 @@ func (ce ConnectionError) Error() string { return fmt.Sprintf("Connecting from %v to %s (jump number %d), Error: %v", ce.CurrentClient, ce.NextOpts, ce.JumpNum, ce.Err) } +func (ce ConnectionError) Unwrap() error { + return ce.Err +} + func SimpleMessageFromPossibleConnectionError(err error) string { if err == nil { return "" @@ -89,6 +118,35 @@ func SimpleMessageFromPossibleConnectionError(err error) string { return err.Error() } +func ClassifyConnError(err error) string { + code := utilds.GetErrorCode(err) + if code != "" { + return code + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return ConnErrCode_Dial + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return ConnErrCode_Dial + } + errStr := err.Error() + if strings.Contains(errStr, "unable to authenticate") { + return ConnErrCode_AuthFailed + } + if strings.Contains(errStr, "handshake failed") { + return ConnErrCode_AuthFailed + } + if strings.Contains(errStr, "connection refused") { + return ConnErrCode_Dial + } + if strings.Contains(errStr, "timed out") || strings.Contains(errStr, "timeout") { + return ConnErrCode_Dial + } + return ConnErrCode_Unknown +} + // This exists to trick the ssh library into continuing to try // different public keys even when the current key cannot be // properly parsed @@ -204,7 +262,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnK // this is an error where we actually do want to stop // trying keys - return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: UserInputCancelError{Err: err}} + return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, UserInputCancelError{Err: err})} } unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text))) if err != nil { @@ -277,7 +335,7 @@ func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteNam echo := echos[i] answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName) if err != nil { - return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, err)} } answers = append(answers, answer) } @@ -433,7 +491,7 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword osUser, err := user.Current() if err != nil { - return nil, nil, err + return nil, nil, utilds.MakeCodedError(ConnErrCode_ConfigParse, err) } var unexpandedKnownHostsFiles []string if osUser.Username == "root" { @@ -453,7 +511,7 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword // there are no good known hosts files if len(knownHostsFiles) == 0 { - return nil, nil, fmt.Errorf("no known_hosts files provided by ssh. defaults are overridden") + return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsNone, "no known_hosts files provided by ssh. defaults are overridden") } var unreadableFiles []string @@ -475,12 +533,11 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword } } if len(okFiles) >= len(knownHostsFiles) { - return nil, nil, fmt.Errorf("problem file (%s) doesn't exist. this should not be possible", badFile) + return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, "problem file (%s) doesn't exist. this should not be possible", badFile) } knownHostsFiles = okFiles } else if err != nil { - // TODO handle obscure problems if possible - return nil, nil, fmt.Errorf("known_hosts formatting error: %+v", err) + return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, "known_hosts formatting error: %w", err) } else { basicCallback = keyDb.HostKeyCallback() hostKeyAlgorithms = keyDb.HostKeyAlgorithms @@ -510,8 +567,7 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword // success return nil } else if _, ok := err.(*xknownhosts.RevokedError); ok { - // revoked credentials are refused outright - return err + return utilds.MakeCodedError(ConnErrCode_HostKeyRevoked, err) } else if _, ok := err.(*xknownhosts.KeyError); !ok { // this is an unknown error (note the !ok is opposite of usual) return err @@ -530,7 +586,7 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword break } if serr, ok := err.(UserInputCancelError); ok { - return serr + return utilds.MakeCodedError(ConnErrCode_UserCancelled, serr) } } @@ -546,12 +602,12 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword break } if serr, ok := err.(UserInputCancelError); ok { - return serr + return utilds.MakeCodedError(ConnErrCode_UserCancelled, serr) } } } if err != nil { - return fmt.Errorf("unable to create new knownhost key: %e", err) + return utilds.Errorf(ConnErrCode_HostKeyVerify, "unable to create new knownhost key: %w", err) } } else { // the key changed @@ -585,7 +641,7 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword // create update into alert message //send update via bus? - return fmt.Errorf("remote host identification has changed") + return utilds.Errorf(ConnErrCode_HostKeyChanged, "remote host identification has changed") } updatedCallback, err := xknownhosts.New(knownHostsFiles...) @@ -629,10 +685,10 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor secretName := *sshKeywords.SshPasswordSecretName password, exists, err := secretstore.GetSecret(secretName) if err != nil { - return nil, fmt.Errorf("error retrieving ssh:passwordsecretname %q: %w", secretName, err) + return nil, utilds.Errorf(ConnErrCode_SecretStore, "error retrieving ssh:passwordsecretname %q: %w", secretName, err) } if !exists { - return nil, fmt.Errorf("ssh:passwordsecretname %q not found in secret store", secretName) + return nil, utilds.Errorf(ConnErrCode_SecretNotFound, "ssh:passwordsecretname %q not found in secret store", secretName) } blocklogger.Infof(connCtx, "[conndebug] successfully retrieved ssh:passwordsecretname %q from secret store\n", secretName) sshPassword = &password @@ -692,14 +748,14 @@ func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh. clientConn, err = d.DialContext(ctx, "tcp", networkAddr) if err != nil { blocklogger.Infof(ctx, "[conndebug] ERROR dial error: %v\n", err) - return nil, err + return nil, utilds.MakeCodedError(ConnErrCode_Dial, err) } } else { blocklogger.Infof(ctx, "[conndebug] ssh dial (from client) %s\n", networkAddr) clientConn, err = currentClient.DialContext(ctx, "tcp", networkAddr) if err != nil { blocklogger.Infof(ctx, "[conndebug] ERROR dial error: %v\n", err) - return nil, err + return nil, utilds.MakeCodedError(ConnErrCode_Dial, err) } } c, chans, reqs, err := ssh.NewClientConn(clientConn, networkAddr, clientConfig) @@ -719,7 +775,7 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. JumpNum: jumpNum, } if jumpNum > SshProxyJumpMaxDepth { - return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)} + return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.Errorf(ConnErrCode_ProxyDepth, "ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)} } rawName := opts.String() @@ -734,14 +790,14 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. var err error sshConfigKeywords, err = findSshDefaults(opts.SSHHost) if err != nil { - err = fmt.Errorf("cannot determine default config keywords: %w", err) + err = utilds.MakeCodedError(ConnErrCode_ConfigDefault, fmt.Errorf("cannot determine default config keywords: %w", err)) return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } } else { var err error sshConfigKeywords, err = findSshConfigKeywords(opts.SSHHost) if err != nil { - err = fmt.Errorf("cannot determine config keywords: %w", err) + err = utilds.MakeCodedError(ConnErrCode_ConfigParse, fmt.Errorf("cannot determine config keywords: %w", err)) return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } } @@ -773,7 +829,7 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. for _, proxyName := range sshKeywords.SshProxyJump { proxyOpts, err := ParseOpts(proxyName) if err != nil { - return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_ProxyParse, err)} } // ensure no overflow (this will likely never happen) diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index ef6afacb6b..957c09e226 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -565,6 +565,7 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w jobParams := jobcontroller.StartJobParams{ ConnName: conn.GetName(), + JobKind: jobcontroller.JobKind_Shell, Cmd: shellPath, Args: shellOpts, Env: env, diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 5c632609d6..d9b245b3ec 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -154,6 +154,9 @@ func mergeActivity(curActivity *telemetrydata.TEventProps, newActivity telemetry curActivity.WaveAIActiveMinutes += newActivity.WaveAIActiveMinutes curActivity.WaveAIFgMinutes += newActivity.WaveAIFgMinutes curActivity.TermCommandsRun += newActivity.TermCommandsRun + curActivity.TermCommandsRemote += newActivity.TermCommandsRemote + curActivity.TermCommandsDurable += newActivity.TermCommandsDurable + curActivity.TermCommandsWsl += newActivity.TermCommandsWsl if newActivity.AppFirstDay { curActivity.AppFirstDay = true } diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 6be7a6854e..b574f586be 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -48,6 +48,10 @@ var ValidEventNames = map[string]bool{ "onboarding:skip": true, "onboarding:fire": true, "onboarding:githubstar": true, + + "job:start": true, + "job:reconnect": true, + "job:done": true, } type TEvent struct { @@ -101,6 +105,9 @@ type TEventProps struct { WaveAIActiveMinutes int `json:"activity:waveaiactiveminutes,omitempty"` WaveAIFgMinutes int `json:"activity:waveaifgminutes,omitempty"` TermCommandsRun int `json:"activity:termcommandsrun,omitempty"` + TermCommandsRemote int `json:"activity:termcommands:remote,omitempty"` + TermCommandsDurable int `json:"activity:termcommands:durable,omitempty"` + TermCommandsWsl int `json:"activity:termcommands:wsl,omitempty"` AppFirstDay bool `json:"app:firstday,omitempty"` AppFirstLaunch bool `json:"app:firstlaunch,omitempty"` @@ -121,6 +128,7 @@ type TEventProps struct { ConnType string `json:"conn:conntype,omitempty"` ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"` + ConnErrorCode string `json:"conn:errorcode,omitempty"` OnboardingFeature string `json:"onboarding:feature,omitempty" tstype:"\"waveai\" | \"durable\" | \"magnify\" | \"wsh\""` OnboardingVersion string `json:"onboarding:version,omitempty"` @@ -132,13 +140,15 @@ type TEventProps struct { DisplayCount int `json:"display:count,omitempty"` DisplayAll interface{} `json:"display:all,omitempty"` - CountBlocks int `json:"count:blocks,omitempty"` - CountTabs int `json:"count:tabs,omitempty"` - CountWindows int `json:"count:windows,omitempty"` - CountWorkspaces int `json:"count:workspaces,omitempty"` - CountSSHConn int `json:"count:sshconn,omitempty"` - CountWSLConn int `json:"count:wslconn,omitempty"` - CountViews map[string]int `json:"count:views,omitempty"` + CountBlocks int `json:"count:blocks,omitempty"` + CountTabs int `json:"count:tabs,omitempty"` + CountWindows int `json:"count:windows,omitempty"` + CountWorkspaces int `json:"count:workspaces,omitempty"` + CountSSHConn int `json:"count:sshconn,omitempty"` + CountWSLConn int `json:"count:wslconn,omitempty"` + CountJobs int `json:"count:jobs,omitempty"` + CountJobsConnected int `json:"count:jobsconnected,omitempty"` + CountViews map[string]int `json:"count:views,omitempty"` WaveAIAPIType string `json:"waveai:apitype,omitempty"` WaveAIModel string `json:"waveai:model,omitempty"` @@ -168,6 +178,9 @@ type TEventProps struct { WaveAIFeedback string `json:"waveai:feedback,omitempty" tstype:"\"good\" | \"bad\""` WaveAIAction string `json:"waveai:action,omitempty"` + JobDoneReason string `json:"job:donereason,omitempty"` + JobKind string `json:"job:kind,omitempty"` + UserSet *TEventUserProps `json:"$set,omitempty"` UserSetOnce *TEventUserProps `json:"$set_once,omitempty"` } diff --git a/pkg/utilds/codederror.go b/pkg/utilds/codederror.go new file mode 100644 index 0000000000..f0f4ed71e6 --- /dev/null +++ b/pkg/utilds/codederror.go @@ -0,0 +1,48 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilds + +import ( + "errors" + "fmt" +) + +// CodedError wraps an error with a string code for categorization. +// The code can be extracted from anywhere in an error chain using GetErrorCode. +type CodedError struct { + Code string + Err error +} + +func (e CodedError) Error() string { + return e.Err.Error() +} + +func (e CodedError) Unwrap() error { + return e.Err +} + +// MakeCodedError creates a new CodedError with the given code and error. +func MakeCodedError(code string, err error) CodedError { + return CodedError{Code: code, Err: err} +} + +// GetErrorCode extracts the error code from anywhere in the error chain. +// Returns empty string if no CodedError is found. +func GetErrorCode(err error) string { + if err == nil { + return "" + } + var coded CodedError + if errors.As(err, &coded) { + return coded.Code + } + return "" +} + +// Errorf creates a formatted error wrapped in a CodedError. +// This is a convenience function that combines fmt.Errorf with MakeCodedError. +func Errorf(code string, format string, args ...interface{}) error { + return MakeCodedError(code, fmt.Errorf(format, args...)) +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index b5a63d50c7..eefd7fabd7 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -824,6 +824,7 @@ type CommandJobCmdExitedData struct { type CommandJobControllerStartJobData struct { ConnName string `json:"connname"` + JobKind string `json:"jobkind"` Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 04b711fa0b..ff6a0eae85 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1473,6 +1473,7 @@ func (ws *WshServer) JobControllerDeleteJobCommand(ctx context.Context, jobId st func (ws *WshServer) JobControllerStartJobCommand(ctx context.Context, data wshrpc.CommandJobControllerStartJobData) (string, error) { params := jobcontroller.StartJobParams{ ConnName: data.ConnName, + JobKind: data.JobKind, Cmd: data.Cmd, Args: data.Args, Env: data.Env,