@cabane/companion 0.6.36 → 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 (3) hide show
  1. package/dist/cli.js +269 -118
  2. package/dist/runtime.js +269 -118
  3. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -2506,13 +2506,55 @@ var turnEventSchema = z5.discriminatedUnion("type", [
2506
2506
  })
2507
2507
  ]);
2508
2508
 
2509
- // packages/agent-runtime/src/failure.ts
2509
+ // packages/agent-runtime/src/turn-diagnostics.ts
2510
2510
  import { z as z6 } from "zod";
2511
- var turnFailureSchema = z6.discriminatedUnion("kind", [
2511
+ var turnResultReasonSchema = z6.discriminatedUnion("kind", [
2512
2512
  z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
2513
2513
  z6.object({ kind: z6.literal("rate_limited") }),
2514
2514
  z6.object({ kind: z6.literal("server_error") }),
2515
- 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") })
2516
2558
  ]);
2517
2559
  var USAGE_CAPPED = "usage_capped";
2518
2560
  var RATE_LIMITED = "rate_limited";
@@ -2617,64 +2659,89 @@ var NEGATED_CAP = /not (your|a) usage limit/g;
2617
2659
  var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
2618
2660
  var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
2619
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
+
2620
2687
  // packages/agent-runtime/src/turn-request.ts
2621
- import { z as z7 } from "zod";
2622
- var contentBlockSchema = z7.discriminatedUnion("type", [
2623
- z7.object({ type: z7.literal("text"), text: z7.string() }),
2624
- z7.object({
2625
- type: z7.literal("image"),
2626
- 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() })
2627
2694
  }),
2628
- z7.object({
2629
- type: z7.literal("document"),
2630
- 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() })
2631
2698
  })
2632
2699
  ]);
