lark-remote 0.1.0

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 (201) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +162 -0
  4. package/README.md +161 -0
  5. package/dist/binder.d.ts +57 -0
  6. package/dist/binder.js +105 -0
  7. package/dist/bridge/index.d.ts +338 -0
  8. package/dist/bridge/index.js +1281 -0
  9. package/dist/bridge/queue-manager.d.ts +277 -0
  10. package/dist/bridge/queue-manager.js +729 -0
  11. package/dist/card/bash-card-session.d.ts +78 -0
  12. package/dist/card/bash-card-session.js +174 -0
  13. package/dist/card/bash-renderer.d.ts +24 -0
  14. package/dist/card/bash-renderer.js +187 -0
  15. package/dist/card/card-budget.d.ts +31 -0
  16. package/dist/card/card-budget.js +280 -0
  17. package/dist/card/card-session.d.ts +83 -0
  18. package/dist/card/card-session.js +185 -0
  19. package/dist/card/card-shared.d.ts +28 -0
  20. package/dist/card/card-shared.js +99 -0
  21. package/dist/card/collapsible.d.ts +26 -0
  22. package/dist/card/collapsible.js +87 -0
  23. package/dist/card/run-card-session.d.ts +78 -0
  24. package/dist/card/run-card-session.js +165 -0
  25. package/dist/card/run-renderer.d.ts +66 -0
  26. package/dist/card/run-renderer.js +841 -0
  27. package/dist/card/run-state.d.ts +103 -0
  28. package/dist/card/run-state.js +313 -0
  29. package/dist/card/text-truncate.d.ts +56 -0
  30. package/dist/card/text-truncate.js +184 -0
  31. package/dist/card/time.d.ts +1 -0
  32. package/dist/card/time.js +14 -0
  33. package/dist/card/tool-render.d.ts +25 -0
  34. package/dist/card/tool-render.js +176 -0
  35. package/dist/cli.d.ts +28 -0
  36. package/dist/cli.js +89 -0
  37. package/dist/common/guards.d.ts +37 -0
  38. package/dist/common/guards.js +65 -0
  39. package/dist/config/codex-config.d.ts +113 -0
  40. package/dist/config/codex-config.js +428 -0
  41. package/dist/config/dir.d.ts +17 -0
  42. package/dist/config/dir.js +81 -0
  43. package/dist/config/index.d.ts +219 -0
  44. package/dist/config/index.js +388 -0
  45. package/dist/config/kimi-config.d.ts +33 -0
  46. package/dist/config/kimi-config.js +111 -0
  47. package/dist/config/opencode-config.d.ts +20 -0
  48. package/dist/config/opencode-config.js +124 -0
  49. package/dist/config/pi-config.d.ts +49 -0
  50. package/dist/config/pi-config.js +151 -0
  51. package/dist/config/wizard.d.ts +12 -0
  52. package/dist/config/wizard.js +84 -0
  53. package/dist/connector/file-limits.d.ts +7 -0
  54. package/dist/connector/file-limits.js +8 -0
  55. package/dist/connector/index.d.ts +86 -0
  56. package/dist/connector/index.js +340 -0
  57. package/dist/error-classification.d.ts +15 -0
  58. package/dist/error-classification.js +24 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +580 -0
  61. package/dist/instance-lock.d.ts +30 -0
  62. package/dist/instance-lock.js +184 -0
  63. package/dist/logger/index.d.ts +57 -0
  64. package/dist/logger/index.js +178 -0
  65. package/dist/order/index.d.ts +27 -0
  66. package/dist/order/index.js +90 -0
  67. package/dist/persistence/atomic-write.d.ts +10 -0
  68. package/dist/persistence/atomic-write.js +65 -0
  69. package/dist/persistence/load-json-file.d.ts +9 -0
  70. package/dist/persistence/load-json-file.js +23 -0
  71. package/dist/restart.d.ts +24 -0
  72. package/dist/restart.js +73 -0
  73. package/dist/router/card-helpers.d.ts +28 -0
  74. package/dist/router/card-helpers.js +65 -0
  75. package/dist/router/config/claude.d.ts +14 -0
  76. package/dist/router/config/claude.js +72 -0
  77. package/dist/router/config/codex.d.ts +22 -0
  78. package/dist/router/config/codex.js +100 -0
  79. package/dist/router/config/common/render.d.ts +16 -0
  80. package/dist/router/config/common/render.js +158 -0
  81. package/dist/router/config/index.d.ts +18 -0
  82. package/dist/router/config/index.js +38 -0
  83. package/dist/router/config/kimi.d.ts +21 -0
  84. package/dist/router/config/kimi.js +63 -0
  85. package/dist/router/config/opencode.d.ts +12 -0
  86. package/dist/router/config/opencode.js +44 -0
  87. package/dist/router/config/pi.d.ts +12 -0
  88. package/dist/router/config/pi.js +50 -0
  89. package/dist/router/config/types.d.ts +43 -0
  90. package/dist/router/config/types.js +8 -0
  91. package/dist/router/index.d.ts +318 -0
  92. package/dist/router/index.js +2715 -0
  93. package/dist/router/order-exec-dispatch.d.ts +30 -0
  94. package/dist/router/order-exec-dispatch.js +48 -0
  95. package/dist/router/utils.d.ts +44 -0
  96. package/dist/router/utils.js +116 -0
  97. package/dist/runner/bash/index.d.ts +4 -0
  98. package/dist/runner/bash/index.js +5 -0
  99. package/dist/runner/bash/runner.d.ts +55 -0
  100. package/dist/runner/bash/runner.js +213 -0
  101. package/dist/runner/claude/index.d.ts +4 -0
  102. package/dist/runner/claude/index.js +5 -0
  103. package/dist/runner/claude/runner.d.ts +29 -0
  104. package/dist/runner/claude/runner.js +83 -0
  105. package/dist/runner/codex/argv.d.ts +33 -0
  106. package/dist/runner/codex/argv.js +40 -0
  107. package/dist/runner/codex/index.d.ts +1 -0
  108. package/dist/runner/codex/index.js +2 -0
  109. package/dist/runner/codex/jsonl.d.ts +37 -0
  110. package/dist/runner/codex/jsonl.js +221 -0
  111. package/dist/runner/codex/runner.d.ts +65 -0
  112. package/dist/runner/codex/runner.js +124 -0
  113. package/dist/runner/common/exec-translator.d.ts +73 -0
  114. package/dist/runner/common/exec-translator.js +105 -0
  115. package/dist/runner/common/jsonl-stream.d.ts +39 -0
  116. package/dist/runner/common/jsonl-stream.js +203 -0
  117. package/dist/runner/common/process-stopper.d.ts +28 -0
  118. package/dist/runner/common/process-stopper.js +79 -0
  119. package/dist/runner/common/runner-utils.d.ts +21 -0
  120. package/dist/runner/common/runner-utils.js +65 -0
  121. package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
  122. package/dist/runner/common/spawn-heartbeat.js +51 -0
  123. package/dist/runner/common/spawning-runner.d.ts +321 -0
  124. package/dist/runner/common/spawning-runner.js +634 -0
  125. package/dist/runner/common/usage.d.ts +31 -0
  126. package/dist/runner/common/usage.js +29 -0
  127. package/dist/runner/index.d.ts +19 -0
  128. package/dist/runner/index.js +24 -0
  129. package/dist/runner/kimi/index.d.ts +1 -0
  130. package/dist/runner/kimi/index.js +2 -0
  131. package/dist/runner/kimi/jsonl.d.ts +28 -0
  132. package/dist/runner/kimi/jsonl.js +143 -0
  133. package/dist/runner/kimi/runner.d.ts +50 -0
  134. package/dist/runner/kimi/runner.js +105 -0
  135. package/dist/runner/opencode/argv.d.ts +32 -0
  136. package/dist/runner/opencode/argv.js +35 -0
  137. package/dist/runner/opencode/index.d.ts +1 -0
  138. package/dist/runner/opencode/index.js +2 -0
  139. package/dist/runner/opencode/jsonl.d.ts +68 -0
  140. package/dist/runner/opencode/jsonl.js +295 -0
  141. package/dist/runner/opencode/runner.d.ts +63 -0
  142. package/dist/runner/opencode/runner.js +93 -0
  143. package/dist/runner/pi/index.d.ts +1 -0
  144. package/dist/runner/pi/index.js +2 -0
  145. package/dist/runner/pi/jsonl.d.ts +128 -0
  146. package/dist/runner/pi/jsonl.js +249 -0
  147. package/dist/runner/pi/runner.d.ts +37 -0
  148. package/dist/runner/pi/runner.js +76 -0
  149. package/dist/runner/registry.d.ts +54 -0
  150. package/dist/runner/registry.js +75 -0
  151. package/dist/runner/resolve-agent-choices.d.ts +7 -0
  152. package/dist/runner/resolve-agent-choices.js +76 -0
  153. package/dist/runner/sync-agent-choices.d.ts +6 -0
  154. package/dist/runner/sync-agent-choices.js +73 -0
  155. package/dist/runner/types.d.ts +250 -0
  156. package/dist/runner/types.js +8 -0
  157. package/dist/session/claude/index.d.ts +1 -0
  158. package/dist/session/claude/index.js +2 -0
  159. package/dist/session/claude/session-reader.d.ts +34 -0
  160. package/dist/session/claude/session-reader.js +46 -0
  161. package/dist/session/claude/sessions.d.ts +53 -0
  162. package/dist/session/claude/sessions.js +528 -0
  163. package/dist/session/codex/index.d.ts +1 -0
  164. package/dist/session/codex/index.js +2 -0
  165. package/dist/session/codex/rollout-reader.d.ts +88 -0
  166. package/dist/session/codex/rollout-reader.js +473 -0
  167. package/dist/session/codex/sessions.d.ts +21 -0
  168. package/dist/session/codex/sessions.js +40 -0
  169. package/dist/session/common/catch-up.d.ts +12 -0
  170. package/dist/session/common/catch-up.js +31 -0
  171. package/dist/session/common/constants.d.ts +11 -0
  172. package/dist/session/common/constants.js +12 -0
  173. package/dist/session/common/content-blocks.d.ts +37 -0
  174. package/dist/session/common/content-blocks.js +68 -0
  175. package/dist/session/common/jsonl.d.ts +77 -0
  176. package/dist/session/common/jsonl.js +351 -0
  177. package/dist/session/common/usage-accumulator.d.ts +58 -0
  178. package/dist/session/common/usage-accumulator.js +72 -0
  179. package/dist/session/index.d.ts +7 -0
  180. package/dist/session/index.js +11 -0
  181. package/dist/session/kimi/index.d.ts +1 -0
  182. package/dist/session/kimi/index.js +2 -0
  183. package/dist/session/kimi/sessions.d.ts +81 -0
  184. package/dist/session/kimi/sessions.js +527 -0
  185. package/dist/session/opencode/index.d.ts +1 -0
  186. package/dist/session/opencode/index.js +2 -0
  187. package/dist/session/opencode/sessions.d.ts +81 -0
  188. package/dist/session/opencode/sessions.js +388 -0
  189. package/dist/session/pi/index.d.ts +1 -0
  190. package/dist/session/pi/index.js +2 -0
  191. package/dist/session/pi/sessions.d.ts +27 -0
  192. package/dist/session/pi/sessions.js +399 -0
  193. package/dist/session/registry.d.ts +12 -0
  194. package/dist/session/registry.js +20 -0
  195. package/dist/session/session-store.d.ts +86 -0
  196. package/dist/session/session-store.js +450 -0
  197. package/dist/startup-contact.d.ts +18 -0
  198. package/dist/startup-contact.js +51 -0
  199. package/dist/workspace/index.d.ts +19 -0
  200. package/dist/workspace/index.js +53 -0
  201. package/package.json +93 -0
