@capagents/forgeqe 1.3.2 → 2.0.1

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.
Files changed (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
package/src/copilot.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { readFileSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import type { LlmProfile } from "./llm.ts"
6
+
7
+ /**
8
+ * GitHub Copilot seats as the model (`provider: github_copilot` in llms.json). ForgeQE's agent and Scribe run on the
9
+ * agent runtime's own GitHub Copilot provider, which GitHub supports for Copilot Pro, Pro+, Business and Enterprise;
10
+ * `forgeqe auth login copilot` signs in through it and keeps the sign-in in ForgeQE's credential store. The browser
11
+ * engine calls the model itself, which Copilot does not offer other tools, so it uses GitHub Models (an OpenAI-style
12
+ * API) with the GitHub CLI's sign-in or GITHUB_TOKEN.
13
+ */
14
+
15
+ /** The agent runtime's provider id. */
16
+ export const COPILOT_PROVIDER_ID = "github-copilot"
17
+ /** The saved Copilot sign-in (JSON), in ForgeQE's credential store; never in a file the agent can read. */
18
+ export const COPILOT_AUTH_ENV = "FORGEQE_COPILOT_AUTH"
19
+ /** The agent runtime reads its sign-ins from this variable instead of its auth.json. */
20
+ export const RUNTIME_AUTH_ENV = "OPENCODE_AUTH_CONTENT"
21
+ /** The GitHub Models token, set only in the engine's own environment. */
22
+ export const MODELS_TOKEN_ENV = "FORGEQE_GITHUB_MODELS_TOKEN"
23
+ export const MODELS_URL = "https://models.github.ai"
24
+ /** The engine's model when the profile names none: GitHub Models' id, which takes screenshots. */
25
+ export const DEFAULT_ENGINE_MODEL = "openai/gpt-4.1"
26
+
27
+ /** A GitHub organization login. */
28
+ const ORG = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/
29
+ /** A GitHub Models id: publisher/model. */
30
+ const MODELS_ID = /^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.:-]*$/
31
+ /** A Copilot model id as the agent runtime lists it (claude-sonnet-4.6, gpt-5.4). */
32
+ const COPILOT_MODEL = /^[A-Za-z0-9][\w.:-]*$/
33
+ /** A GitHub Enterprise Cloud host with data residency (acme.ghe.com) or a GitHub Enterprise Server host. */
34
+ const HOST = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/
35
+
36
+ export type CopilotAuth = { type: "oauth"; refresh: string; access: string; expires: number; enterpriseUrl?: string }
37
+
38
+ const TOKEN = /^[A-Za-z0-9_.-]{20,512}$/
39
+
40
+ /** A sign-in as the agent runtime writes it, or null when `value` is not one. */
41
+ export function parseCopilotAuth(value: unknown): CopilotAuth | null {
42
+ let v = value
43
+ if (typeof v === "string") {
44
+ try {
45
+ v = JSON.parse(v)
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+ if (!v || typeof v !== "object") return null
51
+ const r = v as Record<string, unknown>
52
+ if (r.type !== "oauth" || typeof r.refresh !== "string" || !TOKEN.test(r.refresh)) return null
53
+ const access = typeof r.access === "string" && TOKEN.test(r.access) ? r.access : r.refresh
54
+ const expires = typeof r.expires === "number" && Number.isFinite(r.expires) ? r.expires : 0
55
+ let enterpriseUrl: string | undefined
56
+ if (r.enterpriseUrl !== undefined) {
57
+ enterpriseUrl = enterpriseHost(String(r.enterpriseUrl)) || undefined
58
+ if (!enterpriseUrl) return null
59
+ }
60
+ return { type: "oauth", refresh: r.refresh, access, expires, ...(enterpriseUrl ? { enterpriseUrl } : {}) }
61
+ }
62
+
63
+ /** "acme.ghe.com" or "https://acme.ghe.com/" → "acme.ghe.com"; null for anything that is not a plain host. */
64
+ export function enterpriseHost(raw: string): string | null {
65
+ const t = raw.trim().toLowerCase()
66
+ let host = t
67
+ if (/^[a-z][a-z0-9+.-]*:/.test(t)) {
68
+ let u: URL
69
+ try {
70
+ u = new URL(t)
71
+ } catch {
72
+ return null
73
+ }
74
+ if (u.protocol !== "https:" || u.username || u.password || u.port || (u.pathname !== "/" && u.pathname !== "") || u.search || u.hash) return null
75
+ host = u.hostname
76
+ } else host = t.replace(/\/+$/, "")
77
+ if (!HOST.test(host) || host === "github.com") return null
78
+ return host
79
+ }
80
+
81
+ /** The agent runtime's sign-ins for this run: the saved Copilot one, as RUNTIME_AUTH_ENV takes them; null when none is saved. */
82
+ export function runtimeAuthContent(saved: string | undefined = process.env[COPILOT_AUTH_ENV]): string | null {
83
+ const auth = parseCopilotAuth(saved)
84
+ return auth ? JSON.stringify({ [COPILOT_PROVIDER_ID]: auth }) : null
85
+ }
86
+
87
+ /** Where the agent runtime keeps the sign-ins of `opencode auth login`. */
88
+ export function runtimeAuthFile(env: Record<string, string | undefined> = process.env): string {
89
+ const base = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
90
+ return path.join(base, "opencode", "auth.json")
91
+ }
92
+
93
+ /** Whether the agent runtime itself has a Copilot sign-in (`opencode auth login`); only the entry's presence is checked. */
94
+ export function runtimeHasCopilot(file = runtimeAuthFile()): boolean {
95
+ try {
96
+ const data = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
97
+ return Boolean(data && typeof data === "object" && data[COPILOT_PROVIDER_ID])
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ /** GitHub Models' OpenAI-style base URL; with `org`, requests are billed to and governed by that organization. */
104
+ export function modelsEndpoint(org?: string): string {
105
+ if (!org) return `${MODELS_URL}/inference`
106
+ if (!ORG.test(org)) throw new Error(`github_org must be a GitHub organization name, got "${org}".`)
107
+ return `${MODELS_URL}/orgs/${org}/inference`
108
+ }
109
+
110
+ export function engineModel(p: Pick<LlmProfile, "engineModel">): string {
111
+ return p.engineModel || DEFAULT_ENGINE_MODEL
112
+ }
113
+
114
+ export const NO_MODELS_TOKEN =
115
+ "The browser engine reaches GitHub Models with a GitHub sign-in, and there is none: run forgeqe auth login github " +
116
+ "(the GitHub CLI), or set GITHUB_TOKEN to a token with the models:read permission."
117
+
118
+ /** The GitHub Models token: the profile's api_key_env, else GITHUB_TOKEN, else the GitHub CLI's sign-in. */
119
+ export function modelsToken(p: Pick<LlmProfile, "apiKeyEnv">, gh: () => string | null, env: Record<string, string | undefined> = process.env): string | null {
120
+ const name = p.apiKeyEnv || "GITHUB_TOKEN"
121
+ return env[name]?.trim() || gh()
122
+ }
123
+
124
+ /** Why the profile's fields cannot work, or null. */
125
+ export function copilotProfileError(p: LlmProfile): string | null {
126
+ if (!COPILOT_MODEL.test(p.model)) return `The github_copilot profile's model must be a Copilot model id such as claude-sonnet-4.6, got "${p.model}".`
127
+ if (p.engineModel && !MODELS_ID.test(p.engineModel)) return `engine_model must be a GitHub Models id such as ${DEFAULT_ENGINE_MODEL}, got "${p.engineModel}".`
128
+ if (p.githubOrg && !ORG.test(p.githubOrg)) return `github_org must be a GitHub organization name, got "${p.githubOrg}".`
129
+ if (p.auth === "entra") return "auth: entra does not apply to github_copilot, which signs in with GitHub."
130
+ return null
131
+ }
132
+
133
+ /** Why the agent runtime cannot use Copilot here, or null. */
134
+ export function missingCopilotSignIn(p: LlmProfile, hasRuntimeSignIn: () => boolean = runtimeHasCopilot): string | null {
135
+ const bad = copilotProfileError(p)
136
+ if (bad) return bad
137
+ if (runtimeAuthContent() || hasRuntimeSignIn()) return null
138
+ return `The github_copilot profile for ${p.model} needs a GitHub Copilot sign-in: run forgeqe auth login copilot (or /auth copilot in the shell).`
139
+ }
140
+
141
+ /**
142
+ * The profile the engine runs for a github_copilot one: GitHub Models' OpenAI-compatible API, with the token only in
143
+ * the engine's environment. Other profiles come back as they are.
144
+ */
145
+ export function engineRoute(p: LlmProfile, gh: () => string | null): { profile: LlmProfile; env: Record<string, string> } {
146
+ if (p.provider !== "github_copilot") return { profile: p, env: {} }
147
+ const bad = copilotProfileError(p)
148
+ if (bad) throw new Error(bad)
149
+ const token = modelsToken(p, gh)
150
+ if (!token) throw new Error(NO_MODELS_TOKEN)
151
+ return {
152
+ profile: { ...p, provider: "openai_compatible", model: engineModel(p), endpoint: modelsEndpoint(p.githubOrg), auth: "key", apiKey: undefined, apiKeyEnv: MODELS_TOKEN_ENV },
153
+ env: { [MODELS_TOKEN_ENV]: token },
154
+ }
155
+ }
@@ -0,0 +1,229 @@
1
+ import { spawn } from "node:child_process"
2
+ import { createInterface } from "node:readline"
3
+ import path from "node:path"
4
+
5
+ import type { DesktopConfig } from "../config.ts"
6
+ import { ghAuthToken } from "../cases/github.ts"
7
+ import { engineRoute } from "../copilot.ts"
8
+ import { AZURE_IDENTITY_ENV } from "../entra.ts"
9
+ import { llmPayload } from "../engine/bridge.ts"
10
+ import { engineEnv } from "../engine/setup.ts"
11
+ import type { LlmProfile } from "../llm.ts"
12
+ import { logEvent } from "../sessionLog.ts"
13
+ import { recordCall, withUsageScope } from "../tokenomics/ledger.ts"
14
+ import type { Usage } from "../types.ts"
15
+ import type { Role } from "../usage.ts"
16
+
17
+ /** One request to `python -m forgeqe_desktop serve`: a replay of a workflow, an explore of a goal, or a recording by hand. */
18
+ export type DesktopRequest = {
19
+ mode: "replay" | "explore" | "record"
20
+ outDir: string
21
+ llm: LlmProfile
22
+ profileName: string
23
+ desktop: DesktopConfig
24
+ /** The pinned UFO checkout (explore). */
25
+ ufoDir?: string
26
+ workflow?: string
27
+ variables?: Record<string, string>
28
+ heal?: boolean
29
+ /** expect_image: replace the baselines with what is captured now. */
30
+ updateBaselines?: boolean
31
+ goal?: string
32
+ app?: string
33
+ name?: string
34
+ }
35
+
36
+ export type DesktopStep = {
37
+ index: number
38
+ op: string
39
+ ok: boolean
40
+ ms: number
41
+ error?: string
42
+ healed_target?: Record<string, unknown> | null
43
+ screenshot?: string | null
44
+ target?: Record<string, unknown> | null
45
+ /** How many tries the step took, when more than one. */
46
+ attempts?: number
47
+ /** Dialogs closed (by the workflow's dismiss list) while the step ran. */
48
+ dismissed?: string[]
49
+ /** expect_image: the run's copies of the baseline, what was captured, and the difference. */
50
+ images?: { name?: string; baseline?: string; actual?: string; diff?: string; differs?: number; baseline_saved?: boolean }
51
+ note?: string
52
+ }
53
+
54
+ /** run.json of a desktop run, as the engine writes it. */
55
+ export type DesktopResult = {
56
+ mode?: "replay" | "explore" | "record" | "maestro"
57
+ /** mobile: the driver a replay ran on (appium or maestro), the Maestro flow written for it. */
58
+ driver?: string
59
+ flow?: string
60
+ /** mobile maestro mode: each team flow's result, and the flow files checked before they ran. */
61
+ flows?: { name: string; ok: boolean; error?: string }[]
62
+ checked?: string[]
63
+ ok: boolean
64
+ error?: string
65
+ stopped?: boolean
66
+ name?: string
67
+ app?: string
68
+ workflow?: string | null
69
+ total?: number
70
+ steps?: DesktopStep[]
71
+ heals?: { index: number; old: Record<string, unknown> | null; new: Record<string, unknown> }[]
72
+ goal?: string
73
+ recorded?: number
74
+ /** expect steps explore added from what the app showed when UFO had finished. */
75
+ checks?: number
76
+ /** Values the app showed at the end of an explore (an order number), each exactly as on screen, by name. */
77
+ values?: Record<string, string>
78
+ skipped?: { index: number; action: string; reason: string }[]
79
+ refused?: { action: string; args?: Record<string, unknown> }[]
80
+ ufo_complete?: boolean | null
81
+ seconds?: number
82
+ /** record: why the recording ended (hotkey, app closed, stopped). */
83
+ stopped_by?: string | null
84
+ /** record: the secrets the workflow types (password fields), which must be in desktop.secrets. */
85
+ secrets?: string[]
86
+ }
87
+
88
+ export type DesktopEvent =
89
+ | { type: "log"; level?: string; text?: string }
90
+ | ({ type: "step" } & DesktopStep)
91
+ | { type: "recorded"; index: number; op: string; target?: Record<string, unknown>; keys?: string; value?: string }
92
+ | { type: "call"; ms?: number; ok?: boolean; input?: number; output?: number; cached?: number }
93
+ | { type: "done"; result?: DesktopResult }
94
+
95
+ export function desktopPayload(req: DesktopRequest): Record<string, unknown> {
96
+ return {
97
+ mode: req.mode,
98
+ out_dir: req.outDir,
99
+ llm: llmPayload(req.llm),
100
+ profile_name: req.profileName,
101
+ desktop: { apps: req.desktop.apps, secrets: req.desktop.secrets, ...(req.ufoDir ? { ufo_dir: req.ufoDir } : {}) },
102
+ ...(req.mode === "replay"
103
+ ? { workflow: req.workflow, variables: req.variables || {}, heal: Boolean(req.heal), screenshots: req.desktop.screenshots, update_baselines: Boolean(req.updateBaselines) }
104
+ : req.mode === "record"
105
+ ? { app: req.app, ...(req.name ? { name: req.name } : {}) }
106
+ : { goal: req.goal, app: req.app, name: req.name || req.goal }),
107
+ }
108
+ }
109
+
110
+ /** What the engine process may see: system settings, the model's key (or Entra variables), and desktop.secrets only. */
111
+ export function desktopEnvNames(req: Pick<DesktopRequest, "llm" | "desktop">): string[] {
112
+ return [...(req.llm.auth === "entra" ? AZURE_IDENTITY_ENV : req.llm.apiKeyEnv ? [req.llm.apiKeyEnv] : []), ...req.desktop.secrets]
113
+ }
114
+
115
+ /**
116
+ * Run one desktop request in the engine and pass its events on. A stop (the signal) asks the engine to stop between
117
+ * steps; one that has not exited `graceMs` later is killed.
118
+ */
119
+ export async function runDesktop(opts: {
120
+ python: string
121
+ /** The engine command; default `<python> -m forgeqe_desktop serve`. */
122
+ command?: string[]
123
+ request: DesktopRequest
124
+ signal?: AbortSignal
125
+ onEvent?: (event: DesktopEvent) => void
126
+ graceMs?: number
127
+ }): Promise<DesktopResult> {
128
+ const req = opts.request
129
+ return runEngine({
130
+ command: opts.command || [opts.python, "-m", "forgeqe_desktop", "serve"],
131
+ payload: desktopPayload(req),
132
+ logged: { ...desktopPayload(req), desktop: { apps: Object.keys(req.desktop.apps), secrets: req.desktop.secrets } },
133
+ envNames: desktopEnvNames(req),
134
+ outDir: req.outDir,
135
+ llm: req.llm,
136
+ agent: "desktop agent",
137
+ log: "desktop",
138
+ signal: opts.signal,
139
+ onEvent: opts.onEvent,
140
+ graceMs: opts.graceMs,
141
+ })
142
+ }
143
+
144
+ /** One request to an engine that speaks the JSON-lines protocol (`serve`): the desktop engine or the mobile engine. */
145
+ export async function runEngine(opts: {
146
+ command: string[]
147
+ payload: Record<string, unknown>
148
+ /** What the session log records of the request (no secrets). */
149
+ logged: Record<string, unknown>
150
+ /** The environment variables the engine may see, besides system settings. */
151
+ envNames: string[]
152
+ outDir: string
153
+ llm: LlmProfile
154
+ /** The usage ledger's name for the engine's model calls. */
155
+ agent: Role
156
+ log: "desktop" | "mobile"
157
+ signal?: AbortSignal
158
+ onEvent?: (event: DesktopEvent) => void
159
+ graceMs?: number
160
+ }): Promise<DesktopResult> {
161
+ const { onEvent } = opts
162
+ // A github_copilot profile's desktop and mobile agents run on GitHub Models, like the browser engine: the engine gets
163
+ // that profile, and the token only in its environment.
164
+ const route = engineRoute(opts.llm, ghAuthToken)
165
+ const req = { llm: route.profile }
166
+ const payload = opts.payload.llm ? { ...opts.payload, llm: llmPayload(route.profile) } : opts.payload
167
+ const scope = { runId: path.basename(opts.outDir) }
168
+ const model = req.llm.deployment || req.llm.model
169
+ const [bin, ...args] = opts.command
170
+ const child = spawn(bin!, args, { env: engineEnv(route.env, [...opts.envNames, ...Object.keys(route.env)]), stdio: ["pipe", "pipe", "pipe"] })
171
+
172
+ const stderrTail: string[] = []
173
+ child.stderr.setEncoding("utf8")
174
+ child.stderr.on("data", (chunk: string) => {
175
+ for (const line of chunk.split(/\r?\n/)) {
176
+ if (!line.trim()) continue
177
+ logEvent(`${opts.log}.log`, line)
178
+ stderrTail.push(line)
179
+ if (stderrTail.length > 40) stderrTail.shift()
180
+ }
181
+ })
182
+
183
+ child.stdin.write(JSON.stringify(payload) + "\n")
184
+ logEvent(`${opts.log}.request`, opts.logged)
185
+
186
+ let killTimer: ReturnType<typeof setTimeout> | undefined
187
+ const onAbort = () => {
188
+ if (child.stdin.writable) child.stdin.write("stop\n")
189
+ killTimer = setTimeout(() => child.exitCode === null && child.kill(), opts.graceMs ?? 30_000)
190
+ }
191
+ if (opts.signal?.aborted) onAbort()
192
+ opts.signal?.addEventListener("abort", onAbort, { once: true })
193
+
194
+ let done: DesktopResult | null = null
195
+ const lines = createInterface({ input: child.stdout })
196
+ lines.on("line", (line) => {
197
+ if (!line.trim()) return
198
+ let ev: DesktopEvent
199
+ try {
200
+ ev = JSON.parse(line) as DesktopEvent
201
+ } catch {
202
+ onEvent?.({ type: "log", level: "info", text: line })
203
+ return
204
+ }
205
+ logEvent(`${opts.log}.event`, ev)
206
+ if (ev.type === "call") {
207
+ const usage: Usage = { input: Number(ev.input || 0), output: Number(ev.output || 0), ...(ev.cached ? { cached: Number(ev.cached) } : {}) }
208
+ withUsageScope(scope, () =>
209
+ recordCall({ agent: opts.agent, usage, price: req.llm.price, provider: opts.llm.provider, model, purpose: opts.log, ms: Number(ev.ms || 0), outcome: ev.ok === false ? "error" : "ok" }),
210
+ )
211
+ }
212
+ if (ev.type === "done") done = ev.result || { ok: false, error: "the engine sent no result" }
213
+ onEvent?.(ev)
214
+ })
215
+
216
+ const code = await new Promise<number>((resolve, reject) => {
217
+ child.on("error", reject)
218
+ child.on("close", (c) => resolve(c ?? 1))
219
+ }).finally(() => {
220
+ if (killTimer) clearTimeout(killTimer)
221
+ opts.signal?.removeEventListener("abort", onAbort)
222
+ })
223
+ logEvent(`${opts.log}.exit`, { code, finished: Boolean(done) })
224
+ if (!done) {
225
+ const detail = stderrTail.slice(-12).join("\n")
226
+ throw new Error(`The ${opts.log} engine exited (${code}) without a result.${detail ? `\n${detail}` : ""}`)
227
+ }
228
+ return done
229
+ }