2633
- var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
2634
- var resolvedRunConfigSchema = z7.object({
2635
- model: z7.string().nullable(),
2700
+ var effortLevelSchema = z8.enum(["low", "medium", "high", "xhigh", "max"]);
2701
+ var resolvedRunConfigSchema = z8.object({
2702
+ model: z8.string().nullable(),
2636
2703
  effort: effortLevelSchema.optional(),
2637
- runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
2704
+ runtimeOptions: z8.record(z8.string(), z8.unknown()).optional()
2638
2705
  });
2639
- var resolvedMcpServerSchema = z7.union([
2640
- z7.object({
2641
- type: z7.literal("stdio").optional(),
2642
- command: z7.string(),
2643
- args: z7.array(z7.string()).optional(),
2644
- 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()
2645
2712
  }),
2646
- z7.object({
2647
- type: z7.literal("http"),
2648
- url: z7.string(),
2649
- 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()
2650
2717
  }),
2651
- z7.object({
2652
- type: z7.literal("sse"),
2653
- url: z7.string(),
2654
- 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()
2655
2722
  })
2656
2723
  ]);
2657
- var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
2658
- var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
2659
- 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({
2660
2727
  // Server-composed system prompt (core + capability prose + adapter addendum +
2661
2728
  // charter). One string to the adapter.
2662
- systemPrompt: z7.string(),
2729
+ systemPrompt: z8.string(),
2663
2730
  // Server-composed per-turn user text (anchor reminder + the triggering message).
2664
- prompt: z7.string(),
2731
+ prompt: z8.string(),
2665
2732
  // The multi-block user-message body (text + vision).
2666
- content: z7.array(contentBlockSchema),
2733
+ content: z8.array(contentBlockSchema),
2667
2734
  // Portable-or-dialect run-config (above).
2668
2735
  config: resolvedRunConfigSchema,
2669
2736
  // Abstract capability grants; the adapter maps them to tool names.
2670
2737
  policy: hostPolicySchema,
2671
2738
  // Prior opaque session state, or null for a fresh session.
2672
- session: z7.string().nullable(),
2739
+ session: z8.string().nullable(),
2673
2740
  // The cabane control-plane coordinates for this turn's MCP + post-back.
2674
- cabane: z7.object({
2675
- mcpUrl: z7.string(),
2676
- bearer: z7.string(),
2677
- activeConversationId: z7.string(),
2741
+ cabane: z8.object({
2742
+ mcpUrl: z8.string(),
2743
+ bearer: z8.string(),
2744
+ activeConversationId: z8.string(),
2678
2745
  // CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
2679
2746
  // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
2680
2747
  // `cabane_companion` — using the same `bearer` (the turn token) and the same
@@ -2684,7 +2751,7 @@ var turnRequestSchema = z7.object({
2684
2751
  // claude-code ignores it (it mounts the in-process instance instead), and
2685
2752
  // every existing `cabane`-block fixture keeps parsing unchanged; the
2686
2753
  // companion always populates it (`build-options.ts`).
2687
- turnControlUrl: z7.string().optional(),
2754
+ turnControlUrl: z8.string().optional(),
2688
2755
  // CT598: the workspace this turn runs in. The claude-code/opencode/codex
2689
2756
  // adapters never need it (they reach Cabane through the `cabane` MCP server,
2690
2757
  // which takes `workspaceId` as a per-tool arg the model supplies); the
@@ -2694,39 +2761,39 @@ var turnRequestSchema = z7.object({
2694
2761
  // adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
2695
2762
  // always populates it (`build-options.ts`), and the native adapter fails the
2696
2763
  // turn loudly when it is somehow absent rather than guessing.
2697
- workspaceId: z7.string().optional(),
2764
+ workspaceId: z8.string().optional(),
2698
2765
  // CT752: the server-resolved workspace surface this credential exposes.
2699
2766
  // Readiness uses this explicit fact to require `sdk` for code mode and the
2700
2767
  // granular floor for classic mode; inventory contents alone cannot infer it
2701
2768
  // because `sdk` is intentionally also available on the classic surface.
2702
- workspaceToolSurface: z7.enum(["code", "classic"]).optional()
2769
+ workspaceToolSurface: z8.enum(["code", "classic"]).optional()
2703
2770
  }),
2704
2771
  // Machine-local resolution (host-filled): the checkout cwd, extra env from a
2705
2772
  // prepare hook, and the resolved user MCP servers.
2706
- local: z7.object({
2707
- cwd: z7.string().optional(),
2708
- 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(),
2709
2776
  mcpServers: resolvedMcpServersSchema.optional(),
2710
2777
  // CT289: machine-local claude-code adapter knobs the operator sets on a
2711
2778
  // companion they run themselves — the auto-memory escape hatch. `autoMemory:
2712
2779
  // true` opts back into Claude Code's auto-memory (governed by the operator's
2713
2780
  // own `.claude/settings.json`); absent/false leaves the adapter's force-off
2714
2781
  // default in place (see `buildClaudeCodeOptions`).
2715
- claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
2782
+ claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
2716
2783
  }),
2717
2784
  // Host-owned injected servers (host-filled) — e.g. the summon server.
2718
- extra: z7.object({
2785
+ extra: z8.object({
2719
2786
  mcpServers: hostInjectedServersSchema
2720
2787
  })
2721
2788
  });
2722
2789
 
2723
2790
  // packages/agent-runtime/src/conformance.ts
2724
- import { z as z8 } from "zod";
2725
- var conformanceFixtureSchema = z8.object({
2726
- name: z8.string(),
2791
+ import { z as z9 } from "zod";
2792
+ var conformanceFixtureSchema = z9.object({
2793
+ name: z9.string(),
2727
2794
  request: turnRequestSchema,
2728
- nativeStream: z8.array(z8.unknown()),
2729
- expected: z8.array(turnEventSchema)
2795
+ nativeStream: z9.array(z9.unknown()),
2796
+ expected: z9.array(turnEventSchema)
2730
2797
  });
2731
2798
 
2732
2799
  // packages/agent-runtime/src/transcript.ts
@@ -2946,6 +3013,7 @@ var TurnPump = class {
2946
3013
  if (evt.final) {
2947
3014
  this.emittedFinal = true;
2948
3015
  this.finalReplyBody = body;
3016
+ this.emittedFinalSource = "runtime_text";
2949
3017
  } else {
2950
3018
  this.lastProgressBody = body;
2951
3019
  }
@@ -2984,6 +3052,7 @@ var TurnPump = class {
2984
3052
  lastProgressBody = null;
2985
3053
  // The turn's final reply text, captured for the dashboard feed / logging.
2986
3054
  finalReplyBody = null;
3055
+ emittedFinalSource = "none";
2987
3056
  // CT11: each tool's persisted seq so the `done`/`error` frame republishes the
2988
3057
  // same seq the `start` row got (the host's upsert keeps the original seq).
2989
3058
  toolSeqByUseId = /* @__PURE__ */ new Map();
@@ -3006,6 +3075,7 @@ var TurnPump = class {
3006
3075
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3007
3076
  this.emittedFinal = true;
3008
3077
  this.finalReplyBody = body;
3078
+ this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3009
3079
  } catch (err) {
3010
3080
  this.opts.onError?.(err, "empty-final");
3011
3081
  }
@@ -3020,6 +3090,9 @@ var TurnPump = class {
3020
3090
  get replyBody() {
3021
3091
  return this.finalReplyBody;
3022
3092
  }
3093
+ get finalSource() {
3094
+ return this.emittedFinalSource;
3095
+ }
3023
3096
  };
3024
3097
 
3025
3098
  // packages/agent-runtime/src/claude-code/index.ts
@@ -3041,7 +3114,7 @@ var CLAUDE_CODE_ADDENDUM = [
3041
3114
  ].join(" ");
3042
3115
 
3043
3116
  // packages/agent-runtime/src/claude-code/policy.ts
3044
- import { z as z9 } from "zod";
3117
+ import { z as z10 } from "zod";
3045
3118
  var HOST_FS_TOOLS = [
3046
3119
  // shell + local filesystem
3047
3120
  "Bash",
@@ -3091,18 +3164,18 @@ function withThinkingSummaries(thinking) {
3091
3164
  if (thinking.type === "disabled") return thinking;
3092
3165
  return { display: "summarized", ...thinking };
3093
3166
  }
3094
- var claudeCodeDialectSchema = z9.object({
3095
- thinking: z9.discriminatedUnion("type", [
3096
- z9.object({
3097
- type: z9.literal("adaptive"),
3098
- 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()
3099
3172
  }),
3100
- z9.object({
3101
- type: z9.literal("enabled"),
3102
- budgetTokens: z9.number().int().positive().optional(),
3103
- 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()
3104
3177
  }),
3105
- z9.object({ type: z9.literal("disabled") })
3178
+ z10.object({ type: z10.literal("disabled") })
3106
3179
  ]).optional()
3107
3180
  }).loose();
3108
3181
  function readThinking(runtimeOptions) {
@@ -4044,7 +4117,7 @@ function sealHeld(held, terminal) {
4044
4117
  }
4045
4118
 
4046
4119
  // packages/agent-runtime/src/opencode/policy.ts
4047
- import { z as z10 } from "zod";
4120
+ import { z as z11 } from "zod";
4048
4121
  var OPENCODE_HOST_TOOLS = [
4049
4122
  "bash",
4050
4123
  "edit",
@@ -4071,8 +4144,8 @@ function opencodeToolPolicy(policy) {
4071
4144
  deny(OPENCODE_UI_PROMPT_TOOLS);
4072
4145
  return { tools, allowAllHostTools: policy.hostFs };
4073
4146
  }
4074
- var opencodeDialectSchema = z10.object({
4075
- agent: z10.string().min(1).optional()
4147
+ var opencodeDialectSchema = z11.object({
4148
+ agent: z11.string().min(1).optional()
4076
4149
  }).loose();
4077
4150
  function readOpencodeDialect(runtimeOptions) {
4078
4151
  const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
@@ -5082,7 +5155,7 @@ function sealHeld2(held, terminal) {
5082
5155
  }
5083
5156
 
5084
5157
  // packages/agent-runtime/src/codex/policy.ts
5085
- import { z as z11 } from "zod";
5158
+ import { z as z12 } from "zod";
5086
5159
  function codexToolPolicy(policy) {
5087
5160
  return policy.hostFs ? {
5088
5161
  sandboxMode: "danger-full-access",
@@ -5101,8 +5174,8 @@ function codexToolPolicy(policy) {
5101
5174
  };
5102
5175
  }
5103
5176
  var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
5104
- var codexDialectSchema = z11.object({
5105
- modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
5177
+ var codexDialectSchema = z12.object({
5178
+ modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
5106
5179
  }).loose();
5107
5180
  function readCodexDialect(runtimeOptions) {
5108
5181
  const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
@@ -6031,12 +6104,12 @@ var ConnectorHealthStore = class {
6031
6104
  };
6032
6105
 
6033
6106
  // src/dispatcher.ts
6034
- import { randomUUID } from "crypto";
6107
+ import { createHash as createHash2, randomUUID } from "crypto";
6035
6108
  import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6036
6109
  import { join as join14 } from "path";
6037
6110
 
6038
6111
  // src/summon.ts
6039
- import { z as z12 } from "zod";
6112
+ import { z as z13 } from "zod";
6040
6113
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6041
6114
  var SUMMON_AGENT_TOOL = "summon_agent";
6042
6115
  var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
@@ -6084,7 +6157,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6084
6157
  SUMMON_AGENT_TOOL,
6085
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.",
6086
6159
  {
6087
- agentId: z12.string().uuid().describe(
6160
+ agentId: z13.string().uuid().describe(
6088
6161
  "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6089
6162
  )
6090
6163
  },
@@ -6101,7 +6174,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6101
6174
  SKIP_TURN_TOOL,
6102
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.`,
6103
6176
  {
6104
- 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.")
6105
6178
  },
6106
6179
  async (args) => {
6107
6180
  skipState.skipped = true;
@@ -6118,23 +6191,23 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6118
6191
  ASK_TOOL,
6119
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.",
6120
6193
  {
6121
- targetUserId: z12.string().uuid().describe(
6194
+ targetUserId: z13.string().uuid().describe(
6122
6195
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
6123
6196
  ),
6124
- question: z12.string().min(1).max(400).optional().describe(
6197
+ question: z13.string().min(1).max(400).optional().describe(
6125
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`."
6126
6199
  ),
6127
- headline: z12.string().min(1).max(120).optional().describe(
6200
+ headline: z13.string().min(1).max(120).optional().describe(
6128
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.'
6129
6202
  ),
6130
- 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."),
6131
- questions: z12.array(
6132
- z12.object({
6133
- 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(
6134
6207
  'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
6135
6208
  ),
6136
- body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6137
- 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.")
6138
6211
  })
6139
6212
  ).min(1).max(5).optional().describe(
6140
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."
@@ -6191,13 +6264,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6191
6264
  SUB_AGENT_TOOL,
6192
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.",
6193
6266
  {
6194
- prompt: z12.string().min(1).max(65536).describe(
6267
+ prompt: z13.string().min(1).max(65536).describe(
6195
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)."
6196
6269
  ),
6197
- agentId: z12.string().uuid().optional().describe(
6270
+ agentId: z13.string().uuid().optional().describe(
6198
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."
6199
6272
  ),
6200
- 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).")
6201
6274
  },
6202
6275
  async (args) => {
6203
6276
  const result = await subAgentCreate(args);
@@ -6227,13 +6300,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6227
6300
  WAKE_ME_TOOL,
6228
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.",
6229
6302
  {
6230
- afterSeconds: z12.number().int().positive().optional().describe(
6303
+ afterSeconds: z13.number().int().positive().optional().describe(
6231
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."
6232
6305
  ),
6233
- at: z12.string().datetime({ offset: true }).optional().describe(
6306
+ at: z13.string().datetime({ offset: true }).optional().describe(
6234
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."
6235
6308
  ),
6236
- note: z12.string().min(1).max(2e3).describe(
6309
+ note: z13.string().min(1).max(2e3).describe(
6237
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").'
6238
6311
  )
6239
6312
  },
@@ -6423,12 +6496,12 @@ function clearPrepared(workspaceId, conversationId, agentId) {
6423
6496
  // src/secrets.ts
6424
6497
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6425
6498
  import { join as join12 } from "path";
6426
- import { z as z13 } from "zod";
6499
+ import { z as z14 } from "zod";
6427
6500
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6428
6501
  function secretsPath() {
6429
6502
  return join12(cabaneDir(), "secrets.json");
6430
6503
  }
6431
- var secretStoreSchema = z13.record(z13.string(), z13.string());
6504
+ var secretStoreSchema = z14.record(z14.string(), z14.string());
6432
6505
  function loadSecretStore() {
6433
6506
  const path = secretsPath();
6434
6507
  if (!existsSync9(path)) return makeStore({});
@@ -6512,12 +6585,13 @@ function resolveMcpSecrets(mcpServers, store) {
6512
6585
  }
6513
6586
 
6514
6587
  // src/transcript-writer.ts
6515
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6516
- 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";
6517
6590
  function transcriptsDir() {
6518
6591
  return join13(cabaneDir(), "transcripts");
6519
6592
  }
6520
6593
  var RETAIN = 200;
6594
+ var ANOMALY_RETAIN = 50;
6521
6595
  var TranscriptWriter = class {
6522
6596
  path;
6523
6597
  broken = false;
@@ -6545,6 +6619,25 @@ var TranscriptWriter = class {
6545
6619
  close(outcome) {
6546
6620
  this.line({ type: "_outcome", ...outcome });
6547
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
+ }
6548
6641
  line(obj) {
6549
6642
  if (this.broken) return;
6550
6643
  try {
@@ -6702,6 +6795,9 @@ var TurnCommitter = class {
6702
6795
  get replyBody() {
6703
6796
  return this.pump.replyBody;
6704
6797
  }
6798
+ get finalSource() {
6799
+ return this.pump.finalSource;
6800
+ }
6705
6801
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
6706
6802
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
6707
6803
  // server strips it again and resolves / ignores an unknown id.
@@ -7565,6 +7661,16 @@ ${reason}`,
7565
7661
  let turnUsage;
7566
7662
  let turnResolvedModel;
7567
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;
7568
7674
  const committer = new TurnCommitter({
7569
7675
  api: this.opts.api,
7570
7676
  workspaceId,
@@ -7660,6 +7766,7 @@ ${reason}`,
7660
7766
  try {
7661
7767
  for await (const event of adapter.runTurn(request, abortController.signal)) {
7662
7768
  transcript?.write(event);
7769
+ eventCounts[event.type] += 1;
7663
7770
  armIdle();
7664
7771
  if (abortController.signal.aborted) {
7665
7772
  turnLog.info("dispatcher: aborted mid-turn");
@@ -7668,6 +7775,7 @@ ${reason}`,
7668
7775
  break;
7669
7776
  }
7670
7777
  if (event.type === "session") {
7778
+ latestSessionState = event.state;
7671
7779
  if (event.degraded) sessionDegraded = true;
7672
7780
  if (!sessionWritten) {
7673
7781
  sessionWritten = true;
@@ -7706,6 +7814,7 @@ ${reason}`,
7706
7814
  turnUsage = event.usage;
7707
7815
  turnResolvedModel = event.resolvedModel;
7708
7816
  turnResolvedConfig = event.resolvedConfig;
7817
+ runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
7709
7818
  } else if (event.type === "text" && skipState.skipped) {
7710
7819
  } else {
7711
7820
  if (event.type === "text" && event.terminal) {
@@ -7828,6 +7937,34 @@ ${reason}`,
7828
7937
  body.sessionWriteRejected = true;
7829
7938
  this.sessionWriteNotified.add(key);
7830
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
+ }
7831
7968
  this.opts.connectorHealth?.recordSettle(turnRuntime, {
7832
7969
  ok: okResult,
7833
7970
  errorReason: body.errorReason ?? null
@@ -7860,6 +7997,9 @@ ${reason}`,
7860
7997
  if (!okResult && resultReason !== "cancelled") {
7861
7998
  turnLog.info(`turn failed \u2014 full transcript: ${transcript.path}`);
7862
7999
  }
8000
+ if (settledDiagnostics?.resultReason?.kind === "empty_result" || settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
8001
+ transcript.preserveAnomaly();
8002
+ }
7863
8003
  }
7864
8004
  if (okResult) {
7865
8005
  turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
@@ -7897,6 +8037,17 @@ ${reason}`,
7897
8037
  return true;
7898
8038
  }
7899
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
+ }
7900
8051
 
7901
8052
  // src/opencode-models.ts
7902
8053
  var OPENCODE_RUNTIME = "opencode";
@@ -8081,43 +8232,43 @@ var Outbox = class {
8081
8232
  };
8082
8233
 
8083
8234
  // src/run-config.ts
8084
- import { z as z14 } from "zod";
8085
- var mcpStdioServerSchema = z14.object({
8086
- type: z14.literal("stdio").optional(),
8087
- command: z14.string().min(1),
8088
- args: z14.array(z14.string()).optional(),
8089
- 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()
8090
8241
  });
8091
- var mcpHttpServerSchema = z14.object({
8092
- type: z14.literal("http"),
8093
- url: z14.string().url(),
8094
- 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()
8095
8246
  });
8096
- var mcpSseServerSchema = z14.object({
8097
- type: z14.literal("sse"),
8098
- url: z14.string().url(),
8099
- 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()
8100
8251
  });
8101
- var mcpServerDefSchema = z14.union([
8252
+ var mcpServerDefSchema = z15.union([
8102
8253
  mcpHttpServerSchema,
8103
8254
  mcpSseServerSchema,
8104
8255
  mcpStdioServerSchema
8105
8256
  ]);
8106
- var thinkingConfigSchema = z14.discriminatedUnion("type", [
8107
- z14.object({ type: z14.literal("adaptive") }),
8108
- z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
8109
- 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") })
8110
8261
  ]);
8111
- var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
8112
- var runConfigSchema = z14.object({
8262
+ var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
8263
+ var runConfigSchema = z15.object({
8113
8264
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8114
8265
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8115
8266
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8116
8267
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8117
8268
  // stripped, so an older companion riding a newer server never rejects the config).
8118
- hostAccess: z14.boolean().optional(),
8119
- mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
8120
- 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(),
8121
8272
  thinking: thinkingConfigSchema.optional(),
8122
8273
  effort: effortSchema.optional()
8123
8274
  });