@cabane/companion 0.6.35 → 0.6.37

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 (4) hide show
  1. package/README.md +128 -36
  2. package/dist/cli.js +416 -789
  3. package/dist/runtime.js +274 -119
  4. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -1284,8 +1284,12 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1284
1284
  ...claudeInstalled ? ["Claude Code"] : [],
1285
1285
  ...codexInstalled ? ["Codex"] : []
1286
1286
  ];
1287
+ const connectCommands = [
1288
+ ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
1289
+ ...codexInstalled ? ["`cabane-companion connect codex`"] : []
1290
+ ];
1287
1291
  warn(
1288
- "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one" : "it"} in the Companion dashboard (or in cabane, Settings \u2192 Devices) and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it in the Companion dashboard.")
1292
+ "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
1289
1293
  );
1290
1294
  }
1291
1295
 
@@ -2502,13 +2506,55 @@ var turnEventSchema = z5.discriminatedUnion("type", [
2502
2506
  })
2503
2507
  ]);
2504
2508
 
2505
- // packages/agent-runtime/src/failure.ts
2509
+ // packages/agent-runtime/src/turn-diagnostics.ts
2506
2510
  import { z as z6 } from "zod";
2507
- var turnFailureSchema = z6.discriminatedUnion("kind", [
2511
+ var turnResultReasonSchema = z6.discriminatedUnion("kind", [
2508
2512
  z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
2509
2513
  z6.object({ kind: z6.literal("rate_limited") }),
2510
2514
  z6.object({ kind: z6.literal("server_error") }),
2511
- z6.object({ kind: z6.literal("auth_expired") })
2515
+ z6.object({ kind: z6.literal("auth_expired") }),
2516
+ z6.object({ kind: z6.literal("no_result") }),
2517
+ z6.object({ kind: z6.literal("empty_result") }),
2518
+ z6.object({ kind: z6.literal("empty_result_unverified") }),
2519
+ z6.object({ kind: z6.literal("timeout_idle") }),
2520
+ z6.object({ kind: z6.literal("timeout_total") }),
2521
+ z6.object({ kind: z6.literal("cancelled") }),
2522
+ z6.object({ kind: z6.literal("skipped") }),
2523
+ z6.object({ kind: z6.literal("runtime_error") })
2524
+ ]);
2525
+ var turnOutcomes = ["success", "failure", "cancelled", "skipped"];
2526
+ var turnSessionModes = ["fresh", "resumed", "degraded"];
2527
+ var turnRuntimeResultKinds = ["success", "error", "no_terminal"];
2528
+ var turnFinalSources = [
2529
+ "runtime_text",
2530
+ "progress_promotion",
2531
+ "host_fallback",
2532
+ "marker",
2533
+ "none"
2534
+ ];
2535
+ var turnDiagnosticsSchema = z6.object({
2536
+ outcome: z6.enum(turnOutcomes),
2537
+ resultReason: turnResultReasonSchema.nullable(),
2538
+ sessionMode: z6.enum(turnSessionModes),
2539
+ sessionFingerprint: z6.string().regex(/^[a-f0-9]{16}$/).nullable(),
2540
+ eventCounts: z6.object({
2541
+ session: z6.number().int().nonnegative(),
2542
+ text: z6.number().int().nonnegative(),
2543
+ thinking: z6.number().int().nonnegative(),
2544
+ tool: z6.number().int().nonnegative(),
2545
+ result: z6.number().int().nonnegative()
2546
+ }),
2547
+ runtimeResultKind: z6.enum(turnRuntimeResultKinds).nullable(),
2548
+ finalSource: z6.enum(turnFinalSources)
2549
+ });
2550
+
2551
+ // packages/agent-runtime/src/failure.ts
2552
+ import { z as z7 } from "zod";
2553
+ var turnFailureSchema = z7.discriminatedUnion("kind", [
2554
+ z7.object({ kind: z7.literal("usage_capped"), resetsAt: z7.string().optional() }),
2555
+ z7.object({ kind: z7.literal("rate_limited") }),
2556
+ z7.object({ kind: z7.literal("server_error") }),
2557
+ z7.object({ kind: z7.literal("auth_expired") })
2512
2558
  ]);
2513
2559
  var USAGE_CAPPED = "usage_capped";
2514
2560
  var RATE_LIMITED = "rate_limited";
@@ -2613,64 +2659,89 @@ var NEGATED_CAP = /not (your|a) usage limit/g;
2613
2659
  var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
2614
2660
  var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
2615
2661
 
2662
+ // packages/agent-runtime/src/turn-diagnostics-normalize.ts
2663
+ function normalizeTurnResultReason(reason) {
2664
+ const classified = decodeFailureReason(reason);
2665
+ if (classified) return classified;
2666
+ switch (reason) {
2667
+ case "no_result":
2668
+ case "no_terminal":
2669
+ return { kind: "no_result" };
2670
+ case "empty_result":
2671
+ return { kind: "empty_result" };
2672
+ case "empty_result_unverified":
2673
+ return { kind: "empty_result_unverified" };
2674
+ case "timeout_idle":
2675
+ return { kind: "timeout_idle" };
2676
+ case "timeout_total":
2677
+ return { kind: "timeout_total" };
2678
+ case "cancelled":
2679
+ return { kind: "cancelled" };
2680
+ case "skipped":
2681
+ return { kind: "skipped" };
2682
+ default:
2683
+ return { kind: "runtime_error" };
2684
+ }
2685
+ }
2686
+
2616
2687
  // packages/agent-runtime/src/turn-request.ts
2617
- import { z as z7 } from "zod";
2618
- var contentBlockSchema = z7.discriminatedUnion("type", [
2619
- z7.object({ type: z7.literal("text"), text: z7.string() }),
2620
- z7.object({
2621
- type: z7.literal("image"),
2622
- source: z7.object({ type: z7.literal("url"), url: z7.string() })
2688
+ import { z as z8 } from "zod";
2689
+ var contentBlockSchema = z8.discriminatedUnion("type", [
2690
+ z8.object({ type: z8.literal("text"), text: z8.string() }),
2691
+ z8.object({
2692
+ type: z8.literal("image"),
2693
+ source: z8.object({ type: z8.literal("url"), url: z8.string() })
2623
2694
  }),
2624
- z7.object({
2625
- type: z7.literal("document"),
2626
- source: z7.object({ type: z7.literal("url"), url: z7.string() })
2695
+ z8.object({
2696
+ type: z8.literal("document"),
2697
+ source: z8.object({ type: z8.literal("url"), url: z8.string() })
2627
2698
  })
2628
2699
  ]);
2629
- var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
2630
- var resolvedRunConfigSchema = z7.object({
2631
- model: z7.string().nullable(),
2700
+ var effortLevelSchema = z8.enum(["low", "medium", "high", "xhigh", "max"]);
2701
+ var resolvedRunConfigSchema = z8.object({
2702
+ model: z8.string().nullable(),
2632
2703
  effort: effortLevelSchema.optional(),
2633
- runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
2704
+ runtimeOptions: z8.record(z8.string(), z8.unknown()).optional()
2634
2705
  });
2635
- var resolvedMcpServerSchema = z7.union([
2636
- z7.object({
2637
- type: z7.literal("stdio").optional(),
2638
- command: z7.string(),
2639
- args: z7.array(z7.string()).optional(),
2640
- env: z7.record(z7.string(), z7.string()).optional()
2706
+ var resolvedMcpServerSchema = z8.union([
2707
+ z8.object({
2708
+ type: z8.literal("stdio").optional(),
2709
+ command: z8.string(),
2710
+ args: z8.array(z8.string()).optional(),
2711
+ env: z8.record(z8.string(), z8.string()).optional()
2641
2712
  }),
2642
- z7.object({
2643
- type: z7.literal("http"),
2644
- url: z7.string(),
2645
- headers: z7.record(z7.string(), z7.string()).optional()
2713
+ z8.object({
2714
+ type: z8.literal("http"),
2715
+ url: z8.string(),
2716
+ headers: z8.record(z8.string(), z8.string()).optional()
2646
2717
  }),
2647
- z7.object({
2648
- type: z7.literal("sse"),
2649
- url: z7.string(),
2650
- headers: z7.record(z7.string(), z7.string()).optional()
2718
+ z8.object({
2719
+ type: z8.literal("sse"),
2720
+ url: z8.string(),
2721
+ headers: z8.record(z8.string(), z8.string()).optional()
2651
2722
  })
2652
2723
  ]);
2653
- var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
2654
- var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
2655
- var turnRequestSchema = z7.object({
2724
+ var resolvedMcpServersSchema = z8.record(z8.string(), resolvedMcpServerSchema);
2725
+ var hostInjectedServersSchema = z8.record(z8.string(), z8.unknown());
2726
+ var turnRequestSchema = z8.object({
2656
2727
  // Server-composed system prompt (core + capability prose + adapter addendum +
2657
2728
  // charter). One string to the adapter.
2658
- systemPrompt: z7.string(),
2729
+ systemPrompt: z8.string(),
2659
2730
  // Server-composed per-turn user text (anchor reminder + the triggering message).
2660
- prompt: z7.string(),
2731
+ prompt: z8.string(),
2661
2732
  // The multi-block user-message body (text + vision).
2662
- content: z7.array(contentBlockSchema),
2733
+ content: z8.array(contentBlockSchema),
2663
2734
  // Portable-or-dialect run-config (above).
2664
2735
  config: resolvedRunConfigSchema,
2665
2736
  // Abstract capability grants; the adapter maps them to tool names.
2666
2737
  policy: hostPolicySchema,
2667
2738
  // Prior opaque session state, or null for a fresh session.
2668
- session: z7.string().nullable(),
2739
+ session: z8.string().nullable(),
2669
2740
  // The cabane control-plane coordinates for this turn's MCP + post-back.
2670
- cabane: z7.object({
2671
- mcpUrl: z7.string(),
2672
- bearer: z7.string(),
2673
- activeConversationId: z7.string(),
2741
+ cabane: z8.object({
2742
+ mcpUrl: z8.string(),
2743
+ bearer: z8.string(),
2744
+ activeConversationId: z8.string(),
2674
2745
  // CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
2675
2746
  // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
2676
2747
  // `cabane_companion` — using the same `bearer` (the turn token) and the same
@@ -2680,7 +2751,7 @@ var turnRequestSchema = z7.object({
2680
2751
  // claude-code ignores it (it mounts the in-process instance instead), and
2681
2752
  // every existing `cabane`-block fixture keeps parsing unchanged; the
2682
2753
  // companion always populates it (`build-options.ts`).
2683
- turnControlUrl: z7.string().optional(),
2754
+ turnControlUrl: z8.string().optional(),
2684
2755
  // CT598: the workspace this turn runs in. The claude-code/opencode/codex
2685
2756
  // adapters never need it (they reach Cabane through the `cabane` MCP server,
2686
2757
  // which takes `workspaceId` as a per-tool arg the model supplies); the
@@ -2690,39 +2761,39 @@ var turnRequestSchema = z7.object({
2690
2761
  // adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
2691
2762
  // always populates it (`build-options.ts`), and the native adapter fails the
2692
2763
  // turn loudly when it is somehow absent rather than guessing.
2693
- workspaceId: z7.string().optional(),
2764
+ workspaceId: z8.string().optional(),
2694
2765
  // CT752: the server-resolved workspace surface this credential exposes.
2695
2766
  // Readiness uses this explicit fact to require `sdk` for code mode and the
2696
2767
  // granular floor for classic mode; inventory contents alone cannot infer it
2697
2768
  // because `sdk` is intentionally also available on the classic surface.
2698
- workspaceToolSurface: z7.enum(["code", "classic"]).optional()
2769
+ workspaceToolSurface: z8.enum(["code", "classic"]).optional()
2699
2770
  }),
2700
2771
  // Machine-local resolution (host-filled): the checkout cwd, extra env from a
2701
2772
  // prepare hook, and the resolved user MCP servers.
2702
- local: z7.object({
2703
- cwd: z7.string().optional(),
2704
- env: z7.record(z7.string(), z7.string()).optional(),
2773
+ local: z8.object({
2774
+ cwd: z8.string().optional(),
2775
+ env: z8.record(z8.string(), z8.string()).optional(),
2705
2776
  mcpServers: resolvedMcpServersSchema.optional(),
2706
2777
  // CT289: machine-local claude-code adapter knobs the operator sets on a
2707
2778
  // companion they run themselves — the auto-memory escape hatch. `autoMemory:
2708
2779
  // true` opts back into Claude Code's auto-memory (governed by the operator's
2709
2780
  // own `.claude/settings.json`); absent/false leaves the adapter's force-off
2710
2781
  // default in place (see `buildClaudeCodeOptions`).
2711
- claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
2782
+ claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
2712
2783
  }),
2713
2784
  // Host-owned injected servers (host-filled) — e.g. the summon server.
2714
- extra: z7.object({
2785
+ extra: z8.object({
2715
2786
  mcpServers: hostInjectedServersSchema
2716
2787
  })
2717
2788
  });
2718
2789
 
2719
2790
  // packages/agent-runtime/src/conformance.ts
2720
- import { z as z8 } from "zod";
2721
- var conformanceFixtureSchema = z8.object({
2722
- name: z8.string(),
2791
+ import { z as z9 } from "zod";
2792
+ var conformanceFixtureSchema = z9.object({
2793
+ name: z9.string(),
2723
2794
  request: turnRequestSchema,
2724
- nativeStream: z8.array(z8.unknown()),
2725
- expected: z8.array(turnEventSchema)
2795
+ nativeStream: z9.array(z9.unknown()),
2796
+ expected: z9.array(turnEventSchema)
2726
2797
  });
2727
2798
 
2728
2799
  // packages/agent-runtime/src/transcript.ts
@@ -2942,6 +3013,7 @@ var TurnPump = class {
2942
3013
  if (evt.final) {
2943
3014
  this.emittedFinal = true;
2944
3015
  this.finalReplyBody = body;
3016
+ this.emittedFinalSource = "runtime_text";
2945
3017
  } else {
2946
3018
  this.lastProgressBody = body;
2947
3019
  }
@@ -2980,6 +3052,7 @@ var TurnPump = class {
2980
3052
  lastProgressBody = null;
2981
3053
  // The turn's final reply text, captured for the dashboard feed / logging.
2982
3054
  finalReplyBody = null;
3055
+ emittedFinalSource = "none";
2983
3056
  // CT11: each tool's persisted seq so the `done`/`error` frame republishes the
2984
3057
  // same seq the `start` row got (the host's upsert keeps the original seq).
2985
3058
  toolSeqByUseId = /* @__PURE__ */ new Map();
@@ -3002,6 +3075,7 @@ var TurnPump = class {
3002
3075
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3003
3076
  this.emittedFinal = true;
3004
3077
  this.finalReplyBody = body;
3078
+ this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3005
3079
  } catch (err) {
3006
3080
  this.opts.onError?.(err, "empty-final");
3007
3081
  }
@@ -3016,6 +3090,9 @@ var TurnPump = class {
3016
3090
  get replyBody() {
3017
3091
  return this.finalReplyBody;
3018
3092
  }
3093
+ get finalSource() {
3094
+ return this.emittedFinalSource;
3095
+ }
3019
3096
  };
3020
3097
 
3021
3098
  // packages/agent-runtime/src/claude-code/index.ts
@@ -3037,7 +3114,7 @@ var CLAUDE_CODE_ADDENDUM = [
3037
3114
  ].join(" ");
3038
3115
 
3039
3116
  // packages/agent-runtime/src/claude-code/policy.ts
3040
- import { z as z9 } from "zod";
3117
+ import { z as z10 } from "zod";
3041
3118
  var HOST_FS_TOOLS = [
3042
3119
  // shell + local filesystem
3043
3120
  "Bash",
@@ -3087,18 +3164,18 @@ function withThinkingSummaries(thinking) {
3087
3164
  if (thinking.type === "disabled") return thinking;
3088
3165
  return { display: "summarized", ...thinking };
3089
3166
  }
3090
- var claudeCodeDialectSchema = z9.object({
3091
- thinking: z9.discriminatedUnion("type", [
3092
- z9.object({
3093
- type: z9.literal("adaptive"),
3094
- display: z9.enum(["summarized", "omitted"]).optional()
3167
+ var claudeCodeDialectSchema = z10.object({
3168
+ thinking: z10.discriminatedUnion("type", [
3169
+ z10.object({
3170
+ type: z10.literal("adaptive"),
3171
+ display: z10.enum(["summarized", "omitted"]).optional()
3095
3172
  }),
3096
- z9.object({
3097
- type: z9.literal("enabled"),
3098
- budgetTokens: z9.number().int().positive().optional(),
3099
- display: z9.enum(["summarized", "omitted"]).optional()
3173
+ z10.object({
3174
+ type: z10.literal("enabled"),
3175
+ budgetTokens: z10.number().int().positive().optional(),
3176
+ display: z10.enum(["summarized", "omitted"]).optional()
3100
3177
  }),
3101
- z9.object({ type: z9.literal("disabled") })
3178
+ z10.object({ type: z10.literal("disabled") })
3102
3179
  ]).optional()
3103
3180
  }).loose();
3104
3181
  function readThinking(runtimeOptions) {
@@ -4040,7 +4117,7 @@ function sealHeld(held, terminal) {
4040
4117
  }
4041
4118
 
4042
4119
  // packages/agent-runtime/src/opencode/policy.ts
4043
- import { z as z10 } from "zod";
4120
+ import { z as z11 } from "zod";
4044
4121
  var OPENCODE_HOST_TOOLS = [
4045
4122
  "bash",
4046
4123
  "edit",
@@ -4067,8 +4144,8 @@ function opencodeToolPolicy(policy) {
4067
4144
  deny(OPENCODE_UI_PROMPT_TOOLS);
4068
4145
  return { tools, allowAllHostTools: policy.hostFs };
4069
4146
  }
4070
- var opencodeDialectSchema = z10.object({
4071
- agent: z10.string().min(1).optional()
4147
+ var opencodeDialectSchema = z11.object({
4148
+ agent: z11.string().min(1).optional()
4072
4149
  }).loose();
4073
4150
  function readOpencodeDialect(runtimeOptions) {
4074
4151
  const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
@@ -5078,7 +5155,7 @@ function sealHeld2(held, terminal) {
5078
5155
  }
5079
5156
 
5080
5157
  // packages/agent-runtime/src/codex/policy.ts
5081
- import { z as z11 } from "zod";
5158
+ import { z as z12 } from "zod";
5082
5159
  function codexToolPolicy(policy) {
5083
5160
  return policy.hostFs ? {
5084
5161
  sandboxMode: "danger-full-access",
@@ -5097,8 +5174,8 @@ function codexToolPolicy(policy) {
5097
5174
  };
5098
5175
  }
5099
5176
  var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
5100
- var codexDialectSchema = z11.object({
5101
- modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
5177
+ var codexDialectSchema = z12.object({
5178
+ modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
5102
5179
  }).loose();
5103
5180
  function readCodexDialect(runtimeOptions) {
5104
5181
  const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
@@ -6027,12 +6104,12 @@ var ConnectorHealthStore = class {
6027
6104
  };
6028
6105
 
6029
6106
  // src/dispatcher.ts
6030
- import { randomUUID } from "crypto";
6107
+ import { createHash as createHash2, randomUUID } from "crypto";
6031
6108
  import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6032
6109
  import { join as join14 } from "path";
6033
6110
 
6034
6111
  // src/summon.ts
6035
- import { z as z12 } from "zod";
6112
+ import { z as z13 } from "zod";
6036
6113
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6037
6114
  var SUMMON_AGENT_TOOL = "summon_agent";
6038
6115
  var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
@@ -6080,7 +6157,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6080
6157
  SUMMON_AGENT_TOOL,
6081
6158
  "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` \u2014 every agent's handle and id is on the roster in your turn context. The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead.",
6082
6159
  {
6083
- agentId: z12.string().uuid().describe(
6160
+ agentId: z13.string().uuid().describe(
6084
6161
  "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6085
6162
  )
6086
6163
  },
@@ -6097,7 +6174,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6097
6174
  SKIP_TURN_TOOL,
6098
6175
  `End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or a pile-on where someone else has it. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
6099
6176
  {
6100
- reason: z12.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
6177
+ reason: z13.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
6101
6178
  },
6102
6179
  async (args) => {
6103
6180
  skipState.skipped = true;
@@ -6114,23 +6191,23 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6114
6191
  ASK_TOOL,
6115
6192
  "Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass `targetUserId` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a `headline` (the actual question as one clear, capitalized sentence ending in `?`, \"Do we go to prod?\") plus a short `question` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a `questions` array of 1\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 `options` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER `question` (single) or `questions` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
6116
6193
  {
6117
- targetUserId: z12.string().uuid().describe(
6194
+ targetUserId: z13.string().uuid().describe(
6118
6195
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
6119
6196
  ),
6120
- question: z12.string().min(1).max(400).optional().describe(
6197
+ question: z13.string().min(1).max(400).optional().describe(
6121
6198
  "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
6122
6199
  ),
6123
- headline: z12.string().min(1).max(120).optional().describe(
6200
+ headline: z13.string().min(1).max(120).optional().describe(
6124
6201
  'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
6125
6202
  ),
6126
- options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
6127
- questions: z12.array(
6128
- z12.object({
6129
- headline: z12.string().min(1).max(120).describe(
6203
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
6204
+ questions: z13.array(
6205
+ z13.object({
6206
+ headline: z13.string().min(1).max(120).describe(
6130
6207
  'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
6131
6208
  ),
6132
- body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6133
- options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6209
+ body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6210
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6134
6211
  })
6135
6212
  ).min(1).max(5).optional().describe(
6136
6213
  "MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
@@ -6187,13 +6264,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6187
6264
  SUB_AGENT_TOOL,
6188
6265
  "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window, whose result comes back to you automatically. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
6189
6266
  {
6190
- prompt: z12.string().min(1).max(65536).describe(
6267
+ prompt: z13.string().min(1).max(65536).describe(
6191
6268
  "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6192
6269
  ),
6193
- agentId: z12.string().uuid().optional().describe(
6270
+ agentId: z13.string().uuid().optional().describe(
6194
6271
  "Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
6195
6272
  ),
6196
- title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6273
+ title: z13.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6197
6274
  },
6198
6275
  async (args) => {
6199
6276
  const result = await subAgentCreate(args);
@@ -6223,13 +6300,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6223
6300
  WAKE_ME_TOOL,
6224
6301
  "Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for \"wait until X\": when the thing you need hasn't happened yet (a PR isn't merged, a human hasn't answered), arm a wake, end your turn, and you're woken later to CHECK \u2014 read the workspace, and either act or re-arm. Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a known reset time. A speculative far-future check-in you invented yourself is the one thing not to arm: if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it from a phrase like \"tomorrow morning\"; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check (\"check whether CT441 merged yet\"). The wake is armed when your turn SETTLES, not now, so the delay counts from the turn ending; one wake per turn (last call wins). This is the sanctioned way to schedule your own continuation \u2014 the ONLY one; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you'll be steered to raise an `ask` to the human instead. If a wake can't be armed you're re-dispatched with a note explaining why \u2014 never a silent drop.",
6225
6302
  {
6226
- afterSeconds: z12.number().int().positive().optional().describe(
6303
+ afterSeconds: z13.number().int().positive().optional().describe(
6227
6304
  "Relative delay in seconds from when this turn ends (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
6228
6305
  ),
6229
- at: z12.string().datetime({ offset: true }).optional().describe(
6306
+ at: z13.string().datetime({ offset: true }).optional().describe(
6230
6307
  "Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
6231
6308
  ),
6232
- note: z12.string().min(1).max(2e3).describe(
6309
+ note: z13.string().min(1).max(2e3).describe(
6233
6310
  'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
6234
6311
  )
6235
6312
  },
@@ -6419,12 +6496,12 @@ function clearPrepared(workspaceId, conversationId, agentId) {
6419
6496
  // src/secrets.ts
6420
6497
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6421
6498
  import { join as join12 } from "path";
6422
- import { z as z13 } from "zod";
6499
+ import { z as z14 } from "zod";
6423
6500
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6424
6501
  function secretsPath() {
6425
6502
  return join12(cabaneDir(), "secrets.json");
6426
6503
  }
6427
- var secretStoreSchema = z13.record(z13.string(), z13.string());
6504
+ var secretStoreSchema = z14.record(z14.string(), z14.string());
6428
6505
  function loadSecretStore() {
6429
6506
  const path = secretsPath();
6430
6507
  if (!existsSync9(path)) return makeStore({});
@@ -6508,12 +6585,13 @@ function resolveMcpSecrets(mcpServers, store) {
6508
6585
  }
6509
6586
 
6510
6587
  // src/transcript-writer.ts
6511
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6512
- import { join as join13 } from "path";
6588
+ import { appendFileSync, chmodSync as chmodSync3, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6589
+ import { basename, dirname as dirname5, join as join13 } from "path";
6513
6590
  function transcriptsDir() {
6514
6591
  return join13(cabaneDir(), "transcripts");
6515
6592
  }
6516
6593
  var RETAIN = 200;
6594
+ var ANOMALY_RETAIN = 50;
6517
6595
  var TranscriptWriter = class {
6518
6596
  path;
6519
6597
  broken = false;
@@ -6541,6 +6619,25 @@ var TranscriptWriter = class {
6541
6619
  close(outcome) {
6542
6620
  this.line({ type: "_outcome", ...outcome });
6543
6621
  }
6622
+ // CT1145: keep an independent copy of an empty successful envelope after the
6623
+ // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
6624
+ // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
6625
+ // Best-effort like every other transcript operation: observability can never
6626
+ // fail the turn.
6627
+ preserveAnomaly() {
6628
+ if (this.broken) return;
6629
+ try {
6630
+ const dir2 = join13(dirname5(this.path), "anomalies");
6631
+ mkdirSync9(dir2, { recursive: true, mode: 448 });
6632
+ chmodSync3(dir2, 448);
6633
+ const target = join13(dir2, basename(this.path));
6634
+ copyFileSync(this.path, target);
6635
+ chmodSync3(target, 384);
6636
+ pruneOld(dir2, ANOMALY_RETAIN);
6637
+ } catch (err) {
6638
+ this.fail(err);
6639
+ }
6640
+ }
6544
6641
  line(obj) {
6545
6642
  if (this.broken) return;
6546
6643
  try {
@@ -6698,6 +6795,9 @@ var TurnCommitter = class {
6698
6795
  get replyBody() {
6699
6796
  return this.pump.replyBody;
6700
6797
  }
6798
+ get finalSource() {
6799
+ return this.pump.finalSource;
6800
+ }
6701
6801
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
6702
6802
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
6703
6803
  // server strips it again and resolves / ignores an unknown id.
@@ -7561,6 +7661,16 @@ ${reason}`,
7561
7661
  let turnUsage;
7562
7662
  let turnResolvedModel;
7563
7663
  let turnResolvedConfig;
7664
+ const eventCounts = {
7665
+ session: 0,
7666
+ text: 0,
7667
+ thinking: 0,
7668
+ tool: 0,
7669
+ result: 0
7670
+ };
7671
+ let runtimeResultKind = null;
7672
+ let latestSessionState = request.session;
7673
+ let settledDiagnostics = null;
7564
7674
  const committer = new TurnCommitter({
7565
7675
  api: this.opts.api,
7566
7676
  workspaceId,
@@ -7656,6 +7766,7 @@ ${reason}`,
7656
7766
  try {
7657
7767
  for await (const event of adapter.runTurn(request, abortController.signal)) {
7658
7768
  transcript?.write(event);
7769
+ eventCounts[event.type] += 1;
7659
7770
  armIdle();
7660
7771
  if (abortController.signal.aborted) {
7661
7772
  turnLog.info("dispatcher: aborted mid-turn");
@@ -7664,6 +7775,7 @@ ${reason}`,
7664
7775
  break;
7665
7776
  }
7666
7777
  if (event.type === "session") {
7778
+ latestSessionState = event.state;
7667
7779
  if (event.degraded) sessionDegraded = true;
7668
7780
  if (!sessionWritten) {
7669
7781
  sessionWritten = true;
@@ -7702,6 +7814,7 @@ ${reason}`,
7702
7814
  turnUsage = event.usage;
7703
7815
  turnResolvedModel = event.resolvedModel;
7704
7816
  turnResolvedConfig = event.resolvedConfig;
7817
+ runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
7705
7818
  } else if (event.type === "text" && skipState.skipped) {
7706
7819
  } else {
7707
7820
  if (event.type === "text" && event.terminal) {
@@ -7824,6 +7937,34 @@ ${reason}`,
7824
7937
  body.sessionWriteRejected = true;
7825
7938
  this.sessionWriteNotified.add(key);
7826
7939
  }
7940
+ const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
7941
+ const noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
7942
+ const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
7943
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : emptySuccessfulEnvelope ? turnUsage?.inputTokens === 0 && turnUsage.outputTokens === 0 ? { kind: "empty_result" } : { kind: "empty_result_unverified" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
7944
+ settledDiagnostics = {
7945
+ outcome,
7946
+ resultReason: diagnosticReason,
7947
+ sessionMode: sessionDegraded ? "degraded" : request.session ? "resumed" : "fresh",
7948
+ sessionFingerprint: fingerprintSessionState(latestSessionState),
7949
+ eventCounts,
7950
+ runtimeResultKind,
7951
+ finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
7952
+ };
7953
+ body.diagnostics = settledDiagnostics;
7954
+ if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
7955
+ diagnosticReason.kind
7956
+ )) {
7957
+ turnLog.warn(
7958
+ {
7959
+ turnId,
7960
+ conversationId: payload.conversationId,
7961
+ agentId: payload.agentId,
7962
+ runtime: turnRuntime,
7963
+ diagnostics: settledDiagnostics
7964
+ },
7965
+ "dispatcher: anomalous turn settled"
7966
+ );
7967
+ }
7827
7968
  this.opts.connectorHealth?.recordSettle(turnRuntime, {
7828
7969
  ok: okResult,
7829
7970
  errorReason: body.errorReason ?? null
@@ -7856,6 +7997,9 @@ ${reason}`,
7856
7997
  if (!okResult && resultReason !== "cancelled") {
7857
7998
  turnLog.info(`turn failed \u2014 full transcript: ${transcript.path}`);
7858
7999
  }
8000
+ if (settledDiagnostics?.resultReason?.kind === "empty_result" || settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
8001
+ transcript.preserveAnomaly();
8002
+ }
7859
8003
  }
7860
8004
  if (okResult) {
7861
8005
  turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
@@ -7893,6 +8037,17 @@ ${reason}`,
7893
8037
  return true;
7894
8038
  }
7895
8039
  };
8040
+ function fingerprintSessionState(state) {
8041
+ if (!state) return null;
8042
+ let opaqueId = state;
8043
+ try {
8044
+ const parsed = JSON.parse(state);
8045
+ const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
8046
+ if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
8047
+ } catch {
8048
+ }
8049
+ return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
8050
+ }
7896
8051
 
7897
8052
  // src/opencode-models.ts
7898
8053
  var OPENCODE_RUNTIME = "opencode";
@@ -8077,43 +8232,43 @@ var Outbox = class {
8077
8232
  };
8078
8233
 
8079
8234
  // src/run-config.ts
8080
- import { z as z14 } from "zod";
8081
- var mcpStdioServerSchema = z14.object({
8082
- type: z14.literal("stdio").optional(),
8083
- command: z14.string().min(1),
8084
- args: z14.array(z14.string()).optional(),
8085
- env: z14.record(z14.string(), z14.string()).optional()
8235
+ import { z as z15 } from "zod";
8236
+ var mcpStdioServerSchema = z15.object({
8237
+ type: z15.literal("stdio").optional(),
8238
+ command: z15.string().min(1),
8239
+ args: z15.array(z15.string()).optional(),
8240
+ env: z15.record(z15.string(), z15.string()).optional()
8086
8241
  });
8087
- var mcpHttpServerSchema = z14.object({
8088
- type: z14.literal("http"),
8089
- url: z14.string().url(),
8090
- headers: z14.record(z14.string(), z14.string()).optional()
8242
+ var mcpHttpServerSchema = z15.object({
8243
+ type: z15.literal("http"),
8244
+ url: z15.string().url(),
8245
+ headers: z15.record(z15.string(), z15.string()).optional()
8091
8246
  });
8092
- var mcpSseServerSchema = z14.object({
8093
- type: z14.literal("sse"),
8094
- url: z14.string().url(),
8095
- headers: z14.record(z14.string(), z14.string()).optional()
8247
+ var mcpSseServerSchema = z15.object({
8248
+ type: z15.literal("sse"),
8249
+ url: z15.string().url(),
8250
+ headers: z15.record(z15.string(), z15.string()).optional()
8096
8251
  });
8097
- var mcpServerDefSchema = z14.union([
8252
+ var mcpServerDefSchema = z15.union([
8098
8253
  mcpHttpServerSchema,
8099
8254
  mcpSseServerSchema,
8100
8255
  mcpStdioServerSchema
8101
8256
  ]);
8102
- var thinkingConfigSchema = z14.discriminatedUnion("type", [
8103
- z14.object({ type: z14.literal("adaptive") }),
8104
- z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
8105
- z14.object({ type: z14.literal("disabled") })
8257
+ var thinkingConfigSchema = z15.discriminatedUnion("type", [
8258
+ z15.object({ type: z15.literal("adaptive") }),
8259
+ z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
8260
+ z15.object({ type: z15.literal("disabled") })
8106
8261
  ]);
8107
- var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
8108
- var runConfigSchema = z14.object({
8262
+ var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
8263
+ var runConfigSchema = z15.object({
8109
8264
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8110
8265
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8111
8266
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8112
8267
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8113
8268
  // stripped, so an older companion riding a newer server never rejects the config).
8114
- hostAccess: z14.boolean().optional(),
8115
- mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
8116
- model: z14.string().min(1).optional(),
8269
+ hostAccess: z15.boolean().optional(),
8270
+ mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
8271
+ model: z15.string().min(1).optional(),
8117
8272
  thinking: thinkingConfigSchema.optional(),
8118
8273
  effort: effortSchema.optional()
8119
8274
  });