@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/cli.js CHANGED
@@ -2999,13 +2999,55 @@ var turnEventSchema = z5.discriminatedUnion("type", [
2999
2999
  })
3000
3000
  ]);
3001
3001
 
3002
- // packages/agent-runtime/src/failure.ts
3002
+ // packages/agent-runtime/src/turn-diagnostics.ts
3003
3003
  import { z as z6 } from "zod";
3004
- var turnFailureSchema = z6.discriminatedUnion("kind", [
3004
+ var turnResultReasonSchema = z6.discriminatedUnion("kind", [
3005
3005
  z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
3006
3006
  z6.object({ kind: z6.literal("rate_limited") }),
3007
3007
  z6.object({ kind: z6.literal("server_error") }),
3008
- z6.object({ kind: z6.literal("auth_expired") })
3008
+ z6.object({ kind: z6.literal("auth_expired") }),
3009
+ z6.object({ kind: z6.literal("no_result") }),
3010
+ z6.object({ kind: z6.literal("empty_result") }),
3011
+ z6.object({ kind: z6.literal("empty_result_unverified") }),
3012
+ z6.object({ kind: z6.literal("timeout_idle") }),
3013
+ z6.object({ kind: z6.literal("timeout_total") }),
3014
+ z6.object({ kind: z6.literal("cancelled") }),
3015
+ z6.object({ kind: z6.literal("skipped") }),
3016
+ z6.object({ kind: z6.literal("runtime_error") })
3017
+ ]);
3018
+ var turnOutcomes = ["success", "failure", "cancelled", "skipped"];
3019
+ var turnSessionModes = ["fresh", "resumed", "degraded"];
3020
+ var turnRuntimeResultKinds = ["success", "error", "no_terminal"];
3021
+ var turnFinalSources = [
3022
+ "runtime_text",
3023
+ "progress_promotion",
3024
+ "host_fallback",
3025
+ "marker",
3026
+ "none"
3027
+ ];
3028
+ var turnDiagnosticsSchema = z6.object({
3029
+ outcome: z6.enum(turnOutcomes),
3030
+ resultReason: turnResultReasonSchema.nullable(),
3031
+ sessionMode: z6.enum(turnSessionModes),
3032
+ sessionFingerprint: z6.string().regex(/^[a-f0-9]{16}$/).nullable(),
3033
+ eventCounts: z6.object({
3034
+ session: z6.number().int().nonnegative(),
3035
+ text: z6.number().int().nonnegative(),
3036
+ thinking: z6.number().int().nonnegative(),
3037
+ tool: z6.number().int().nonnegative(),
3038
+ result: z6.number().int().nonnegative()
3039
+ }),
3040
+ runtimeResultKind: z6.enum(turnRuntimeResultKinds).nullable(),
3041
+ finalSource: z6.enum(turnFinalSources)
3042
+ });
3043
+
3044
+ // packages/agent-runtime/src/failure.ts
3045
+ import { z as z7 } from "zod";
3046
+ var turnFailureSchema = z7.discriminatedUnion("kind", [
3047
+ z7.object({ kind: z7.literal("usage_capped"), resetsAt: z7.string().optional() }),
3048
+ z7.object({ kind: z7.literal("rate_limited") }),
3049
+ z7.object({ kind: z7.literal("server_error") }),
3050
+ z7.object({ kind: z7.literal("auth_expired") })
3009
3051
  ]);
3010
3052
  var USAGE_CAPPED = "usage_capped";
3011
3053
  var RATE_LIMITED = "rate_limited";
@@ -3110,64 +3152,89 @@ var NEGATED_CAP = /not (your|a) usage limit/g;
3110
3152
  var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
3111
3153
  var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
3112
3154
 
3155
+ // packages/agent-runtime/src/turn-diagnostics-normalize.ts
3156
+ function normalizeTurnResultReason(reason) {
3157
+ const classified = decodeFailureReason(reason);
3158
+ if (classified) return classified;
3159
+ switch (reason) {
3160
+ case "no_result":
3161
+ case "no_terminal":
3162
+ return { kind: "no_result" };
3163
+ case "empty_result":
3164
+ return { kind: "empty_result" };
3165
+ case "empty_result_unverified":
3166
+ return { kind: "empty_result_unverified" };
3167
+ case "timeout_idle":
3168
+ return { kind: "timeout_idle" };
3169
+ case "timeout_total":
3170
+ return { kind: "timeout_total" };
3171
+ case "cancelled":
3172
+ return { kind: "cancelled" };
3173
+ case "skipped":
3174
+ return { kind: "skipped" };
3175
+ default:
3176
+ return { kind: "runtime_error" };
3177
+ }
3178
+ }
3179
+
3113
3180
  // packages/agent-runtime/src/turn-request.ts
3114
- import { z as z7 } from "zod";
3115
- var contentBlockSchema = z7.discriminatedUnion("type", [
3116
- z7.object({ type: z7.literal("text"), text: z7.string() }),
3117
- z7.object({
3118
- type: z7.literal("image"),
3119
- source: z7.object({ type: z7.literal("url"), url: z7.string() })
3181
+ import { z as z8 } from "zod";
3182
+ var contentBlockSchema = z8.discriminatedUnion("type", [
3183
+ z8.object({ type: z8.literal("text"), text: z8.string() }),
3184
+ z8.object({
3185
+ type: z8.literal("image"),
3186
+ source: z8.object({ type: z8.literal("url"), url: z8.string() })
3120
3187
  }),
3121
- z7.object({
3122
- type: z7.literal("document"),
3123
- source: z7.object({ type: z7.literal("url"), url: z7.string() })
3188
+ z8.object({
3189
+ type: z8.literal("document"),
3190
+ source: z8.object({ type: z8.literal("url"), url: z8.string() })
3124
3191
  })
3125
3192
  ]);
3126
- var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
3127
- var resolvedRunConfigSchema = z7.object({
3128
- model: z7.string().nullable(),
3193
+ var effortLevelSchema = z8.enum(["low", "medium", "high", "xhigh", "max"]);
3194
+ var resolvedRunConfigSchema = z8.object({
3195
+ model: z8.string().nullable(),
3129
3196
  effort: effortLevelSchema.optional(),
3130
- runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
3197
+ runtimeOptions: z8.record(z8.string(), z8.unknown()).optional()
3131
3198
  });
3132
- var resolvedMcpServerSchema = z7.union([
3133
- z7.object({
3134
- type: z7.literal("stdio").optional(),
3135
- command: z7.string(),
3136
- args: z7.array(z7.string()).optional(),
3137
- env: z7.record(z7.string(), z7.string()).optional()
3199
+ var resolvedMcpServerSchema = z8.union([
3200
+ z8.object({
3201
+ type: z8.literal("stdio").optional(),
3202
+ command: z8.string(),
3203
+ args: z8.array(z8.string()).optional(),
3204
+ env: z8.record(z8.string(), z8.string()).optional()
3138
3205
  }),
3139
- z7.object({
3140
- type: z7.literal("http"),
3141
- url: z7.string(),
3142
- headers: z7.record(z7.string(), z7.string()).optional()
3206
+ z8.object({
3207
+ type: z8.literal("http"),
3208
+ url: z8.string(),
3209
+ headers: z8.record(z8.string(), z8.string()).optional()
3143
3210
  }),
3144
- z7.object({
3145
- type: z7.literal("sse"),
3146
- url: z7.string(),
3147
- headers: z7.record(z7.string(), z7.string()).optional()
3211
+ z8.object({
3212
+ type: z8.literal("sse"),
3213
+ url: z8.string(),
3214
+ headers: z8.record(z8.string(), z8.string()).optional()
3148
3215
  })
3149
3216
  ]);
3150
- var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
3151
- var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
3152
- var turnRequestSchema = z7.object({
3217
+ var resolvedMcpServersSchema = z8.record(z8.string(), resolvedMcpServerSchema);
3218
+ var hostInjectedServersSchema = z8.record(z8.string(), z8.unknown());
3219
+ var turnRequestSchema = z8.object({
3153
3220
  // Server-composed system prompt (core + capability prose + adapter addendum +
3154
3221
  // charter). One string to the adapter.
3155
- systemPrompt: z7.string(),
3222
+ systemPrompt: z8.string(),
3156
3223
  // Server-composed per-turn user text (anchor reminder + the triggering message).
3157
- prompt: z7.string(),
3224
+ prompt: z8.string(),
3158
3225
  // The multi-block user-message body (text + vision).
3159
- content: z7.array(contentBlockSchema),
3226
+ content: z8.array(contentBlockSchema),
3160
3227
  // Portable-or-dialect run-config (above).
3161
3228
  config: resolvedRunConfigSchema,
3162
3229
  // Abstract capability grants; the adapter maps them to tool names.
3163
3230
  policy: hostPolicySchema,
3164
3231
  // Prior opaque session state, or null for a fresh session.
3165
- session: z7.string().nullable(),
3232
+ session: z8.string().nullable(),
3166
3233
  // The cabane control-plane coordinates for this turn's MCP + post-back.
3167
- cabane: z7.object({
3168
- mcpUrl: z7.string(),
3169
- bearer: z7.string(),
3170
- activeConversationId: z7.string(),
3234
+ cabane: z8.object({
3235
+ mcpUrl: z8.string(),
3236
+ bearer: z8.string(),
3237
+ activeConversationId: z8.string(),
3171
3238
  // CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
3172
3239
  // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
3173
3240
  // `cabane_companion` — using the same `bearer` (the turn token) and the same
@@ -3177,7 +3244,7 @@ var turnRequestSchema = z7.object({
3177
3244
  // claude-code ignores it (it mounts the in-process instance instead), and
3178
3245
  // every existing `cabane`-block fixture keeps parsing unchanged; the
3179
3246
  // companion always populates it (`build-options.ts`).
3180
- turnControlUrl: z7.string().optional(),
3247
+ turnControlUrl: z8.string().optional(),
3181
3248
  // CT598: the workspace this turn runs in. The claude-code/opencode/codex
3182
3249
  // adapters never need it (they reach Cabane through the `cabane` MCP server,
3183
3250
  // which takes `workspaceId` as a per-tool arg the model supplies); the
@@ -3187,39 +3254,39 @@ var turnRequestSchema = z7.object({
3187
3254
  // adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
3188
3255
  // always populates it (`build-options.ts`), and the native adapter fails the
3189
3256
  // turn loudly when it is somehow absent rather than guessing.
3190
- workspaceId: z7.string().optional(),
3257
+ workspaceId: z8.string().optional(),
3191
3258
  // CT752: the server-resolved workspace surface this credential exposes.
3192
3259
  // Readiness uses this explicit fact to require `sdk` for code mode and the
3193
3260
  // granular floor for classic mode; inventory contents alone cannot infer it
3194
3261
  // because `sdk` is intentionally also available on the classic surface.
3195
- workspaceToolSurface: z7.enum(["code", "classic"]).optional()
3262
+ workspaceToolSurface: z8.enum(["code", "classic"]).optional()
3196
3263
  }),
3197
3264
  // Machine-local resolution (host-filled): the checkout cwd, extra env from a
3198
3265
  // prepare hook, and the resolved user MCP servers.
3199
- local: z7.object({
3200
- cwd: z7.string().optional(),
3201
- env: z7.record(z7.string(), z7.string()).optional(),
3266
+ local: z8.object({
3267
+ cwd: z8.string().optional(),
3268
+ env: z8.record(z8.string(), z8.string()).optional(),
3202
3269
  mcpServers: resolvedMcpServersSchema.optional(),
3203
3270
  // CT289: machine-local claude-code adapter knobs the operator sets on a
3204
3271
  // companion they run themselves — the auto-memory escape hatch. `autoMemory:
3205
3272
  // true` opts back into Claude Code's auto-memory (governed by the operator's
3206
3273
  // own `.claude/settings.json`); absent/false leaves the adapter's force-off
3207
3274
  // default in place (see `buildClaudeCodeOptions`).
3208
- claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
3275
+ claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
3209
3276
  }),
3210
3277
  // Host-owned injected servers (host-filled) — e.g. the summon server.
3211
- extra: z7.object({
3278
+ extra: z8.object({
3212
3279
  mcpServers: hostInjectedServersSchema
3213
3280
  })
3214
3281
  });
3215
3282
 
3216
3283
  // packages/agent-runtime/src/conformance.ts
3217
- import { z as z8 } from "zod";
3218
- var conformanceFixtureSchema = z8.object({
3219
- name: z8.string(),
3284
+ import { z as z9 } from "zod";
3285
+ var conformanceFixtureSchema = z9.object({
3286
+ name: z9.string(),
3220
3287
  request: turnRequestSchema,
3221
- nativeStream: z8.array(z8.unknown()),
3222
- expected: z8.array(turnEventSchema)
3288
+ nativeStream: z9.array(z9.unknown()),
3289
+ expected: z9.array(turnEventSchema)
3223
3290
  });
3224
3291
 
3225
3292
  // packages/agent-runtime/src/transcript.ts
@@ -3439,6 +3506,7 @@ var TurnPump = class {
3439
3506
  if (evt.final) {
3440
3507
  this.emittedFinal = true;
3441
3508
  this.finalReplyBody = body;
3509
+ this.emittedFinalSource = "runtime_text";
3442
3510
  } else {
3443
3511
  this.lastProgressBody = body;
3444
3512
  }
@@ -3477,6 +3545,7 @@ var TurnPump = class {
3477
3545
  lastProgressBody = null;
3478
3546
  // The turn's final reply text, captured for the dashboard feed / logging.
3479
3547
  finalReplyBody = null;
3548
+ emittedFinalSource = "none";
3480
3549
  // CT11: each tool's persisted seq so the `done`/`error` frame republishes the
3481
3550
  // same seq the `start` row got (the host's upsert keeps the original seq).
3482
3551
  toolSeqByUseId = /* @__PURE__ */ new Map();
@@ -3499,6 +3568,7 @@ var TurnPump = class {
3499
3568
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3500
3569
  this.emittedFinal = true;
3501
3570
  this.finalReplyBody = body;
3571
+ this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3502
3572
  } catch (err) {
3503
3573
  this.opts.onError?.(err, "empty-final");
3504
3574
  }
@@ -3513,6 +3583,9 @@ var TurnPump = class {
3513
3583
  get replyBody() {
3514
3584
  return this.finalReplyBody;
3515
3585
  }
3586
+ get finalSource() {
3587
+ return this.emittedFinalSource;
3588
+ }
3516
3589
  };
3517
3590
 
3518
3591
  // packages/agent-runtime/src/claude-code/index.ts
@@ -3534,7 +3607,7 @@ var CLAUDE_CODE_ADDENDUM = [
3534
3607
  ].join(" ");
3535
3608
 
3536
3609
  // packages/agent-runtime/src/claude-code/policy.ts
3537
- import { z as z9 } from "zod";
3610
+ import { z as z10 } from "zod";
3538
3611
  var HOST_FS_TOOLS = [
3539
3612
  // shell + local filesystem
3540
3613
  "Bash",
@@ -3584,18 +3657,18 @@ function withThinkingSummaries(thinking) {
3584
3657
  if (thinking.type === "disabled") return thinking;
3585
3658
  return { display: "summarized", ...thinking };
3586
3659
  }
3587
- var claudeCodeDialectSchema = z9.object({
3588
- thinking: z9.discriminatedUnion("type", [
3589
- z9.object({
3590
- type: z9.literal("adaptive"),
3591
- display: z9.enum(["summarized", "omitted"]).optional()
3660
+ var claudeCodeDialectSchema = z10.object({
3661
+ thinking: z10.discriminatedUnion("type", [
3662
+ z10.object({
3663
+ type: z10.literal("adaptive"),
3664
+ display: z10.enum(["summarized", "omitted"]).optional()
3592
3665
  }),
3593
- z9.object({
3594
- type: z9.literal("enabled"),
3595
- budgetTokens: z9.number().int().positive().optional(),
3596
- display: z9.enum(["summarized", "omitted"]).optional()
3666
+ z10.object({
3667
+ type: z10.literal("enabled"),
3668
+ budgetTokens: z10.number().int().positive().optional(),
3669
+ display: z10.enum(["summarized", "omitted"]).optional()
3597
3670
  }),
3598
- z9.object({ type: z9.literal("disabled") })
3671
+ z10.object({ type: z10.literal("disabled") })
3599
3672
  ]).optional()
3600
3673
  }).loose();
3601
3674
  function readThinking(runtimeOptions) {
@@ -4537,7 +4610,7 @@ function sealHeld(held, terminal) {
4537
4610
  }
4538
4611
 
4539
4612
  // packages/agent-runtime/src/opencode/policy.ts
4540
- import { z as z10 } from "zod";
4613
+ import { z as z11 } from "zod";
4541
4614
  var OPENCODE_HOST_TOOLS = [
4542
4615
  "bash",
4543
4616
  "edit",
@@ -4564,8 +4637,8 @@ function opencodeToolPolicy(policy) {
4564
4637
  deny(OPENCODE_UI_PROMPT_TOOLS);
4565
4638
  return { tools, allowAllHostTools: policy.hostFs };
4566
4639
  }
4567
- var opencodeDialectSchema = z10.object({
4568
- agent: z10.string().min(1).optional()
4640
+ var opencodeDialectSchema = z11.object({
4641
+ agent: z11.string().min(1).optional()
4569
4642
  }).loose();
4570
4643
  function readOpencodeDialect(runtimeOptions) {
4571
4644
  const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
@@ -5575,7 +5648,7 @@ function sealHeld2(held, terminal) {
5575
5648
  }
5576
5649
 
5577
5650
  // packages/agent-runtime/src/codex/policy.ts
5578
- import { z as z11 } from "zod";
5651
+ import { z as z12 } from "zod";
5579
5652
  function codexToolPolicy(policy) {
5580
5653
  return policy.hostFs ? {
5581
5654
  sandboxMode: "danger-full-access",
@@ -5594,8 +5667,8 @@ function codexToolPolicy(policy) {
5594
5667
  };
5595
5668
  }
5596
5669
  var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
5597
- var codexDialectSchema = z11.object({
5598
- modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
5670
+ var codexDialectSchema = z12.object({
5671
+ modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
5599
5672
  }).loose();
5600
5673
  function readCodexDialect(runtimeOptions) {
5601
5674
  const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
@@ -6524,12 +6597,12 @@ var ConnectorHealthStore = class {
6524
6597
  };
6525
6598
 
6526
6599
  // src/dispatcher.ts
6527
- import { randomUUID } from "crypto";
6600
+ import { createHash as createHash2, randomUUID } from "crypto";
6528
6601
  import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6529
6602
  import { join as join14 } from "path";
6530
6603
 
6531
6604
  // src/summon.ts
6532
- import { z as z12 } from "zod";
6605
+ import { z as z13 } from "zod";
6533
6606
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6534
6607
  var SUMMON_AGENT_TOOL = "summon_agent";
6535
6608
  var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
@@ -6577,7 +6650,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6577
6650
  SUMMON_AGENT_TOOL,
6578
6651
  "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.",
6579
6652
  {
6580
- agentId: z12.string().uuid().describe(
6653
+ agentId: z13.string().uuid().describe(
6581
6654
  "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6582
6655
  )
6583
6656
  },
@@ -6594,7 +6667,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6594
6667
  SKIP_TURN_TOOL,
6595
6668
  `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.`,
6596
6669
  {
6597
- reason: z12.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
6670
+ reason: z13.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
6598
6671
  },
6599
6672
  async (args) => {
6600
6673
  skipState.skipped = true;
@@ -6611,23 +6684,23 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6611
6684
  ASK_TOOL,
6612
6685
  "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.",
6613
6686
  {
6614
- targetUserId: z12.string().uuid().describe(
6687
+ targetUserId: z13.string().uuid().describe(
6615
6688
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
6616
6689
  ),
6617
- question: z12.string().min(1).max(400).optional().describe(
6690
+ question: z13.string().min(1).max(400).optional().describe(
6618
6691
  "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`."
6619
6692
  ),
6620
- headline: z12.string().min(1).max(120).optional().describe(
6693
+ headline: z13.string().min(1).max(120).optional().describe(
6621
6694
  '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.'
6622
6695
  ),
6623
- 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."),
6624
- questions: z12.array(
6625
- z12.object({
6626
- headline: z12.string().min(1).max(120).describe(
6696
+ 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."),
6697
+ questions: z13.array(
6698
+ z13.object({
6699
+ headline: z13.string().min(1).max(120).describe(
6627
6700
  'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
6628
6701
  ),
6629
- body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6630
- options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6702
+ body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6703
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6631
6704
  })
6632
6705
  ).min(1).max(5).optional().describe(
6633
6706
  "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."
@@ -6684,13 +6757,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6684
6757
  SUB_AGENT_TOOL,
6685
6758
  "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.",
6686
6759
  {
6687
- prompt: z12.string().min(1).max(65536).describe(
6760
+ prompt: z13.string().min(1).max(65536).describe(
6688
6761
  "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6689
6762
  ),
6690
- agentId: z12.string().uuid().optional().describe(
6763
+ agentId: z13.string().uuid().optional().describe(
6691
6764
  "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."
6692
6765
  ),
6693
- title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6766
+ title: z13.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6694
6767
  },
6695
6768
  async (args) => {
6696
6769
  const result = await subAgentCreate(args);
@@ -6720,13 +6793,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6720
6793
  WAKE_ME_TOOL,
6721
6794
  "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.",
6722
6795
  {
6723
- afterSeconds: z12.number().int().positive().optional().describe(
6796
+ afterSeconds: z13.number().int().positive().optional().describe(
6724
6797
  "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."
6725
6798
  ),
6726
- at: z12.string().datetime({ offset: true }).optional().describe(
6799
+ at: z13.string().datetime({ offset: true }).optional().describe(
6727
6800
  "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."
6728
6801
  ),
6729
- note: z12.string().min(1).max(2e3).describe(
6802
+ note: z13.string().min(1).max(2e3).describe(
6730
6803
  '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").'
6731
6804
  )
6732
6805
  },
@@ -6916,12 +6989,12 @@ function clearPrepared(workspaceId, conversationId, agentId) {
6916
6989
  // src/secrets.ts
6917
6990
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6918
6991
  import { join as join12 } from "path";
6919
- import { z as z13 } from "zod";
6992
+ import { z as z14 } from "zod";
6920
6993
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6921
6994
  function secretsPath() {
6922
6995
  return join12(cabaneDir(), "secrets.json");
6923
6996
  }
6924
- var secretStoreSchema = z13.record(z13.string(), z13.string());
6997
+ var secretStoreSchema = z14.record(z14.string(), z14.string());
6925
6998
  function loadSecretStore() {
6926
6999
  const path = secretsPath();
6927
7000
  if (!existsSync9(path)) return makeStore({});
@@ -7005,12 +7078,13 @@ function resolveMcpSecrets(mcpServers, store) {
7005
7078
  }
7006
7079
 
7007
7080
  // src/transcript-writer.ts
7008
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
7009
- import { join as join13 } from "path";
7081
+ import { appendFileSync, chmodSync as chmodSync3, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
7082
+ import { basename, dirname as dirname5, join as join13 } from "path";
7010
7083
  function transcriptsDir() {
7011
7084
  return join13(cabaneDir(), "transcripts");
7012
7085
  }
7013
7086
  var RETAIN = 200;
7087
+ var ANOMALY_RETAIN = 50;
7014
7088
  var TranscriptWriter = class {
7015
7089
  path;
7016
7090
  broken = false;
@@ -7038,6 +7112,25 @@ var TranscriptWriter = class {
7038
7112
  close(outcome) {
7039
7113
  this.line({ type: "_outcome", ...outcome });
7040
7114
  }
7115
+ // CT1145: keep an independent copy of an empty successful envelope after the
7116
+ // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
7117
+ // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
7118
+ // Best-effort like every other transcript operation: observability can never
7119
+ // fail the turn.
7120
+ preserveAnomaly() {
7121
+ if (this.broken) return;
7122
+ try {
7123
+ const dir2 = join13(dirname5(this.path), "anomalies");
7124
+ mkdirSync9(dir2, { recursive: true, mode: 448 });
7125
+ chmodSync3(dir2, 448);
7126
+ const target = join13(dir2, basename(this.path));
7127
+ copyFileSync(this.path, target);
7128
+ chmodSync3(target, 384);
7129
+ pruneOld(dir2, ANOMALY_RETAIN);
7130
+ } catch (err) {
7131
+ this.fail(err);
7132
+ }
7133
+ }
7041
7134
  line(obj) {
7042
7135
  if (this.broken) return;
7043
7136
  try {
@@ -7195,6 +7288,9 @@ var TurnCommitter = class {
7195
7288
  get replyBody() {
7196
7289
  return this.pump.replyBody;
7197
7290
  }
7291
+ get finalSource() {
7292
+ return this.pump.finalSource;
7293
+ }
7198
7294
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7199
7295
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7200
7296
  // server strips it again and resolves / ignores an unknown id.
@@ -8058,6 +8154,16 @@ ${reason}`,
8058
8154
  let turnUsage;
8059
8155
  let turnResolvedModel;
8060
8156
  let turnResolvedConfig;
8157
+ const eventCounts = {
8158
+ session: 0,
8159
+ text: 0,
8160
+ thinking: 0,
8161
+ tool: 0,
8162
+ result: 0
8163
+ };
8164
+ let runtimeResultKind = null;
8165
+ let latestSessionState = request.session;
8166
+ let settledDiagnostics = null;
8061
8167
  const committer = new TurnCommitter({
8062
8168
  api: this.opts.api,
8063
8169
  workspaceId,
@@ -8153,6 +8259,7 @@ ${reason}`,
8153
8259
  try {
8154
8260
  for await (const event of adapter.runTurn(request, abortController.signal)) {
8155
8261
  transcript2?.write(event);
8262
+ eventCounts[event.type] += 1;
8156
8263
  armIdle();
8157
8264
  if (abortController.signal.aborted) {
8158
8265
  turnLog.info("dispatcher: aborted mid-turn");
@@ -8161,6 +8268,7 @@ ${reason}`,
8161
8268
  break;
8162
8269
  }
8163
8270
  if (event.type === "session") {
8271
+ latestSessionState = event.state;
8164
8272
  if (event.degraded) sessionDegraded = true;
8165
8273
  if (!sessionWritten) {
8166
8274
  sessionWritten = true;
@@ -8199,6 +8307,7 @@ ${reason}`,
8199
8307
  turnUsage = event.usage;
8200
8308
  turnResolvedModel = event.resolvedModel;
8201
8309
  turnResolvedConfig = event.resolvedConfig;
8310
+ runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
8202
8311
  } else if (event.type === "text" && skipState.skipped) {
8203
8312
  } else {
8204
8313
  if (event.type === "text" && event.terminal) {
@@ -8321,6 +8430,34 @@ ${reason}`,
8321
8430
  body.sessionWriteRejected = true;
8322
8431
  this.sessionWriteNotified.add(key);
8323
8432
  }
8433
+ const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
8434
+ const noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
8435
+ const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
8436
+ 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;
8437
+ settledDiagnostics = {
8438
+ outcome,
8439
+ resultReason: diagnosticReason,
8440
+ sessionMode: sessionDegraded ? "degraded" : request.session ? "resumed" : "fresh",
8441
+ sessionFingerprint: fingerprintSessionState(latestSessionState),
8442
+ eventCounts,
8443
+ runtimeResultKind,
8444
+ finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8445
+ };
8446
+ body.diagnostics = settledDiagnostics;
8447
+ if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
8448
+ diagnosticReason.kind
8449
+ )) {
8450
+ turnLog.warn(
8451
+ {
8452
+ turnId,
8453
+ conversationId: payload.conversationId,
8454
+ agentId: payload.agentId,
8455
+ runtime: turnRuntime,
8456
+ diagnostics: settledDiagnostics
8457
+ },
8458
+ "dispatcher: anomalous turn settled"
8459
+ );
8460
+ }
8324
8461
  this.opts.connectorHealth?.recordSettle(turnRuntime, {
8325
8462
  ok: okResult,
8326
8463
  errorReason: body.errorReason ?? null
@@ -8353,6 +8490,9 @@ ${reason}`,
8353
8490
  if (!okResult && resultReason !== "cancelled") {
8354
8491
  turnLog.info(`turn failed \u2014 full transcript: ${transcript2.path}`);
8355
8492
  }
8493
+ if (settledDiagnostics?.resultReason?.kind === "empty_result" || settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
8494
+ transcript2.preserveAnomaly();
8495
+ }
8356
8496
  }
8357
8497
  if (okResult) {
8358
8498
  turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
@@ -8390,6 +8530,17 @@ ${reason}`,
8390
8530
  return true;
8391
8531
  }
8392
8532
  };
8533
+ function fingerprintSessionState(state) {
8534
+ if (!state) return null;
8535
+ let opaqueId = state;
8536
+ try {
8537
+ const parsed = JSON.parse(state);
8538
+ const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
8539
+ if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
8540
+ } catch {
8541
+ }
8542
+ return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
8543
+ }
8393
8544
 
8394
8545
  // src/opencode-models.ts
8395
8546
  var OPENCODE_RUNTIME = "opencode";
@@ -8574,43 +8725,43 @@ var Outbox = class {
8574
8725
  };
8575
8726
 
8576
8727
  // src/run-config.ts
8577
- import { z as z14 } from "zod";
8578
- var mcpStdioServerSchema = z14.object({
8579
- type: z14.literal("stdio").optional(),
8580
- command: z14.string().min(1),
8581
- args: z14.array(z14.string()).optional(),
8582
- env: z14.record(z14.string(), z14.string()).optional()
8728
+ import { z as z15 } from "zod";
8729
+ var mcpStdioServerSchema = z15.object({
8730
+ type: z15.literal("stdio").optional(),
8731
+ command: z15.string().min(1),
8732
+ args: z15.array(z15.string()).optional(),
8733
+ env: z15.record(z15.string(), z15.string()).optional()
8583
8734
  });
8584
- var mcpHttpServerSchema = z14.object({
8585
- type: z14.literal("http"),
8586
- url: z14.string().url(),
8587
- headers: z14.record(z14.string(), z14.string()).optional()
8735
+ var mcpHttpServerSchema = z15.object({
8736
+ type: z15.literal("http"),
8737
+ url: z15.string().url(),
8738
+ headers: z15.record(z15.string(), z15.string()).optional()
8588
8739
  });
8589
- var mcpSseServerSchema = z14.object({
8590
- type: z14.literal("sse"),
8591
- url: z14.string().url(),
8592
- headers: z14.record(z14.string(), z14.string()).optional()
8740
+ var mcpSseServerSchema = z15.object({
8741
+ type: z15.literal("sse"),
8742
+ url: z15.string().url(),
8743
+ headers: z15.record(z15.string(), z15.string()).optional()
8593
8744
  });
8594
- var mcpServerDefSchema = z14.union([
8745
+ var mcpServerDefSchema = z15.union([
8595
8746
  mcpHttpServerSchema,
8596
8747
  mcpSseServerSchema,
8597
8748
  mcpStdioServerSchema
8598
8749
  ]);
8599
- var thinkingConfigSchema = z14.discriminatedUnion("type", [
8600
- z14.object({ type: z14.literal("adaptive") }),
8601
- z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
8602
- z14.object({ type: z14.literal("disabled") })
8750
+ var thinkingConfigSchema = z15.discriminatedUnion("type", [
8751
+ z15.object({ type: z15.literal("adaptive") }),
8752
+ z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
8753
+ z15.object({ type: z15.literal("disabled") })
8603
8754
  ]);
8604
- var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
8605
- var runConfigSchema = z14.object({
8755
+ var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
8756
+ var runConfigSchema = z15.object({
8606
8757
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8607
8758
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8608
8759
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8609
8760
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8610
8761
  // stripped, so an older companion riding a newer server never rejects the config).
8611
- hostAccess: z14.boolean().optional(),
8612
- mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
8613
- model: z14.string().min(1).optional(),
8762
+ hostAccess: z15.boolean().optional(),
8763
+ mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
8764
+ model: z15.string().min(1).optional(),
8614
8765
  thinking: thinkingConfigSchema.optional(),
8615
8766
  effort: effortSchema.optional()
8616
8767
  });