@nowcrew/daemon 0.6.98-beta.7 → 0.6.98-beta.8

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 (35) hide show
  1. package/README.md +20 -16
  2. package/dist/automatic-daemon-installer.js +15 -11
  3. package/dist/automatic-service-capture.js +27 -0
  4. package/dist/computer-main.js +0 -0
  5. package/dist/execution-journal-lock.js +51 -2
  6. package/dist/execution-protocol.js +2 -0
  7. package/dist/execution-runner.js +19 -13
  8. package/dist/host-execution-coordinator.js +10 -0
  9. package/dist/local-executor.js +8 -1
  10. package/dist/machine-info.js +4 -1
  11. package/dist/machine-runtime-environment.js +1 -0
  12. package/dist/main.js +0 -0
  13. package/dist/managed-service-lifecycle.js +10 -0
  14. package/dist/project-skills/execution-projection.js +4 -5
  15. package/dist/project-skills/reconciler.js +8 -9
  16. package/dist/project-skills/scanner.js +9 -2
  17. package/dist/project-skills/source-directory.js +13 -0
  18. package/dist/runtime-observation.js +219 -0
  19. package/dist/runtime-tool-update-bridge.js +126 -0
  20. package/dist/runtime-tool-update-controller.js +151 -0
  21. package/dist/runtime-tool-update-protocol.js +24 -0
  22. package/dist/runtime-tool-update-serve.js +54 -0
  23. package/dist/runtimes/claude-observation-runner.js +55 -0
  24. package/dist/runtimes/claude-thread-runner.js +17 -3
  25. package/dist/runtimes/claude.js +5 -3
  26. package/dist/runtimes/codex-app-server-runner.js +16 -2
  27. package/dist/runtimes/deepseek-harness-runner.js +16 -0
  28. package/dist/runtimes/kimi-acp-runner.js +19 -0
  29. package/dist/runtimes/observation-context.js +18 -0
  30. package/dist/runtimes/observation-mapper.js +314 -0
  31. package/dist/runtimes/opencode-runner.js +18 -4
  32. package/dist/runtimes/pi-runner.js +17 -1
  33. package/dist/serve.js +9 -0
  34. package/dist/token.js +4 -1
  35. package/package.json +9 -10
@@ -8,6 +8,9 @@ import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import spawn from "cross-spawn";
9
9
  import { z } from "zod";
10
10
  import { prepareThreadDelivery, ThreadDeliveryColdStartRequired } from "../thread-input-delivery.js";
11
+ import { createRuntimeObservationCapture } from "../runtime-observation.js";
12
+ import { createObservationMapper } from "./observation-mapper.js";
13
+ import { observeClaudeContext } from "./observation-context.js";
11
14
  const InputSchema = z.object({ bin: z.string().min(1), args: z.array(z.string()),
12
15
  wakePrompt: z.string().min(1), sessionId: z.string().min(1).optional(), resume: z.boolean() }).strict();
13
16
  class PreflightError extends Error {
@@ -52,7 +55,7 @@ async function bounded(promise, milliseconds, label) {
52
55
  clearTimeout(timer);
53
56
  }
54
57
  }
55
- async function attempt(input, sessionId, resume, options) {
58
+ async function attempt(input, sessionId, resume, options, observer) {
56
59
  const proofDir = await mkdtemp(join(tmpdir(), "nowwork-claude-session-"));
57
60
  const proofPath = join(proofDir, "proof.json");
58
61
  const nonce = randomUUID();
@@ -182,6 +185,7 @@ async function attempt(input, sessionId, resume, options) {
182
185
  // work is outstanding; background shells must not hold it open forever.
183
186
  if (event.type === "result")
184
187
  sawResult = pendingAgents.size === 0;
188
+ observer.observe(event);
185
189
  output(event);
186
190
  }).catch(error => { fail(error instanceof Error ? error : new Error("claude_event_failed")); void stop().catch(fail); });
187
191
  });
@@ -207,6 +211,7 @@ async function attempt(input, sessionId, resume, options) {
207
211
  if (resume)
208
212
  output({ type: "nowwork.native_resume", runtime: "claude", session_id: sessionId });
209
213
  submitted = true;
214
+ await observeClaudeContext(observer, input.args, delivery.prompt);
210
215
  await send({ type: "user", session_id: "", uuid: userId, parent_tool_use_id: null,
211
216
  message: { role: "user", content: delivery.prompt } });
212
217
  await bounded(accepted.promise, options.ackTimeoutMs ?? 30_000, "claude_user_message_not_acknowledged");
@@ -237,16 +242,25 @@ async function attempt(input, sessionId, resume, options) {
237
242
  }
238
243
  }
