@kolisachint/hoocode-agent 0.4.96 → 0.4.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +7 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +2 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -0
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +3 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/tools/browser-flow.d.ts +2 -0
- package/dist/core/tools/browser-flow.d.ts.map +1 -1
- package/dist/core/tools/browser-flow.js +51 -1
- package/dist/core/tools/browser-flow.js.map +1 -1
- package/dist/core/tools/browsertools-shared.d.ts +9 -0
- package/dist/core/tools/browsertools-shared.d.ts.map +1 -1
- package/dist/core/tools/browsertools-shared.js +7 -0
- package/dist/core/tools/browsertools-shared.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +9 -0
- package/dist/utils/tools-manager.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -83,12 +83,20 @@ export interface BrowsertoolsToolOptions {
|
|
|
83
83
|
requestTimeoutMs?: number;
|
|
84
84
|
/** Extra args appended after `serve` (mainly for tests). */
|
|
85
85
|
serveArgs?: string[];
|
|
86
|
+
/** Launch a real on-screen Chromium window instead of headless (sets
|
|
87
|
+
* `BROWSERTOOLS_HEADFUL=1` on the serve process). */
|
|
88
|
+
headful?: boolean;
|
|
89
|
+
/** Default the streamed live viewer on for every flow (the per-call `live_view`
|
|
90
|
+
* param still overrides). Set by the --enable-browser-live-preview flag. */
|
|
91
|
+
liveView?: boolean;
|
|
86
92
|
}
|
|
87
93
|
/** Resolve effective options from explicit overrides, then env, then defaults. */
|
|
88
94
|
export declare function resolveBrowsertoolsOptions(options?: BrowsertoolsToolOptions): Required<Pick<BrowsertoolsToolOptions, "idleTimeoutMs" | "maxParentRounds" | "requestTimeoutMs">> & {
|
|
89
95
|
binaryPath?: string;
|
|
90
96
|
browserPath?: string;
|
|
91
97
|
serveArgs: string[];
|
|
98
|
+
headful: boolean;
|
|
99
|
+
liveView: boolean;
|
|
92
100
|
};
|
|
93
101
|
/** Resolve the `browsertools` binary path (explicit override, else download/PATH). */
|
|
94
102
|
export declare function resolveBrowsertoolsBinary(options?: BrowsertoolsToolOptions): Promise<string>;
|
|
@@ -116,6 +124,7 @@ export declare class BrowsertoolsServeClient {
|
|
|
116
124
|
browserPath?: string;
|
|
117
125
|
serveArgs: string[];
|
|
118
126
|
requestTimeoutMs: number;
|
|
127
|
+
headful?: boolean;
|
|
119
128
|
});
|
|
120
129
|
private onStdout;
|
|
121
130
|
private onLine;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browsertools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/browsertools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `browser_flow` and `browser_resume` tools.\n *\n * Unlike `webtools` (one-shot fetch/search subprocesses), `browsertools` drives\n * a *stateful* parent-in-the-loop protocol: a single long-lived `browsertools\n * serve` process holds the live Chromium and the paused-flow state in memory,\n * and the parent (hoocode) talks to it over newline-delimited JSON-RPC on\n * stdin/stdout. A flow can suspend mid-replay with `Outcome::NeedsParent`,\n * yielding a typed `ParentRequest` + `ResumeToken`; the parent reasons about it\n * (often by looking at a screenshot) and calls `flow_resume(token, response)` to\n * continue. See https://github.com/kolisachint/browsertools (contract.rs).\n *\n * Because that single serve process must survive *between* the `browser_flow`\n * call that starts the flow and the `browser_resume` call that answers a\n * `NeedsParent`, this module owns a process-wide {@link sessionRegistry} keyed by\n * `ResumeToken`. Each paused session keeps its serve client alive and is reaped\n * after an idle timeout so an abandoned flow never leaks a Chromium forever.\n *\n * This module owns:\n * - the persistent JSON-RPC serve client (id-correlated request/response),\n * - the locked contract types (mirrors browsertools `src/contract.rs`),\n * - the paused-session registry + lifecycle (idle reap, round cap, teardown),\n * - binary + browser-path resolution.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\n\n// ============================================================================\n// Contract types (locked against browsertools `src/contract.rs`)\n// ============================================================================\n\n/** Opaque token identifying a paused flow, returned with every `NeedsParent`. */\nexport type ResumeToken = string;\n\n/** Reference to bytes (e.g. a screenshot) the parent can fetch via `get_resource`. */\nexport type ResourceId = string;\n\n/**\n * A typed request the engine yields when replay hits ambiguity only an LLM can\n * resolve. The `request` discriminant matches the serde tag in contract.rs.\n * Fields beyond the discriminant are passed through verbatim to the model.\n */\nexport interface ParentRequest {\n\trequest: \"classify_state\" | \"verify_visual\" | \"extract_semantic\" | \"decide_next_action\" | \"reidentify_element\";\n\t/** Resource id of the screenshot captured at the suspension point, if any. */\n\tscreenshot_ref?: ResourceId;\n\t[key: string]: unknown;\n}\n\n/** Result of a `flow_start` / `flow_resume` RPC (the `result` field's `outcome`). */\nexport type FlowOutcome =\n\t| { outcome: \"complete\"; result?: unknown }\n\t| { outcome: \"needs_parent\"; request: ParentRequest; token: ResumeToken }\n\t| { outcome: \"failed\"; step_id?: string; kind?: string; detail?: string };\n\n/** Result of a `get_resource` RPC. */\nexport interface GetResourceResult {\n\tref: ResourceId;\n\tmime: string;\n\tlen: number;\n\tpng_base64: string;\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\n/** Default per-request RPC timeout. Flow steps drive a real browser, so this is\n * generous relative to webtools' 15s. */\nexport const BROWSERTOOLS_REQUEST_TIMEOUT_MS = 120_000;\n\n/** Default idle window a paused flow may sit in the registry before it is reaped\n * (killing its serve process + Chromium). Reset on every resume round. The\n * parent may reason / look at a screenshot / make other tool calls between\n * rounds, so this is minutes, not seconds. */\nexport const BROWSERTOOLS_DEFAULT_IDLE_MS = 5 * 60 * 1000;\n\n/** Default hard cap on `NeedsParent` rounds for a single flow, bounding a runaway\n * suspend/resume loop. */\nexport const BROWSERTOOLS_DEFAULT_MAX_ROUNDS = 12;\n\n/** Options threaded into the browser tools by their factories (tests/overrides). */\nexport interface BrowsertoolsToolOptions {\n\t/** Explicit path to the `browsertools` binary; otherwise resolved via {@link ensureTool}. */\n\tbinaryPath?: string;\n\t/** Chromium/Chrome executable path forwarded to the serve process. */\n\tbrowserPath?: string;\n\t/** Idle reap window for paused sessions (ms). */\n\tidleTimeoutMs?: number;\n\t/** Hard cap on NeedsParent rounds per flow. */\n\tmaxParentRounds?: number;\n\t/** Per-request RPC timeout (ms). */\n\trequestTimeoutMs?: number;\n\t/** Extra args appended after `serve` (mainly for tests). */\n\tserveArgs?: string[];\n}\n\nfunction envNumber(name: string): number | undefined {\n\tconst raw = process.env[name];\n\tif (!raw) return undefined;\n\tconst n = Number(raw);\n\treturn Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/** Resolve effective options from explicit overrides, then env, then defaults. */\nexport function resolveBrowsertoolsOptions(options?: BrowsertoolsToolOptions): Required<\n\tPick<BrowsertoolsToolOptions, \"idleTimeoutMs\" | \"maxParentRounds\" | \"requestTimeoutMs\">\n> & {\n\tbinaryPath?: string;\n\tbrowserPath?: string;\n\tserveArgs: string[];\n} {\n\treturn {\n\t\tbinaryPath: options?.binaryPath,\n\t\tbrowserPath: options?.browserPath ?? (process.env.HOOCODE_BROWSERTOOLS_BROWSER_PATH?.trim() || undefined),\n\t\tidleTimeoutMs:\n\t\t\toptions?.idleTimeoutMs ?? envNumber(\"HOOCODE_BROWSERTOOLS_IDLE_MS\") ?? BROWSERTOOLS_DEFAULT_IDLE_MS,\n\t\tmaxParentRounds:\n\t\t\toptions?.maxParentRounds ?? envNumber(\"HOOCODE_BROWSERTOOLS_MAX_ROUNDS\") ?? BROWSERTOOLS_DEFAULT_MAX_ROUNDS,\n\t\trequestTimeoutMs:\n\t\t\toptions?.requestTimeoutMs ??\n\t\t\tenvNumber(\"HOOCODE_BROWSERTOOLS_REQUEST_TIMEOUT_MS\") ??\n\t\t\tBROWSERTOOLS_REQUEST_TIMEOUT_MS,\n\t\tserveArgs: options?.serveArgs ?? [],\n\t};\n}\n\nconst BINARY_MISSING_MESSAGE =\n\t\"browsertools binary unavailable and could not be downloaded — the browser tools require the `browsertools` CLI on PATH or a published release for this platform\";\n\n/** Resolve the `browsertools` binary path (explicit override, else download/PATH). */\nexport async function resolveBrowsertoolsBinary(options?: BrowsertoolsToolOptions): Promise<string> {\n\tif (options?.binaryPath) return options.binaryPath;\n\tconst binaryPath = await ensureTool(\"browsertools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\n// ============================================================================\n// Persistent JSON-RPC serve client\n// ============================================================================\n\ninterface PendingRequest {\n\tresolve: (value: unknown) => void;\n\treject: (error: Error) => void;\n\ttimer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * A live `browsertools serve` process spoken to over newline-delimited JSON-RPC.\n *\n * Wire format (browsertools `src/serve.rs`):\n * request: `{ \"id\": <n>, \"method\": <string>, \"params\": <object> }\\n`\n * response: `{ \"id\": <n>, \"result\": <value> }` or `{ \"id\": <n>, \"error\": <RpcError> }`\\n\n *\n * Responses are correlated by `id`, so concurrent requests are safe, though the\n * tools drive it sequentially.\n */\nexport class BrowsertoolsServeClient {\n\tprivate readonly proc: ChildProcess;\n\tprivate readonly pending = new Map<number, PendingRequest>();\n\tprivate readonly requestTimeoutMs: number;\n\tprivate buffer = \"\";\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate exitError: Error | undefined;\n\tprivate stderrTail = \"\";\n\n\tconstructor(\n\t\tbinaryPath: string,\n\t\topts: { cwd?: string; browserPath?: string; serveArgs: string[]; requestTimeoutMs: number },\n\t) {\n\t\tthis.requestTimeoutMs = opts.requestTimeoutMs;\n\t\t// The exact CLI flag for the browser path is not part of the locked RPC\n\t\t// contract; v0.1.3 added a \"configurable browser path\". We forward it via\n\t\t// env vars the binary is likely to read. Passing extra env is harmless if\n\t\t// the binary ignores them, and centralizing it here makes it a one-line\n\t\t// change once the exact mechanism is confirmed.\n\t\tconst env = { ...process.env };\n\t\tif (opts.browserPath) {\n\t\t\tenv.BROWSERTOOLS_BROWSER_PATH = opts.browserPath;\n\t\t\tenv.CHROME_PATH = opts.browserPath;\n\t\t}\n\t\t// Run the serve process in the project cwd so relative flow paths and the\n\t\t// evidence store resolve the same way the user expects.\n\t\tthis.proc = spawn(binaryPath, [\"serve\", ...opts.serveArgs], {\n\t\t\tcwd: opts.cwd,\n\t\t\tshell: false,\n\t\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t\tenv,\n\t\t});\n\t\tthis.proc.stdout?.setEncoding(\"utf8\");\n\t\tthis.proc.stdout?.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\tthis.proc.stderr?.setEncoding(\"utf8\");\n\t\tthis.proc.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t// Keep only a bounded tail so a chatty serve can't grow unbounded; it is\n\t\t\t// surfaced in the error when the process dies unexpectedly.\n\t\t\tthis.stderrTail = (this.stderrTail + chunk).slice(-4000);\n\t\t});\n\t\tthis.proc.on(\"error\", (err) => this.fail(err instanceof Error ? err : new Error(String(err))));\n\t\tthis.proc.on(\"exit\", (code, sig) => {\n\t\t\tif (this.closed) return;\n\t\t\tconst detail = this.stderrTail.trim();\n\t\t\tthis.fail(\n\t\t\t\tnew Error(\n\t\t\t\t\t`browsertools serve exited unexpectedly (code=${code ?? \"null\"}, signal=${sig ?? \"null\"})` +\n\t\t\t\t\t\t(detail ? `: ${detail}` : \"\"),\n\t\t\t\t),\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\tlet newlineIndex = this.buffer.indexOf(\"\\n\");\n\t\twhile (newlineIndex !== -1) {\n\t\t\tconst line = this.buffer.slice(0, newlineIndex).trim();\n\t\t\tthis.buffer = this.buffer.slice(newlineIndex + 1);\n\t\t\tif (line) this.onLine(line);\n\t\t\tnewlineIndex = this.buffer.indexOf(\"\\n\");\n\t\t}\n\t}\n\n\tprivate onLine(line: string): void {\n\t\tlet message: { id?: unknown; result?: unknown; error?: unknown };\n\t\ttry {\n\t\t\tmessage = JSON.parse(line);\n\t\t} catch {\n\t\t\t// Non-JSON noise on stdout (shouldn't happen; logs go to stderr). Skip it\n\t\t\t// rather than corrupt id correlation.\n\t\t\treturn;\n\t\t}\n\t\tconst id = typeof message.id === \"number\" ? message.id : undefined;\n\t\tif (id === undefined) return;\n\t\tconst entry = this.pending.get(id);\n\t\tif (!entry) return;\n\t\tthis.pending.delete(id);\n\t\tclearTimeout(entry.timer);\n\t\tif (message.error !== undefined && message.error !== null) {\n\t\t\tentry.reject(new Error(`browsertools ${describeRpcError(message.error)}`));\n\t\t} else {\n\t\t\tentry.resolve(message.result);\n\t\t}\n\t}\n\n\t/** Reject all in-flight requests and mark the client dead. Idempotent. */\n\tprivate fail(error: Error): void {\n\t\tif (this.closed) return;\n\t\tthis.exitError = error;\n\t\tfor (const [, entry] of this.pending) {\n\t\t\tclearTimeout(entry.timer);\n\t\t\tentry.reject(error);\n\t\t}\n\t\tthis.pending.clear();\n\t}\n\n\t/** Send a JSON-RPC request and resolve with its `result` (or reject on `error`). */\n\trequest<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n\t\tif (this.closed) {\n\t\t\treturn Promise.reject(this.exitError ?? new Error(\"browsertools serve client is closed\"));\n\t\t}\n\t\tconst id = this.nextId++;\n\t\treturn new Promise<T>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (this.pending.delete(id)) {\n\t\t\t\t\treject(new Error(`browsertools ${method} timed out after ${this.requestTimeoutMs}ms`));\n\t\t\t\t}\n\t\t\t}, this.requestTimeoutMs);\n\t\t\tthis.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });\n\t\t\tconst payload = `${JSON.stringify({ id, method, params })}\\n`;\n\t\t\tthis.proc.stdin?.write(payload, (err) => {\n\t\t\t\tif (err && this.pending.delete(id)) {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\t/** Kill the serve process and reject any outstanding requests. Idempotent. */\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.fail(new Error(\"browsertools serve client disposed\"));\n\t\tthis.closed = true;\n\t\t// Best-effort graceful shutdown, then ensure the process is gone.\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(`${JSON.stringify({ id: this.nextId++, method: \"shutdown\", params: {} })}\\n`);\n\t\t} catch {\n\t\t\t// stdin may already be closed; fall through to kill.\n\t\t}\n\t\tthis.proc.stdin?.end();\n\t\tthis.proc.kill(\"SIGTERM\");\n\t\tconst proc = this.proc;\n\t\tsetTimeout(() => {\n\t\t\tif (proc.exitCode === null && proc.signalCode === null) proc.kill(\"SIGKILL\");\n\t\t}, 3000).unref?.();\n\t}\n}\n\nfunction describeRpcError(error: unknown): string {\n\tif (error && typeof error === \"object\") {\n\t\tconst message = (error as { message?: unknown }).message;\n\t\tif (typeof message === \"string\" && message) return message;\n\t}\n\tif (typeof error === \"string\" && error) return error;\n\treturn `RPC error: ${JSON.stringify(error)}`;\n}\n\n// ============================================================================\n// Paused-session registry (process-wide, keyed by ResumeToken)\n// ============================================================================\n\ninterface PausedSession {\n\tclient: BrowsertoolsServeClient;\n\t/** Number of NeedsParent rounds yielded so far for this flow. */\n\trounds: number;\n\tidleTimer: ReturnType<typeof setTimeout>;\n\tidleTimeoutMs: number;\n}\n\nconst sessionRegistry = new Map<ResumeToken, PausedSession>();\n\n/**\n * Park a paused flow's live serve client under its resume token and arm the idle\n * reaper. Any previous entry for the token is disposed first.\n */\nexport function parkSession(\n\ttoken: ResumeToken,\n\tclient: BrowsertoolsServeClient,\n\trounds: number,\n\tidleTimeoutMs: number,\n): void {\n\tconst existing = sessionRegistry.get(token);\n\tif (existing && existing.client !== client) {\n\t\tclearTimeout(existing.idleTimer);\n\t\texisting.client.dispose();\n\t}\n\tconst idleTimer = setTimeout(() => {\n\t\tconst session = sessionRegistry.get(token);\n\t\tif (session) {\n\t\t\tsessionRegistry.delete(token);\n\t\t\tsession.client.dispose();\n\t\t}\n\t}, idleTimeoutMs);\n\tidleTimer.unref?.();\n\tsessionRegistry.set(token, { client, rounds, idleTimer, idleTimeoutMs });\n}\n\n/**\n * Remove a paused session from the registry and return it, stopping its idle\n * timer. The caller now owns the client (to resume or dispose). Returns\n * undefined if the token is unknown (expired/reaped/already completed).\n */\nexport function takeSession(token: ResumeToken): PausedSession | undefined {\n\tconst session = sessionRegistry.get(token);\n\tif (!session) return undefined;\n\tclearTimeout(session.idleTimer);\n\tsessionRegistry.delete(token);\n\treturn session;\n}\n\n/** Dispose every parked session. Used on shutdown and in tests. */\nexport function disposeAllSessions(): void {\n\tfor (const [, session] of sessionRegistry) {\n\t\tclearTimeout(session.idleTimer);\n\t\tsession.client.dispose();\n\t}\n\tsessionRegistry.clear();\n}\n\n/** Number of currently parked sessions (for tests/diagnostics). */\nexport function pausedSessionCount(): number {\n\treturn sessionRegistry.size;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"browsertools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/browsertools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `browser_flow` and `browser_resume` tools.\n *\n * Unlike `webtools` (one-shot fetch/search subprocesses), `browsertools` drives\n * a *stateful* parent-in-the-loop protocol: a single long-lived `browsertools\n * serve` process holds the live Chromium and the paused-flow state in memory,\n * and the parent (hoocode) talks to it over newline-delimited JSON-RPC on\n * stdin/stdout. A flow can suspend mid-replay with `Outcome::NeedsParent`,\n * yielding a typed `ParentRequest` + `ResumeToken`; the parent reasons about it\n * (often by looking at a screenshot) and calls `flow_resume(token, response)` to\n * continue. See https://github.com/kolisachint/browsertools (contract.rs).\n *\n * Because that single serve process must survive *between* the `browser_flow`\n * call that starts the flow and the `browser_resume` call that answers a\n * `NeedsParent`, this module owns a process-wide {@link sessionRegistry} keyed by\n * `ResumeToken`. Each paused session keeps its serve client alive and is reaped\n * after an idle timeout so an abandoned flow never leaks a Chromium forever.\n *\n * This module owns:\n * - the persistent JSON-RPC serve client (id-correlated request/response),\n * - the locked contract types (mirrors browsertools `src/contract.rs`),\n * - the paused-session registry + lifecycle (idle reap, round cap, teardown),\n * - binary + browser-path resolution.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\n\n// ============================================================================\n// Contract types (locked against browsertools `src/contract.rs`)\n// ============================================================================\n\n/** Opaque token identifying a paused flow, returned with every `NeedsParent`. */\nexport type ResumeToken = string;\n\n/** Reference to bytes (e.g. a screenshot) the parent can fetch via `get_resource`. */\nexport type ResourceId = string;\n\n/**\n * A typed request the engine yields when replay hits ambiguity only an LLM can\n * resolve. The `request` discriminant matches the serde tag in contract.rs.\n * Fields beyond the discriminant are passed through verbatim to the model.\n */\nexport interface ParentRequest {\n\trequest: \"classify_state\" | \"verify_visual\" | \"extract_semantic\" | \"decide_next_action\" | \"reidentify_element\";\n\t/** Resource id of the screenshot captured at the suspension point, if any. */\n\tscreenshot_ref?: ResourceId;\n\t[key: string]: unknown;\n}\n\n/** Result of a `flow_start` / `flow_resume` RPC (the `result` field's `outcome`). */\nexport type FlowOutcome =\n\t| { outcome: \"complete\"; result?: unknown }\n\t| { outcome: \"needs_parent\"; request: ParentRequest; token: ResumeToken }\n\t| { outcome: \"failed\"; step_id?: string; kind?: string; detail?: string };\n\n/** Result of a `get_resource` RPC. */\nexport interface GetResourceResult {\n\tref: ResourceId;\n\tmime: string;\n\tlen: number;\n\tpng_base64: string;\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\n/** Default per-request RPC timeout. Flow steps drive a real browser, so this is\n * generous relative to webtools' 15s. */\nexport const BROWSERTOOLS_REQUEST_TIMEOUT_MS = 120_000;\n\n/** Default idle window a paused flow may sit in the registry before it is reaped\n * (killing its serve process + Chromium). Reset on every resume round. The\n * parent may reason / look at a screenshot / make other tool calls between\n * rounds, so this is minutes, not seconds. */\nexport const BROWSERTOOLS_DEFAULT_IDLE_MS = 5 * 60 * 1000;\n\n/** Default hard cap on `NeedsParent` rounds for a single flow, bounding a runaway\n * suspend/resume loop. */\nexport const BROWSERTOOLS_DEFAULT_MAX_ROUNDS = 12;\n\n/** Options threaded into the browser tools by their factories (tests/overrides). */\nexport interface BrowsertoolsToolOptions {\n\t/** Explicit path to the `browsertools` binary; otherwise resolved via {@link ensureTool}. */\n\tbinaryPath?: string;\n\t/** Chromium/Chrome executable path forwarded to the serve process. */\n\tbrowserPath?: string;\n\t/** Idle reap window for paused sessions (ms). */\n\tidleTimeoutMs?: number;\n\t/** Hard cap on NeedsParent rounds per flow. */\n\tmaxParentRounds?: number;\n\t/** Per-request RPC timeout (ms). */\n\trequestTimeoutMs?: number;\n\t/** Extra args appended after `serve` (mainly for tests). */\n\tserveArgs?: string[];\n\t/** Launch a real on-screen Chromium window instead of headless (sets\n\t * `BROWSERTOOLS_HEADFUL=1` on the serve process). */\n\theadful?: boolean;\n\t/** Default the streamed live viewer on for every flow (the per-call `live_view`\n\t * param still overrides). Set by the --enable-browser-live-preview flag. */\n\tliveView?: boolean;\n}\n\nfunction envNumber(name: string): number | undefined {\n\tconst raw = process.env[name];\n\tif (!raw) return undefined;\n\tconst n = Number(raw);\n\treturn Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/** Resolve effective options from explicit overrides, then env, then defaults. */\nexport function resolveBrowsertoolsOptions(options?: BrowsertoolsToolOptions): Required<\n\tPick<BrowsertoolsToolOptions, \"idleTimeoutMs\" | \"maxParentRounds\" | \"requestTimeoutMs\">\n> & {\n\tbinaryPath?: string;\n\tbrowserPath?: string;\n\tserveArgs: string[];\n\theadful: boolean;\n\tliveView: boolean;\n} {\n\treturn {\n\t\tbinaryPath: options?.binaryPath,\n\t\tbrowserPath: options?.browserPath ?? (process.env.HOOCODE_BROWSERTOOLS_BROWSER_PATH?.trim() || undefined),\n\t\theadful: options?.headful ?? false,\n\t\tliveView: options?.liveView ?? false,\n\t\tidleTimeoutMs:\n\t\t\toptions?.idleTimeoutMs ?? envNumber(\"HOOCODE_BROWSERTOOLS_IDLE_MS\") ?? BROWSERTOOLS_DEFAULT_IDLE_MS,\n\t\tmaxParentRounds:\n\t\t\toptions?.maxParentRounds ?? envNumber(\"HOOCODE_BROWSERTOOLS_MAX_ROUNDS\") ?? BROWSERTOOLS_DEFAULT_MAX_ROUNDS,\n\t\trequestTimeoutMs:\n\t\t\toptions?.requestTimeoutMs ??\n\t\t\tenvNumber(\"HOOCODE_BROWSERTOOLS_REQUEST_TIMEOUT_MS\") ??\n\t\t\tBROWSERTOOLS_REQUEST_TIMEOUT_MS,\n\t\tserveArgs: options?.serveArgs ?? [],\n\t};\n}\n\nconst BINARY_MISSING_MESSAGE =\n\t\"browsertools binary unavailable and could not be downloaded — the browser tools require the `browsertools` CLI on PATH or a published release for this platform\";\n\n/** Resolve the `browsertools` binary path (explicit override, else download/PATH). */\nexport async function resolveBrowsertoolsBinary(options?: BrowsertoolsToolOptions): Promise<string> {\n\tif (options?.binaryPath) return options.binaryPath;\n\tconst binaryPath = await ensureTool(\"browsertools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\n// ============================================================================\n// Persistent JSON-RPC serve client\n// ============================================================================\n\ninterface PendingRequest {\n\tresolve: (value: unknown) => void;\n\treject: (error: Error) => void;\n\ttimer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * A live `browsertools serve` process spoken to over newline-delimited JSON-RPC.\n *\n * Wire format (browsertools `src/serve.rs`):\n * request: `{ \"id\": <n>, \"method\": <string>, \"params\": <object> }\\n`\n * response: `{ \"id\": <n>, \"result\": <value> }` or `{ \"id\": <n>, \"error\": <RpcError> }`\\n\n *\n * Responses are correlated by `id`, so concurrent requests are safe, though the\n * tools drive it sequentially.\n */\nexport class BrowsertoolsServeClient {\n\tprivate readonly proc: ChildProcess;\n\tprivate readonly pending = new Map<number, PendingRequest>();\n\tprivate readonly requestTimeoutMs: number;\n\tprivate buffer = \"\";\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate exitError: Error | undefined;\n\tprivate stderrTail = \"\";\n\n\tconstructor(\n\t\tbinaryPath: string,\n\t\topts: {\n\t\t\tcwd?: string;\n\t\t\tbrowserPath?: string;\n\t\t\tserveArgs: string[];\n\t\t\trequestTimeoutMs: number;\n\t\t\theadful?: boolean;\n\t\t},\n\t) {\n\t\tthis.requestTimeoutMs = opts.requestTimeoutMs;\n\t\t// The exact CLI flag for the browser path is not part of the locked RPC\n\t\t// contract; v0.1.3 added a \"configurable browser path\". We forward it via\n\t\t// env vars the binary is likely to read. Passing extra env is harmless if\n\t\t// the binary ignores them, and centralizing it here makes it a one-line\n\t\t// change once the exact mechanism is confirmed.\n\t\tconst env = { ...process.env };\n\t\tif (opts.browserPath) {\n\t\t\tenv.BROWSERTOOLS_BROWSER_PATH = opts.browserPath;\n\t\t\tenv.CHROME_PATH = opts.browserPath;\n\t\t}\n\t\t// Opt into a real on-screen Chromium window. The serve process reads this\n\t\t// in `Driver::launch`; harmless if an older binary ignores it.\n\t\tif (opts.headful) {\n\t\t\tenv.BROWSERTOOLS_HEADFUL = \"1\";\n\t\t}\n\t\t// Run the serve process in the project cwd so relative flow paths and the\n\t\t// evidence store resolve the same way the user expects.\n\t\tthis.proc = spawn(binaryPath, [\"serve\", ...opts.serveArgs], {\n\t\t\tcwd: opts.cwd,\n\t\t\tshell: false,\n\t\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t\tenv,\n\t\t});\n\t\tthis.proc.stdout?.setEncoding(\"utf8\");\n\t\tthis.proc.stdout?.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\tthis.proc.stderr?.setEncoding(\"utf8\");\n\t\tthis.proc.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t// Keep only a bounded tail so a chatty serve can't grow unbounded; it is\n\t\t\t// surfaced in the error when the process dies unexpectedly.\n\t\t\tthis.stderrTail = (this.stderrTail + chunk).slice(-4000);\n\t\t});\n\t\tthis.proc.on(\"error\", (err) => this.fail(err instanceof Error ? err : new Error(String(err))));\n\t\tthis.proc.on(\"exit\", (code, sig) => {\n\t\t\tif (this.closed) return;\n\t\t\tconst detail = this.stderrTail.trim();\n\t\t\tthis.fail(\n\t\t\t\tnew Error(\n\t\t\t\t\t`browsertools serve exited unexpectedly (code=${code ?? \"null\"}, signal=${sig ?? \"null\"})` +\n\t\t\t\t\t\t(detail ? `: ${detail}` : \"\"),\n\t\t\t\t),\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\tlet newlineIndex = this.buffer.indexOf(\"\\n\");\n\t\twhile (newlineIndex !== -1) {\n\t\t\tconst line = this.buffer.slice(0, newlineIndex).trim();\n\t\t\tthis.buffer = this.buffer.slice(newlineIndex + 1);\n\t\t\tif (line) this.onLine(line);\n\t\t\tnewlineIndex = this.buffer.indexOf(\"\\n\");\n\t\t}\n\t}\n\n\tprivate onLine(line: string): void {\n\t\tlet message: { id?: unknown; result?: unknown; error?: unknown };\n\t\ttry {\n\t\t\tmessage = JSON.parse(line);\n\t\t} catch {\n\t\t\t// Non-JSON noise on stdout (shouldn't happen; logs go to stderr). Skip it\n\t\t\t// rather than corrupt id correlation.\n\t\t\treturn;\n\t\t}\n\t\tconst id = typeof message.id === \"number\" ? message.id : undefined;\n\t\tif (id === undefined) return;\n\t\tconst entry = this.pending.get(id);\n\t\tif (!entry) return;\n\t\tthis.pending.delete(id);\n\t\tclearTimeout(entry.timer);\n\t\tif (message.error !== undefined && message.error !== null) {\n\t\t\tentry.reject(new Error(`browsertools ${describeRpcError(message.error)}`));\n\t\t} else {\n\t\t\tentry.resolve(message.result);\n\t\t}\n\t}\n\n\t/** Reject all in-flight requests and mark the client dead. Idempotent. */\n\tprivate fail(error: Error): void {\n\t\tif (this.closed) return;\n\t\tthis.exitError = error;\n\t\tfor (const [, entry] of this.pending) {\n\t\t\tclearTimeout(entry.timer);\n\t\t\tentry.reject(error);\n\t\t}\n\t\tthis.pending.clear();\n\t}\n\n\t/** Send a JSON-RPC request and resolve with its `result` (or reject on `error`). */\n\trequest<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n\t\tif (this.closed) {\n\t\t\treturn Promise.reject(this.exitError ?? new Error(\"browsertools serve client is closed\"));\n\t\t}\n\t\tconst id = this.nextId++;\n\t\treturn new Promise<T>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (this.pending.delete(id)) {\n\t\t\t\t\treject(new Error(`browsertools ${method} timed out after ${this.requestTimeoutMs}ms`));\n\t\t\t\t}\n\t\t\t}, this.requestTimeoutMs);\n\t\t\tthis.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });\n\t\t\tconst payload = `${JSON.stringify({ id, method, params })}\\n`;\n\t\t\tthis.proc.stdin?.write(payload, (err) => {\n\t\t\t\tif (err && this.pending.delete(id)) {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\t/** Kill the serve process and reject any outstanding requests. Idempotent. */\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.fail(new Error(\"browsertools serve client disposed\"));\n\t\tthis.closed = true;\n\t\t// Best-effort graceful shutdown, then ensure the process is gone.\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(`${JSON.stringify({ id: this.nextId++, method: \"shutdown\", params: {} })}\\n`);\n\t\t} catch {\n\t\t\t// stdin may already be closed; fall through to kill.\n\t\t}\n\t\tthis.proc.stdin?.end();\n\t\tthis.proc.kill(\"SIGTERM\");\n\t\tconst proc = this.proc;\n\t\tsetTimeout(() => {\n\t\t\tif (proc.exitCode === null && proc.signalCode === null) proc.kill(\"SIGKILL\");\n\t\t}, 3000).unref?.();\n\t}\n}\n\nfunction describeRpcError(error: unknown): string {\n\tif (error && typeof error === \"object\") {\n\t\tconst message = (error as { message?: unknown }).message;\n\t\tif (typeof message === \"string\" && message) return message;\n\t}\n\tif (typeof error === \"string\" && error) return error;\n\treturn `RPC error: ${JSON.stringify(error)}`;\n}\n\n// ============================================================================\n// Paused-session registry (process-wide, keyed by ResumeToken)\n// ============================================================================\n\ninterface PausedSession {\n\tclient: BrowsertoolsServeClient;\n\t/** Number of NeedsParent rounds yielded so far for this flow. */\n\trounds: number;\n\tidleTimer: ReturnType<typeof setTimeout>;\n\tidleTimeoutMs: number;\n}\n\nconst sessionRegistry = new Map<ResumeToken, PausedSession>();\n\n/**\n * Park a paused flow's live serve client under its resume token and arm the idle\n * reaper. Any previous entry for the token is disposed first.\n */\nexport function parkSession(\n\ttoken: ResumeToken,\n\tclient: BrowsertoolsServeClient,\n\trounds: number,\n\tidleTimeoutMs: number,\n): void {\n\tconst existing = sessionRegistry.get(token);\n\tif (existing && existing.client !== client) {\n\t\tclearTimeout(existing.idleTimer);\n\t\texisting.client.dispose();\n\t}\n\tconst idleTimer = setTimeout(() => {\n\t\tconst session = sessionRegistry.get(token);\n\t\tif (session) {\n\t\t\tsessionRegistry.delete(token);\n\t\t\tsession.client.dispose();\n\t\t}\n\t}, idleTimeoutMs);\n\tidleTimer.unref?.();\n\tsessionRegistry.set(token, { client, rounds, idleTimer, idleTimeoutMs });\n}\n\n/**\n * Remove a paused session from the registry and return it, stopping its idle\n * timer. The caller now owns the client (to resume or dispose). Returns\n * undefined if the token is unknown (expired/reaped/already completed).\n */\nexport function takeSession(token: ResumeToken): PausedSession | undefined {\n\tconst session = sessionRegistry.get(token);\n\tif (!session) return undefined;\n\tclearTimeout(session.idleTimer);\n\tsessionRegistry.delete(token);\n\treturn session;\n}\n\n/** Dispose every parked session. Used on shutdown and in tests. */\nexport function disposeAllSessions(): void {\n\tfor (const [, session] of sessionRegistry) {\n\t\tclearTimeout(session.idleTimer);\n\t\tsession.client.dispose();\n\t}\n\tsessionRegistry.clear();\n}\n\n/** Number of currently parked sessions (for tests/diagnostics). */\nexport function pausedSessionCount(): number {\n\treturn sessionRegistry.size;\n}\n"]}
|
|
@@ -50,6 +50,8 @@ export function resolveBrowsertoolsOptions(options) {
|
|
|
50
50
|
return {
|
|
51
51
|
binaryPath: options?.binaryPath,
|
|
52
52
|
browserPath: options?.browserPath ?? (process.env.HOOCODE_BROWSERTOOLS_BROWSER_PATH?.trim() || undefined),
|
|
53
|
+
headful: options?.headful ?? false,
|
|
54
|
+
liveView: options?.liveView ?? false,
|
|
53
55
|
idleTimeoutMs: options?.idleTimeoutMs ?? envNumber("HOOCODE_BROWSERTOOLS_IDLE_MS") ?? BROWSERTOOLS_DEFAULT_IDLE_MS,
|
|
54
56
|
maxParentRounds: options?.maxParentRounds ?? envNumber("HOOCODE_BROWSERTOOLS_MAX_ROUNDS") ?? BROWSERTOOLS_DEFAULT_MAX_ROUNDS,
|
|
55
57
|
requestTimeoutMs: options?.requestTimeoutMs ??
|
|
@@ -99,6 +101,11 @@ export class BrowsertoolsServeClient {
|
|
|
99
101
|
env.BROWSERTOOLS_BROWSER_PATH = opts.browserPath;
|
|
100
102
|
env.CHROME_PATH = opts.browserPath;
|
|
101
103
|
}
|
|
104
|
+
// Opt into a real on-screen Chromium window. The serve process reads this
|
|
105
|
+
// in `Driver::launch`; harmless if an older binary ignores it.
|
|
106
|
+
if (opts.headful) {
|
|
107
|
+
env.BROWSERTOOLS_HEADFUL = "1";
|
|
108
|
+
}
|
|
102
109
|
// Run the serve process in the project cwd so relative flow paths and the
|
|
103
110
|
// evidence store resolve the same way the user expects.
|
|
104
111
|
this.proc = spawn(binaryPath, ["serve", ...opts.serveArgs], {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browsertools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/browsertools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `browser_flow` and `browser_resume` tools.\n *\n * Unlike `webtools` (one-shot fetch/search subprocesses), `browsertools` drives\n * a *stateful* parent-in-the-loop protocol: a single long-lived `browsertools\n * serve` process holds the live Chromium and the paused-flow state in memory,\n * and the parent (hoocode) talks to it over newline-delimited JSON-RPC on\n * stdin/stdout. A flow can suspend mid-replay with `Outcome::NeedsParent`,\n * yielding a typed `ParentRequest` + `ResumeToken`; the parent reasons about it\n * (often by looking at a screenshot) and calls `flow_resume(token, response)` to\n * continue. See https://github.com/kolisachint/browsertools (contract.rs).\n *\n * Because that single serve process must survive *between* the `browser_flow`\n * call that starts the flow and the `browser_resume` call that answers a\n * `NeedsParent`, this module owns a process-wide {@link sessionRegistry} keyed by\n * `ResumeToken`. Each paused session keeps its serve client alive and is reaped\n * after an idle timeout so an abandoned flow never leaks a Chromium forever.\n *\n * This module owns:\n * - the persistent JSON-RPC serve client (id-correlated request/response),\n * - the locked contract types (mirrors browsertools `src/contract.rs`),\n * - the paused-session registry + lifecycle (idle reap, round cap, teardown),\n * - binary + browser-path resolution.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\n\n// ============================================================================\n// Contract types (locked against browsertools `src/contract.rs`)\n// ============================================================================\n\n/** Opaque token identifying a paused flow, returned with every `NeedsParent`. */\nexport type ResumeToken = string;\n\n/** Reference to bytes (e.g. a screenshot) the parent can fetch via `get_resource`. */\nexport type ResourceId = string;\n\n/**\n * A typed request the engine yields when replay hits ambiguity only an LLM can\n * resolve. The `request` discriminant matches the serde tag in contract.rs.\n * Fields beyond the discriminant are passed through verbatim to the model.\n */\nexport interface ParentRequest {\n\trequest: \"classify_state\" | \"verify_visual\" | \"extract_semantic\" | \"decide_next_action\" | \"reidentify_element\";\n\t/** Resource id of the screenshot captured at the suspension point, if any. */\n\tscreenshot_ref?: ResourceId;\n\t[key: string]: unknown;\n}\n\n/** Result of a `flow_start` / `flow_resume` RPC (the `result` field's `outcome`). */\nexport type FlowOutcome =\n\t| { outcome: \"complete\"; result?: unknown }\n\t| { outcome: \"needs_parent\"; request: ParentRequest; token: ResumeToken }\n\t| { outcome: \"failed\"; step_id?: string; kind?: string; detail?: string };\n\n/** Result of a `get_resource` RPC. */\nexport interface GetResourceResult {\n\tref: ResourceId;\n\tmime: string;\n\tlen: number;\n\tpng_base64: string;\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\n/** Default per-request RPC timeout. Flow steps drive a real browser, so this is\n * generous relative to webtools' 15s. */\nexport const BROWSERTOOLS_REQUEST_TIMEOUT_MS = 120_000;\n\n/** Default idle window a paused flow may sit in the registry before it is reaped\n * (killing its serve process + Chromium). Reset on every resume round. The\n * parent may reason / look at a screenshot / make other tool calls between\n * rounds, so this is minutes, not seconds. */\nexport const BROWSERTOOLS_DEFAULT_IDLE_MS = 5 * 60 * 1000;\n\n/** Default hard cap on `NeedsParent` rounds for a single flow, bounding a runaway\n * suspend/resume loop. */\nexport const BROWSERTOOLS_DEFAULT_MAX_ROUNDS = 12;\n\n/** Options threaded into the browser tools by their factories (tests/overrides). */\nexport interface BrowsertoolsToolOptions {\n\t/** Explicit path to the `browsertools` binary; otherwise resolved via {@link ensureTool}. */\n\tbinaryPath?: string;\n\t/** Chromium/Chrome executable path forwarded to the serve process. */\n\tbrowserPath?: string;\n\t/** Idle reap window for paused sessions (ms). */\n\tidleTimeoutMs?: number;\n\t/** Hard cap on NeedsParent rounds per flow. */\n\tmaxParentRounds?: number;\n\t/** Per-request RPC timeout (ms). */\n\trequestTimeoutMs?: number;\n\t/** Extra args appended after `serve` (mainly for tests). */\n\tserveArgs?: string[];\n}\n\nfunction envNumber(name: string): number | undefined {\n\tconst raw = process.env[name];\n\tif (!raw) return undefined;\n\tconst n = Number(raw);\n\treturn Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/** Resolve effective options from explicit overrides, then env, then defaults. */\nexport function resolveBrowsertoolsOptions(options?: BrowsertoolsToolOptions): Required<\n\tPick<BrowsertoolsToolOptions, \"idleTimeoutMs\" | \"maxParentRounds\" | \"requestTimeoutMs\">\n> & {\n\tbinaryPath?: string;\n\tbrowserPath?: string;\n\tserveArgs: string[];\n} {\n\treturn {\n\t\tbinaryPath: options?.binaryPath,\n\t\tbrowserPath: options?.browserPath ?? (process.env.HOOCODE_BROWSERTOOLS_BROWSER_PATH?.trim() || undefined),\n\t\tidleTimeoutMs:\n\t\t\toptions?.idleTimeoutMs ?? envNumber(\"HOOCODE_BROWSERTOOLS_IDLE_MS\") ?? BROWSERTOOLS_DEFAULT_IDLE_MS,\n\t\tmaxParentRounds:\n\t\t\toptions?.maxParentRounds ?? envNumber(\"HOOCODE_BROWSERTOOLS_MAX_ROUNDS\") ?? BROWSERTOOLS_DEFAULT_MAX_ROUNDS,\n\t\trequestTimeoutMs:\n\t\t\toptions?.requestTimeoutMs ??\n\t\t\tenvNumber(\"HOOCODE_BROWSERTOOLS_REQUEST_TIMEOUT_MS\") ??\n\t\t\tBROWSERTOOLS_REQUEST_TIMEOUT_MS,\n\t\tserveArgs: options?.serveArgs ?? [],\n\t};\n}\n\nconst BINARY_MISSING_MESSAGE =\n\t\"browsertools binary unavailable and could not be downloaded — the browser tools require the `browsertools` CLI on PATH or a published release for this platform\";\n\n/** Resolve the `browsertools` binary path (explicit override, else download/PATH). */\nexport async function resolveBrowsertoolsBinary(options?: BrowsertoolsToolOptions): Promise<string> {\n\tif (options?.binaryPath) return options.binaryPath;\n\tconst binaryPath = await ensureTool(\"browsertools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\n// ============================================================================\n// Persistent JSON-RPC serve client\n// ============================================================================\n\ninterface PendingRequest {\n\tresolve: (value: unknown) => void;\n\treject: (error: Error) => void;\n\ttimer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * A live `browsertools serve` process spoken to over newline-delimited JSON-RPC.\n *\n * Wire format (browsertools `src/serve.rs`):\n * request: `{ \"id\": <n>, \"method\": <string>, \"params\": <object> }\\n`\n * response: `{ \"id\": <n>, \"result\": <value> }` or `{ \"id\": <n>, \"error\": <RpcError> }`\\n\n *\n * Responses are correlated by `id`, so concurrent requests are safe, though the\n * tools drive it sequentially.\n */\nexport class BrowsertoolsServeClient {\n\tprivate readonly proc: ChildProcess;\n\tprivate readonly pending = new Map<number, PendingRequest>();\n\tprivate readonly requestTimeoutMs: number;\n\tprivate buffer = \"\";\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate exitError: Error | undefined;\n\tprivate stderrTail = \"\";\n\n\tconstructor(\n\t\tbinaryPath: string,\n\t\topts: { cwd?: string; browserPath?: string; serveArgs: string[]; requestTimeoutMs: number },\n\t) {\n\t\tthis.requestTimeoutMs = opts.requestTimeoutMs;\n\t\t// The exact CLI flag for the browser path is not part of the locked RPC\n\t\t// contract; v0.1.3 added a \"configurable browser path\". We forward it via\n\t\t// env vars the binary is likely to read. Passing extra env is harmless if\n\t\t// the binary ignores them, and centralizing it here makes it a one-line\n\t\t// change once the exact mechanism is confirmed.\n\t\tconst env = { ...process.env };\n\t\tif (opts.browserPath) {\n\t\t\tenv.BROWSERTOOLS_BROWSER_PATH = opts.browserPath;\n\t\t\tenv.CHROME_PATH = opts.browserPath;\n\t\t}\n\t\t// Run the serve process in the project cwd so relative flow paths and the\n\t\t// evidence store resolve the same way the user expects.\n\t\tthis.proc = spawn(binaryPath, [\"serve\", ...opts.serveArgs], {\n\t\t\tcwd: opts.cwd,\n\t\t\tshell: false,\n\t\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t\tenv,\n\t\t});\n\t\tthis.proc.stdout?.setEncoding(\"utf8\");\n\t\tthis.proc.stdout?.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\tthis.proc.stderr?.setEncoding(\"utf8\");\n\t\tthis.proc.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t// Keep only a bounded tail so a chatty serve can't grow unbounded; it is\n\t\t\t// surfaced in the error when the process dies unexpectedly.\n\t\t\tthis.stderrTail = (this.stderrTail + chunk).slice(-4000);\n\t\t});\n\t\tthis.proc.on(\"error\", (err) => this.fail(err instanceof Error ? err : new Error(String(err))));\n\t\tthis.proc.on(\"exit\", (code, sig) => {\n\t\t\tif (this.closed) return;\n\t\t\tconst detail = this.stderrTail.trim();\n\t\t\tthis.fail(\n\t\t\t\tnew Error(\n\t\t\t\t\t`browsertools serve exited unexpectedly (code=${code ?? \"null\"}, signal=${sig ?? \"null\"})` +\n\t\t\t\t\t\t(detail ? `: ${detail}` : \"\"),\n\t\t\t\t),\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\tlet newlineIndex = this.buffer.indexOf(\"\\n\");\n\t\twhile (newlineIndex !== -1) {\n\t\t\tconst line = this.buffer.slice(0, newlineIndex).trim();\n\t\t\tthis.buffer = this.buffer.slice(newlineIndex + 1);\n\t\t\tif (line) this.onLine(line);\n\t\t\tnewlineIndex = this.buffer.indexOf(\"\\n\");\n\t\t}\n\t}\n\n\tprivate onLine(line: string): void {\n\t\tlet message: { id?: unknown; result?: unknown; error?: unknown };\n\t\ttry {\n\t\t\tmessage = JSON.parse(line);\n\t\t} catch {\n\t\t\t// Non-JSON noise on stdout (shouldn't happen; logs go to stderr). Skip it\n\t\t\t// rather than corrupt id correlation.\n\t\t\treturn;\n\t\t}\n\t\tconst id = typeof message.id === \"number\" ? message.id : undefined;\n\t\tif (id === undefined) return;\n\t\tconst entry = this.pending.get(id);\n\t\tif (!entry) return;\n\t\tthis.pending.delete(id);\n\t\tclearTimeout(entry.timer);\n\t\tif (message.error !== undefined && message.error !== null) {\n\t\t\tentry.reject(new Error(`browsertools ${describeRpcError(message.error)}`));\n\t\t} else {\n\t\t\tentry.resolve(message.result);\n\t\t}\n\t}\n\n\t/** Reject all in-flight requests and mark the client dead. Idempotent. */\n\tprivate fail(error: Error): void {\n\t\tif (this.closed) return;\n\t\tthis.exitError = error;\n\t\tfor (const [, entry] of this.pending) {\n\t\t\tclearTimeout(entry.timer);\n\t\t\tentry.reject(error);\n\t\t}\n\t\tthis.pending.clear();\n\t}\n\n\t/** Send a JSON-RPC request and resolve with its `result` (or reject on `error`). */\n\trequest<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n\t\tif (this.closed) {\n\t\t\treturn Promise.reject(this.exitError ?? new Error(\"browsertools serve client is closed\"));\n\t\t}\n\t\tconst id = this.nextId++;\n\t\treturn new Promise<T>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (this.pending.delete(id)) {\n\t\t\t\t\treject(new Error(`browsertools ${method} timed out after ${this.requestTimeoutMs}ms`));\n\t\t\t\t}\n\t\t\t}, this.requestTimeoutMs);\n\t\t\tthis.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });\n\t\t\tconst payload = `${JSON.stringify({ id, method, params })}\\n`;\n\t\t\tthis.proc.stdin?.write(payload, (err) => {\n\t\t\t\tif (err && this.pending.delete(id)) {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\t/** Kill the serve process and reject any outstanding requests. Idempotent. */\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.fail(new Error(\"browsertools serve client disposed\"));\n\t\tthis.closed = true;\n\t\t// Best-effort graceful shutdown, then ensure the process is gone.\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(`${JSON.stringify({ id: this.nextId++, method: \"shutdown\", params: {} })}\\n`);\n\t\t} catch {\n\t\t\t// stdin may already be closed; fall through to kill.\n\t\t}\n\t\tthis.proc.stdin?.end();\n\t\tthis.proc.kill(\"SIGTERM\");\n\t\tconst proc = this.proc;\n\t\tsetTimeout(() => {\n\t\t\tif (proc.exitCode === null && proc.signalCode === null) proc.kill(\"SIGKILL\");\n\t\t}, 3000).unref?.();\n\t}\n}\n\nfunction describeRpcError(error: unknown): string {\n\tif (error && typeof error === \"object\") {\n\t\tconst message = (error as { message?: unknown }).message;\n\t\tif (typeof message === \"string\" && message) return message;\n\t}\n\tif (typeof error === \"string\" && error) return error;\n\treturn `RPC error: ${JSON.stringify(error)}`;\n}\n\n// ============================================================================\n// Paused-session registry (process-wide, keyed by ResumeToken)\n// ============================================================================\n\ninterface PausedSession {\n\tclient: BrowsertoolsServeClient;\n\t/** Number of NeedsParent rounds yielded so far for this flow. */\n\trounds: number;\n\tidleTimer: ReturnType<typeof setTimeout>;\n\tidleTimeoutMs: number;\n}\n\nconst sessionRegistry = new Map<ResumeToken, PausedSession>();\n\n/**\n * Park a paused flow's live serve client under its resume token and arm the idle\n * reaper. Any previous entry for the token is disposed first.\n */\nexport function parkSession(\n\ttoken: ResumeToken,\n\tclient: BrowsertoolsServeClient,\n\trounds: number,\n\tidleTimeoutMs: number,\n): void {\n\tconst existing = sessionRegistry.get(token);\n\tif (existing && existing.client !== client) {\n\t\tclearTimeout(existing.idleTimer);\n\t\texisting.client.dispose();\n\t}\n\tconst idleTimer = setTimeout(() => {\n\t\tconst session = sessionRegistry.get(token);\n\t\tif (session) {\n\t\t\tsessionRegistry.delete(token);\n\t\t\tsession.client.dispose();\n\t\t}\n\t}, idleTimeoutMs);\n\tidleTimer.unref?.();\n\tsessionRegistry.set(token, { client, rounds, idleTimer, idleTimeoutMs });\n}\n\n/**\n * Remove a paused session from the registry and return it, stopping its idle\n * timer. The caller now owns the client (to resume or dispose). Returns\n * undefined if the token is unknown (expired/reaped/already completed).\n */\nexport function takeSession(token: ResumeToken): PausedSession | undefined {\n\tconst session = sessionRegistry.get(token);\n\tif (!session) return undefined;\n\tclearTimeout(session.idleTimer);\n\tsessionRegistry.delete(token);\n\treturn session;\n}\n\n/** Dispose every parked session. Used on shutdown and in tests. */\nexport function disposeAllSessions(): void {\n\tfor (const [, session] of sessionRegistry) {\n\t\tclearTimeout(session.idleTimer);\n\t\tsession.client.dispose();\n\t}\n\tsessionRegistry.clear();\n}\n\n/** Number of currently parked sessions (for tests/diagnostics). */\nexport function pausedSessionCount(): number {\n\treturn sessionRegistry.size;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"browsertools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/browsertools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `browser_flow` and `browser_resume` tools.\n *\n * Unlike `webtools` (one-shot fetch/search subprocesses), `browsertools` drives\n * a *stateful* parent-in-the-loop protocol: a single long-lived `browsertools\n * serve` process holds the live Chromium and the paused-flow state in memory,\n * and the parent (hoocode) talks to it over newline-delimited JSON-RPC on\n * stdin/stdout. A flow can suspend mid-replay with `Outcome::NeedsParent`,\n * yielding a typed `ParentRequest` + `ResumeToken`; the parent reasons about it\n * (often by looking at a screenshot) and calls `flow_resume(token, response)` to\n * continue. See https://github.com/kolisachint/browsertools (contract.rs).\n *\n * Because that single serve process must survive *between* the `browser_flow`\n * call that starts the flow and the `browser_resume` call that answers a\n * `NeedsParent`, this module owns a process-wide {@link sessionRegistry} keyed by\n * `ResumeToken`. Each paused session keeps its serve client alive and is reaped\n * after an idle timeout so an abandoned flow never leaks a Chromium forever.\n *\n * This module owns:\n * - the persistent JSON-RPC serve client (id-correlated request/response),\n * - the locked contract types (mirrors browsertools `src/contract.rs`),\n * - the paused-session registry + lifecycle (idle reap, round cap, teardown),\n * - binary + browser-path resolution.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\n\n// ============================================================================\n// Contract types (locked against browsertools `src/contract.rs`)\n// ============================================================================\n\n/** Opaque token identifying a paused flow, returned with every `NeedsParent`. */\nexport type ResumeToken = string;\n\n/** Reference to bytes (e.g. a screenshot) the parent can fetch via `get_resource`. */\nexport type ResourceId = string;\n\n/**\n * A typed request the engine yields when replay hits ambiguity only an LLM can\n * resolve. The `request` discriminant matches the serde tag in contract.rs.\n * Fields beyond the discriminant are passed through verbatim to the model.\n */\nexport interface ParentRequest {\n\trequest: \"classify_state\" | \"verify_visual\" | \"extract_semantic\" | \"decide_next_action\" | \"reidentify_element\";\n\t/** Resource id of the screenshot captured at the suspension point, if any. */\n\tscreenshot_ref?: ResourceId;\n\t[key: string]: unknown;\n}\n\n/** Result of a `flow_start` / `flow_resume` RPC (the `result` field's `outcome`). */\nexport type FlowOutcome =\n\t| { outcome: \"complete\"; result?: unknown }\n\t| { outcome: \"needs_parent\"; request: ParentRequest; token: ResumeToken }\n\t| { outcome: \"failed\"; step_id?: string; kind?: string; detail?: string };\n\n/** Result of a `get_resource` RPC. */\nexport interface GetResourceResult {\n\tref: ResourceId;\n\tmime: string;\n\tlen: number;\n\tpng_base64: string;\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\n/** Default per-request RPC timeout. Flow steps drive a real browser, so this is\n * generous relative to webtools' 15s. */\nexport const BROWSERTOOLS_REQUEST_TIMEOUT_MS = 120_000;\n\n/** Default idle window a paused flow may sit in the registry before it is reaped\n * (killing its serve process + Chromium). Reset on every resume round. The\n * parent may reason / look at a screenshot / make other tool calls between\n * rounds, so this is minutes, not seconds. */\nexport const BROWSERTOOLS_DEFAULT_IDLE_MS = 5 * 60 * 1000;\n\n/** Default hard cap on `NeedsParent` rounds for a single flow, bounding a runaway\n * suspend/resume loop. */\nexport const BROWSERTOOLS_DEFAULT_MAX_ROUNDS = 12;\n\n/** Options threaded into the browser tools by their factories (tests/overrides). */\nexport interface BrowsertoolsToolOptions {\n\t/** Explicit path to the `browsertools` binary; otherwise resolved via {@link ensureTool}. */\n\tbinaryPath?: string;\n\t/** Chromium/Chrome executable path forwarded to the serve process. */\n\tbrowserPath?: string;\n\t/** Idle reap window for paused sessions (ms). */\n\tidleTimeoutMs?: number;\n\t/** Hard cap on NeedsParent rounds per flow. */\n\tmaxParentRounds?: number;\n\t/** Per-request RPC timeout (ms). */\n\trequestTimeoutMs?: number;\n\t/** Extra args appended after `serve` (mainly for tests). */\n\tserveArgs?: string[];\n\t/** Launch a real on-screen Chromium window instead of headless (sets\n\t * `BROWSERTOOLS_HEADFUL=1` on the serve process). */\n\theadful?: boolean;\n\t/** Default the streamed live viewer on for every flow (the per-call `live_view`\n\t * param still overrides). Set by the --enable-browser-live-preview flag. */\n\tliveView?: boolean;\n}\n\nfunction envNumber(name: string): number | undefined {\n\tconst raw = process.env[name];\n\tif (!raw) return undefined;\n\tconst n = Number(raw);\n\treturn Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/** Resolve effective options from explicit overrides, then env, then defaults. */\nexport function resolveBrowsertoolsOptions(options?: BrowsertoolsToolOptions): Required<\n\tPick<BrowsertoolsToolOptions, \"idleTimeoutMs\" | \"maxParentRounds\" | \"requestTimeoutMs\">\n> & {\n\tbinaryPath?: string;\n\tbrowserPath?: string;\n\tserveArgs: string[];\n\theadful: boolean;\n\tliveView: boolean;\n} {\n\treturn {\n\t\tbinaryPath: options?.binaryPath,\n\t\tbrowserPath: options?.browserPath ?? (process.env.HOOCODE_BROWSERTOOLS_BROWSER_PATH?.trim() || undefined),\n\t\theadful: options?.headful ?? false,\n\t\tliveView: options?.liveView ?? false,\n\t\tidleTimeoutMs:\n\t\t\toptions?.idleTimeoutMs ?? envNumber(\"HOOCODE_BROWSERTOOLS_IDLE_MS\") ?? BROWSERTOOLS_DEFAULT_IDLE_MS,\n\t\tmaxParentRounds:\n\t\t\toptions?.maxParentRounds ?? envNumber(\"HOOCODE_BROWSERTOOLS_MAX_ROUNDS\") ?? BROWSERTOOLS_DEFAULT_MAX_ROUNDS,\n\t\trequestTimeoutMs:\n\t\t\toptions?.requestTimeoutMs ??\n\t\t\tenvNumber(\"HOOCODE_BROWSERTOOLS_REQUEST_TIMEOUT_MS\") ??\n\t\t\tBROWSERTOOLS_REQUEST_TIMEOUT_MS,\n\t\tserveArgs: options?.serveArgs ?? [],\n\t};\n}\n\nconst BINARY_MISSING_MESSAGE =\n\t\"browsertools binary unavailable and could not be downloaded — the browser tools require the `browsertools` CLI on PATH or a published release for this platform\";\n\n/** Resolve the `browsertools` binary path (explicit override, else download/PATH). */\nexport async function resolveBrowsertoolsBinary(options?: BrowsertoolsToolOptions): Promise<string> {\n\tif (options?.binaryPath) return options.binaryPath;\n\tconst binaryPath = await ensureTool(\"browsertools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\n// ============================================================================\n// Persistent JSON-RPC serve client\n// ============================================================================\n\ninterface PendingRequest {\n\tresolve: (value: unknown) => void;\n\treject: (error: Error) => void;\n\ttimer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * A live `browsertools serve` process spoken to over newline-delimited JSON-RPC.\n *\n * Wire format (browsertools `src/serve.rs`):\n * request: `{ \"id\": <n>, \"method\": <string>, \"params\": <object> }\\n`\n * response: `{ \"id\": <n>, \"result\": <value> }` or `{ \"id\": <n>, \"error\": <RpcError> }`\\n\n *\n * Responses are correlated by `id`, so concurrent requests are safe, though the\n * tools drive it sequentially.\n */\nexport class BrowsertoolsServeClient {\n\tprivate readonly proc: ChildProcess;\n\tprivate readonly pending = new Map<number, PendingRequest>();\n\tprivate readonly requestTimeoutMs: number;\n\tprivate buffer = \"\";\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate exitError: Error | undefined;\n\tprivate stderrTail = \"\";\n\n\tconstructor(\n\t\tbinaryPath: string,\n\t\topts: {\n\t\t\tcwd?: string;\n\t\t\tbrowserPath?: string;\n\t\t\tserveArgs: string[];\n\t\t\trequestTimeoutMs: number;\n\t\t\theadful?: boolean;\n\t\t},\n\t) {\n\t\tthis.requestTimeoutMs = opts.requestTimeoutMs;\n\t\t// The exact CLI flag for the browser path is not part of the locked RPC\n\t\t// contract; v0.1.3 added a \"configurable browser path\". We forward it via\n\t\t// env vars the binary is likely to read. Passing extra env is harmless if\n\t\t// the binary ignores them, and centralizing it here makes it a one-line\n\t\t// change once the exact mechanism is confirmed.\n\t\tconst env = { ...process.env };\n\t\tif (opts.browserPath) {\n\t\t\tenv.BROWSERTOOLS_BROWSER_PATH = opts.browserPath;\n\t\t\tenv.CHROME_PATH = opts.browserPath;\n\t\t}\n\t\t// Opt into a real on-screen Chromium window. The serve process reads this\n\t\t// in `Driver::launch`; harmless if an older binary ignores it.\n\t\tif (opts.headful) {\n\t\t\tenv.BROWSERTOOLS_HEADFUL = \"1\";\n\t\t}\n\t\t// Run the serve process in the project cwd so relative flow paths and the\n\t\t// evidence store resolve the same way the user expects.\n\t\tthis.proc = spawn(binaryPath, [\"serve\", ...opts.serveArgs], {\n\t\t\tcwd: opts.cwd,\n\t\t\tshell: false,\n\t\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t\tenv,\n\t\t});\n\t\tthis.proc.stdout?.setEncoding(\"utf8\");\n\t\tthis.proc.stdout?.on(\"data\", (chunk: string) => this.onStdout(chunk));\n\t\tthis.proc.stderr?.setEncoding(\"utf8\");\n\t\tthis.proc.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t// Keep only a bounded tail so a chatty serve can't grow unbounded; it is\n\t\t\t// surfaced in the error when the process dies unexpectedly.\n\t\t\tthis.stderrTail = (this.stderrTail + chunk).slice(-4000);\n\t\t});\n\t\tthis.proc.on(\"error\", (err) => this.fail(err instanceof Error ? err : new Error(String(err))));\n\t\tthis.proc.on(\"exit\", (code, sig) => {\n\t\t\tif (this.closed) return;\n\t\t\tconst detail = this.stderrTail.trim();\n\t\t\tthis.fail(\n\t\t\t\tnew Error(\n\t\t\t\t\t`browsertools serve exited unexpectedly (code=${code ?? \"null\"}, signal=${sig ?? \"null\"})` +\n\t\t\t\t\t\t(detail ? `: ${detail}` : \"\"),\n\t\t\t\t),\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate onStdout(chunk: string): void {\n\t\tthis.buffer += chunk;\n\t\tlet newlineIndex = this.buffer.indexOf(\"\\n\");\n\t\twhile (newlineIndex !== -1) {\n\t\t\tconst line = this.buffer.slice(0, newlineIndex).trim();\n\t\t\tthis.buffer = this.buffer.slice(newlineIndex + 1);\n\t\t\tif (line) this.onLine(line);\n\t\t\tnewlineIndex = this.buffer.indexOf(\"\\n\");\n\t\t}\n\t}\n\n\tprivate onLine(line: string): void {\n\t\tlet message: { id?: unknown; result?: unknown; error?: unknown };\n\t\ttry {\n\t\t\tmessage = JSON.parse(line);\n\t\t} catch {\n\t\t\t// Non-JSON noise on stdout (shouldn't happen; logs go to stderr). Skip it\n\t\t\t// rather than corrupt id correlation.\n\t\t\treturn;\n\t\t}\n\t\tconst id = typeof message.id === \"number\" ? message.id : undefined;\n\t\tif (id === undefined) return;\n\t\tconst entry = this.pending.get(id);\n\t\tif (!entry) return;\n\t\tthis.pending.delete(id);\n\t\tclearTimeout(entry.timer);\n\t\tif (message.error !== undefined && message.error !== null) {\n\t\t\tentry.reject(new Error(`browsertools ${describeRpcError(message.error)}`));\n\t\t} else {\n\t\t\tentry.resolve(message.result);\n\t\t}\n\t}\n\n\t/** Reject all in-flight requests and mark the client dead. Idempotent. */\n\tprivate fail(error: Error): void {\n\t\tif (this.closed) return;\n\t\tthis.exitError = error;\n\t\tfor (const [, entry] of this.pending) {\n\t\t\tclearTimeout(entry.timer);\n\t\t\tentry.reject(error);\n\t\t}\n\t\tthis.pending.clear();\n\t}\n\n\t/** Send a JSON-RPC request and resolve with its `result` (or reject on `error`). */\n\trequest<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {\n\t\tif (this.closed) {\n\t\t\treturn Promise.reject(this.exitError ?? new Error(\"browsertools serve client is closed\"));\n\t\t}\n\t\tconst id = this.nextId++;\n\t\treturn new Promise<T>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (this.pending.delete(id)) {\n\t\t\t\t\treject(new Error(`browsertools ${method} timed out after ${this.requestTimeoutMs}ms`));\n\t\t\t\t}\n\t\t\t}, this.requestTimeoutMs);\n\t\t\tthis.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });\n\t\t\tconst payload = `${JSON.stringify({ id, method, params })}\\n`;\n\t\t\tthis.proc.stdin?.write(payload, (err) => {\n\t\t\t\tif (err && this.pending.delete(id)) {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\t/** Kill the serve process and reject any outstanding requests. Idempotent. */\n\tdispose(): void {\n\t\tif (this.closed) return;\n\t\tthis.fail(new Error(\"browsertools serve client disposed\"));\n\t\tthis.closed = true;\n\t\t// Best-effort graceful shutdown, then ensure the process is gone.\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(`${JSON.stringify({ id: this.nextId++, method: \"shutdown\", params: {} })}\\n`);\n\t\t} catch {\n\t\t\t// stdin may already be closed; fall through to kill.\n\t\t}\n\t\tthis.proc.stdin?.end();\n\t\tthis.proc.kill(\"SIGTERM\");\n\t\tconst proc = this.proc;\n\t\tsetTimeout(() => {\n\t\t\tif (proc.exitCode === null && proc.signalCode === null) proc.kill(\"SIGKILL\");\n\t\t}, 3000).unref?.();\n\t}\n}\n\nfunction describeRpcError(error: unknown): string {\n\tif (error && typeof error === \"object\") {\n\t\tconst message = (error as { message?: unknown }).message;\n\t\tif (typeof message === \"string\" && message) return message;\n\t}\n\tif (typeof error === \"string\" && error) return error;\n\treturn `RPC error: ${JSON.stringify(error)}`;\n}\n\n// ============================================================================\n// Paused-session registry (process-wide, keyed by ResumeToken)\n// ============================================================================\n\ninterface PausedSession {\n\tclient: BrowsertoolsServeClient;\n\t/** Number of NeedsParent rounds yielded so far for this flow. */\n\trounds: number;\n\tidleTimer: ReturnType<typeof setTimeout>;\n\tidleTimeoutMs: number;\n}\n\nconst sessionRegistry = new Map<ResumeToken, PausedSession>();\n\n/**\n * Park a paused flow's live serve client under its resume token and arm the idle\n * reaper. Any previous entry for the token is disposed first.\n */\nexport function parkSession(\n\ttoken: ResumeToken,\n\tclient: BrowsertoolsServeClient,\n\trounds: number,\n\tidleTimeoutMs: number,\n): void {\n\tconst existing = sessionRegistry.get(token);\n\tif (existing && existing.client !== client) {\n\t\tclearTimeout(existing.idleTimer);\n\t\texisting.client.dispose();\n\t}\n\tconst idleTimer = setTimeout(() => {\n\t\tconst session = sessionRegistry.get(token);\n\t\tif (session) {\n\t\t\tsessionRegistry.delete(token);\n\t\t\tsession.client.dispose();\n\t\t}\n\t}, idleTimeoutMs);\n\tidleTimer.unref?.();\n\tsessionRegistry.set(token, { client, rounds, idleTimer, idleTimeoutMs });\n}\n\n/**\n * Remove a paused session from the registry and return it, stopping its idle\n * timer. The caller now owns the client (to resume or dispose). Returns\n * undefined if the token is unknown (expired/reaped/already completed).\n */\nexport function takeSession(token: ResumeToken): PausedSession | undefined {\n\tconst session = sessionRegistry.get(token);\n\tif (!session) return undefined;\n\tclearTimeout(session.idleTimer);\n\tsessionRegistry.delete(token);\n\treturn session;\n}\n\n/** Dispose every parked session. Used on shutdown and in tests. */\nexport function disposeAllSessions(): void {\n\tfor (const [, session] of sessionRegistry) {\n\t\tclearTimeout(session.idleTimer);\n\t\tsession.client.dispose();\n\t}\n\tsessionRegistry.clear();\n}\n\n/** Number of currently parked sessions (for tests/diagnostics). */\nexport function pausedSessionCount(): number {\n\treturn sessionRegistry.size;\n}\n"]}
|
package/dist/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuBH,OAAO,KAAK,EAAgB,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AA6ejF,MAAM,WAAW,WAAW;IAC3B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,CAAC;CACxC;AAED,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,iBAuZ/D","sourcesContent":["/**\n * Main entry point for the coding agent CLI.\n *\n * This file handles CLI argument parsing and translates them into\n * createAgentSession() options. The SDK does the heavy lifting.\n */\n\nimport { resolve } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport { type ImageContent, modelsAreEqual } from \"@kolisachint/hoocode-ai\";\nimport { ProcessTerminal, setKeybindings, TUI } from \"@kolisachint/hoocode-tui\";\nimport chalk from \"chalk\";\nimport { type Args, type Mode, parseArgs, printHelp } from \"./cli/args.js\";\nimport { processFileArguments } from \"./cli/file-processor.js\";\nimport { buildInitialMessage } from \"./cli/initial-message.js\";\nimport { listModels } from \"./cli/list-models.js\";\nimport { selectSession } from \"./cli/session-picker.js\";\nimport { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from \"./config.js\";\nimport { setAgentCliPaths } from \"./core/agent-manifest-paths.js\";\nimport { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from \"./core/agent-session-runtime.js\";\nimport {\n\ttype AgentSessionRuntimeDiagnostic,\n\tcreateAgentSessionFromServices,\n\tcreateAgentSessionServices,\n} from \"./core/agent-session-services.js\";\nimport { formatNoModelsAvailableMessage } from \"./core/auth-guidance.js\";\nimport { AuthStorage } from \"./core/auth-storage.js\";\nimport { exportFromFile } from \"./core/export-html/index.js\";\nimport type { ExtensionAPI, ExtensionFactory } from \"./core/extensions/types.js\";\nimport { KeybindingsManager } from \"./core/keybindings.js\";\nimport type { ModelRegistry } from \"./core/model-registry.js\";\nimport { resolveCliModel, resolveModelScope, type ScopedModel } from \"./core/model-resolver.js\";\nimport { restoreStdout, takeOverStdout } from \"./core/output-guard.js\";\nimport type { CreateAgentSessionOptions } from \"./core/sdk.js\";\nimport {\n\tformatMissingSessionCwdPrompt,\n\tgetMissingSessionCwdIssue,\n\tMissingSessionCwdError,\n\ttype SessionCwdIssue,\n} from \"./core/session-cwd.js\";\nimport { SessionManager } from \"./core/session-manager.js\";\nimport { SettingsManager } from \"./core/settings-manager.js\";\nimport {\n\tcanSpawnSubagent,\n\tDELEGATE_ALLOW_ENV,\n\tNESTED_CONCURRENCY_ENV,\n\tresolveMaxSubagentDepth,\n\tresolveNestedConcurrency,\n\tSUBAGENT_MAX_DEPTH_ENV,\n} from \"./core/subagent-depth.js\";\nimport { printTimings, resetTimings, time } from \"./core/timings.js\";\nimport {\n\tbuildTaskMainPrompt,\n\tcreateTaskOutputToolDefinition,\n\tcreateTaskToolDefinition,\n} from \"./core/tools/subagent.js\";\nimport { createTodoWriteToolDefinition } from \"./core/tools/todo.js\";\nimport { WARM_SUBAGENTS_ENV } from \"./core/warm-subagent-pool-instance.js\";\nimport { runMigrations, showDeprecationWarnings } from \"./migrations.js\";\nimport { InteractiveMode, runPrintMode, runRpcMode } from \"./modes/index.js\";\nimport { ExtensionSelectorComponent } from \"./modes/interactive/components/extension-selector.js\";\nimport { initTheme, stopThemeWatcher } from \"./modes/interactive/theme/theme.js\";\nimport { handleConfigCommand, handlePackageCommand } from \"./package-manager-cli.js\";\nimport { handleResourcesCommand } from \"./resources-cli.js\";\nimport { isLocalPath } from \"./utils/paths.js\";\n\n/**\n * Read all content from piped stdin.\n * Returns undefined if stdin is a TTY (interactive terminal).\n */\nasync function readPipedStdin(): Promise<string | undefined> {\n\t// If stdin is a TTY, we're running interactively - don't read stdin\n\tif (process.stdin.isTTY) {\n\t\treturn undefined;\n\t}\n\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tprocess.stdin.on(\"end\", () => {\n\t\t\tresolve(data.trim() || undefined);\n\t\t});\n\t\tprocess.stdin.resume();\n\t});\n}\n\nfunction collectSettingsDiagnostics(\n\tsettingsManager: SettingsManager,\n\tcontext: string,\n): AgentSessionRuntimeDiagnostic[] {\n\treturn settingsManager.drainErrors().map(({ scope, error }) => ({\n\t\ttype: \"warning\",\n\t\tmessage: `(${context}, ${scope} settings) ${error.message}`,\n\t}));\n}\n\nfunction reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {\n\tfor (const diagnostic of diagnostics) {\n\t\tconst color = diagnostic.type === \"error\" ? chalk.red : diagnostic.type === \"warning\" ? chalk.yellow : chalk.dim;\n\t\tconst prefix = diagnostic.type === \"error\" ? \"Error: \" : diagnostic.type === \"warning\" ? \"Warning: \" : \"\";\n\t\tconsole.error(color(`${prefix}${diagnostic.message}`));\n\t}\n}\n\nfunction isTruthyEnvFlag(value: string | undefined): boolean {\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\ntype AppMode = \"interactive\" | \"print\" | \"json\" | \"rpc\";\n\nfunction resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {\n\tif (parsed.mode === \"rpc\") {\n\t\treturn \"rpc\";\n\t}\n\tif (parsed.mode === \"json\") {\n\t\treturn \"json\";\n\t}\n\tif (parsed.print || !stdinIsTTY) {\n\t\treturn \"print\";\n\t}\n\treturn \"interactive\";\n}\n\nfunction toPrintOutputMode(appMode: AppMode): Exclude<Mode, \"rpc\"> {\n\treturn appMode === \"json\" ? \"json\" : \"text\";\n}\n\nasync function prepareInitialMessage(\n\tparsed: Args,\n\tautoResizeImages: boolean,\n\tstdinContent?: string,\n): Promise<{\n\tinitialMessage?: string;\n\tinitialImages?: ImageContent[];\n}> {\n\tif (parsed.fileArgs.length === 0) {\n\t\treturn buildInitialMessage({ parsed, stdinContent });\n\t}\n\n\tconst { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });\n\treturn buildInitialMessage({\n\t\tparsed,\n\t\tfileText: text,\n\t\tfileImages: images,\n\t\tstdinContent,\n\t});\n}\n\n/** Result from resolving a session argument */\ntype ResolvedSession =\n\t| { type: \"path\"; path: string } // Direct file path\n\t| { type: \"local\"; path: string } // Found in current project\n\t| { type: \"global\"; path: string; cwd: string } // Found in different project\n\t| { type: \"not_found\"; arg: string }; // Not found anywhere\n\n/**\n * Resolve a session argument to a file path.\n * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.\n */\nasync function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {\n\t// If it looks like a file path, use as-is\n\tif (sessionArg.includes(\"/\") || sessionArg.includes(\"\\\\\") || sessionArg.endsWith(\".jsonl\")) {\n\t\treturn { type: \"path\", path: sessionArg };\n\t}\n\n\t// Try to match as session ID in current project first\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatches = localSessions.filter((s) => s.id.startsWith(sessionArg));\n\n\tif (localMatches.length >= 1) {\n\t\treturn { type: \"local\", path: localMatches[0].path };\n\t}\n\n\t// Try global search across all projects\n\tconst allSessions = await SessionManager.listAll();\n\tconst globalMatches = allSessions.filter((s) => s.id.startsWith(sessionArg));\n\n\tif (globalMatches.length >= 1) {\n\t\tconst match = globalMatches[0];\n\t\treturn { type: \"global\", path: match.path, cwd: match.cwd };\n\t}\n\n\t// Not found anywhere\n\treturn { type: \"not_found\", arg: sessionArg };\n}\n\n/** Prompt user for yes/no confirmation */\nasync function promptConfirm(message: string): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst rl = createInterface({\n\t\t\tinput: process.stdin,\n\t\t\toutput: process.stdout,\n\t\t});\n\t\trl.question(`${message} [y/N] `, (answer) => {\n\t\t\trl.close();\n\t\t\tresolve(answer.toLowerCase() === \"y\" || answer.toLowerCase() === \"yes\");\n\t\t});\n\t});\n}\n\nfunction validateForkFlags(parsed: Args): void {\n\tif (!parsed.fork) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t\tparsed.noSession ? \"--no-session\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.forkFrom(sourcePath, cwd, sessionDir);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nasync function createSessionManager(\n\tparsed: Args,\n\tcwd: string,\n\tsessionDir: string | undefined,\n\tsettingsManager: SettingsManager,\n): Promise<SessionManager> {\n\tif (parsed.noSession) {\n\t\treturn SessionManager.inMemory();\n\t}\n\n\tif (parsed.fork) {\n\t\tconst resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\tcase \"global\":\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.session) {\n\t\tconst resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\t\treturn SessionManager.open(resolved.path, sessionDir);\n\n\t\t\tcase \"global\": {\n\t\t\t\tconsole.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));\n\t\t\t\tconst shouldFork = await promptConfirm(\"Fork this session into current directory?\");\n\t\t\t\tif (!shouldFork) {\n\t\t\t\t\tconsole.log(chalk.dim(\"Aborted.\"));\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\t\t\t}\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.resume) {\n\t\tinitTheme(settingsManager.getTheme(), true);\n\t\ttry {\n\t\t\tconst selectedPath = await selectSession(\n\t\t\t\t(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),\n\t\t\t\tSessionManager.listAll,\n\t\t\t);\n\t\t\tif (!selectedPath) {\n\t\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\treturn SessionManager.open(selectedPath, sessionDir);\n\t\t} finally {\n\t\t\tstopThemeWatcher();\n\t\t}\n\t}\n\n\tif (parsed.continue) {\n\t\treturn SessionManager.continueRecent(cwd, sessionDir);\n\t}\n\n\treturn SessionManager.create(cwd, sessionDir);\n}\n\nfunction buildSessionOptions(\n\tparsed: Args,\n\tscopedModels: ScopedModel[],\n\thasExistingSession: boolean,\n\tmodelRegistry: ModelRegistry,\n\tsettingsManager: SettingsManager,\n): {\n\toptions: CreateAgentSessionOptions;\n\tcliThinkingFromModel: boolean;\n\tdiagnostics: AgentSessionRuntimeDiagnostic[];\n} {\n\tconst options: CreateAgentSessionOptions = {};\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\tlet cliThinkingFromModel = false;\n\n\t// Model from CLI\n\t// - supports --provider <name> --model <pattern>\n\t// - supports --model <provider>/<pattern>\n\tif (parsed.model) {\n\t\tconst resolved = resolveCliModel({\n\t\t\tcliProvider: parsed.provider,\n\t\t\tcliModel: parsed.model,\n\t\t\tmodelRegistry,\n\t\t});\n\t\tif (resolved.warning) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: resolved.warning });\n\t\t}\n\t\tif (resolved.error) {\n\t\t\tdiagnostics.push({ type: \"error\", message: resolved.error });\n\t\t}\n\t\tif (resolved.model) {\n\t\t\toptions.model = resolved.model;\n\t\t\t// Allow \"--model <pattern>:<thinking>\" as a shorthand.\n\t\t\t// Explicit --thinking still takes precedence (applied later).\n\t\t\tif (!parsed.thinking && resolved.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = resolved.thinkingLevel;\n\t\t\t\tcliThinkingFromModel = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!options.model && scopedModels.length > 0 && !hasExistingSession) {\n\t\t// Check if saved default is in scoped models - use it if so, otherwise first scoped model\n\t\tconst savedProvider = settingsManager.getDefaultProvider();\n\t\tconst savedModelId = settingsManager.getDefaultModel();\n\t\tconst savedModel = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined;\n\t\tconst savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;\n\n\t\tif (savedInScope) {\n\t\t\toptions.model = savedInScope.model;\n\t\t\t// Use thinking level from scoped model config if explicitly set\n\t\t\tif (!parsed.thinking && savedInScope.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = savedInScope.thinkingLevel;\n\t\t\t}\n\t\t} else {\n\t\t\toptions.model = scopedModels[0].model;\n\t\t\t// Use thinking level from first scoped model if explicitly set\n\t\t\tif (!parsed.thinking && scopedModels[0].thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = scopedModels[0].thinkingLevel;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Thinking level from CLI (takes precedence over scoped model thinking levels set above)\n\tif (parsed.thinking) {\n\t\toptions.thinkingLevel = parsed.thinking;\n\t}\n\n\t// Scoped models for Ctrl+P cycling\n\t// Keep thinking level undefined when not explicitly set in the model pattern.\n\t// Undefined means \"inherit current session thinking level\" during cycling.\n\tif (scopedModels.length > 0) {\n\t\toptions.scopedModels = scopedModels.map((sm) => ({\n\t\t\tmodel: sm.model,\n\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t}));\n\t}\n\n\t// API key from CLI - set in authStorage\n\t// (handled by caller before createAgentSession)\n\n\t// Tools\n\tif (parsed.noTools) {\n\t\toptions.noTools = \"all\";\n\t} else if (parsed.noBuiltinTools) {\n\t\toptions.noTools = \"builtin\";\n\t}\n\tif (parsed.tools) {\n\t\toptions.tools = [...parsed.tools];\n\t}\n\tif (parsed.disallowedTools) {\n\t\toptions.disallowedTools = [...parsed.disallowedTools];\n\t}\n\t// Web tools (webfetch + websearch): opt-in via --enable-webtools flag or the\n\t// enableWebTools setting. They are registered as base tools but inactive by\n\t// default; this adds them to the default active set.\n\tif (parsed.enableWebTools ?? settingsManager.getEnableWebTools()) {\n\t\toptions.enableWebTools = true;\n\t}\n\t// Browser tools (browser_flow + browser_resume): opt-in via --enable-browsertools\n\t// flag or the enableBrowserTools setting. Registered as base tools but inactive by\n\t// default; this adds them to the default active set.\n\tif (parsed.enableBrowserTools ?? settingsManager.getEnableBrowserTools()) {\n\t\toptions.enableBrowserTools = true;\n\t}\n\t// Document tools (DocRead/DocEdit/DocWrite + the DocScan/DocGrep/DocPeek\n\t// discovery loop): opt-in via --enable-filetools flag or the enableFileTools\n\t// setting. Registered as base tools but inactive by default; this adds them to\n\t// the default active set.\n\tif (parsed.enableFileTools ?? settingsManager.getEnableFileTools()) {\n\t\toptions.enableFileTools = true;\n\t\t// DocRead/DocEdit/DocWrite drive a lossless id-based extract -> patch ->\n\t\t// reconstruct flow that depends on precise, well-formed tool calls. Models\n\t\t// that are weak at tool calling tend to mangle the id-based patches and\n\t\t// corrupt documents, so surface a one-time heads-up when these are enabled.\n\t\tdiagnostics.push({\n\t\t\ttype: \"warning\",\n\t\t\tmessage:\n\t\t\t\t\"Document tools (DocRead/DocEdit/DocWrite) are enabled. They require precise, id-based patches; \" +\n\t\t\t\t\"use a model that is strong at tool calling, or these edits can corrupt files.\",\n\t\t});\n\t}\n\n\t// Optional Task (subagent) tool: opt-in via --enable-subagents flag or the enableSubagent setting.\n\t// Registered as a custom tool; respects --tools/--no-tools allowlists like any other tool.\n\t//\n\t// Nesting is bounded by the tree-wide cap (maxSubagentDepth, default 1). The root\n\t// seeds the cap into the environment so every descendant agrees on one value; the\n\t// Task tool is registered only while this process's depth is below that cap. At the\n\t// default cap this reproduces the original guard exactly: subagents (depth >= 1) get\n\t// no Task tool and cannot recursively dispatch.\n\tconst isSubagentChild = parsed.taskId !== undefined;\n\tif (process.env[SUBAGENT_MAX_DEPTH_ENV] === undefined) {\n\t\t// The root seeds the tree-wide cap; the --max-subagent-depth flag overrides the\n\t\t// setting. resolveMaxSubagentDepth clamps it to the supported range so the seeded\n\t\t// env (and everything that inherits it) carries a sane value. Descendants inherit\n\t\t// it via the environment (env already set => keep it).\n\t\tprocess.env[SUBAGENT_MAX_DEPTH_ENV] = String(\n\t\t\tresolveMaxSubagentDepth(parsed.maxSubagentDepth ?? settingsManager.getMaxSubagentDepth()),\n\t\t);\n\t}\n\tif (process.env[NESTED_CONCURRENCY_ENV] === undefined) {\n\t\t// Seed the nested-pool concurrency from settings so descendants agree on one value.\n\t\tprocess.env[NESTED_CONCURRENCY_ENV] = String(\n\t\t\tresolveNestedConcurrency(settingsManager.getNestedSubagentConcurrency()),\n\t\t);\n\t}\n\t// Scoped delegation: --delegate-allow is the authoritative restriction for this\n\t// process. Set it from the flag, or clear any inherited value so a restricted\n\t// parent's scope never leaks into a child that wasn't given its own.\n\tif (parsed.delegateAllow && parsed.delegateAllow.length > 0) {\n\t\tprocess.env[DELEGATE_ALLOW_ENV] = parsed.delegateAllow.join(\",\");\n\t} else {\n\t\tdelete process.env[DELEGATE_ALLOW_ENV];\n\t}\n\tif (canSpawnSubagent() && (parsed.subagent ?? settingsManager.getEnableSubagent())) {\n\t\toptions.customTools = [\n\t\t\t...(options.customTools ?? []),\n\t\t\tcreateTaskToolDefinition(),\n\t\t\tcreateTaskOutputToolDefinition(),\n\t\t];\n\t\t// Warm subagents (experimental): dispatch eligible foreground subagents on\n\t\t// reused RPC workers to skip the cold-boot. Root-only — the Task tool exists\n\t\t// only where canSpawnSubagent holds and never inside a spawned child — and\n\t\t// carried via env so the Task tool reads it without threading a setting.\n\t\tif (!isSubagentChild && (parsed.warmSubagents ?? settingsManager.getWarmSubagents())) {\n\t\t\tprocess.env[WARM_SUBAGENTS_ENV] = \"1\";\n\t\t}\n\t}\n\n\t// Optional TodoWrite tool: opt-in via --enable-todowrite flag or the\n\t// enableTodoWrite setting. Never registered inside a spawned subagent child —\n\t// its todos would otherwise leak into the parent's \"main\" task group in the pane.\n\tif (!isSubagentChild && (parsed.todoWrite ?? settingsManager.getEnableTodoWrite())) {\n\t\toptions.customTools = [...(options.customTools ?? []), createTodoWriteToolDefinition()];\n\t}\n\n\treturn { options, cliThinkingFromModel, diagnostics };\n}\n\nfunction resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {\n\treturn paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value));\n}\n\nasync function promptForMissingSessionCwd(\n\tissue: SessionCwdIssue,\n\tsettingsManager: SettingsManager,\n): Promise<string | undefined> {\n\tinitTheme(settingsManager.getTheme());\n\tsetKeybindings(KeybindingsManager.create());\n\n\treturn new Promise((resolve) => {\n\t\tconst ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());\n\t\tui.setClearOnShrink(settingsManager.getClearOnShrink());\n\n\t\tlet settled = false;\n\t\tconst finish = (result: string | undefined) => {\n\t\t\tif (settled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsettled = true;\n\t\t\tui.stop();\n\t\t\tresolve(result);\n\t\t};\n\n\t\tconst selector = new ExtensionSelectorComponent(\n\t\t\tformatMissingSessionCwdPrompt(issue),\n\t\t\t[\"Continue\", \"Cancel\"],\n\t\t\t(option) => finish(option === \"Continue\" ? issue.fallbackCwd : undefined),\n\t\t\t() => finish(undefined),\n\t\t\t{ tui: ui },\n\t\t);\n\t\tui.addChild(selector);\n\t\tui.setFocus(selector);\n\t\tui.start();\n\t});\n}\n\nexport interface MainOptions {\n\textensionFactories?: ExtensionFactory[];\n}\n\nexport async function main(args: string[], options?: MainOptions) {\n\tresetTimings();\n\tconst offlineMode =\n\t\targs.includes(\"--offline\") || isTruthyEnvFlag(process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE);\n\tif (offlineMode) {\n\t\tprocess.env.HOOCODE_OFFLINE = \"1\";\n\t\tprocess.env.HOOCODE_SKIP_VERSION_CHECK = \"1\";\n\t}\n\n\tif (await handlePackageCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await handleConfigCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await handleResourcesCommand(args)) {\n\t\treturn;\n\t}\n\n\tconst parsed = parseArgs(args);\n\tif (parsed.diagnostics.length > 0) {\n\t\tfor (const d of parsed.diagnostics) {\n\t\t\tconst color = d.type === \"error\" ? chalk.red : chalk.yellow;\n\t\t\tconsole.error(color(`${d.type === \"error\" ? \"Error\" : \"Warning\"}: ${d.message}`));\n\t\t}\n\t\tif (parsed.diagnostics.some((d) => d.type === \"error\")) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"parseArgs\");\n\tlet appMode = resolveAppMode(parsed, process.stdin.isTTY);\n\tconst shouldTakeOverStdout = appMode !== \"interactive\";\n\tif (shouldTakeOverStdout) {\n\t\ttakeOverStdout();\n\t}\n\n\tif (parsed.version) {\n\t\tconsole.log(VERSION);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.export) {\n\t\tlet result: string;\n\t\ttry {\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tresult = await exportFromFile(parsed.export, outputPath);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Failed to export session\";\n\t\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconsole.log(`Exported to: ${result}`);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tvalidateForkFlags(parsed);\n\n\t// Run migrations (pass cwd for project-local migrations)\n\tconst { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());\n\ttime(\"runMigrations\");\n\n\tconst cwd = process.cwd();\n\tconst agentDir = getAgentDir();\n\tconst startupSettingsManager = SettingsManager.create(cwd, agentDir);\n\treportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, \"startup session lookup\"));\n\n\t// Decide the final runtime cwd before creating cwd-bound runtime services.\n\t// --session and --resume may select a session from another project, so project-local\n\t// settings, resources, provider registrations, and models must be resolved only after\n\t// the target session cwd is known. The startup-cwd settings manager is used only for\n\t// sessionDir lookup during session selection.\n\tconst envSessionDir = process.env[ENV_SESSION_DIR];\n\tconst sessionDir =\n\t\tparsed.sessionDir ??\n\t\t(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??\n\t\tstartupSettingsManager.getSessionDir();\n\tlet sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);\n\tconst missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);\n\tif (missingSessionCwdIssue) {\n\t\tif (appMode === \"interactive\") {\n\t\t\tconst selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);\n\t\t\tif (!selectedCwd) {\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\tsessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);\n\t\t} else {\n\t\t\tconsole.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"createSessionManager\");\n\n\tconst resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);\n\tconst resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);\n\tconst resolvedAgentPaths = resolveCliPaths(cwd, parsed.agents);\n\tconst resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);\n\tconst resolvedSlashCommandPaths = resolveCliPaths(cwd, parsed.slashCommands);\n\tconst resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);\n\n\t// Populate the module-level CLI agent store before any session is created.\n\tsetAgentCliPaths(resolvedAgentPaths ?? []);\n\n\t// Synthetic factory: feed CLI --mode-path values into the extension runtime\n\t// so hoo-core (and any other extension that reads pi.getModeSearchPaths)\n\t// sees them alongside extension-registered dirs.\n\tconst cliModePaths = parsed.modePaths ?? [];\n\tconst cliResourcePathFactories: ExtensionFactory[] =\n\t\tcliModePaths.length === 0\n\t\t\t? []\n\t\t\t: [\n\t\t\t\t\tObject.assign(\n\t\t\t\t\t\t(pi: ExtensionAPI) => {\n\t\t\t\t\t\t\tfor (const p of cliModePaths) pi.addModeSearchPath(p);\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ internal: true },\n\t\t\t\t\t),\n\t\t\t\t];\n\tconst allExtensionFactories: ExtensionFactory[] = [\n\t\t...cliResourcePathFactories,\n\t\t...(options?.extensionFactories ?? []),\n\t];\n\tconst authStorage = AuthStorage.create();\n\t// A spawned subagent (run with a task id) is a single-shot, non-interactive\n\t// process: it renders no TUI and its prompt is a plain task, never a slash\n\t// command. Themes, slash commands, and prompt templates are dead weight in that\n\t// path, so skip loading them to trim the child's cold-boot cost. Skills, context\n\t// files, and extensions (which carry the core tools) are kept — they affect the\n\t// subagent's actual work.\n\tconst isSubagentBoot = parsed.taskId !== undefined;\n\tconst createRuntime: CreateAgentSessionRuntimeFactory = async ({\n\t\tcwd,\n\t\tagentDir,\n\t\tsessionManager,\n\t\tsessionStartEvent,\n\t}) => {\n\t\tconst services = await createAgentSessionServices({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tauthStorage,\n\t\t\textensionFlagValues: parsed.unknownFlags,\n\t\t\tresourceLoaderOptions: {\n\t\t\t\tadditionalExtensionPaths: resolvedExtensionPaths,\n\t\t\t\tadditionalSkillPaths: resolvedSkillPaths,\n\t\t\t\tadditionalPromptTemplatePaths: resolvedPromptTemplatePaths,\n\t\t\t\tadditionalSlashCommandPaths: resolvedSlashCommandPaths,\n\t\t\t\tadditionalThemePaths: resolvedThemePaths,\n\t\t\t\tnoExtensions: parsed.noExtensions,\n\t\t\t\tnoSkills: parsed.noSkills,\n\t\t\t\tnoPromptTemplates: parsed.noPromptTemplates || isSubagentBoot,\n\t\t\t\tnoSlashCommands: parsed.noSlashCommands || isSubagentBoot,\n\t\t\t\tnoThemes: parsed.noThemes || isSubagentBoot,\n\t\t\t\tnoContextFiles: parsed.noContextFiles,\n\t\t\t\tsystemPrompt: parsed.systemPrompt,\n\t\t\t\textensionFactories: allExtensionFactories,\n\t\t\t},\n\t\t});\n\t\tconst { settingsManager, modelRegistry, resourceLoader } = services;\n\t\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [\n\t\t\t...services.diagnostics,\n\t\t\t...collectSettingsDiagnostics(settingsManager, \"runtime creation\"),\n\t\t\t...resourceLoader.getExtensions().errors.map(({ path, error }) => ({\n\t\t\t\ttype: \"error\" as const,\n\t\t\t\tmessage: `Failed to load extension \"${path}\": ${error}`,\n\t\t\t})),\n\t\t];\n\n\t\t// When subagent tooling is enabled, append the main session subagent instructions.\n\t\tif (parsed.subagent ?? settingsManager.getEnableSubagent()) {\n\t\t\tresourceLoader.addAppendSystemPrompt(buildTaskMainPrompt());\n\t\t}\n\n\t\tconst modelPatterns = parsed.models ?? settingsManager.getEnabledModels();\n\t\tconst scopedModels =\n\t\t\tmodelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : [];\n\t\tconst {\n\t\t\toptions: sessionOptions,\n\t\t\tcliThinkingFromModel,\n\t\t\tdiagnostics: sessionOptionDiagnostics,\n\t\t} = buildSessionOptions(\n\t\t\tparsed,\n\t\t\tscopedModels,\n\t\t\tsessionManager.buildSessionContext().messages.length > 0,\n\t\t\tmodelRegistry,\n\t\t\tsettingsManager,\n\t\t);\n\t\tdiagnostics.push(...sessionOptionDiagnostics);\n\n\t\tif (parsed.apiKey) {\n\t\t\tif (!sessionOptions.model) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\tmessage: \"--api-key requires a model to be specified via --model, --provider/--model, or --models\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tauthStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);\n\t\t\t}\n\t\t}\n\n\t\tconst created = await createAgentSessionFromServices({\n\t\t\tservices,\n\t\t\tsessionManager,\n\t\t\tsessionStartEvent,\n\t\t\tmodel: sessionOptions.model,\n\t\t\tthinkingLevel: sessionOptions.thinkingLevel,\n\t\t\tscopedModels: sessionOptions.scopedModels,\n\t\t\ttools: sessionOptions.tools,\n\t\t\tnoTools: sessionOptions.noTools,\n\t\t\tcustomTools: sessionOptions.customTools,\n\t\t\tenableWebTools: sessionOptions.enableWebTools,\n\t\t\tenableBrowserTools: sessionOptions.enableBrowserTools,\n\t\t\tenableFileTools: sessionOptions.enableFileTools,\n\t\t});\n\t\tconst cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;\n\t\tif (created.session.model && cliThinkingOverride) {\n\t\t\tcreated.session.setThinkingLevel(created.session.thinkingLevel);\n\t\t}\n\n\t\treturn {\n\t\t\t...created,\n\t\t\tservices,\n\t\t\tdiagnostics,\n\t\t};\n\t};\n\ttime(\"createRuntime\");\n\tconst runtime = await createAgentSessionRuntime(createRuntime, {\n\t\tcwd: sessionManager.getCwd(),\n\t\tagentDir,\n\t\tsessionManager,\n\t});\n\tconst { services, session, modelFallbackMessage } = runtime;\n\tconst { settingsManager, modelRegistry, resourceLoader } = services;\n\n\tif (parsed.help) {\n\t\tconst extensionFlags = resourceLoader\n\t\t\t.getExtensions()\n\t\t\t.extensions.flatMap((extension) => Array.from(extension.flags.values()));\n\t\tprintHelp(extensionFlags);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.listModels !== undefined) {\n\t\tconst searchPattern = typeof parsed.listModels === \"string\" ? parsed.listModels : undefined;\n\t\tawait listModels(modelRegistry, searchPattern);\n\t\tprocess.exit(0);\n\t}\n\n\t// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC\n\tlet stdinContent: string | undefined;\n\tif (appMode !== \"rpc\") {\n\t\tstdinContent = await readPipedStdin();\n\t\tif (stdinContent !== undefined && appMode === \"interactive\") {\n\t\t\tappMode = \"print\";\n\t\t}\n\t}\n\ttime(\"readPipedStdin\");\n\n\tconst { initialMessage, initialImages } = await prepareInitialMessage(\n\t\tparsed,\n\t\tsettingsManager.getImageAutoResize(),\n\t\tstdinContent,\n\t);\n\ttime(\"prepareInitialMessage\");\n\tinitTheme(settingsManager.getTheme(), appMode === \"interactive\");\n\ttime(\"initTheme\");\n\n\t// Show deprecation warnings in interactive mode\n\tif (appMode === \"interactive\" && deprecationWarnings.length > 0) {\n\t\tawait showDeprecationWarnings(deprecationWarnings);\n\t}\n\n\tconst scopedModels = [...session.scopedModels];\n\ttime(\"resolveModelScope\");\n\treportDiagnostics(runtime.diagnostics);\n\tif (runtime.diagnostics.some((diagnostic) => diagnostic.type === \"error\")) {\n\t\tprocess.exit(1);\n\t}\n\ttime(\"createAgentSession\");\n\n\tif (appMode !== \"interactive\" && !session.model) {\n\t\tconsole.error(chalk.red(formatNoModelsAvailableMessage()));\n\t\tprocess.exit(1);\n\t}\n\n\tconst startupBenchmark = isTruthyEnvFlag(process.env.HOOCODE_STARTUP_BENCHMARK ?? process.env.PI_STARTUP_BENCHMARK);\n\tif (startupBenchmark && appMode !== \"interactive\") {\n\t\tconsole.error(chalk.red(\"Error: HOOCODE_STARTUP_BENCHMARK only supports interactive mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tif (appMode === \"rpc\") {\n\t\tprintTimings();\n\t\tawait runRpcMode(runtime);\n\t} else if (appMode === \"interactive\") {\n\t\tif (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {\n\t\t\tconst modelList = scopedModels\n\t\t\t\t.map((sm) => {\n\t\t\t\t\tconst thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : \"\";\n\t\t\t\t\treturn `${sm.model.id}${thinkingStr}`;\n\t\t\t\t})\n\t\t\t\t.join(\", \");\n\t\t\tconsole.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray(\"(Ctrl+P to cycle)\")}`));\n\t\t}\n\n\t\tconst interactiveMode = new InteractiveMode(runtime, {\n\t\t\tmigratedProviders,\n\t\t\tmodelFallbackMessage,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\tinitialMessages: parsed.messages,\n\t\t\tverbose: parsed.verbose,\n\t\t});\n\n\t\t// Optional hooteams bridge. `--team auto` discovers a config, spawns a\n\t\t// local hooteams child on a free port, and proceeds as if its URL had\n\t\t// been passed; the child is reaped on exit (clean or signal) via a\n\t\t// process \"exit\" hook plus the explicit stop below.\n\t\tlet autoTeam: { url: string; stop(): Promise<void> } | undefined;\n\t\tlet teamUrl = parsed.team;\n\t\tif (teamUrl === \"auto\") {\n\t\t\tconst { startAutoTeam } = await import(\"./core/team-auto.js\");\n\t\t\ttry {\n\t\t\t\tautoTeam = await startAutoTeam(process.cwd(), { log: (message) => console.log(chalk.dim(message)) });\n\t\t\t\tteamUrl = autoTeam.url;\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red(error instanceof Error ? error.message : String(error)));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\n\t\t// Team mirror + client. Fire-and-forget: connect failures and drops warn\n\t\t// in the background and never block the main agent. Warnings go through\n\t\t// the chat, not console.error: a raw stderr write while the TUI owns the\n\t\t// screen scribbles over the render and can leave the editor looking\n\t\t// frozen. The same connection powers team focus / nudge / attach.\n\t\tlet teamView: { stop(): void } | undefined;\n\t\tif (teamUrl) {\n\t\t\tconst { connectTeamView } = await import(\"./core/team-view.js\");\n\t\t\tconst teamClient = connectTeamView(teamUrl, {\n\t\t\t\twarn: (message) => interactiveMode.showWarning(message),\n\t\t\t});\n\t\t\tteamView = teamClient;\n\t\t\tinteractiveMode.attachTeamClient(teamClient);\n\t\t}\n\t\tif (startupBenchmark) {\n\t\t\tawait interactiveMode.init();\n\t\t\ttime(\"interactiveMode.init\");\n\t\t\tprintTimings();\n\t\t\tteamView?.stop();\n\t\t\tawait autoTeam?.stop();\n\t\t\tinteractiveMode.stop();\n\t\t\tstopThemeWatcher();\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tprintTimings();\n\t\ttry {\n\t\t\tawait interactiveMode.run();\n\t\t} finally {\n\t\t\tteamView?.stop();\n\t\t\tawait autoTeam?.stop();\n\t\t}\n\t} else {\n\t\tprintTimings();\n\t\tconst exitCode = await runPrintMode(runtime, {\n\t\t\tmode: toPrintOutputMode(appMode),\n\t\t\tmessages: parsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\ttaskId: parsed.taskId,\n\t\t\tmaxTurns: parsed.maxTurns,\n\t\t});\n\t\tstopThemeWatcher();\n\t\trestoreStdout();\n\t\tif (exitCode !== 0) {\n\t\t\tprocess.exitCode = exitCode;\n\t\t}\n\t\t// Spawned subagents (run with a task id) must exit promptly once their work is\n\t\t// done and result.json is written. The child's runtime can leave handles open\n\t\t// (e.g. MCP client connections) that keep the event loop alive, so a natural\n\t\t// exit may never happen. When that occurs the parent lifeguard SIGKILLs the idle\n\t\t// child at the 60s heartbeat threshold and misreports an already-completed task\n\t\t// as \"stalled\". Force a clean exit after draining output to avoid that false stall.\n\t\tconst ranAsSubagent = typeof parsed.taskId === \"string\" && parsed.taskId.length > 0;\n\t\tif (ranAsSubagent) {\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tprocess.exit(exitCode);\n\t\t}\n\t\treturn;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuBH,OAAO,KAAK,EAAgB,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAmfjF,MAAM,WAAW,WAAW;IAC3B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,CAAC;CACxC;AAED,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,iBAuZ/D","sourcesContent":["/**\n * Main entry point for the coding agent CLI.\n *\n * This file handles CLI argument parsing and translates them into\n * createAgentSession() options. The SDK does the heavy lifting.\n */\n\nimport { resolve } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport { type ImageContent, modelsAreEqual } from \"@kolisachint/hoocode-ai\";\nimport { ProcessTerminal, setKeybindings, TUI } from \"@kolisachint/hoocode-tui\";\nimport chalk from \"chalk\";\nimport { type Args, type Mode, parseArgs, printHelp } from \"./cli/args.js\";\nimport { processFileArguments } from \"./cli/file-processor.js\";\nimport { buildInitialMessage } from \"./cli/initial-message.js\";\nimport { listModels } from \"./cli/list-models.js\";\nimport { selectSession } from \"./cli/session-picker.js\";\nimport { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from \"./config.js\";\nimport { setAgentCliPaths } from \"./core/agent-manifest-paths.js\";\nimport { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from \"./core/agent-session-runtime.js\";\nimport {\n\ttype AgentSessionRuntimeDiagnostic,\n\tcreateAgentSessionFromServices,\n\tcreateAgentSessionServices,\n} from \"./core/agent-session-services.js\";\nimport { formatNoModelsAvailableMessage } from \"./core/auth-guidance.js\";\nimport { AuthStorage } from \"./core/auth-storage.js\";\nimport { exportFromFile } from \"./core/export-html/index.js\";\nimport type { ExtensionAPI, ExtensionFactory } from \"./core/extensions/types.js\";\nimport { KeybindingsManager } from \"./core/keybindings.js\";\nimport type { ModelRegistry } from \"./core/model-registry.js\";\nimport { resolveCliModel, resolveModelScope, type ScopedModel } from \"./core/model-resolver.js\";\nimport { restoreStdout, takeOverStdout } from \"./core/output-guard.js\";\nimport type { CreateAgentSessionOptions } from \"./core/sdk.js\";\nimport {\n\tformatMissingSessionCwdPrompt,\n\tgetMissingSessionCwdIssue,\n\tMissingSessionCwdError,\n\ttype SessionCwdIssue,\n} from \"./core/session-cwd.js\";\nimport { SessionManager } from \"./core/session-manager.js\";\nimport { SettingsManager } from \"./core/settings-manager.js\";\nimport {\n\tcanSpawnSubagent,\n\tDELEGATE_ALLOW_ENV,\n\tNESTED_CONCURRENCY_ENV,\n\tresolveMaxSubagentDepth,\n\tresolveNestedConcurrency,\n\tSUBAGENT_MAX_DEPTH_ENV,\n} from \"./core/subagent-depth.js\";\nimport { printTimings, resetTimings, time } from \"./core/timings.js\";\nimport {\n\tbuildTaskMainPrompt,\n\tcreateTaskOutputToolDefinition,\n\tcreateTaskToolDefinition,\n} from \"./core/tools/subagent.js\";\nimport { createTodoWriteToolDefinition } from \"./core/tools/todo.js\";\nimport { WARM_SUBAGENTS_ENV } from \"./core/warm-subagent-pool-instance.js\";\nimport { runMigrations, showDeprecationWarnings } from \"./migrations.js\";\nimport { InteractiveMode, runPrintMode, runRpcMode } from \"./modes/index.js\";\nimport { ExtensionSelectorComponent } from \"./modes/interactive/components/extension-selector.js\";\nimport { initTheme, stopThemeWatcher } from \"./modes/interactive/theme/theme.js\";\nimport { handleConfigCommand, handlePackageCommand } from \"./package-manager-cli.js\";\nimport { handleResourcesCommand } from \"./resources-cli.js\";\nimport { isLocalPath } from \"./utils/paths.js\";\n\n/**\n * Read all content from piped stdin.\n * Returns undefined if stdin is a TTY (interactive terminal).\n */\nasync function readPipedStdin(): Promise<string | undefined> {\n\t// If stdin is a TTY, we're running interactively - don't read stdin\n\tif (process.stdin.isTTY) {\n\t\treturn undefined;\n\t}\n\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tprocess.stdin.on(\"end\", () => {\n\t\t\tresolve(data.trim() || undefined);\n\t\t});\n\t\tprocess.stdin.resume();\n\t});\n}\n\nfunction collectSettingsDiagnostics(\n\tsettingsManager: SettingsManager,\n\tcontext: string,\n): AgentSessionRuntimeDiagnostic[] {\n\treturn settingsManager.drainErrors().map(({ scope, error }) => ({\n\t\ttype: \"warning\",\n\t\tmessage: `(${context}, ${scope} settings) ${error.message}`,\n\t}));\n}\n\nfunction reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {\n\tfor (const diagnostic of diagnostics) {\n\t\tconst color = diagnostic.type === \"error\" ? chalk.red : diagnostic.type === \"warning\" ? chalk.yellow : chalk.dim;\n\t\tconst prefix = diagnostic.type === \"error\" ? \"Error: \" : diagnostic.type === \"warning\" ? \"Warning: \" : \"\";\n\t\tconsole.error(color(`${prefix}${diagnostic.message}`));\n\t}\n}\n\nfunction isTruthyEnvFlag(value: string | undefined): boolean {\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\ntype AppMode = \"interactive\" | \"print\" | \"json\" | \"rpc\";\n\nfunction resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {\n\tif (parsed.mode === \"rpc\") {\n\t\treturn \"rpc\";\n\t}\n\tif (parsed.mode === \"json\") {\n\t\treturn \"json\";\n\t}\n\tif (parsed.print || !stdinIsTTY) {\n\t\treturn \"print\";\n\t}\n\treturn \"interactive\";\n}\n\nfunction toPrintOutputMode(appMode: AppMode): Exclude<Mode, \"rpc\"> {\n\treturn appMode === \"json\" ? \"json\" : \"text\";\n}\n\nasync function prepareInitialMessage(\n\tparsed: Args,\n\tautoResizeImages: boolean,\n\tstdinContent?: string,\n): Promise<{\n\tinitialMessage?: string;\n\tinitialImages?: ImageContent[];\n}> {\n\tif (parsed.fileArgs.length === 0) {\n\t\treturn buildInitialMessage({ parsed, stdinContent });\n\t}\n\n\tconst { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });\n\treturn buildInitialMessage({\n\t\tparsed,\n\t\tfileText: text,\n\t\tfileImages: images,\n\t\tstdinContent,\n\t});\n}\n\n/** Result from resolving a session argument */\ntype ResolvedSession =\n\t| { type: \"path\"; path: string } // Direct file path\n\t| { type: \"local\"; path: string } // Found in current project\n\t| { type: \"global\"; path: string; cwd: string } // Found in different project\n\t| { type: \"not_found\"; arg: string }; // Not found anywhere\n\n/**\n * Resolve a session argument to a file path.\n * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.\n */\nasync function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {\n\t// If it looks like a file path, use as-is\n\tif (sessionArg.includes(\"/\") || sessionArg.includes(\"\\\\\") || sessionArg.endsWith(\".jsonl\")) {\n\t\treturn { type: \"path\", path: sessionArg };\n\t}\n\n\t// Try to match as session ID in current project first\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatches = localSessions.filter((s) => s.id.startsWith(sessionArg));\n\n\tif (localMatches.length >= 1) {\n\t\treturn { type: \"local\", path: localMatches[0].path };\n\t}\n\n\t// Try global search across all projects\n\tconst allSessions = await SessionManager.listAll();\n\tconst globalMatches = allSessions.filter((s) => s.id.startsWith(sessionArg));\n\n\tif (globalMatches.length >= 1) {\n\t\tconst match = globalMatches[0];\n\t\treturn { type: \"global\", path: match.path, cwd: match.cwd };\n\t}\n\n\t// Not found anywhere\n\treturn { type: \"not_found\", arg: sessionArg };\n}\n\n/** Prompt user for yes/no confirmation */\nasync function promptConfirm(message: string): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst rl = createInterface({\n\t\t\tinput: process.stdin,\n\t\t\toutput: process.stdout,\n\t\t});\n\t\trl.question(`${message} [y/N] `, (answer) => {\n\t\t\trl.close();\n\t\t\tresolve(answer.toLowerCase() === \"y\" || answer.toLowerCase() === \"yes\");\n\t\t});\n\t});\n}\n\nfunction validateForkFlags(parsed: Args): void {\n\tif (!parsed.fork) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t\tparsed.noSession ? \"--no-session\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.forkFrom(sourcePath, cwd, sessionDir);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nasync function createSessionManager(\n\tparsed: Args,\n\tcwd: string,\n\tsessionDir: string | undefined,\n\tsettingsManager: SettingsManager,\n): Promise<SessionManager> {\n\tif (parsed.noSession) {\n\t\treturn SessionManager.inMemory();\n\t}\n\n\tif (parsed.fork) {\n\t\tconst resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\tcase \"global\":\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.session) {\n\t\tconst resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\t\treturn SessionManager.open(resolved.path, sessionDir);\n\n\t\t\tcase \"global\": {\n\t\t\t\tconsole.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));\n\t\t\t\tconst shouldFork = await promptConfirm(\"Fork this session into current directory?\");\n\t\t\t\tif (!shouldFork) {\n\t\t\t\t\tconsole.log(chalk.dim(\"Aborted.\"));\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\t\t\t}\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.resume) {\n\t\tinitTheme(settingsManager.getTheme(), true);\n\t\ttry {\n\t\t\tconst selectedPath = await selectSession(\n\t\t\t\t(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),\n\t\t\t\tSessionManager.listAll,\n\t\t\t);\n\t\t\tif (!selectedPath) {\n\t\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\treturn SessionManager.open(selectedPath, sessionDir);\n\t\t} finally {\n\t\t\tstopThemeWatcher();\n\t\t}\n\t}\n\n\tif (parsed.continue) {\n\t\treturn SessionManager.continueRecent(cwd, sessionDir);\n\t}\n\n\treturn SessionManager.create(cwd, sessionDir);\n}\n\nfunction buildSessionOptions(\n\tparsed: Args,\n\tscopedModels: ScopedModel[],\n\thasExistingSession: boolean,\n\tmodelRegistry: ModelRegistry,\n\tsettingsManager: SettingsManager,\n): {\n\toptions: CreateAgentSessionOptions;\n\tcliThinkingFromModel: boolean;\n\tdiagnostics: AgentSessionRuntimeDiagnostic[];\n} {\n\tconst options: CreateAgentSessionOptions = {};\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\tlet cliThinkingFromModel = false;\n\n\t// Model from CLI\n\t// - supports --provider <name> --model <pattern>\n\t// - supports --model <provider>/<pattern>\n\tif (parsed.model) {\n\t\tconst resolved = resolveCliModel({\n\t\t\tcliProvider: parsed.provider,\n\t\t\tcliModel: parsed.model,\n\t\t\tmodelRegistry,\n\t\t});\n\t\tif (resolved.warning) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: resolved.warning });\n\t\t}\n\t\tif (resolved.error) {\n\t\t\tdiagnostics.push({ type: \"error\", message: resolved.error });\n\t\t}\n\t\tif (resolved.model) {\n\t\t\toptions.model = resolved.model;\n\t\t\t// Allow \"--model <pattern>:<thinking>\" as a shorthand.\n\t\t\t// Explicit --thinking still takes precedence (applied later).\n\t\t\tif (!parsed.thinking && resolved.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = resolved.thinkingLevel;\n\t\t\t\tcliThinkingFromModel = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!options.model && scopedModels.length > 0 && !hasExistingSession) {\n\t\t// Check if saved default is in scoped models - use it if so, otherwise first scoped model\n\t\tconst savedProvider = settingsManager.getDefaultProvider();\n\t\tconst savedModelId = settingsManager.getDefaultModel();\n\t\tconst savedModel = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined;\n\t\tconst savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;\n\n\t\tif (savedInScope) {\n\t\t\toptions.model = savedInScope.model;\n\t\t\t// Use thinking level from scoped model config if explicitly set\n\t\t\tif (!parsed.thinking && savedInScope.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = savedInScope.thinkingLevel;\n\t\t\t}\n\t\t} else {\n\t\t\toptions.model = scopedModels[0].model;\n\t\t\t// Use thinking level from first scoped model if explicitly set\n\t\t\tif (!parsed.thinking && scopedModels[0].thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = scopedModels[0].thinkingLevel;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Thinking level from CLI (takes precedence over scoped model thinking levels set above)\n\tif (parsed.thinking) {\n\t\toptions.thinkingLevel = parsed.thinking;\n\t}\n\n\t// Scoped models for Ctrl+P cycling\n\t// Keep thinking level undefined when not explicitly set in the model pattern.\n\t// Undefined means \"inherit current session thinking level\" during cycling.\n\tif (scopedModels.length > 0) {\n\t\toptions.scopedModels = scopedModels.map((sm) => ({\n\t\t\tmodel: sm.model,\n\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t}));\n\t}\n\n\t// API key from CLI - set in authStorage\n\t// (handled by caller before createAgentSession)\n\n\t// Tools\n\tif (parsed.noTools) {\n\t\toptions.noTools = \"all\";\n\t} else if (parsed.noBuiltinTools) {\n\t\toptions.noTools = \"builtin\";\n\t}\n\tif (parsed.tools) {\n\t\toptions.tools = [...parsed.tools];\n\t}\n\tif (parsed.disallowedTools) {\n\t\toptions.disallowedTools = [...parsed.disallowedTools];\n\t}\n\t// Web tools (webfetch + websearch): opt-in via --enable-webtools flag or the\n\t// enableWebTools setting. They are registered as base tools but inactive by\n\t// default; this adds them to the default active set.\n\tif (parsed.enableWebTools ?? settingsManager.getEnableWebTools()) {\n\t\toptions.enableWebTools = true;\n\t}\n\t// Browser tools (browser_flow + browser_resume): opt-in via --enable-browsertools\n\t// flag or the enableBrowserTools setting. Registered as base tools but inactive by\n\t// default; this adds them to the default active set.\n\tif (parsed.enableBrowserTools ?? settingsManager.getEnableBrowserTools()) {\n\t\toptions.enableBrowserTools = true;\n\t}\n\t// Live preview for browser_flow: defaults the streamed viewer on and auto-opens\n\t// it. Applied as a runtime settings override so the session's tool factory reads\n\t// it via the shared settingsManager (no extra option plumbing).\n\tif (parsed.enableBrowserLivePreview) {\n\t\tsettingsManager.applyOverrides({ enableBrowserLivePreview: true });\n\t}\n\t// Document tools (DocRead/DocEdit/DocWrite + the DocScan/DocGrep/DocPeek\n\t// discovery loop): opt-in via --enable-filetools flag or the enableFileTools\n\t// setting. Registered as base tools but inactive by default; this adds them to\n\t// the default active set.\n\tif (parsed.enableFileTools ?? settingsManager.getEnableFileTools()) {\n\t\toptions.enableFileTools = true;\n\t\t// DocRead/DocEdit/DocWrite drive a lossless id-based extract -> patch ->\n\t\t// reconstruct flow that depends on precise, well-formed tool calls. Models\n\t\t// that are weak at tool calling tend to mangle the id-based patches and\n\t\t// corrupt documents, so surface a one-time heads-up when these are enabled.\n\t\tdiagnostics.push({\n\t\t\ttype: \"warning\",\n\t\t\tmessage:\n\t\t\t\t\"Document tools (DocRead/DocEdit/DocWrite) are enabled. They require precise, id-based patches; \" +\n\t\t\t\t\"use a model that is strong at tool calling, or these edits can corrupt files.\",\n\t\t});\n\t}\n\n\t// Optional Task (subagent) tool: opt-in via --enable-subagents flag or the enableSubagent setting.\n\t// Registered as a custom tool; respects --tools/--no-tools allowlists like any other tool.\n\t//\n\t// Nesting is bounded by the tree-wide cap (maxSubagentDepth, default 1). The root\n\t// seeds the cap into the environment so every descendant agrees on one value; the\n\t// Task tool is registered only while this process's depth is below that cap. At the\n\t// default cap this reproduces the original guard exactly: subagents (depth >= 1) get\n\t// no Task tool and cannot recursively dispatch.\n\tconst isSubagentChild = parsed.taskId !== undefined;\n\tif (process.env[SUBAGENT_MAX_DEPTH_ENV] === undefined) {\n\t\t// The root seeds the tree-wide cap; the --max-subagent-depth flag overrides the\n\t\t// setting. resolveMaxSubagentDepth clamps it to the supported range so the seeded\n\t\t// env (and everything that inherits it) carries a sane value. Descendants inherit\n\t\t// it via the environment (env already set => keep it).\n\t\tprocess.env[SUBAGENT_MAX_DEPTH_ENV] = String(\n\t\t\tresolveMaxSubagentDepth(parsed.maxSubagentDepth ?? settingsManager.getMaxSubagentDepth()),\n\t\t);\n\t}\n\tif (process.env[NESTED_CONCURRENCY_ENV] === undefined) {\n\t\t// Seed the nested-pool concurrency from settings so descendants agree on one value.\n\t\tprocess.env[NESTED_CONCURRENCY_ENV] = String(\n\t\t\tresolveNestedConcurrency(settingsManager.getNestedSubagentConcurrency()),\n\t\t);\n\t}\n\t// Scoped delegation: --delegate-allow is the authoritative restriction for this\n\t// process. Set it from the flag, or clear any inherited value so a restricted\n\t// parent's scope never leaks into a child that wasn't given its own.\n\tif (parsed.delegateAllow && parsed.delegateAllow.length > 0) {\n\t\tprocess.env[DELEGATE_ALLOW_ENV] = parsed.delegateAllow.join(\",\");\n\t} else {\n\t\tdelete process.env[DELEGATE_ALLOW_ENV];\n\t}\n\tif (canSpawnSubagent() && (parsed.subagent ?? settingsManager.getEnableSubagent())) {\n\t\toptions.customTools = [\n\t\t\t...(options.customTools ?? []),\n\t\t\tcreateTaskToolDefinition(),\n\t\t\tcreateTaskOutputToolDefinition(),\n\t\t];\n\t\t// Warm subagents (experimental): dispatch eligible foreground subagents on\n\t\t// reused RPC workers to skip the cold-boot. Root-only — the Task tool exists\n\t\t// only where canSpawnSubagent holds and never inside a spawned child — and\n\t\t// carried via env so the Task tool reads it without threading a setting.\n\t\tif (!isSubagentChild && (parsed.warmSubagents ?? settingsManager.getWarmSubagents())) {\n\t\t\tprocess.env[WARM_SUBAGENTS_ENV] = \"1\";\n\t\t}\n\t}\n\n\t// Optional TodoWrite tool: opt-in via --enable-todowrite flag or the\n\t// enableTodoWrite setting. Never registered inside a spawned subagent child —\n\t// its todos would otherwise leak into the parent's \"main\" task group in the pane.\n\tif (!isSubagentChild && (parsed.todoWrite ?? settingsManager.getEnableTodoWrite())) {\n\t\toptions.customTools = [...(options.customTools ?? []), createTodoWriteToolDefinition()];\n\t}\n\n\treturn { options, cliThinkingFromModel, diagnostics };\n}\n\nfunction resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {\n\treturn paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value));\n}\n\nasync function promptForMissingSessionCwd(\n\tissue: SessionCwdIssue,\n\tsettingsManager: SettingsManager,\n): Promise<string | undefined> {\n\tinitTheme(settingsManager.getTheme());\n\tsetKeybindings(KeybindingsManager.create());\n\n\treturn new Promise((resolve) => {\n\t\tconst ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());\n\t\tui.setClearOnShrink(settingsManager.getClearOnShrink());\n\n\t\tlet settled = false;\n\t\tconst finish = (result: string | undefined) => {\n\t\t\tif (settled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsettled = true;\n\t\t\tui.stop();\n\t\t\tresolve(result);\n\t\t};\n\n\t\tconst selector = new ExtensionSelectorComponent(\n\t\t\tformatMissingSessionCwdPrompt(issue),\n\t\t\t[\"Continue\", \"Cancel\"],\n\t\t\t(option) => finish(option === \"Continue\" ? issue.fallbackCwd : undefined),\n\t\t\t() => finish(undefined),\n\t\t\t{ tui: ui },\n\t\t);\n\t\tui.addChild(selector);\n\t\tui.setFocus(selector);\n\t\tui.start();\n\t});\n}\n\nexport interface MainOptions {\n\textensionFactories?: ExtensionFactory[];\n}\n\nexport async function main(args: string[], options?: MainOptions) {\n\tresetTimings();\n\tconst offlineMode =\n\t\targs.includes(\"--offline\") || isTruthyEnvFlag(process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE);\n\tif (offlineMode) {\n\t\tprocess.env.HOOCODE_OFFLINE = \"1\";\n\t\tprocess.env.HOOCODE_SKIP_VERSION_CHECK = \"1\";\n\t}\n\n\tif (await handlePackageCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await handleConfigCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await handleResourcesCommand(args)) {\n\t\treturn;\n\t}\n\n\tconst parsed = parseArgs(args);\n\tif (parsed.diagnostics.length > 0) {\n\t\tfor (const d of parsed.diagnostics) {\n\t\t\tconst color = d.type === \"error\" ? chalk.red : chalk.yellow;\n\t\t\tconsole.error(color(`${d.type === \"error\" ? \"Error\" : \"Warning\"}: ${d.message}`));\n\t\t}\n\t\tif (parsed.diagnostics.some((d) => d.type === \"error\")) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"parseArgs\");\n\tlet appMode = resolveAppMode(parsed, process.stdin.isTTY);\n\tconst shouldTakeOverStdout = appMode !== \"interactive\";\n\tif (shouldTakeOverStdout) {\n\t\ttakeOverStdout();\n\t}\n\n\tif (parsed.version) {\n\t\tconsole.log(VERSION);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.export) {\n\t\tlet result: string;\n\t\ttry {\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tresult = await exportFromFile(parsed.export, outputPath);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Failed to export session\";\n\t\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconsole.log(`Exported to: ${result}`);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tvalidateForkFlags(parsed);\n\n\t// Run migrations (pass cwd for project-local migrations)\n\tconst { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());\n\ttime(\"runMigrations\");\n\n\tconst cwd = process.cwd();\n\tconst agentDir = getAgentDir();\n\tconst startupSettingsManager = SettingsManager.create(cwd, agentDir);\n\treportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, \"startup session lookup\"));\n\n\t// Decide the final runtime cwd before creating cwd-bound runtime services.\n\t// --session and --resume may select a session from another project, so project-local\n\t// settings, resources, provider registrations, and models must be resolved only after\n\t// the target session cwd is known. The startup-cwd settings manager is used only for\n\t// sessionDir lookup during session selection.\n\tconst envSessionDir = process.env[ENV_SESSION_DIR];\n\tconst sessionDir =\n\t\tparsed.sessionDir ??\n\t\t(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??\n\t\tstartupSettingsManager.getSessionDir();\n\tlet sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);\n\tconst missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);\n\tif (missingSessionCwdIssue) {\n\t\tif (appMode === \"interactive\") {\n\t\t\tconst selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);\n\t\t\tif (!selectedCwd) {\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\tsessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);\n\t\t} else {\n\t\t\tconsole.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"createSessionManager\");\n\n\tconst resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);\n\tconst resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);\n\tconst resolvedAgentPaths = resolveCliPaths(cwd, parsed.agents);\n\tconst resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);\n\tconst resolvedSlashCommandPaths = resolveCliPaths(cwd, parsed.slashCommands);\n\tconst resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);\n\n\t// Populate the module-level CLI agent store before any session is created.\n\tsetAgentCliPaths(resolvedAgentPaths ?? []);\n\n\t// Synthetic factory: feed CLI --mode-path values into the extension runtime\n\t// so hoo-core (and any other extension that reads pi.getModeSearchPaths)\n\t// sees them alongside extension-registered dirs.\n\tconst cliModePaths = parsed.modePaths ?? [];\n\tconst cliResourcePathFactories: ExtensionFactory[] =\n\t\tcliModePaths.length === 0\n\t\t\t? []\n\t\t\t: [\n\t\t\t\t\tObject.assign(\n\t\t\t\t\t\t(pi: ExtensionAPI) => {\n\t\t\t\t\t\t\tfor (const p of cliModePaths) pi.addModeSearchPath(p);\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ internal: true },\n\t\t\t\t\t),\n\t\t\t\t];\n\tconst allExtensionFactories: ExtensionFactory[] = [\n\t\t...cliResourcePathFactories,\n\t\t...(options?.extensionFactories ?? []),\n\t];\n\tconst authStorage = AuthStorage.create();\n\t// A spawned subagent (run with a task id) is a single-shot, non-interactive\n\t// process: it renders no TUI and its prompt is a plain task, never a slash\n\t// command. Themes, slash commands, and prompt templates are dead weight in that\n\t// path, so skip loading them to trim the child's cold-boot cost. Skills, context\n\t// files, and extensions (which carry the core tools) are kept — they affect the\n\t// subagent's actual work.\n\tconst isSubagentBoot = parsed.taskId !== undefined;\n\tconst createRuntime: CreateAgentSessionRuntimeFactory = async ({\n\t\tcwd,\n\t\tagentDir,\n\t\tsessionManager,\n\t\tsessionStartEvent,\n\t}) => {\n\t\tconst services = await createAgentSessionServices({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tauthStorage,\n\t\t\textensionFlagValues: parsed.unknownFlags,\n\t\t\tresourceLoaderOptions: {\n\t\t\t\tadditionalExtensionPaths: resolvedExtensionPaths,\n\t\t\t\tadditionalSkillPaths: resolvedSkillPaths,\n\t\t\t\tadditionalPromptTemplatePaths: resolvedPromptTemplatePaths,\n\t\t\t\tadditionalSlashCommandPaths: resolvedSlashCommandPaths,\n\t\t\t\tadditionalThemePaths: resolvedThemePaths,\n\t\t\t\tnoExtensions: parsed.noExtensions,\n\t\t\t\tnoSkills: parsed.noSkills,\n\t\t\t\tnoPromptTemplates: parsed.noPromptTemplates || isSubagentBoot,\n\t\t\t\tnoSlashCommands: parsed.noSlashCommands || isSubagentBoot,\n\t\t\t\tnoThemes: parsed.noThemes || isSubagentBoot,\n\t\t\t\tnoContextFiles: parsed.noContextFiles,\n\t\t\t\tsystemPrompt: parsed.systemPrompt,\n\t\t\t\textensionFactories: allExtensionFactories,\n\t\t\t},\n\t\t});\n\t\tconst { settingsManager, modelRegistry, resourceLoader } = services;\n\t\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [\n\t\t\t...services.diagnostics,\n\t\t\t...collectSettingsDiagnostics(settingsManager, \"runtime creation\"),\n\t\t\t...resourceLoader.getExtensions().errors.map(({ path, error }) => ({\n\t\t\t\ttype: \"error\" as const,\n\t\t\t\tmessage: `Failed to load extension \"${path}\": ${error}`,\n\t\t\t})),\n\t\t];\n\n\t\t// When subagent tooling is enabled, append the main session subagent instructions.\n\t\tif (parsed.subagent ?? settingsManager.getEnableSubagent()) {\n\t\t\tresourceLoader.addAppendSystemPrompt(buildTaskMainPrompt());\n\t\t}\n\n\t\tconst modelPatterns = parsed.models ?? settingsManager.getEnabledModels();\n\t\tconst scopedModels =\n\t\t\tmodelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : [];\n\t\tconst {\n\t\t\toptions: sessionOptions,\n\t\t\tcliThinkingFromModel,\n\t\t\tdiagnostics: sessionOptionDiagnostics,\n\t\t} = buildSessionOptions(\n\t\t\tparsed,\n\t\t\tscopedModels,\n\t\t\tsessionManager.buildSessionContext().messages.length > 0,\n\t\t\tmodelRegistry,\n\t\t\tsettingsManager,\n\t\t);\n\t\tdiagnostics.push(...sessionOptionDiagnostics);\n\n\t\tif (parsed.apiKey) {\n\t\t\tif (!sessionOptions.model) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\tmessage: \"--api-key requires a model to be specified via --model, --provider/--model, or --models\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tauthStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);\n\t\t\t}\n\t\t}\n\n\t\tconst created = await createAgentSessionFromServices({\n\t\t\tservices,\n\t\t\tsessionManager,\n\t\t\tsessionStartEvent,\n\t\t\tmodel: sessionOptions.model,\n\t\t\tthinkingLevel: sessionOptions.thinkingLevel,\n\t\t\tscopedModels: sessionOptions.scopedModels,\n\t\t\ttools: sessionOptions.tools,\n\t\t\tnoTools: sessionOptions.noTools,\n\t\t\tcustomTools: sessionOptions.customTools,\n\t\t\tenableWebTools: sessionOptions.enableWebTools,\n\t\t\tenableBrowserTools: sessionOptions.enableBrowserTools,\n\t\t\tenableFileTools: sessionOptions.enableFileTools,\n\t\t});\n\t\tconst cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;\n\t\tif (created.session.model && cliThinkingOverride) {\n\t\t\tcreated.session.setThinkingLevel(created.session.thinkingLevel);\n\t\t}\n\n\t\treturn {\n\t\t\t...created,\n\t\t\tservices,\n\t\t\tdiagnostics,\n\t\t};\n\t};\n\ttime(\"createRuntime\");\n\tconst runtime = await createAgentSessionRuntime(createRuntime, {\n\t\tcwd: sessionManager.getCwd(),\n\t\tagentDir,\n\t\tsessionManager,\n\t});\n\tconst { services, session, modelFallbackMessage } = runtime;\n\tconst { settingsManager, modelRegistry, resourceLoader } = services;\n\n\tif (parsed.help) {\n\t\tconst extensionFlags = resourceLoader\n\t\t\t.getExtensions()\n\t\t\t.extensions.flatMap((extension) => Array.from(extension.flags.values()));\n\t\tprintHelp(extensionFlags);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.listModels !== undefined) {\n\t\tconst searchPattern = typeof parsed.listModels === \"string\" ? parsed.listModels : undefined;\n\t\tawait listModels(modelRegistry, searchPattern);\n\t\tprocess.exit(0);\n\t}\n\n\t// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC\n\tlet stdinContent: string | undefined;\n\tif (appMode !== \"rpc\") {\n\t\tstdinContent = await readPipedStdin();\n\t\tif (stdinContent !== undefined && appMode === \"interactive\") {\n\t\t\tappMode = \"print\";\n\t\t}\n\t}\n\ttime(\"readPipedStdin\");\n\n\tconst { initialMessage, initialImages } = await prepareInitialMessage(\n\t\tparsed,\n\t\tsettingsManager.getImageAutoResize(),\n\t\tstdinContent,\n\t);\n\ttime(\"prepareInitialMessage\");\n\tinitTheme(settingsManager.getTheme(), appMode === \"interactive\");\n\ttime(\"initTheme\");\n\n\t// Show deprecation warnings in interactive mode\n\tif (appMode === \"interactive\" && deprecationWarnings.length > 0) {\n\t\tawait showDeprecationWarnings(deprecationWarnings);\n\t}\n\n\tconst scopedModels = [...session.scopedModels];\n\ttime(\"resolveModelScope\");\n\treportDiagnostics(runtime.diagnostics);\n\tif (runtime.diagnostics.some((diagnostic) => diagnostic.type === \"error\")) {\n\t\tprocess.exit(1);\n\t}\n\ttime(\"createAgentSession\");\n\n\tif (appMode !== \"interactive\" && !session.model) {\n\t\tconsole.error(chalk.red(formatNoModelsAvailableMessage()));\n\t\tprocess.exit(1);\n\t}\n\n\tconst startupBenchmark = isTruthyEnvFlag(process.env.HOOCODE_STARTUP_BENCHMARK ?? process.env.PI_STARTUP_BENCHMARK);\n\tif (startupBenchmark && appMode !== \"interactive\") {\n\t\tconsole.error(chalk.red(\"Error: HOOCODE_STARTUP_BENCHMARK only supports interactive mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tif (appMode === \"rpc\") {\n\t\tprintTimings();\n\t\tawait runRpcMode(runtime);\n\t} else if (appMode === \"interactive\") {\n\t\tif (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {\n\t\t\tconst modelList = scopedModels\n\t\t\t\t.map((sm) => {\n\t\t\t\t\tconst thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : \"\";\n\t\t\t\t\treturn `${sm.model.id}${thinkingStr}`;\n\t\t\t\t})\n\t\t\t\t.join(\", \");\n\t\t\tconsole.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray(\"(Ctrl+P to cycle)\")}`));\n\t\t}\n\n\t\tconst interactiveMode = new InteractiveMode(runtime, {\n\t\t\tmigratedProviders,\n\t\t\tmodelFallbackMessage,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\tinitialMessages: parsed.messages,\n\t\t\tverbose: parsed.verbose,\n\t\t});\n\n\t\t// Optional hooteams bridge. `--team auto` discovers a config, spawns a\n\t\t// local hooteams child on a free port, and proceeds as if its URL had\n\t\t// been passed; the child is reaped on exit (clean or signal) via a\n\t\t// process \"exit\" hook plus the explicit stop below.\n\t\tlet autoTeam: { url: string; stop(): Promise<void> } | undefined;\n\t\tlet teamUrl = parsed.team;\n\t\tif (teamUrl === \"auto\") {\n\t\t\tconst { startAutoTeam } = await import(\"./core/team-auto.js\");\n\t\t\ttry {\n\t\t\t\tautoTeam = await startAutoTeam(process.cwd(), { log: (message) => console.log(chalk.dim(message)) });\n\t\t\t\tteamUrl = autoTeam.url;\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red(error instanceof Error ? error.message : String(error)));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\n\t\t// Team mirror + client. Fire-and-forget: connect failures and drops warn\n\t\t// in the background and never block the main agent. Warnings go through\n\t\t// the chat, not console.error: a raw stderr write while the TUI owns the\n\t\t// screen scribbles over the render and can leave the editor looking\n\t\t// frozen. The same connection powers team focus / nudge / attach.\n\t\tlet teamView: { stop(): void } | undefined;\n\t\tif (teamUrl) {\n\t\t\tconst { connectTeamView } = await import(\"./core/team-view.js\");\n\t\t\tconst teamClient = connectTeamView(teamUrl, {\n\t\t\t\twarn: (message) => interactiveMode.showWarning(message),\n\t\t\t});\n\t\t\tteamView = teamClient;\n\t\t\tinteractiveMode.attachTeamClient(teamClient);\n\t\t}\n\t\tif (startupBenchmark) {\n\t\t\tawait interactiveMode.init();\n\t\t\ttime(\"interactiveMode.init\");\n\t\t\tprintTimings();\n\t\t\tteamView?.stop();\n\t\t\tawait autoTeam?.stop();\n\t\t\tinteractiveMode.stop();\n\t\t\tstopThemeWatcher();\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tprintTimings();\n\t\ttry {\n\t\t\tawait interactiveMode.run();\n\t\t} finally {\n\t\t\tteamView?.stop();\n\t\t\tawait autoTeam?.stop();\n\t\t}\n\t} else {\n\t\tprintTimings();\n\t\tconst exitCode = await runPrintMode(runtime, {\n\t\t\tmode: toPrintOutputMode(appMode),\n\t\t\tmessages: parsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\ttaskId: parsed.taskId,\n\t\t\tmaxTurns: parsed.maxTurns,\n\t\t});\n\t\tstopThemeWatcher();\n\t\trestoreStdout();\n\t\tif (exitCode !== 0) {\n\t\t\tprocess.exitCode = exitCode;\n\t\t}\n\t\t// Spawned subagents (run with a task id) must exit promptly once their work is\n\t\t// done and result.json is written. The child's runtime can leave handles open\n\t\t// (e.g. MCP client connections) that keep the event loop alive, so a natural\n\t\t// exit may never happen. When that occurs the parent lifeguard SIGKILLs the idle\n\t\t// child at the 60s heartbeat threshold and misreports an already-completed task\n\t\t// as \"stalled\". Force a clean exit after draining output to avoid that false stall.\n\t\tconst ranAsSubagent = typeof parsed.taskId === \"string\" && parsed.taskId.length > 0;\n\t\tif (ranAsSubagent) {\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tprocess.exit(exitCode);\n\t\t}\n\t\treturn;\n\t}\n}\n"]}
|
package/dist/main.js
CHANGED
|
@@ -312,6 +312,12 @@ function buildSessionOptions(parsed, scopedModels, hasExistingSession, modelRegi
|
|
|
312
312
|
if (parsed.enableBrowserTools ?? settingsManager.getEnableBrowserTools()) {
|
|
313
313
|
options.enableBrowserTools = true;
|
|
314
314
|
}
|
|
315
|
+
// Live preview for browser_flow: defaults the streamed viewer on and auto-opens
|
|
316
|
+
// it. Applied as a runtime settings override so the session's tool factory reads
|
|
317
|
+
// it via the shared settingsManager (no extra option plumbing).
|
|
318
|
+
if (parsed.enableBrowserLivePreview) {
|
|
319
|
+
settingsManager.applyOverrides({ enableBrowserLivePreview: true });
|
|
320
|
+
}
|
|
315
321
|
// Document tools (DocRead/DocEdit/DocWrite + the DocScan/DocGrep/DocPeek
|
|
316
322
|
// discovery loop): opt-in via --enable-filetools flag or the enableFileTools
|
|
317
323
|
// setting. Registered as base tools but inactive by default; this adds them to
|