@@ -0,0 +1,634 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import fs from 'node:fs';
5
+ import { getLogger } from '../../logger/index.js';
6
+ import { ProcessStopper } from './process-stopper.js';
7
+ import { SpawnHeartbeat } from './spawn-heartbeat.js';
8
+ import { createJSONLStream } from './jsonl-stream.js';
9
+ import { authErrorEvent } from './runner-utils.js';
10
+ import { DEFAULT_STOP_GRACE_MS } from '../../config/index.js';
11
+ /**
12
+ * Magic-number constants (Clean Code P3-1, G25 Replace Magic Numbers with
13
+ * Named Constants). Centralized here so the truncation budgets are visible
14
+ * and adjustable in one place rather than scattered as raw literals.
15
+ */
16
+ /** Max bytes of stderr retained for the result-event error message. */
17
+ const STDERR_TAIL_BYTES = 4000;
18
+ /** Max bytes of stderr surfaced in the non-zero-exit log line. */
19
+ const STDERR_LOG_TAIL = 500;
20
+ /**
21
+ * Timeout (ms) for awaiting the spawn 'error' event when proc.pid === undefined
22
+ * (review P2-11). Node guarantees 'error' for ENOENT/EACCES, but some binaries
23
+ * fail silently; the race keeps a silent failure from hanging run() forever.
24
+ * Matches the 5s race kimi's override used before it was hoisted to the base.
25
+ */
26
+ const SPAWN_ERROR_TIMEOUT_MS = 5000;
27
+ const registeredRunners = new Set();
28
+ let exitListenersInstalled = false;
29
+ function cleanupRegisteredRunners() {
30
+ for (const runner of registeredRunners) {
31
+ runner.cleanupOnExit();
32
+ }
33
+ }
34
+ function installExitListenersOnce() {
35
+ if (exitListenersInstalled)
36
+ return;
37
+ exitListenersInstalled = true;
38
+ process.on('exit', cleanupRegisteredRunners);
39
+ process.on('SIGINT', () => {
40
+ cleanupRegisteredRunners();
41
+ process.exit(130);
42
+ });
43
+ process.on('SIGTERM', () => {
44
+ cleanupRegisteredRunners();
45
+ process.exit(143);
46
+ });
47
+ }
48
+ /** Register a process-exit cleanup handler (installs listeners at most once). */
49
+ export function registerExitCleanup(handler) {
50
+ installExitListenersOnce();
51
+ registeredRunners.add(handler);
52
+ }
53
+ /** Remove a handler so it can be GC'd (agent slots / finished bash runs). */
54
+ export function unregisterExitCleanup(handler) {
55
+ registeredRunners.delete(handler);
56
+ }
57
+ /**
58
+ * Abstract base class for runners that spawn a child process per turn.
59
+ *
60
+ * Encapsulates the spawn + completion + cleanup orchestration shared by
61
+ * Claude/Codex/OpenCode/Pi/Kimi runners. Subclasses override:
62
+ * - buildArgv(opts) → agent-specific CLI flags (required)
63
+ * - createTranslator(opts) → stateful translator (codex/opencode/pi/kimi)
64
+ * OR translate(raw, ctx) → stateless passthrough (claude)
65
+ *
66
+ * Optional hooks (with sensible defaults):
67
+ * - getStdio() → stdio config (default ['ignore','pipe','pipe'])
68
+ * - getSpawnEnv(opts) → env vars (default process.env)
69
+ * - setupStdin(proc, message) → write prompt to stdin (default no-op)
70
+ * - createStreamReader(stdout) → JSONL or readline parser (default createJSONLStream)
71
+ * - validateBeforeRun(opts) → pre-spawn validation (default no-op)
72
+ * - awaitCompletion(proc, p) → wait for exit (default return p; kimi adds 5s race)
73
+ * - awaitSpawnError(proc) → wait for spawn error (default once('error'); kimi adds 5s race)
74
+ * - resolveTranslatorError(t, c) → translatorError precedence (default honors agent errors)
75
+ *
76
+ * The base class always emits a unified result event at the end of `run()`
77
+ * via `buildResultEvent(...)` — never throws on non-zero exit. This matches
78
+ * the contract pinned by codex/opencode/pi/kimi subclass anchors and the
79
+ * bridge's `runAgentStreamToEnd` error handling (which accepts both thrown
80
+ * errors and yielded error-result events).
81
+ */
82
+ export class SpawningRunner {
83
+ currentProcess = null;
84
+ pidFilePath;
85
+ stopper;
86
+ spawnHeartbeat;
87
+ binary;
88
+ stopGraceMs;
89
+ /**
90
+ * Log prefix used in all operational log lines emitted by this runner
91
+ * (spawn, pid file, non-zero exit, stderr, killOrphan, stop cleanup) and
92
+ * as the SpawnHeartbeat label. Subclasses pass their own tag (e.g.
93
+ * 'claude-runner') so operators can grep agent-specific logs; the default
94
+ * 'spawning-runner' keeps the base-class anchor tests' neutral identity.
95
+ */
96
+ logTag;
97
+ /**
98
+ * The `message` argument from the most recent `run(message, opts)` call.
99
+ * Set in `run()` before `buildArgv(opts)` is invoked so subclasses can
100
+ * embed the message in their agent-specific argv (e.g. claude's `-p
101
+ * <message>`). This avoids changing the `buildArgv(opts)` signature,
102
+ * which would break existing anchor subclasses.
103
+ */
104
+ currentMessage = '';
105
+ /**
106
+ * Whether the current run was interrupted by user-initiated stop().
107
+ * Set to true by stop() when a running process is being terminated;
108
+ * reset to false at the top of run(). `resolveTranslatorError()` and
109
+ * `buildResultEvent()` read this to decide result event subtype
110
+ * (error vs success) and the error message precedence.
111
+ */
112
+ stoppedByUser = false;
113
+ constructor(opts) {
114
+ // Neutral default — empty string fails loudly at spawn time if a caller
115
+ // forgets to configure binary. 'claude' would be wrong (biases the base
116
+ // class toward one agent). Subclasses pass their own binary.
117
+ this.binary = opts.binary ?? '';
118
+ this.stopGraceMs = opts.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
119
+ this.logTag = opts.logTag ?? 'spawning-runner';
120
+ const pidDir = opts.pidDir ?? path.join(os.homedir(), '.lark-remote');
121
+ const workspaceSuffix = `-${opts.workspace.replace(/[^a-zA-Z0-9]/g, '_')}`;
122
+ const pidFilePrefix = opts.pidFilePrefix ?? 'spawning';
123
+ this.pidFilePath = path.join(pidDir, `${pidFilePrefix}${workspaceSuffix}.pid`);
124
+ this.stopper = new ProcessStopper({ graceMs: this.stopGraceMs });
125
+ this.spawnHeartbeat = new SpawnHeartbeat(opts.spawnHeartbeatMs ?? 30_000, this.logTag);
126
+ }
127
+ get isRunning() {
128
+ return (this.currentProcess !== null &&
129
+ this.currentProcess.exitCode === null &&
130
+ this.currentProcess.signalCode === null);
131
+ }
132
+ // --- Hooks (subclasses override as needed) ---
133
+ /**
134
+ * stdio config for spawn. Default: stdin ignored (claude/kimi/pi pass the
135
+ * prompt via argv). codex/opencode override to ['pipe','pipe','pipe'] so
136
+ * they can write the prompt to stdin via `setupStdin`.
137
+ */
138
+ getStdio() {
139
+ return ['ignore', 'pipe', 'pipe'];
140
+ }
141
+ /**
142
+ * Env vars for spawn. Default: process.env. opencode overrides to sync
143
+ * PWD=opts.cwd (opencode's directory detection reads PWD, not cwd).
144
+ */
145
+ getSpawnEnv(_opts) {
146
+ return process.env;
147
+ }
148
+ /**
149
+ * Write the prompt to the child's stdin (called after spawn succeeds).
150
+ * Default: no-op. codex/opencode override to `proc.stdin.end(message)`.
151
+ */
152
+ setupStdin(_proc, _message) {
153
+ // no-op by default
154
+ }
155
+ /**
156
+ * Create an async iterator over the child's stdout. Default: createJSONLStream
157
+ * (claude/kimi/pi) with P1-4 backpressure enabled (pauseThreshold=100).
158
+ * Subclasses that need to silently skip non-JSON lines should override
159
+ * `handleBadStreamLine` instead of this method.
160
+ */
161
+ createStreamReader(stdout) {
162
+ return createJSONLStream(stdout, {
163
+ onParseError: (line) => this.handleBadStreamLine(line),
164
+ pauseThreshold: 100,
165
+ resumeThreshold: 50,
166
+ });
167
+ }
168
+ /**
169
+ * Hook called when createJSONLStream encounters a line that cannot be parsed
170
+ * as JSON. Default: log a warning via getLogger().warn (same format as
171
+ * jsonl-stream's built-in warning). Subclasses override to change behavior
172
+ * (e.g. codex/opencode silently skip by providing an empty override).
173
+ */
174
+ handleBadStreamLine(line) {
175
+ getLogger().warn(`[jsonl-stream] failed to parse JSONL: ${line.slice(0, 100)}`);
176
+ }
177
+ /**
178
+ * Create a stateful translator for this run. Default: null (uses the
179
+ * stateless `translate(raw, ctx)` hook). codex/opencode/pi/kimi override to
180
+ * return their translator instance.
181
+ */
182
+ createTranslator(_opts) {
183
+ return null;
184
+ }
185
+ /**
186
+ * Pre-spawn validation. Return an AgentEvent to yield it and abort run()
187
+ * (e.g. codex's API key check yields an error result and returns).
188
+ * Default: null (no validation).
189
+ */
190
+ validateBeforeRun(_opts) {
191
+ return null;
192
+ }
193
+ /**
194
+ * Await process completion. Default: return the completion promise as-is.
195
+ * kimi overrides to add a 5s timeout race + SIGKILL fallback (kimi
196
+ * sometimes hangs after stdout closes).
197
+ */
198
+ awaitCompletion(_proc, completion) {
199
+ return completion;
200
+ }
201
+ /**
202
+ * Await the spawn 'error' event when proc.pid === undefined. Races a
203
+ * finite timeout (review P2-11) against the 'error' event: Node guarantees
204
+ * 'error' for ENOENT/EACCES, but some binaries fail silently without ever
205
+ * emitting it (kimi's CLI is documented to do so). Without the race, run()
206
+ * hangs forever → the workspace serial queue never settles → permanent
207
+ * deadlock, and /stop can't recover (ProcessStopper returns early when
208
+ * pid === undefined). The timeout keeps the deadlock bounded.
209
+ */
210
+ awaitSpawnError(proc) {
211
+ return Promise.race([
212
+ new Promise((resolve) => {
213
+ proc.once('error', (err) => resolve(err));
214
+ }),
215
+ new Promise((resolve) => setTimeout(() => resolve(undefined), SPAWN_ERROR_TIMEOUT_MS)),
216
+ ]);
217
+ }
218
+ /**
219
+ * Resolve the translatorError to pass to buildResultEvent. Default
220
+ * precedence (most-specific first):
221
+ * - agent-reported terminal error (translator.hasAgentTerminalError()):
222
+ * always wins — the agent error is the root cause, signal/code is a
223
+ * consequence. E.g. codex `turn.failed` then non-zero exit.
224
+ * - stream-ended-early symptom (translator.getTerminalError() set by
225
+ * finish('failed')): ONLY surfaces when there is no external cause
226
+ * (signal / non-zero code / user stop). The signal/code is the root
227
+ * cause; the early stream-end is a symptom.
228
+ * - otherwise: undefined (signal/code/stoppedByUser drives the message).
229
+ *
230
+ * Subclasses can override for custom precedence but the default matches
231
+ * the contract pinned by codex/opencode subclass anchors.
232
+ */
233
+ resolveTranslatorError(translator, ctx) {
234
+ if (!translator)
235
+ return undefined;
236
+ const agentError = translator.hasAgentTerminalError();
237
+ const hasExternalCause = this.stoppedByUser || ctx.signal !== null || (ctx.code !== null && ctx.code !== 0);
238
+ return agentError || !hasExternalCause ? translator.getTerminalError() : undefined;
239
+ }
240
+ /**
241
+ * Spawn the agent binary with argv built by buildArgv(opts), iterate its
242
+ * stdout stream, and yield translated events. Always emits a unified
243
+ * result event at the end via `buildResultEvent(...)` — never throws on
244
+ * non-zero exit (the bridge accepts both thrown errors and yielded
245
+ * error-result events, and the latter carries richer diagnostic info).
246
+ *
247
+ * Spawn failures (binary missing, pid undefined) yield an `authErrorEvent`
248
+ * instead of throwing, so the user sees a friendly error card.
249
+ */
250
+ async *run(message, opts) {
251
+ if (this.isRunning) {
252
+ getLogger().warn(`[${this.logTag}] run() called while already running, refusing`);
253
+ throw new Error(`${this.binary} process already running`);
254
+ }
255
+ this.stoppedByUser = false;
256
+ this.currentMessage = message;
257
+ // Pre-spawn validation hook (e.g. codex API key check)
258
+ const validationError = this.validateBeforeRun(opts);
259
+ if (validationError) {
260
+ yield validationError;
261
+ return;
262
+ }
263
+ const args = this.buildArgv(opts);
264
+ const proc = spawn(this.binary, args, {
265
+ cwd: opts.cwd,
266
+ stdio: this.getStdio(),
267
+ env: this.getSpawnEnv(opts),
268
+ // Spawn in a new process group so we can kill the entire group
269
+ // when stopping. This ensures child processes (shell wrappers,
270
+ // sub-processes) are also terminated.
271
+ detached: true,
272
+ });
273
+ this.currentProcess = proc;
274
+ getLogger().info(`[${this.logTag}] spawn pid=${proc.pid ?? '(none)'} binary=${this.binary} ` +
275
+ `cwd=${opts.cwd} sessionId=${opts.sessionId ?? '(none)'}`);
276
+ if (proc.pid === undefined) {
277
+ const spawnErr = await this.awaitSpawnError(proc);
278
+ if (spawnErr) {
279
+ getLogger().error(`[${this.logTag}] spawn failed: ${spawnErr.message} binary=${this.binary} cwd=${opts.cwd}`);
280
+ }
281
+ else {
282
+ getLogger().error(`[${this.logTag}] spawn failed: timeout waiting for error event`);
283
+ }
284
+ this.currentProcess = null;
285
+ this.spawnHeartbeat.clear();
286
+ // P2-13: surface the real spawn error cause instead of a fixed "binary
287
+ // not found" message. ENOENT (binary missing) keeps the friendly hint,
288
+ // but EMFILE/EACCES/ENOMEM/bad-cwd failures must name the actual reason
289
+ // so the user is not misdiagnosed into reinstalling the binary.
290
+ const baseMsg = `${this.binary} 命令不可用(未找到或不可执行),请检查是否已安装或在 PATH 中`;
291
+ const msg = spawnErr ? `${baseMsg}:${spawnErr.message}` : baseMsg;
292
+ yield authErrorEvent(msg);
293
+ return;
294
+ }
295
+ const completion = new Promise((resolve) => {
296
+ proc.once('error', (err) => {
297
+ this.spawnHeartbeat.clear();
298
+ getLogger().error(`[${this.logTag}] spawn failed: ${err.message} binary=${this.binary} cwd=${opts.cwd}`);
299
+ // Resolve (not reject) so the unified result event is emitted
300
+ // via buildResultEvent instead of throwing past the finally
301
+ // into the bridge's catch (which would leak the pid file).
302
+ // P2-12: a post-spawn 'error' (e.g. kill failure, broken pipe)
303
+ // MUST surface as an error result, not success. Previously
304
+ // {code:null,signal:null} was classified as success by
305
+ // buildResultEvent; code=1 forces the error branch so the
306
+ // failure is never silently swallowed as a successful run.
307
+ resolve({ code: 1, signal: null });
308
+ });
309
+ proc.once('close', (code, signal) => {
310
+ this.spawnHeartbeat.clear();
311
+ resolve({ code, signal });
312
+ });
313
+ });
314
+ const pidDir = path.dirname(this.pidFilePath);
315
+ fs.mkdirSync(pidDir, { recursive: true });
316
+ fs.writeFileSync(this.pidFilePath, String(proc.pid), 'utf-8');
317
+ getLogger().info(`[${this.logTag}] wrote pid file ${this.pidFilePath}=${proc.pid}`);
318
+ this.spawnHeartbeat.start({ pid: proc.pid, binary: this.binary, cwd: opts.cwd });
319
+ proc.stdout?.once('data', () => {
320
+ this.spawnHeartbeat.notifyStdout();
321
+ });
322
+ let stderr = '';
323
+ proc.stderr?.on('data', (chunk) => {
324
+ const text = chunk.toString('utf-8').trim();
325
+ if (text) {
326
+ stderr = (stderr + '\n' + text).trim().slice(-STDERR_TAIL_BYTES);
327
+ // P2-16: most agent CLIs emit progress/warnings/deprecation notices on
328
+ // stderr, not real errors. Logging each chunk at error level drowns out
329
+ // genuine errors. Downgrade to warn — the accumulated stderr is already
330
+ // surfaced at error level in the non-zero-exit result path.
331
+ getLogger().warn(`[${this.logTag} stderr] ${text}`);
332
+ }
333
+ });
334
+ // Hook: write prompt to stdin (codex/opencode)
335
+ this.setupStdin(proc, message);
336
+ // Create translator (per-run state) — null for stateless passthrough (claude)
337
+ const translator = this.createTranslator(opts);
338
+ // Create stream reader (jsonl or readline)
339
+ const stream = this.createStreamReader(proc.stdout);
340
+ try {
341
+ // Stream loop wrapped in try/catch so a stream error (e.g. stdout
342
+ // 'error' event propagated through the async generator) does not
343
+ // bypass the completion await and result event emission. Matches
344
+ // kimi's long-standing behavior; safe for all runners because the
345
+ // completion promise still resolves via proc 'close'.
346
+ try {
347
+ for await (const rawEvent of stream) {
348
+ let translated;
349
+ if (translator) {
350
+ // Per-event error isolation: a translator throw must not kill
351
+ // the stream (pi's accumulator could throw on malformed input).
352
+ try {
353
+ translated = translator.translate(rawEvent);
354
+ }
355
+ catch (err) {
356
+ getLogger().warn(`[${this.logTag}] translator error: ${err.message}, raw: ${JSON.stringify(rawEvent).slice(0, 100)}`);
357
+ continue;
358
+ }
359
+ }
360
+ else {
361
+ translated = this.translate(rawEvent, { message, opts });
362
+ }
363
+ if (translated === null)
364
+ continue;
365
+ if (Array.isArray(translated)) {
366
+ for (const e of translated)
367
+ yield e;
368
+ }
369
+ else {
370
+ yield translated;
371
+ }
372
+ }
373
+ }
374
+ catch (error) {
375
+ getLogger().error(`[${this.logTag}] stream error: ${error}`);
376
+ }
377
+ const { code, signal } = await this.awaitCompletion(proc, completion);
378
+ // If the stream ended before a terminal event, let the translator
379
+ // record its terminal state (e.g. codex `finish('failed')` stashes
380
+ // "stream ended before a terminal event"). The runner folds this
381
+ // into buildResultEvent below.
382
+ if (translator && !translator.isTerminal()) {
383
+ const reason = this.stoppedByUser ? 'interrupted' : 'failed';
384
+ translator.finish(reason);
385
+ }
386
+ const translatorError = this.resolveTranslatorError(translator, { code, signal });
387
+ const nonUserError = !this.stoppedByUser && ((code !== null && code !== 0) || signal !== null);
388
+ yield this.buildResultEvent({
389
+ code,
390
+ signal,
391
+ stderr,
392
+ sessionId: translator?.getSessionId?.() ?? '',
393
+ usage: translator?.getLastUsage?.(),
394
+ translatorError,
395
+ });
396
+ if (nonUserError) {
397
+ getLogger().error(`[${this.logTag}] non-zero exit code=${code} signal=${signal} stderr=${stderr.slice(-STDERR_LOG_TAIL)}`);
398
+ }
399
+ }
400
+ finally {
401
+ this.spawnHeartbeat.clear();
402
+ // P1-11: 消费者提前关闭生成器(for-await 循环体抛错/break 触发的 .return())
403
+ // 会跳过 completion await 直接进 finally——此时子进程可能仍在运行。若不杀,
404
+ // 它会成为 stop()(currentProcess 已置 null)、killOrphan()(pid 文件已删)、
405
+ // exit handler(同一字段)都够不到的孤儿黑洞,且 stdout 管道背压会把它永久
406
+ // 阻塞。正常完成路径进程已退出,stop 是 no-op,零成本。
407
+ const proc = this.currentProcess;
408
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
409
+ try {
410
+ await this.stopper.stop(proc, { immediate: true });
411
+ }
412
+ catch {
413
+ /* ignore */
414
+ }
415
+ }
416
+ this.currentProcess = null;
417
+ try {
418
+ fs.unlinkSync(this.pidFilePath);
419
+ }
420
+ catch {
421
+ /* ignore — file may not exist if spawn failed before write */
422
+ }
423
+ }
424
+ }
425
+ /**
426
+ * Stop the current process if one is running. Delegates the actual
427
+ * SIGTERM → grace → SIGKILL sequence to `this.stopper.stop(proc, opts)`
428
+ * (see `src/runner/common/process-stopper.ts`), forwarding the `immediate`
429
+ * flag verbatim so all 5 subclasses inherit identical stop semantics.
430
+ */
431
+ async stop(opts) {
432
+ const proc = this.currentProcess;
433
+ if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
434
+ this.spawnHeartbeat.clear();
435
+ this.currentProcess = null;
436
+ return;
437
+ }
438
+ this.stoppedByUser = true;
439
+ await this.stopper.stop(proc, { immediate: opts?.immediate });
440
+ this.currentProcess = null;
441
+ try {
442
+ fs.unlinkSync(this.pidFilePath);
443
+ }
444
+ catch { }
445
+ getLogger().debug(`[${this.logTag}] cleaned pid file ${this.pidFilePath}`);
446
+ }
447
+ killOrphan() {
448
+ if (!fs.existsSync(this.pidFilePath))
449
+ return;
450
+ try {
451
+ const pidStr = fs.readFileSync(this.pidFilePath, 'utf-8').trim();
452
+ const pid = Number(pidStr);
453
+ if (isNaN(pid) || pid <= 0) {
454
+ fs.unlinkSync(this.pidFilePath);
455
+ return;
456
+ }
457
+ // P1-10: pid 复用防护。pid 文件里的 pid 可能是 bridge 崩溃后系统回收复用
458
+ // 给了无关进程——kill(pid, 0) 存活探测无法区分,必须先验证进程身份。
459
+ const match = this.matchPidToBinary(pid);
460
+ if (match !== 'match') {
461
+ // 'gone':ps 查不到/报错 → 进程已消失,陈旧追踪物自愈清除(不杀)。
462
+ // 'mismatch':身份不匹配 → pid 已被无关进程占用,绝不能杀;文件是
463
+ // 陈旧垃圾,一并清除。
464
+ // 统一原则:只有身份匹配才杀;验证失败一律不杀。
465
+ getLogger().warn(`[${this.logTag}] killOrphan: pid ${pid} not confirmed as ${this.binary} ` +
466
+ `(${match}), skipping kill`);
467
+ fs.unlinkSync(this.pidFilePath);
468
+ return;
469
+ }
470
+ // 身份匹配:杀整个进程组(detached:true 下 agent 是组长),与
471
+ // ProcessStopper 的 kill(-pid) 语义对齐,子进程不会孤儿化(P1-12)。
472
+ getLogger().info(`[${this.logTag}] killing orphan process group ${-pid}`);
473
+ try {
474
+ process.kill(-pid, 'SIGTERM');
475
+ }
476
+ catch {
477
+ // process may have exited
478
+ }
479
+ fs.unlinkSync(this.pidFilePath);
480
+ }
481
+ catch {
482
+ // ignore
483
+ }
484
+ }
485
+ /**
486
+ * P1-10: verify the process behind a stale pid file is actually ours before
487
+ * sending any signal. `ps -o command=` (not `comm=`) is used because agent
488
+ * binaries may be bash-wrapped scripts whose comm is the interpreter name
489
+ * (`/bin/bash`); the full command line contains the binary path/basename.
490
+ *
491
+ * Returns:
492
+ * 'match' — ps succeeded and the command line contains this.binary
493
+ * (absolute path) or its basename (PATH lookup).
494
+ * 'mismatch' — ps succeeded but the command line is clearly a different
495
+ * process (pid was recycled to an unrelated process).
496
+ * 'unknown' — ps failed / process gone / output unreadable: cannot tell.
497
+ */
498
+ matchPidToBinary(pid) {
499
+ try {
500
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
501
+ encoding: 'utf-8',
502
+ }).trim();
503
+ if (!out || out.includes('<defunct>'))
504
+ return 'gone';
505
+ const binary = this.binary;
506
+ if (!binary)
507
+ return 'gone';
508
+ const needle = binary.includes('/') ? binary : path.basename(binary);
509
+ return out.includes(needle) ? 'match' : 'mismatch';
510
+ }
511
+ catch {
512
+ // ps 失败(进程不存在/权限)→ 视作 gone:不杀,让陈旧文件自愈
513
+ return 'gone';
514
+ }
515
+ }
516
+ /**
517
+ * Register this runner with the process-level exit dispatcher (singleton).
518
+ * Process listeners are installed at most once; subsequent calls only add
519
+ * the instance to the internal Set (idempotent — no listener accumulation).
520
+ * Bridge evicts the (cwd, kind) slot after each run; call
521
+ * `unregisterExitHandlers()` there so the instance can be GC'd.
522
+ */
523
+ registerExitHandlers() {
524
+ registerExitCleanup(this);
525
+ }
526
+ /**
527
+ * Remove this runner from the process-level exit dispatcher. Called when
528
+ * the bridge evicts the runner cache slot (finalizeRun / interruptCurrentRun
529
+ * / clearRunners) so the instance is no longer retained by the dispatcher.
530
+ */
531
+ unregisterExitHandlers() {
532
+ unregisterExitCleanup(this);
533
+ }
534
+ /**
535
+ * Current number of runners registered with the process-level exit
536
+ * dispatcher. Diagnostics / test-support introspection (P1-1 anchor asserts
537
+ * the count returns to baseline after bridge eviction).
538
+ */
539
+ static getRegisteredExitHandlerCount() {
540
+ return registeredRunners.size;
541
+ }
542
+ /**
543
+ * Shared process-exit cleanup: SIGTERM the still-running child process and
544
+ * remove the pid file. Called by the singleton dispatcher for every
545
+ * registered runner; previously duplicated verbatim in all 5 runner
546
+ * subclasses.
547
+ */
548
+ cleanupOnExit() {
549
+ if (this.currentProcess && this.currentProcess.exitCode === null) {
550
+ try {
551
+ // P1-12: 只杀组长会让组内子进程(工具调用起的后台进程)reparent 成孤儿。
552
+ // 复用 ProcessStopper 的 immediate 组杀(SIGTERM+SIGKILL 同步发出,
553
+ // 进程退出路径 fire-and-forget 足够;正常进程退出时是 no-op)。
554
+ void this.stopper.stop(this.currentProcess, { immediate: true });
555
+ }
556
+ catch { }
557
+ }
558
+ try {
559
+ fs.unlinkSync(this.pidFilePath);
560
+ }
561
+ catch { }
562
+ }
563
+ /**
564
+ * Build a unified result event from process exit state. Centralizes the
565
+ * result-event semantics specified in Candidate 2:
566
+ * - stoppedByUser → error ("interrupted by user")
567
+ * - translatorError set → error (translator-supplied message, e.g. codex
568
+ * `turn.failed` or "stream ended before a terminal event")
569
+ * - code !== 0 && code !== null → error (exit code + stderr tail)
570
+ * - signal !== null → error (killed by signal)
571
+ * - otherwise → success (with optional usage)
572
+ *
573
+ * Error message precedence (most-specific first):
574
+ * - stoppedByUser: "{binary} interrupted by user"
575
+ * - translatorError: verbatim translator-supplied message
576
+ * - signal: "{binary} killed by signal {signal}{stderr tail}"
577
+ * - non-zero code: "{binary} exited code={code}{stderr tail}"
578
+ *
579
+ * @param opts.code - Process exit code (null if killed by signal)
580
+ * @param opts.signal - Process signal code (null if exited normally)
581
+ * @param opts.stderr - Accumulated stderr (tail included in code/signal errors)
582
+ * @param opts.sessionId - Session ID to include in the result event
583
+ * @param opts.usage - Token usage to include on success
584
+ * @param opts.translatorError - Agent-specific terminal error message (e.g.
585
+ * codex turn.failed, or "stream ended early")
586
+ * @returns AgentEvent with type='result'
587
+ */
588
+ buildResultEvent(opts) {
589
+ const isError = this.stoppedByUser ||
590
+ opts.translatorError !== undefined ||
591
+ (opts.code !== null && opts.code !== 0) ||
592
+ opts.signal !== null;
593
+ const sessionId = opts.sessionId ?? '';
594
+ if (isError) {
595
+ let errorMessage;
596
+ const stderrTail = opts.stderr ? opts.stderr.slice(-STDERR_LOG_TAIL) : '';
597
+ if (this.stoppedByUser) {
598
+ errorMessage = `${this.binary} interrupted by user`;
599
+ }
600
+ else if (opts.translatorError !== undefined) {
601
+ errorMessage = opts.translatorError;
602
+ }
603
+ else if (opts.signal !== null) {
604
+ errorMessage = `${this.binary} killed by signal ${opts.signal}${stderrTail ? `: ${stderrTail}` : ''}`;
605
+ }
606
+ else {
607
+ errorMessage = `${this.binary} exited code=${opts.code}${stderrTail ? `: ${stderrTail}` : ''}`;
608
+ }
609
+ return {
610
+ type: 'result',
611
+ subtype: 'error',
612
+ session_id: sessionId,
613
+ errorMessage,
614
+ };
615
+ }
616
+ return {
617
+ type: 'result',
618
+ subtype: 'success',
619
+ session_id: sessionId,
620
+ ...(opts.usage ? { usage: opts.usage } : {}),
621
+ };
622
+ }
623
+ /**
624
+ * Stateless passthrough translator hook. Used by ClaudeRunner
625
+ * which does not need per-run translation state. Subclasses that return
626
+ * a translator from `createTranslator(opts)` leave this as the default
627
+ * no-op — the base `run()` loop only calls `translate()` when
628
+ * `createTranslator()` returns null.
629
+ */
630
+ translate(_rawEvent, _ctx) {
631
+ return null;
632
+ }
633
+ }
634
+ //# sourceMappingURL=spawning-runner.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Normalized usage extracted from a result event.
3
+ *
4
+ * @module runner/common/usage
5
+ */
6
+ import type { TokenUsage } from '../types.js';
7
+ /** Normalized, camelCase view of a `TokenUsage` payload.
8
+ *
9
+ * **Naming rule** — canonical field names (`cache_read_tokens`,
10
+ * `cache_creation_tokens`) take priority over Anthropic native naming
11
+ * (`cache_read_input_tokens`, `cache_creation_input_tokens`). When both are
12
+ * present the canonical value wins; when only the native name exists it is
13
+ * used as a fallback.
14
+ *
15
+ * **contextLength rule** — `total_tokens` (the agent-declared total) takes
16
+ * priority. When absent, contextLength is reconstructed as
17
+ * `input + cacheRead + cacheCreation + output` (missing cache components
18
+ * default to 0).
19
+ */
20
+ interface NormalizedResultUsage {
21
+ inputTokens: number;
22
+ outputTokens: number;
23
+ cacheReadTokens?: number;
24
+ cacheCreationTokens?: number;
25
+ totalTokens?: number;
26
+ /** Reconstructed or agent-declared context length (see JSDoc above). */
27
+ contextLength: number;
28
+ }
29
+ /** Extract and normalize usage fields from a result event payload. */
30
+ export declare function normalizeResultUsage(usage: TokenUsage): NormalizedResultUsage;
31
+ export {};