239
244
  export async function runClaudeThread(input, options = {}) {
245
+ const capture = createRuntimeObservationCapture("claude", options.env ?? process.env, options.output);
246
+ try {
247
+ return await runClaudeObserved(input, options, capture);
248
+ }
249
+ finally {
250
+ capture.finish();
251
+ }
252
+ }
253
+ async function runClaudeObserved(input, options, capture) {
240
254
  const sessionId = input.sessionId ?? randomUUID();
241
255
  try {
242
- return await attempt(input, sessionId, input.resume, options);
256
+ return await attempt(input, sessionId, input.resume, options, createObservationMapper("claude", capture, "attempt:1"));
243
257
  }
244
258
  catch (error) {
245
259
  // The failed attempt never received a business user turn or entered its
246
260
  // model/tool loop. Native startup hooks may run again on cold recovery.
247
261
  if (!input.resume || !(error instanceof PreflightError || error instanceof ThreadDeliveryColdStartRequired))
248
262
  throw error;
249
- return attempt(input, randomUUID(), false, options);
263
+ return attempt(input, randomUUID(), false, options, createObservationMapper("claude", capture, "attempt:2"));
250
264
  }
251
265
  }
252
266
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -7,6 +7,7 @@ import { posix, win32 } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  // cross-spawn:win32 上 npm CLI 是 .cmd shim,node 原生 spawn 不带 shell 无法执行(ENOENT/EINVAL)
9
9
  import spawn from "cross-spawn";
10
+ import { observationPolicyFromEnvironment } from "../runtime-observation.js";
10
11
  // Claude Code 原生 --effort 档位(claude 2.1.196 实测:--help 与非法值告警均枚举这五档)。
11
12
  // 白名单外的值(含 "default" 与 codex 专属档)回落 CLAUDE_DEFAULT_EFFORT,脏数据不影响启动。
12
13
  export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
@@ -280,14 +281,15 @@ export function buildClaudeArgs(input) {
280
281
  }
281
282
  export function buildClaudeLaunch(input) {
282
283
  const args = buildClaudeArgs(input);
283
- if (!input.env.CREW_THREAD_ROOT?.trim())
284
+ const thread = Boolean(input.env.CREW_THREAD_ROOT?.trim());
285
+ if (!thread && !observationPolicyFromEnvironment(input.env))
284
286
  return { command: input.bin, args, cwd: input.cwd, env: input.env };
285
287
  const source = import.meta.url.endsWith(".ts");
286
288
  return { command: process.execPath,
287
289
  args: [...(source ? ["--import", pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm")).href] : []),
288
- fileURLToPath(new URL(`./claude-thread-runner.${source ? "ts" : "js"}`, import.meta.url))],
290
+ fileURLToPath(new URL(`./${thread ? "claude-thread-runner" : "claude-observation-runner"}.${source ? "ts" : "js"}`, import.meta.url))],
289
291
  cwd: input.cwd, env: input.env,
290
- stdinText: JSON.stringify({ bin: input.bin, args: args.slice(0, -1), wakePrompt: input.wakePrompt,
292
+ stdinText: JSON.stringify(!thread ? { bin: input.bin, args } : { bin: input.bin, args: args.slice(0, -1), wakePrompt: input.wakePrompt,
291
293
  ...(input.sessionId ? { sessionId: input.sessionId } : {}), resume: input.resume === true }) };
292
294
  }
293
295
  export function spawnClaude(input) {
@@ -11,6 +11,8 @@ import { decideTurnRetry, itemMakesTurnUnsafe } from "./codex-turn-retry.js";
11
11
  import { startFirstProgressWatchdog } from "./progress-watchdog.js";
12
12
  import { prepareThreadDelivery, ThreadDeliveryColdStartRequired } from "../thread-input-delivery.js";
13
13
  import { codexExecutionPolicyArgs } from "./codex-execution-policy.js";
14
+ import { createRuntimeObservationCapture } from "../runtime-observation.js";
15
+ import { createObservationMapper } from "./observation-mapper.js";
14
16
  const RunnerInputSchema = z.object({
15
17
  systemPrompt: z.string().min(1),
16
18
  wakePrompt: z.string().min(1),
@@ -443,7 +445,8 @@ async function stopChildTree(child) {
443
445
  throw new Error(`Codex app-server process tree ${child.pid ?? "unknown"} did not exit`);
444
446
  }
445
447
  }
446
- async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs, resumeEventSessionId) {
448
+ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs, resumeEventSessionId, capture) {
449
+ const observer = createObservationMapper("codex", capture, `attempt:${attempt}`);
447
450
  const attemptStartedAt = Date.now();
448
451
  const child = spawn(bin, codexAppServerArgs({
449
452
  ...(input.projectRootMarkers === undefined ? {} : { projectRootMarkers: input.projectRootMarkers }),
@@ -483,6 +486,7 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
483
486
  let firstProgress = startFirstProgressWatchdog(() => undefined);
484
487
  firstProgress.stop();
485
488
  const rpc = new CodexRpcClient(child, input.effectivePermission, async (method, params) => {
489
+ observer.observe(params, method);
486
490
  if (method === "thread/tokenUsage/updated") {
487
491
  lastUsage = params.tokenUsage;
488
492
  }
@@ -625,6 +629,7 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
625
629
  });
626
630
  activeStage = "model_init";
627
631
  stageStartedAt = Date.now();
632
+ observer.context({ systemPrompt: input.systemPrompt, prompt: delivery.prompt });
628
633
  const started = await rpc.request("turn/start", {
629
634
  threadId,
630
635
  input: [
@@ -712,6 +717,15 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
712
717
  }
713
718
  }
714
719
  export async function runCodexAppServer(bin, options = {}) {
720
+ const capture = createRuntimeObservationCapture("codex");
721
+ try {
722
+ return await runCodexObserved(bin, options, capture);
723
+ }
724
+ finally {
725
+ capture.finish();
726
+ }
727
+ }
728
+ async function runCodexObserved(bin, options, capture) {
715
729
  const input = await readRunnerInput();
716
730
  const initializeTimeoutMs = Math.min(options.initializeTimeoutMs ?? INITIALIZE_RPC_TIMEOUT_MS, INITIALIZE_RPC_TIMEOUT_MS);
717
731
  const turnRetryDelayMs = options.turnRetryDelayMs ?? TRANSIENT_TURN_RETRY_DELAY_MS;
@@ -721,7 +735,7 @@ export async function runCodexAppServer(bin, options = {}) {
721
735
  let attemptInput = input;
722
736
  const resumeEventSessionId = input.resume ? input.sessionId : undefined;
723
737
  for (let attempt = 1;; attempt += 1) {
724
- const result = await runCodexAppServerAttempt(bin, attemptInput, attempt, initializeTimeoutMs, resumeEventSessionId);
738
+ const result = await runCodexAppServerAttempt(bin, attemptInput, attempt, initializeTimeoutMs, resumeEventSessionId, capture);
725
739
  if (result.restartCold) {
726
740
  if (coldRestarted)
727
741
  return result.code;
@@ -11,6 +11,8 @@ import { deepSeekHarnessLaunch } from "./deepseek-harness-launch.js";
11
11
  import { prepareHarnessProfile } from "./deepseek-harness-profile.js";
12
12
  import { mapAcpUpdate } from "./kimi-acp-runner.js";
13
13
  import { prepareThreadDelivery } from "../thread-input-delivery.js";
14
+ import { createRuntimeObservationCapture } from "../runtime-observation.js";
15
+ import { createObservationMapper } from "./observation-mapper.js";
14
16
  const absolutePath = z.string().refine(isAbsolute, "must be an absolute path");
15
17
  export const HarnessInputSchema = z
16
18
  .object({
@@ -45,6 +47,17 @@ async function settleWithin(promise, ms) {
45
47
  }
46
48
  }
47
49
  export async function runHarness(input, bin, patchPath) {
50
+ const capture = createRuntimeObservationCapture("deepseek-harness");
51
+ const observer = createObservationMapper("deepseek-harness", capture);
52
+ try {
53
+ return await runHarnessObserved(input, bin, patchPath, observer);
54
+ }
55
+ finally {
56
+ observer.finish();
57
+ capture.finish();
58
+ }
59
+ }
60
+ async function runHarnessObserved(input, bin, patchPath, observer) {
48
61
  const delivery = await prepareThreadDelivery({
49
62
  env: process.env,
50
63
  resumed: false,
@@ -115,6 +128,7 @@ export async function runHarness(input, bin, patchPath) {
115
128
  .onNotification(methods.client.session.update, async ({ params }) => {
116
129
  if (params.sessionId !== sessionId)
117
130
  return;
131
+ observer.observe(params);
118
132
  for (const event of mapAcpUpdate("deepseek-harness", params.update))
119
133
  await emit(event);
120
134
  });
@@ -141,6 +155,7 @@ export async function runHarness(input, bin, patchPath) {
141
155
  throw new Error("Harness session unavailable");
142
156
  await emit({ type: "thread.started", thread_id: sessionId });
143
157
  stage = "prompt";
158
+ observer.context({ systemPrompt: input.systemPrompt, prompt: delivery.prompt });
144
159
  const response = await ctx.request(methods.agent.session.prompt, {
145
160
  sessionId,
146
161
  prompt: [{ type: "text", text: delivery.prompt }],
@@ -155,6 +170,7 @@ export async function runHarness(input, bin, patchPath) {
155
170
  }
156
171
  return response;
157
172
  });
173
+ observer.observe({ type: "nowwork.acp.result", sessionId, ...result });
158
174
  const usage = result.usage;
159
175
  if (!cancelled)
160
176
  await emit({
@@ -12,6 +12,8 @@ import { augmentedPath } from "../runtime-path.js";
12
12
  import { prepareThreadDelivery } from "../thread-input-delivery.js";
13
13
  import { startFirstProgressWatchdog } from "./progress-watchdog.js";
14
14
  import { assertKimiLegacyPromptFits, buildKimiArgs } from "./kimi.js";
15
+ import { createRuntimeObservationCapture } from "../runtime-observation.js";
16
+ import { createObservationMapper } from "./observation-mapper.js";
15
17
  const ERROR_MESSAGE_CAP = 2_000;
16
18
  // macOS launchd cold-starts ACP runtimes materially slower than an interactive
17
19
  // shell. DeepSeek Harness was measured at ~4.4s before returning initialize
@@ -322,6 +324,18 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
322
324
  }
323
325
  }
324
326
  export async function runKimiAcp(requestedOptions) {
327
+ const provider = requestedOptions.provider ?? "kimi";
328
+ const capture = createRuntimeObservationCapture(provider);
329
+ const observer = createObservationMapper(provider, capture);
330
+ try {
331
+ return await runKimiObserved(requestedOptions, observer, capture);
332
+ }
333
+ finally {
334
+ observer.finish();
335
+ capture.finish();
336
+ }
337
+ }
338
+ async function runKimiObserved(requestedOptions, observer, capture) {
325
339
  // Until this adapter verifies native restoration, thread-bound runs are cold.
326
340
  const options = process.env.CREW_THREAD_ROOT ? { ...requestedOptions, resume: false } : requestedOptions;
327
341
  const provider = options.provider ?? "kimi";
@@ -364,6 +378,7 @@ export async function runKimiAcp(requestedOptions) {
364
378
  const app = client({ name: `nowcrew-daemon-${provider}` })
365
379
  .onRequest(methods.client.session.requestPermission, ({ params }) => selectKimiPermission(params, permission))
366
380
  .onNotification(methods.client.session.update, async ({ params }) => {
381
+ observer.observe(params);
367
382
  acpSemanticProgress = true;
368
383
  firstProgress.observe();
369
384
  for (const event of mapAcpUpdate(provider, params.update))
@@ -433,6 +448,7 @@ export async function runKimiAcp(requestedOptions) {
433
448
  progressTimedOut = true;
434
449
  void cancel();
435
450
  });
451
+ observer.context({ prompt });
436
452
  return nextContext.request(methods.agent.session.prompt, {
437
453
  sessionId,
438
454
  prompt: [{ type: "text", text: prompt }],
@@ -443,6 +459,7 @@ export async function runKimiAcp(requestedOptions) {
443
459
  process.stderr.write(`${displayName} produced no semantic progress within the startup window\n`);
444
460
  return 1;
445
461
  }
462
+ observer.observe({ type: "nowwork.acp.result", sessionId, ...result });
446
463
  const usage = result.usage;
447
464
  await jsonLine({
448
465
  type: "turn.completed",
@@ -464,6 +481,8 @@ export async function runKimiAcp(requestedOptions) {
464
481
  return 1;
465
482
  }
466
483
  if (provider === "kimi" && !acpSemanticProgress && isKimiAuthenticationRequired(error)) {
484
+ capture.observe({ observationId: "fallback", type: "event", phase: "event", name: "capture.unsupported",
485
+ unavailable: ["kimi_legacy_fallback_events"] });
467
486
  firstProgress.stop();
468
487
  await stopChild(child);
469
488
  process.stderr.write("Kimi ACP requires account login; falling back to configured CLI provider transport\n");
@@ -0,0 +1,18 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /** 只观察实际发给 runtime 的 prompt;不读取配置、环境变量或凭据文件。 */
3
+ export async function observeClaudeContext(observer, args, prompt) {
4
+ if (!observer.enabled)
5
+ return;
6
+ const at = args.indexOf("--append-system-prompt-file");
7
+ const path = at >= 0 ? args[at + 1] : undefined;
8
+ if (!path) {
9
+ observer.context({ prompt }, ["system_prompt"]);
10
+ return;
11
+ }
12
+ try {
13
+ observer.context({ systemPrompt: await readFile(path, "utf8"), prompt });
14
+ }
15
+ catch {
16
+ observer.context({ prompt }, ["system_prompt"]);
17
+ }
18
+ }
@@ -0,0 +1,314 @@
1
+ import { createHash } from "node:crypto";
2
+ const record = (raw) => raw !== null && typeof raw === "object" ? raw : {};
3
+ const string = (raw) => typeof raw === "string" && raw.length > 0 ? raw : undefined;
4
+ const list = (raw) => Array.isArray(raw) ? raw : [];
5
+ const boundedId = (raw) => {
6
+ const value = string(raw);
7
+ return value && value.length <= 150 ? value : undefined;
8
+ };
9
+ function usage(raw) {
10
+ const value = record(raw);
11
+ const pairs = {
12
+ inputTokens: value.input_tokens ?? value.inputTokens ?? value.input,
13
+ outputTokens: value.output_tokens ?? value.outputTokens ?? value.output,
14
+ cacheReadTokens: value.cache_read_input_tokens ?? value.cachedInputTokens ?? value.cachedReadTokens ?? value.cacheRead ?? record(value.cache).read,
15
+ cacheCreationTokens: value.cache_creation_input_tokens ?? value.cachedWriteTokens ?? value.cacheWrite ?? record(value.cache).write,
16
+ reasoningTokens: value.reasoning_tokens ?? value.reasoningTokens ?? value.reasoning,
17
+ costUsd: value.total_cost_usd ?? value.costUsd ?? record(value.cost).total,
18
+ };
19
+ const entries = Object.entries(pairs).filter(([, v]) => typeof v === "number" && Number.isFinite(v) && v >= 0);
20
+ return entries.length ? Object.fromEntries(entries) : undefined;
21
+ }
22
+ const errorStatus = (raw) => ["failed", "error", "cancelled", "interrupted", "aborted"].includes(String(raw)) ? "error" : "ok";
23
+ /** 原始事件的轻量投影;状态仅属于一次短命 runtime attempt,不从日志邻接猜父子关系。 */
24
+ export function createObservationMapper(runtime, capture, prefix = "run") {
25
+ let eventIndex = 0;
26
+ let nativeSessionId;
27
+ let nativeTurnId;
28
+ let textBuffer = "";
29
+ let thoughtBuffer = "";
30
+ let bufferedText = false;
31
+ let bufferedThought = false;
32
+ let bufferTruncated = false;
33
+ const started = new Map();
34
+ const emit = (fields) => capture.observe({
35
+ ...(nativeSessionId ? { nativeSessionId } : {}), ...(nativeTurnId ? { nativeTurnId } : {}), ...fields,
36
+ });
37
+ const id = (native) => `${prefix}:${native}`.slice(0, 200);
38
+ const event = (name, fields = {}) => {
39
+ emit({ observationId: id(`event:${++eventIndex}`), type: "event", phase: "event", name, ...fields });
40
+ };
41
+ const tool = (native, phase, name, fields = {}) => {
42
+ const callId = boundedId(native);
43
+ const key = callId ? id(`tool:${callId}`) : undefined;
44
+ const knownName = key ? started.get(key) : undefined;
45
+ // pending/running 等状态更新不重置开始时间,缓存仅限一次短命 attempt。
46
+ if (phase === "start" && knownName !== undefined)
47
+ return;
48
+ if (key && phase === "start" && started.size < 20_000)
49
+ started.set(key, name);
50
+ const hasStart = key !== undefined && started.has(key);
51
+ emit({ observationId: key ?? id(`tool-unpaired:${++eventIndex}`), type: "tool", phase: key ? phase : "event",
52
+ name: phase === "end" && knownName !== undefined ? knownName : name,
53
+ ...(callId ? { nativeCallId: callId } : {}),
54
+ ...(!callId ? { unavailable: ["native_call_id", "paired_duration"] }
55
+ : phase === "end" && !hasStart ? { unavailable: ["start_time"] } : {}), ...fields });
56
+ };
57
+ const flush = () => {
58
+ if (bufferedText)
59
+ event("model.output", { output: textBuffer, ...(bufferTruncated ? { truncated: true } : {}) });
60
+ if (bufferedThought)
61
+ event("model.reasoning", { output: thoughtBuffer, ...(bufferTruncated ? { truncated: true } : {}) });
62
+ textBuffer = "";
63
+ thoughtBuffer = "";
64
+ bufferedText = false;
65
+ bufferedThought = false;
66
+ bufferTruncated = false;
67
+ };
68
+ const buffer = (value, thought) => {
69
+ const text = typeof value === "string" ? value : "";
70
+ if (thought) {
71
+ bufferedThought = true;
72
+ thoughtBuffer += text;
73
+ }
74
+ else {
75
+ bufferedText = true;
76
+ textBuffer += text;
77
+ }
78
+ if (textBuffer.length > 8192 || thoughtBuffer.length > 8192)
79
+ bufferTruncated = true;
80
+ const clip = (value) => value.slice(0, value.charCodeAt(8191) >= 0xd800 && value.charCodeAt(8191) <= 0xdbff ? 8191 : 8192);
81
+ textBuffer = clip(textBuffer);
82
+ thoughtBuffer = clip(thoughtBuffer);
83
+ };
84
+ const totals = (raw, scope, name = "runtime.usage") => {
85
+ const measured = usage(raw);
86
+ if (measured)
87
+ event(name, { usage: measured, usageScope: scope });
88
+ };
89
+ const codex = (method, raw) => {
90
+ nativeSessionId = boundedId(raw.threadId) ?? boundedId(record(raw.thread).id) ?? nativeSessionId;
91
+ nativeTurnId = boundedId(raw.turnId) ?? boundedId(record(raw.turn).id) ?? nativeTurnId;
92
+ if (method === "thread/tokenUsage/updated") {
93
+ const explicitTurn = boundedId(raw.turnId);
94
+ const explicitSession = boundedId(raw.threadId);
95
+ for (const source of ["last", "total"]) {
96
+ const measured = usage(record(raw.tokenUsage)[source]);
97
+ if (!measured)
98
+ continue;
99
+ const owner = source === "last" && explicitTurn ? `turn:${explicitTurn}`
100
+ : explicitSession ? `thread:${explicitSession}` : undefined;
101
+ event(`runtime.usage.${source}`, { usage: measured,
102
+ ...(owner ? { observationId: id(`usage:${source}:${owner}`) } : {}),
103
+ ...(source === "last" && explicitTurn ? { parentObservationId: id(`turn:${explicitTurn}`) } : {}),
104
+ nativeTurnId: explicitTurn,
105
+ unavailable: ["usage_scope", "provider_request_id", ...(owner ? [] : ["native_usage_owner"])],
106
+ });
107
+ }
108
+ return;
109
+ }
110
+ if (method === "turn/started" || method === "turn/completed") {
111
+ const turn = record(raw.turn);
112
+ emit({ observationId: id(`turn:${nativeTurnId ?? ++eventIndex}`), type: "agent",
113
+ phase: method === "turn/started" ? "start" : "end", name: "agent.turn",
114
+ ...(method === "turn/completed" ? { status: errorStatus(turn.status), output: record(turn.error).message } : {}),
115
+ unavailable: ["provider_request_id", "per_generation_input", "per_generation_usage"] });
116
+ return;
117
+ }
118
+ if (method !== "item/started" && method !== "item/completed")
119
+ return;
120
+ const item = record(raw.item);
121
+ const itemType = String(item.type ?? "unknown");
122
+ const phase = method === "item/started" ? "start" : "end";
123
+ const explicitTurn = boundedId(raw.turnId);
124
+ const turnParent = explicitTurn ? { parentObservationId: id(`turn:${explicitTurn}`) } : {};
125
+ if (["commandExecution", "mcpToolCall", "dynamicToolCall", "fileChange", "webSearch", "collabAgentToolCall"].includes(itemType)) {
126
+ const native = boundedId(item.id);
127
+ const name = itemType === "mcpToolCall" ? `mcp.${String(item.tool ?? "tool")}` : itemType;
128
+ tool(native, phase, name.slice(0, 200), {
129
+ ...turnParent,
130
+ ...(phase === "start" ? { input: item.arguments ?? item.command ?? item.prompt ?? item.changes } : {
131
+ output: item.result ?? item.aggregatedOutput ?? item.error ?? item.agentsStates ?? item.changes,
132
+ status: itemType === "commandExecution" && typeof item.exitCode === "number" && item.exitCode !== 0
133
+ ? "error" : errorStatus(item.status),
134
+ }),
135
+ });
136
+ if (itemType === "collabAgentToolCall" && phase === "end") {
137
+ for (const childId of list(item.receiverThreadIds)) {
138
+ const child = boundedId(childId);
139
+ if (child && native)
140
+ emit({ observationId: id(`child:${createHash("sha256").update(JSON.stringify([native, child])).digest("hex")}`), parentObservationId: id(`tool:${native}`),
141
+ type: "agent", phase: "event", name: "agent.delegated", nativeSessionId: child,
142
+ unavailable: ["child_internal_events", "child_start_time", "child_end_time"] });
143
+ }
144
+ }
145
+ return;
146
+ }
147
+ if (phase !== "end")
148
+ return;
149
+ const nativeItem = boundedId(item.id);
150
+ const itemIdentity = { ...turnParent, ...(nativeItem ? { observationId: id(`item:${nativeItem}`) } : {}) };
151
+ if (itemType === "agentMessage")
152
+ event("model.output", { output: item.text, ...itemIdentity });
153
+ else if (itemType === "reasoning")
154
+ event("model.reasoning", {
155
+ output: item.summary ?? item.content ?? item.text, ...itemIdentity,
156
+ ...(!item.summary && !item.content && !item.text ? { unavailable: ["reasoning_content"] } : {}),
157
+ });
158
+ };
159
+ const claude = (raw) => {
160
+ nativeSessionId = boundedId(raw.session_id) ?? nativeSessionId;
161
+ const message = record(raw.message);
162
+ const messageId = boundedId(message.id) ?? boundedId(raw.uuid);
163
+ const parent = boundedId(raw.parent_tool_use_id);
164
+ const parentFields = parent ? { parentObservationId: id(`tool:${parent}`) } : {};
165
+ if (raw.type === "assistant") {
166
+ for (const [blockIndex, value] of list(message.content).entries()) {
167
+ const block = record(value);
168
+ const identity = messageId ? { observationId: id(`message:${messageId}:block:${blockIndex}`) } : {};
169
+ if (block.type === "tool_use")
170
+ tool(block.id, "start", String(block.name ?? "tool").slice(0, 200), { input: block.input, ...parentFields });
171
+ if (block.type === "text")
172
+ event("model.output", { output: block.text, ...parentFields, ...identity });
173
+ if (block.type === "thinking" || block.type === "redacted_thinking")
174
+ event("model.reasoning", {
175
+ ...(string(block.thinking) ? { output: block.thinking } : { unavailable: ["reasoning_content"] }), ...parentFields, ...identity,
176
+ });
177
+ }
178
+ const measured = usage(message.usage);
179
+ if (measured)
180
+ event("model.response", { usage: measured, usageScope: "generation",
181
+ ...(messageId ? { observationId: id(`message:${messageId}:usage`) } : {}),
182
+ ...(string(message.model) ? { model: String(message.model) } : {}),
183
+ unavailable: ["request_start_time", "provider_request_id", ...(messageId ? [] : ["native_message_id"])], ...parentFields });
184
+ }
185
+ else if (raw.type === "user") {
186
+ for (const value of list(message.content)) {
187
+ const block = record(value);
188
+ if (block.type === "tool_result")
189
+ tool(block.tool_use_id, "end", "tool.result", {
190
+ output: block.content, status: block.is_error === true ? "error" : "ok", ...parentFields,
191
+ });
192
+ }
193
+ }
194
+ else if (raw.type === "system" && boundedId(raw.task_id)) {
195
+ const task = String(raw.task_id);
196
+ const callId = boundedId(raw.tool_use_id);
197
+ emit({ observationId: id(`task:${task}`), type: "agent", name: "agent.delegated",
198
+ phase: raw.subtype === "task_started" ? "start" : raw.subtype === "task_notification" ? "end" : "event",
199
+ ...(callId ? { parentObservationId: id(`tool:${callId}`) } : {}), output: raw.summary,
200
+ ...(raw.status ? { status: errorStatus(raw.status) } : {}) });
201
+ }
202
+ else if (raw.type === "result") {
203
+ totals({ ...record(raw.usage), total_cost_usd: raw.total_cost_usd }, "execution");
204
+ event("agent.result", { status: raw.is_error === true ? "error" : "ok", output: raw.result });
205
+ }
206
+ };
207
+ const acp = (raw) => {
208
+ nativeSessionId = boundedId(raw.sessionId) ?? nativeSessionId;
209
+ const update = raw.update ? record(raw.update) : raw;
210
+ const kind = update.sessionUpdate;
211
+ if (kind === "agent_message_chunk" || kind === "agent_thought_chunk") {
212
+ const part = record(update.content);
213
+ if (part.type === "text")
214
+ buffer(part.text, kind === "agent_thought_chunk");
215
+ }
216
+ else if (kind === "tool_call" || kind === "tool_call_update") {
217
+ flush();
218
+ const complete = ["completed", "failed"].includes(String(update.status));
219
+ const name = string(update.kind) ? `tool.${update.kind}` : "tool.result";
220
+ if (kind === "tool_call" && !complete)
221
+ tool(update.toolCallId, "start", name, { input: update.rawInput });
222
+ if (complete)
223
+ tool(update.toolCallId, "end", name, {
224
+ input: update.rawInput, output: update.rawOutput ?? update.content, status: errorStatus(update.status),
225
+ });
226
+ }
227
+ else if (raw.type === "nowwork.acp.result") {
228
+ flush();
229
+ totals(raw.usage, "turn");
230
+ event("agent.result", { status: raw.stopReason === "end_turn" ? "ok" : "error", unavailable: ["per_generation_input", "per_generation_usage", "provider_request_id"] });
231
+ }
232
+ };
233
+ const openCode = (raw) => {
234
+ nativeSessionId = boundedId(raw.sessionID) ?? nativeSessionId;
235
+ const part = record(raw.part);
236
+ if (raw.type === "tool_use") {
237
+ const state = record(part.state);
238
+ if (state.status === "running" || state.status === "pending")
239
+ tool(part.callID, "start", String(part.tool ?? "tool"), { input: state.input });
240
+ if (state.status === "completed" || state.status === "error")
241
+ tool(part.callID, "end", String(part.tool ?? "tool"), {
242
+ input: state.input, output: state.output ?? state.error, status: errorStatus(state.status),
243
+ });
244
+ }
245
+ else if (raw.type === "step_start" || raw.type === "step_finish") {
246
+ const measured = usage({ ...record(part.tokens), costUsd: part.cost });
247
+ event(String(raw.type), { ...(measured ? { usage: measured, usageScope: "generation" } : {}),
248
+ unavailable: ["provider_request_id", "request_start_time", "per_generation_input"] });
249
+ }
250
+ else if (raw.type === "text" || raw.type === "reasoning") {
251
+ event(raw.type === "text" ? "model.output" : "model.reasoning", { output: part.text });
252
+ }
253
+ else if (raw.type === "error")
254
+ event("runtime.error", { status: "error", output: raw.error });
255
+ };
256
+ const pi = (raw) => {
257
+ if (raw.type === "session")
258
+ nativeSessionId = boundedId(raw.id) ?? nativeSessionId;
259
+ const assistant = record(raw.assistantMessageEvent);
260
+ if (raw.type === "message_update" && ["text_end", "thinking_end"].includes(String(assistant.type))) {
261
+ event(assistant.type === "text_end" ? "model.output" : "model.reasoning", { output: assistant.content });
262
+ }
263
+ else if (raw.type === "tool_execution_start") {
264
+ tool(raw.toolCallId, "start", String(raw.toolName ?? "tool"), { input: raw.args });
265
+ }
266
+ else if (raw.type === "tool_execution_end") {
267
+ tool(raw.toolCallId, "end", String(raw.toolName ?? "tool.result"), { output: raw.result, status: raw.isError === true ? "error" : "ok" });
268
+ }
269
+ else if (raw.type === "message_end" && record(raw.message).role === "assistant") {
270
+ const message = record(raw.message);
271
+ const measured = usage(message.usage);
272
+ event("model.response", { ...(measured ? { usage: measured, usageScope: "generation" } : {}),
273
+ ...(string(message.model) ? { model: String(message.model) } : {}),
274
+ unavailable: ["provider_request_id", "request_start_time", "per_generation_input"] });
275
+ }
276
+ else if (raw.type === "error" || raw.type === "agent_error")
277
+ event("runtime.error", { status: "error", output: raw.error });
278
+ };
279
+ return {
280
+ enabled: capture.enabled,
281
+ context(input, unavailable = []) {
282
+ if (!capture.enabled)
283
+ return;
284
+ try {
285
+ event("context.input", { input, inputBytes: Buffer.byteLength(typeof input === "string" ? input : JSON.stringify(input)),
286
+ ...(unavailable.length ? { unavailable: [...unavailable] } : {}) });
287
+ }
288
+ catch {
289
+ event("capture.unsupported", { unavailable: ["input_context"] });
290
+ }
291
+ },
292
+ observe(raw, method) {
293
+ if (!capture.enabled)
294
+ return;
295
+ try {
296
+ const value = record(raw);
297
+ if (runtime === "codex")
298
+ codex(method ?? "", value);
299
+ else if (runtime === "claude")
300
+ claude(value);
301
+ else if (runtime === "opencode")
302
+ openCode(value);
303
+ else if (runtime === "pi")
304
+ pi(value);
305
+ else
306
+ acp(value);
307
+ }
308
+ catch {
309
+ event("capture.unsupported", { unavailable: ["invalid_runtime_event"] });
310
+ }
311
+ },
312
+ finish: flush,
313
+ };
314
+ }
@@ -6,6 +6,8 @@ import spawn from "cross-spawn";
6
6
  import { augmentedPath } from "../runtime-path.js";
7
7
  import { prepareThreadDelivery } from "../thread-input-delivery.js";
8
8
  import { buildOpenCodeArgs, createOpenCodeEventDecoder } from "./opencode.js";
9
+ import { createRuntimeObservationCapture } from "../runtime-observation.js";
10
+ import { createObservationMapper } from "./observation-mapper.js";
9
11
  const ERROR_CAP = 2_000;
10
12
  async function readPrompt() {
11
13
  process.stdin.setEncoding("utf8");
@@ -25,7 +27,8 @@ export function isOpenCodeSessionNotFound(message) {
25
27
  return /(?:session|conversation).*(?:not found|does not exist|unknown|invalid)|(?:not found|unknown|invalid).*(?:session|conversation)/iu
26
28
  .test(message);
27
29
  }
28
- async function runAttempt(options, prompt) {
30
+ async function runAttempt(options, prompt, capture, attempt) {
31
+ const observer = createObservationMapper("opencode", capture, `attempt:${attempt}`);
29
32
  const cwd = process.cwd();
30
33
  const child = spawn(options.bin, buildOpenCodeArgs({ cwd, ...options }), {
31
34
  cwd,
@@ -37,6 +40,7 @@ async function runAttempt(options, prompt) {
37
40
  }
38
41
  child.stderr.pipe(process.stderr, { end: false });
39
42
  const childExit = once(child, "close");
43
+ observer.context({ prompt });
40
44
  child.stdin.end(prompt);
41
45
  const decoder = createOpenCodeEventDecoder();
42
46
  const lines = createInterface({ input: child.stdout });
@@ -50,6 +54,7 @@ async function runAttempt(options, prompt) {
50
54
  catch {
51
55
  continue;
52
56
  }
57
+ observer.observe(event);
53
58
  for (const normalized of decoder.push(event)) {
54
59
  const type = normalized.type;
55
60
  const semantic = type === "opencode.text_delta"
@@ -87,17 +92,26 @@ async function runAttempt(options, prompt) {
87
92
  return { code: 0, staleSession: false };
88
93
  }
89
94
  export async function runOpenCode(options) {
95
+ const capture = createRuntimeObservationCapture("opencode");
96
+ try {
97
+ return await runOpenCodeObserved(options, capture);
98
+ }
99
+ finally {
100
+ capture.finish();
101
+ }
102
+ }
103
+ async function runOpenCodeObserved(options, capture) {
90
104
  const prompt = (await prepareThreadDelivery({ env: process.env, resumed: false,
91
105
  sessionId: "unverified-cold-opencode", wakePrompt: await readPrompt() })).prompt;
92
106
  if (process.env.CREW_THREAD_ROOT) {
93
107
  const { sessionId: _unverified, ...coldOptions } = options;
94
- return (await runAttempt(coldOptions, prompt)).code;
108
+ return (await runAttempt(coldOptions, prompt, capture, 1)).code;
95
109
  }
96
- const first = await runAttempt(options, prompt);
110
+ const first = await runAttempt(options, prompt, capture, 1);
97
111
  if (!first.staleSession)
98
112
  return first.code;
99
113
  const { sessionId: _staleSessionId, ...freshOptions } = options;
100
- return (await runAttempt(freshOptions, prompt)).code;
114
+ return (await runAttempt(freshOptions, prompt, capture, 2)).code;
101
115
  }
102
116
  function optionsFromArgv(argv) {
103
117
  const { values } = parseArgs({