@cabane/companion 0.6.68 → 0.6.70

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 +239 -88
  2. package/dist/runtime.js +239 -88
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2284,6 +2284,7 @@ import { randomUUID as randomUUID2 } from "crypto";
2284
2284
  // src/api.ts
2285
2285
  var RETRY_BACKOFF_MS = [250, 750];
2286
2286
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
2287
+ var LEASE_CHECK_TIMEOUT_MS = 1e4;
2287
2288
  function activeRunOutboxKey(conversationId, agentId) {
2288
2289
  return `activerun:${conversationId}:${agentId}`;
2289
2290
  }
@@ -2473,6 +2474,52 @@ var CabaneApi = class {
2473
2474
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
2474
2475
  );
2475
2476
  }
2477
+ // CT1292: RENEW THE TURN'S LEASE — ask the server whether this turn is still
2478
+ // running. The dispatcher calls this on a cadence for the life of the SDK loop,
2479
+ // and out of cadence the moment a commit is refused, so a loop whose turn was
2480
+ // terminalized underneath it stops instead of running to the six-hour cap.
2481
+ //
2482
+ // Single-shot and NEVER retried, on purpose. A renewal is a question about
2483
+ // right now, not a durable write: a failed one has no answer worth replaying,
2484
+ // and the next tick asks again in a few seconds anyway. Retrying would only
2485
+ // stretch one tick over the next one's slot.
2486
+ //
2487
+ // ALL FAILURE IS `unknown`, and this is the safety property the whole feature
2488
+ // rests on. The only thing that may stop a running loop is the server SAYING
2489
+ // the turn is over; a 500, a dropped connection, an expired token or a body we
2490
+ // cannot parse says nothing about the turn, and reading any of them as death
2491
+ // would kill healthy turns every time the API hiccups — strictly worse than the
2492
+ // gap this closes. So the failure direction is fixed here, at the boundary,
2493
+ // rather than left to each caller to remember.
2494
+ //
2495
+ // AND IT IS DEADLINED (Codo's blocking finding #1). `fetch` has no default
2496
+ // timeout, so a request that neither resolves nor rejects — a black-holed
2497
+ // connection, a half-open socket surviving a network change — would hang this
2498
+ // promise forever. That is not merely a slow probe: the caller single-flights
2499
+ // on it, so one stalled request latches the check permanently, every later
2500
+ // cadence tick returns immediately, and the loop this exists to stop runs to
2501
+ // the six-hour cap even after connectivity comes back — the exact defect the
2502
+ // task is about, reintroduced through the mechanism meant to close it.
2503
+ //
2504
+ // The deadline turns that into an ordinary `unknown`: the request rejects, the
2505
+ // in-flight latch clears, and the NEXT tick asks again. Well under the renewal
2506
+ // cadence so a stalled probe is finished with before its successor is due.
2507
+ async checkTurnLease(workspaceId, conversationId, agentId, turnId) {
2508
+ const q = `turnId=${encodeURIComponent(turnId)}`;
2509
+ try {
2510
+ const res = await this.request(
2511
+ "GET",
2512
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
2513
+ void 0,
2514
+ {
2515
+ signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
2516
+ }
2517
+ );
2518
+ return res?.state === "running" || res?.state === "ended" ? res.state : "unknown";
2519
+ } catch {
2520
+ return "unknown";
2521
+ }
2522
+ }
2476
2523
  // Flips `active_run_started_at` and optionally captures the SDK session
2477
2524
  // id. The dispatcher hits this twice per turn (now() before the SDK
2478
2525
  // loop; null when it settles) plus once with the session id on the
@@ -3110,7 +3157,9 @@ var turnResultReasonSchema = z6.discriminatedUnion("kind", [
3110
3157
  z6.object({ kind: z6.literal("timeout_idle") }),
3111
3158
  z6.object({ kind: z6.literal("timeout_total") }),
3112
3159
  z6.object({ kind: z6.literal("cancelled") }),
3160
+ z6.object({ kind: z6.literal("lease_lost") }),
3113
3161
  z6.object({ kind: z6.literal("skipped") }),
3162
+ z6.object({ kind: z6.literal("session_start_failed") }),
3114
3163
  z6.object({ kind: z6.literal("workspace_tools_missing") }),
3115
3164
  z6.object({ kind: z6.literal("runtime_error") })
3116
3165
  ]);
@@ -3274,8 +3323,12 @@ function normalizeTurnResultReason(reason) {
3274
3323
  return { kind: "timeout_total" };
3275
3324
  case "cancelled":
3276
3325
  return { kind: "cancelled" };
3326
+ case "lease_lost":
3327
+ return { kind: "lease_lost" };
3277
3328
  case "skipped":
3278
3329
  return { kind: "skipped" };
3330
+ case "session_start_failed":
3331
+ return { kind: "session_start_failed" };
3279
3332
  case "workspace_tools_missing":
3280
3333
  return { kind: "workspace_tools_missing" };
3281
3334
  default:
@@ -3724,8 +3777,9 @@ import {
3724
3777
 
3725
3778
  // packages/agent-runtime/src/claude-code/addendum.ts
3726
3779
  var CLAUDE_CODE_ADDENDUM = [
3727
- "On this harness the Cabane tools register under MCP prefixes. The workspace tool is",
3728
- "`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
3780
+ "On this harness the Cabane tools register under MCP prefixes. The workspace tools are",
3781
+ "`mcp__cabane__query`, `mcp__cabane__mutate` and `mcp__cabane__describe`; the ancillary",
3782
+ "tools are `mcp__cabane__list_workspaces`,",
3729
3783
  "`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
3730
3784
  "`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
3731
3785
  "`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
@@ -4002,7 +4056,7 @@ async function* decodeSdkStream(iter, ctx) {
4002
4056
  };
4003
4057
  }
4004
4058
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
4005
- resultReason = "workspace_tools_missing";
4059
+ resultReason = "session_start_failed";
4006
4060
  break;
4007
4061
  } else if (msg.type === "assistant") {
4008
4062
  const assistantErr = msg.error;
@@ -4069,7 +4123,7 @@ async function* decodeSdkStream(iter, ctx) {
4069
4123
  if (ctx.signal.aborted) return;
4070
4124
  if (!workspaceProven) {
4071
4125
  ok = false;
4072
- resultReason ??= "workspace_tools_missing";
4126
+ resultReason ??= "session_start_failed";
4073
4127
  }
4074
4128
  await flushHeldText(buffer, emit, ok);
4075
4129
  yield* drain(out);
@@ -4096,7 +4150,7 @@ function readMcpInventory(msg) {
4096
4150
  }
4097
4151
  function provesWorkspaceTools(msg) {
4098
4152
  const tools = msg.tools;
4099
- return Array.isArray(tools) && tools.includes("mcp__cabane__sdk");
4153
+ return Array.isArray(tools) && tools.includes("mcp__cabane__query");
4100
4154
  }
4101
4155
  function isSubscriptionWindow(value) {
4102
4156
  return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
@@ -4150,6 +4204,47 @@ function buildQueryPrompt(req) {
4150
4204
  }
4151
4205
 
4152
4206
  // packages/agent-runtime/src/claude-code/index.ts
4207
+ function attachAbortController(options, signal) {
4208
+ if (options.abortController) return;
4209
+ const sdkAbort = new AbortController();
4210
+ if (signal.aborted) sdkAbort.abort();
4211
+ else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
4212
+ options.abortController = sdkAbort;
4213
+ }
4214
+ function sdkEvents(queryFn, req, signal, built, degraded) {
4215
+ attachAbortController(built.options, signal);
4216
+ const prompt = buildQueryPrompt(req);
4217
+ const iter = queryFn({ prompt, options: built.options });
4218
+ return decodeSdkStream(iter, {
4219
+ signal,
4220
+ cwd: req.local.cwd,
4221
+ resumedSessionId: built.resume,
4222
+ degraded
4223
+ });
4224
+ }
4225
+ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded) {
4226
+ let emitted = false;
4227
+ for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
4228
+ const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
4229
+ if (recoverResume) {
4230
+ if (signal.aborted) return;
4231
+ onWarn?.(
4232
+ `claude-code adapter: stored session ${initial.resume} ended before system/init \u2014 starting one fresh session (CT1293 recovery)`,
4233
+ {
4234
+ cwd: req.local.cwd,
4235
+ reason: "session_start_failed",
4236
+ discardedSessionId: initial.resume
4237
+ }
4238
+ );
4239
+ const freshReq = { ...req, session: null };
4240
+ const fresh = buildClaudeCodeOptions(freshReq, augmentOptions);
4241
+ yield* sdkEvents(queryFn, freshReq, signal, fresh, true);
4242
+ return;
4243
+ }
4244
+ emitted = true;
4245
+ yield event;
4246
+ }
4247
+ }
4153
4248
  function createClaudeCodeAdapter(deps = {}) {
4154
4249
  const queryFn = deps.queryFn ?? defaultQuery;
4155
4250
  return {
@@ -4165,20 +4260,15 @@ function createClaudeCodeAdapter(deps = {}) {
4165
4260
  );
4166
4261
  }
4167
4262
  const degraded = freshReason !== void 0 && freshReason !== "no_session";
4168
- if (!options.abortController) {
4169
- const sdkAbort = new AbortController();
4170
- if (signal.aborted) sdkAbort.abort();
4171
- else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
4172
- options.abortController = sdkAbort;
4173
- }
4174
- const prompt = buildQueryPrompt(req);
4175
- const iter = queryFn({ prompt, options });
4176
- return decodeSdkStream(iter, {
4263
+ return runWithResumeRecovery(
4264
+ queryFn,
4265
+ req,
4177
4266
  signal,
4178
- cwd: req.local.cwd,
4179
- resumedSessionId: resume,
4267
+ { options, resume, freshReason },
4268
+ deps.augmentOptions,
4269
+ deps.onWarn,
4180
4270
  degraded
4181
- });
4271
+ );
4182
4272
  }
4183
4273
  };
4184
4274
  }
@@ -4222,7 +4312,7 @@ var init = (sessionId, model) => ({
4222
4312
  subtype: "init",
4223
4313
  session_id: sessionId,
4224
4314
  mcp_servers: HEALTHY_MCP_INVENTORY.servers,
4225
- tools: ["mcp__cabane__sdk"],
4315
+ tools: ["mcp__cabane__query"],
4226
4316
  ...model ? { model } : {}
4227
4317
  });
4228
4318
  var assistantText = (text) => ({
@@ -4700,12 +4790,14 @@ function selectAdapter(registry, runtime) {
4700
4790
 
4701
4791
  // packages/agent-runtime/src/opencode/addendum.ts
4702
4792
  var OPENCODE_ADDENDUM = [
4703
- "Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
4704
- "`ask`, `reply_to`, `wake_me`, `send`, `skip_turn`; the ancillary tools are",
4705
- "`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
4706
- "`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
4707
- "the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
4708
- "workspace read is `cabane.files.read` inside an `sdk` program. If a tool ever appears with",
4793
+ "Every tool here goes by its plain name: the workspace tools are `query`, `mutate` and",
4794
+ "`describe`; the turn tools are `ask`, `reply_to`, `wake_me`, `send`, `skip_turn`; the",
4795
+ "ancillary tools are `list_workspaces`, `read_binary`, `upload`, `begin_upload`,",
4796
+ "`finalize_upload`, `mint_render_token`; and the host tools are plain verbs too (`bash`,",
4797
+ "`read`, `edit`). Mind the collision: a bare `read` or `edit` is the HOST tool, acting on the",
4798
+ "local machine \u2014 a workspace read is `query` with the operation name `files.read`. The",
4799
+ "collision runs the other way too: `read` is a host tool here AND `files.read` is an",
4800
+ "operation name, so keep the bare verb and the dotted name apart. If a tool ever appears with",
4709
4801
  "an `mcp__\u2026__` prefix, that prefix is not part of its name here. One more harness fact: you",
4710
4802
  "can interleave narration with tool calls, but only your final message is recorded as the",
4711
4803
  "turn's reply \u2014 write your closing answer last."
@@ -5674,15 +5766,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5674
5766
  // packages/agent-runtime/src/codex/addendum.ts
5675
5767
  var CODEX_ADDENDUM = [
5676
5768
  "Codex surfaces MCP tools under qualified names, and on some versions defers them. The",
5677
- "workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
5678
- "the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
5769
+ "workspace tools are `mcp__cabane__query`, `mcp__cabane__mutate` and",
5770
+ "`mcp__cabane__describe` \u2014 if they aren't in your visible tool list, locate them in",
5771
+ "the deferred-tool inventory and invoke those exact qualified names; never conclude the",
5772
+ "workspace is",
5679
5773
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
5680
5774
  "error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
5681
5775
  "`send` / `skip_turn`, and the ancillary tools",
5682
5776
  "`mcp__cabane__list_workspaces` /",
5683
5777
  "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
5684
5778
  "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
5685
- "`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
5779
+ "operation NAMES you pass to `query` / `mutate`. One more harness fact: you can interleave",
5780
+ "narration",
5686
5781
  "with tool calls, but only your final message is recorded as the turn's reply \u2014 write your",
5687
5782
  "closing answer last."
5688
5783
  ].join(" ");
@@ -5780,9 +5875,12 @@ function readUsage(ev) {
5780
5875
  const usage = asRecord2(ev.usage);
5781
5876
  if (!usage) return void 0;
5782
5877
  const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
5783
- const inputTokens = num(usage.input_tokens) + num(usage.cached_input_tokens);
5784
- const outputTokens = num(usage.output_tokens) + num(usage.reasoning_output_tokens);
5785
- return { inputTokens, outputTokens };
5878
+ return {
5879
+ inputTokens: num(usage.input_tokens),
5880
+ outputTokens: num(usage.output_tokens),
5881
+ cacheReadTokens: num(usage.cached_input_tokens),
5882
+ cacheCreationTokens: num(usage.cache_write_input_tokens)
5883
+ };
5786
5884
  }
5787
5885
  function toStatus(v) {
5788
5886
  return v === "completed" || v === "failed" ? v : "in_progress";
@@ -6344,24 +6442,14 @@ var errorItem = (id, message) => ({
6344
6442
  type: "item.completed",
6345
6443
  item: { id, type: "error", message }
6346
6444
  });
6347
- var SDK_SUCCESS = { ok: true, result: { source: "native" }, logs: "", truncated: false };
6348
- var SDK_FAILURE = {
6445
+ var OP_SUCCESS = { ok: true, result: { source: "native" } };
6446
+ var OP_FAILURE = {
6349
6447
  ok: false,
6350
- result: null,
6351
- preview: '{"rows":[1,2,3]}',
6352
- logs: "",
6353
- truncated: false,
6354
- error: { kind: "output_too_large", bytes: 169167, limit: 65536 },
6355
- warnings: [],
6356
- committed: {
6357
- calls: [
6358
- {
6359
- method: "conversations.update",
6360
- args: { id: "conv-1", opts: { title: "Renamed before failure" } }
6361
- }
6362
- ]
6363
- },
6364
- inFlight: null
6448
+ error: {
6449
+ kind: "output_too_large",
6450
+ code: "output_too_large",
6451
+ message: "files.read returned more than the result ceiling"
6452
+ }
6365
6453
  };
6366
6454
  var sessionEvent3 = (threadId, cwd = DIR2, degraded = false, promptFingerprint = fingerprintPrompt(PROMPT)) => ({
6367
6455
  type: "session",
@@ -6372,6 +6460,11 @@ var CODEX_CONFORMANCE_FIXTURES = [
6372
6460
  {
6373
6461
  // A plain text reply: the held agent message seals as the terminal final at
6374
6462
  // `turn.completed`; the usage counters ride the `result`.
6463
+ //
6464
+ // CT1283: the counters NEST — `cached_input_tokens` is a slice of
6465
+ // `input_tokens`, `reasoning_output_tokens` a slice of `output_tokens` — so
6466
+ // input is 100 (not 120) and output is 50 (not 60), with the cache read broken
6467
+ // out beside them. This fixture pinned the double-count before CT1283.
6375
6468
  name: "clean turn",
6376
6469
  request: makeRequest3(),
6377
6470
  nativeStream: [
@@ -6380,6 +6473,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
6380
6473
  turnCompleted({
6381
6474
  input_tokens: 100,
6382
6475
  cached_input_tokens: 20,
6476
+ cache_write_input_tokens: 5,
6383
6477
  output_tokens: 50,
6384
6478
  reasoning_output_tokens: 10
6385
6479
  })
@@ -6387,7 +6481,16 @@ var CODEX_CONFORMANCE_FIXTURES = [
6387
6481
  expected: [
6388
6482
  sessionEvent3(NEW_THREAD_ID),
6389
6483
  { type: "text", body: "Hello there.", terminal: true },
6390
- { type: "result", ok: true, usage: { inputTokens: 120, outputTokens: 60 } }
6484
+ {
6485
+ type: "result",
6486
+ ok: true,
6487
+ usage: {
6488
+ inputTokens: 100,
6489
+ outputTokens: 50,
6490
+ cacheReadTokens: 20,
6491
+ cacheCreationTokens: 5
6492
+ }
6493
+ }
6391
6494
  ]
6392
6495
  },
6393
6496
  {
@@ -6443,30 +6546,30 @@ var CODEX_CONFORMANCE_FIXTURES = [
6443
6546
  ]
6444
6547
  },
6445
6548
  {
6446
- // CT1211: recorded SDK success shape. Codex exposes the text compatibility
6549
+ // CT1211: recorded MCP success shape. Codex exposes the text compatibility
6447
6550
  // copy and the native object together; normalization must keep the object.
6448
6551
  name: "MCP success prefers native structured content",
6449
6552
  request: makeRequest3(),
6450
6553
  nativeStream: [
6451
6554
  threadStarted(NEW_THREAD_ID),
6452
6555
  toolFrame("item.started", {
6453
- id: "sdk-success",
6556
+ id: "query-success",
6454
6557
  type: "mcp_tool_call",
6455
6558
  server: "cabane",
6456
- tool: "sdk",
6457
- arguments: { code: 'return { source: "native" }' },
6559
+ tool: "query",
6560
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6458
6561
  status: "in_progress"
6459
6562
  }),
6460
6563
  toolFrame("item.completed", {
6461
- id: "sdk-success",
6564
+ id: "query-success",
6462
6565
  type: "mcp_tool_call",
6463
6566
  server: "cabane",
6464
- tool: "sdk",
6465
- arguments: { code: 'return { source: "native" }' },
6567
+ tool: "query",
6568
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6466
6569
  status: "completed",
6467
6570
  result: {
6468
- content: [{ type: "text", text: JSON.stringify(SDK_SUCCESS) }],
6469
- structured_content: SDK_SUCCESS
6571
+ content: [{ type: "text", text: JSON.stringify(OP_SUCCESS) }],
6572
+ structured_content: OP_SUCCESS
6470
6573
  }
6471
6574
  }),
6472
6575
  turnCompleted()
@@ -6475,20 +6578,20 @@ var CODEX_CONFORMANCE_FIXTURES = [
6475
6578
  sessionEvent3(NEW_THREAD_ID),
6476
6579
  {
6477
6580
  type: "tool",
6478
- id: "sdk-success",
6479
- name: "sdk",
6581
+ id: "query-success",
6582
+ name: "query",
6480
6583
  phase: "start",
6481
6584
  summary: "",
6482
- input: { code: 'return { source: "native" }' }
6585
+ input: { op: "files.read", args: { path: "/ABOUT.md" } }
6483
6586
  },
6484
6587
  {
6485
6588
  type: "tool",
6486
- id: "sdk-success",
6487
- name: "sdk",
6589
+ id: "query-success",
6590
+ name: "query",
6488
6591
  phase: "done",
6489
6592
  summary: "",
6490
- input: { code: 'return { source: "native" }' },
6491
- result: SDK_SUCCESS
6593
+ input: { op: "files.read", args: { path: "/ABOUT.md" } },
6594
+ result: OP_SUCCESS
6492
6595
  },
6493
6596
  { type: "result", ok: true }
6494
6597
  ]
@@ -6496,28 +6599,28 @@ var CODEX_CONFORMANCE_FIXTURES = [
6496
6599
  {
6497
6600
  // CT1211 incident shape: MCP `isError` keeps the failed phase, while the raw
6498
6601
  // camel-case structured payload survives instead of the generic `error` token.
6499
- name: "failed SDK call preserves structured receipt while remaining error phase",
6602
+ name: "failed MCP call preserves structured receipt while remaining error phase",
6500
6603
  request: makeRequest3(),
6501
6604
  nativeStream: [
6502
6605
  threadStarted(NEW_THREAD_ID),
6503
6606
  toolFrame("item.started", {
6504
- id: "sdk-failed",
6607
+ id: "query-failed",
6505
6608
  type: "mcp_tool_call",
6506
6609
  server: "cabane",
6507
- tool: "sdk",
6508
- arguments: { code: "return largeResult" },
6610
+ tool: "query",
6611
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6509
6612
  status: "in_progress"
6510
6613
  }),
6511
6614
  toolFrame("item.completed", {
6512
- id: "sdk-failed",
6615
+ id: "query-failed",
6513
6616
  type: "mcp_tool_call",
6514
6617
  server: "cabane",
6515
- tool: "sdk",
6516
- arguments: { code: "return largeResult" },
6618
+ tool: "query",
6619
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6517
6620
  status: "failed",
6518
6621
  result: {
6519
- content: [{ type: "text", text: JSON.stringify(SDK_FAILURE) }],
6520
- structuredContent: SDK_FAILURE,
6622
+ content: [{ type: "text", text: JSON.stringify(OP_FAILURE) }],
6623
+ structuredContent: OP_FAILURE,
6521
6624
  isError: true
6522
6625
  },
6523
6626
  error: { message: "error" }
@@ -6528,20 +6631,20 @@ var CODEX_CONFORMANCE_FIXTURES = [
6528
6631
  sessionEvent3(NEW_THREAD_ID),
6529
6632
  {
6530
6633
  type: "tool",
6531
- id: "sdk-failed",
6532
- name: "sdk",
6634
+ id: "query-failed",
6635
+ name: "query",
6533
6636
  phase: "start",
6534
6637
  summary: "",
6535
- input: { code: "return largeResult" }
6638
+ input: { op: "files.read", args: { path: "/ABOUT.md" } }
6536
6639
  },
6537
6640
  {
6538
6641
  type: "tool",
6539
- id: "sdk-failed",
6540
- name: "sdk",
6642
+ id: "query-failed",
6643
+ name: "query",
6541
6644
  phase: "error",
6542
6645
  summary: "",
6543
- input: { code: "return largeResult" },
6544
- result: SDK_FAILURE
6646
+ input: { op: "files.read", args: { path: "/ABOUT.md" } },
6647
+ result: OP_FAILURE
6545
6648
  },
6546
6649
  { type: "result", ok: true }
6547
6650
  ]
@@ -7772,6 +7875,7 @@ var TurnCommitter = class {
7772
7875
  { err: err instanceof Error ? err.message : String(err), hook },
7773
7876
  "dispatcher: transcript callback failed"
7774
7877
  );
7878
+ deps.onCommitFailed?.(err);
7775
7879
  };
7776
7880
  const commit = {
7777
7881
  commitMessage: async ({ body, kind, seq }) => {
@@ -7836,6 +7940,7 @@ var TurnCommitter = class {
7836
7940
  { err: err instanceof Error ? err.message : String(err) },
7837
7941
  "dispatcher: progress-promotion commit failed"
7838
7942
  );
7943
+ deps.onCommitFailed?.(err);
7839
7944
  }
7840
7945
  });
7841
7946
  this.emit = sinkEmitter(this.pump.sink, this.onError);
@@ -7995,6 +8100,7 @@ var SILENT_MARKER_BODY = "(no reply)";
7995
8100
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7996
8101
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7997
8102
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8103
+ var DEFAULT_LEASE_RENEWAL_MS = 3e4;
7998
8104
  function checkoutState(cwd) {
7999
8105
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
8000
8106
  if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
@@ -8027,13 +8133,19 @@ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8027
8133
  "turn_already_ended",
8028
8134
  "turn_belongs_elsewhere"
8029
8135
  ]);
8030
- function leaseRefusal(err) {
8136
+ function apiErrorCode(err) {
8031
8137
  if (!(err instanceof ApiError)) return null;
8032
8138
  const body = err.body;
8033
- const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8139
+ return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8140
+ }
8141
+ function leaseRefusal(err) {
8142
+ const code = apiErrorCode(err);
8034
8143
  if (code && LEASE_REFUSALS.has(code)) return code;
8035
8144
  return null;
8036
8145
  }
8146
+ function isWriteFenceRefusal(err) {
8147
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
8148
+ }
8037
8149
  var ERROR_BODY_LOG_CAP = 2e3;
8038
8150
  function describeErrorBody(body) {
8039
8151
  if (body === void 0 || body === null) return void 0;
@@ -8379,6 +8491,7 @@ ${reason}`,
8379
8491
  if (pair2.size === 0) this.aborts.delete(key);
8380
8492
  };
8381
8493
  let timeoutReason = null;
8494
+ let leaseLost = false;
8382
8495
  try {
8383
8496
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8384
8497
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8526,6 +8639,8 @@ ${reason}`,
8526
8639
  let latestSessionState = request.session;
8527
8640
  let settledDiagnostics = null;
8528
8641
  let silentMarkerEmitted = false;
8642
+ let noteCommitFailed = () => {
8643
+ };
8529
8644
  const committer = new TurnCommitter({
8530
8645
  api: this.opts.api,
8531
8646
  workspaceId,
@@ -8551,7 +8666,9 @@ ${reason}`,
8551
8666
  replyState,
8552
8667
  // The ledger-derived owed reply, for the runtime's own declaration when
8553
8668
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8554
- owedReplyMessageId: turnContext.owedReplyMessageId ?? null
8669
+ owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8670
+ // CT1292: a commit that didn't land may mean this turn's lease is gone.
8671
+ onCommitFailed: (err) => noteCommitFailed(err)
8555
8672
  });
8556
8673
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8557
8674
  let turnControlIntentFetched = false;
@@ -8631,6 +8748,35 @@ ${reason}`,
8631
8748
  const totalTimer = setTimeout(() => fireTimeout("timeout_total"), totalTimeoutMs);
8632
8749
  totalTimer.unref?.();
8633
8750
  armIdle();
8751
+ const leaseRenewalMs = this.opts.leaseRenewalMs ?? DEFAULT_LEASE_RENEWAL_MS;
8752
+ let leaseCheckInFlight = false;
8753
+ const checkLease = async (trigger) => {
8754
+ if (leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8755
+ leaseCheckInFlight = true;
8756
+ try {
8757
+ const state = await this.opts.api.checkTurnLease(
8758
+ workspaceId,
8759
+ payload.conversationId,
8760
+ payload.agentId,
8761
+ turnId
8762
+ );
8763
+ if (state !== "ended") return;
8764
+ if (abortController.signal.aborted) return;
8765
+ leaseLost = true;
8766
+ turnLog.warn(
8767
+ { turnId, trigger },
8768
+ "dispatcher: this turn is no longer running server-side \u2014 aborting the loop"
8769
+ );
8770
+ abortController.abort();
8771
+ } finally {
8772
+ leaseCheckInFlight = false;
8773
+ }
8774
+ };
8775
+ const leaseTimer = setInterval(() => void checkLease("cadence"), leaseRenewalMs);
8776
+ leaseTimer.unref?.();
8777
+ noteCommitFailed = (err) => {
8778
+ if (isWriteFenceRefusal(err)) void checkLease("commit_refused");
8779
+ };
8634
8780
  try {
8635
8781
  for await (const event of adapter.runTurn(request, abortController.signal)) {
8636
8782
  transcript2?.write(event);
@@ -8695,7 +8841,7 @@ ${reason}`,
8695
8841
  }
8696
8842
  if (abortController.signal.aborted) {
8697
8843
  okResult = false;
8698
- resultReason = timeoutReason ?? "cancelled";
8844
+ resultReason = leaseLost ? "lease_lost" : timeoutReason ?? "cancelled";
8699
8845
  }
8700
8846
  const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
8701
8847
  if (emptyResultReason) {
@@ -8760,7 +8906,12 @@ ${reason}`,
8760
8906
  } finally {
8761
8907
  if (idleTimer) clearTimeout(idleTimer);
8762
8908
  clearTimeout(totalTimer);
8763
- const userCancelled = abortController.signal.aborted && timeoutReason === null;
8909
+ clearInterval(leaseTimer);
8910
+ if (leaseLost) {
8911
+ resultReason = "lease_lost";
8912
+ okResult = false;
8913
+ }
8914
+ const userCancelled = abortController.signal.aborted && timeoutReason === null && !leaseLost;
8764
8915
  if (timeoutReason !== null) {
8765
8916
  resultReason = timeoutReason;
8766
8917
  okResult = false;
@@ -8802,7 +8953,7 @@ ${reason}`,
8802
8953
  if (turnResolvedConfig && Object.keys(turnResolvedConfig).length > 0) {
8803
8954
  body.resolvedConfig = turnResolvedConfig;
8804
8955
  }
8805
- if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !skipState.skipped) {
8956
+ if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !leaseLost && !skipState.skipped) {
8806
8957
  body.errorReason = resultReason.slice(0, 200);
8807
8958
  }
8808
8959
  if (okResult) {
@@ -8815,8 +8966,8 @@ ${reason}`,
8815
8966
  body.sessionWriteRejected = true;
8816
8967
  this.sessionWriteNotified.add(key);
8817
8968
  }
8818
- const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
8819
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8969
+ const outcome = skipState.skipped ? "skipped" : userCancelled || leaseLost ? "cancelled" : okResult ? "success" : "failure";
8970
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8820
8971
  settledDiagnostics = {
8821
8972
  outcome,
8822
8973
  resultReason: diagnosticReason,
package/dist/runtime.js CHANGED
@@ -1704,6 +1704,7 @@ import { randomUUID as randomUUID2 } from "crypto";
1704
1704
  // src/api.ts
1705
1705
  var RETRY_BACKOFF_MS = [250, 750];
1706
1706
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
1707
+ var LEASE_CHECK_TIMEOUT_MS = 1e4;
1707
1708
  function activeRunOutboxKey(conversationId, agentId) {
1708
1709
  return `activerun:${conversationId}:${agentId}`;
1709
1710
  }
@@ -1893,6 +1894,52 @@ var CabaneApi = class {
1893
1894
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
1894
1895
  );
1895
1896
  }
1897
+ // CT1292: RENEW THE TURN'S LEASE — ask the server whether this turn is still
1898
+ // running. The dispatcher calls this on a cadence for the life of the SDK loop,
1899
+ // and out of cadence the moment a commit is refused, so a loop whose turn was
1900
+ // terminalized underneath it stops instead of running to the six-hour cap.
1901
+ //
1902
+ // Single-shot and NEVER retried, on purpose. A renewal is a question about
1903
+ // right now, not a durable write: a failed one has no answer worth replaying,
1904
+ // and the next tick asks again in a few seconds anyway. Retrying would only
1905
+ // stretch one tick over the next one's slot.
1906
+ //
1907
+ // ALL FAILURE IS `unknown`, and this is the safety property the whole feature
1908
+ // rests on. The only thing that may stop a running loop is the server SAYING
1909
+ // the turn is over; a 500, a dropped connection, an expired token or a body we
1910
+ // cannot parse says nothing about the turn, and reading any of them as death
1911
+ // would kill healthy turns every time the API hiccups — strictly worse than the
1912
+ // gap this closes. So the failure direction is fixed here, at the boundary,
1913
+ // rather than left to each caller to remember.
1914
+ //
1915
+ // AND IT IS DEADLINED (Codo's blocking finding #1). `fetch` has no default
1916
+ // timeout, so a request that neither resolves nor rejects — a black-holed
1917
+ // connection, a half-open socket surviving a network change — would hang this
1918
+ // promise forever. That is not merely a slow probe: the caller single-flights
1919
+ // on it, so one stalled request latches the check permanently, every later
1920
+ // cadence tick returns immediately, and the loop this exists to stop runs to
1921
+ // the six-hour cap even after connectivity comes back — the exact defect the
1922
+ // task is about, reintroduced through the mechanism meant to close it.
1923
+ //
1924
+ // The deadline turns that into an ordinary `unknown`: the request rejects, the
1925
+ // in-flight latch clears, and the NEXT tick asks again. Well under the renewal
1926
+ // cadence so a stalled probe is finished with before its successor is due.
1927
+ async checkTurnLease(workspaceId, conversationId, agentId, turnId) {
1928
+ const q = `turnId=${encodeURIComponent(turnId)}`;
1929
+ try {
1930
+ const res = await this.request(
1931
+ "GET",
1932
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
1933
+ void 0,
1934
+ {
1935
+ signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
1936
+ }
1937
+ );
1938
+ return res?.state === "running" || res?.state === "ended" ? res.state : "unknown";
1939
+ } catch {
1940
+ return "unknown";
1941
+ }
1942
+ }
1896
1943
  // Flips `active_run_started_at` and optionally captures the SDK session
1897
1944
  // id. The dispatcher hits this twice per turn (now() before the SDK
1898
1945
  // loop; null when it settles) plus once with the session id on the
@@ -2609,7 +2656,9 @@ var turnResultReasonSchema = z6.discriminatedUnion("kind", [
2609
2656
  z6.object({ kind: z6.literal("timeout_idle") }),
2610
2657
  z6.object({ kind: z6.literal("timeout_total") }),
2611
2658
  z6.object({ kind: z6.literal("cancelled") }),
2659
+ z6.object({ kind: z6.literal("lease_lost") }),
2612
2660
  z6.object({ kind: z6.literal("skipped") }),
2661
+ z6.object({ kind: z6.literal("session_start_failed") }),
2613
2662
  z6.object({ kind: z6.literal("workspace_tools_missing") }),
2614
2663
  z6.object({ kind: z6.literal("runtime_error") })
2615
2664
  ]);
@@ -2773,8 +2822,12 @@ function normalizeTurnResultReason(reason) {
2773
2822
  return { kind: "timeout_total" };
2774
2823
  case "cancelled":
2775
2824
  return { kind: "cancelled" };
2825
+ case "lease_lost":
2826
+ return { kind: "lease_lost" };
2776
2827
  case "skipped":
2777
2828
  return { kind: "skipped" };
2829
+ case "session_start_failed":
2830
+ return { kind: "session_start_failed" };
2778
2831
  case "workspace_tools_missing":
2779
2832
  return { kind: "workspace_tools_missing" };
2780
2833
  default:
@@ -3223,8 +3276,9 @@ import {
3223
3276
 
3224
3277
  // packages/agent-runtime/src/claude-code/addendum.ts
3225
3278
  var CLAUDE_CODE_ADDENDUM = [
3226
- "On this harness the Cabane tools register under MCP prefixes. The workspace tool is",
3227
- "`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
3279
+ "On this harness the Cabane tools register under MCP prefixes. The workspace tools are",
3280
+ "`mcp__cabane__query`, `mcp__cabane__mutate` and `mcp__cabane__describe`; the ancillary",
3281
+ "tools are `mcp__cabane__list_workspaces`,",
3228
3282
  "`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
3229
3283
  "`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
3230
3284
  "`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
@@ -3501,7 +3555,7 @@ async function* decodeSdkStream(iter, ctx) {
3501
3555
  };
3502
3556
  }
3503
3557
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
3504
- resultReason = "workspace_tools_missing";
3558
+ resultReason = "session_start_failed";
3505
3559
  break;
3506
3560
  } else if (msg.type === "assistant") {
3507
3561
  const assistantErr = msg.error;
@@ -3568,7 +3622,7 @@ async function* decodeSdkStream(iter, ctx) {
3568
3622
  if (ctx.signal.aborted) return;
3569
3623
  if (!workspaceProven) {
3570
3624
  ok = false;
3571
- resultReason ??= "workspace_tools_missing";
3625
+ resultReason ??= "session_start_failed";
3572
3626
  }
3573
3627
  await flushHeldText(buffer, emit, ok);
3574
3628
  yield* drain(out);
@@ -3595,7 +3649,7 @@ function readMcpInventory(msg) {
3595
3649
  }
3596
3650
  function provesWorkspaceTools(msg) {
3597
3651
  const tools = msg.tools;
3598
- return Array.isArray(tools) && tools.includes("mcp__cabane__sdk");
3652
+ return Array.isArray(tools) && tools.includes("mcp__cabane__query");
3599
3653
  }
3600
3654
  function isSubscriptionWindow(value) {
3601
3655
  return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
@@ -3649,6 +3703,47 @@ function buildQueryPrompt(req) {
3649
3703
  }
3650
3704
 
3651
3705
  // packages/agent-runtime/src/claude-code/index.ts
3706
+ function attachAbortController(options, signal) {
3707
+ if (options.abortController) return;
3708
+ const sdkAbort = new AbortController();
3709
+ if (signal.aborted) sdkAbort.abort();
3710
+ else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
3711
+ options.abortController = sdkAbort;
3712
+ }
3713
+ function sdkEvents(queryFn, req, signal, built, degraded) {
3714
+ attachAbortController(built.options, signal);
3715
+ const prompt = buildQueryPrompt(req);
3716
+ const iter = queryFn({ prompt, options: built.options });
3717
+ return decodeSdkStream(iter, {
3718
+ signal,
3719
+ cwd: req.local.cwd,
3720
+ resumedSessionId: built.resume,
3721
+ degraded
3722
+ });
3723
+ }
3724
+ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded) {
3725
+ let emitted = false;
3726
+ for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
3727
+ const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
3728
+ if (recoverResume) {
3729
+ if (signal.aborted) return;
3730
+ onWarn?.(
3731
+ `claude-code adapter: stored session ${initial.resume} ended before system/init \u2014 starting one fresh session (CT1293 recovery)`,
3732
+ {
3733
+ cwd: req.local.cwd,
3734
+ reason: "session_start_failed",
3735
+ discardedSessionId: initial.resume
3736
+ }
3737
+ );
3738
+ const freshReq = { ...req, session: null };
3739
+ const fresh = buildClaudeCodeOptions(freshReq, augmentOptions);
3740
+ yield* sdkEvents(queryFn, freshReq, signal, fresh, true);
3741
+ return;
3742
+ }
3743
+ emitted = true;
3744
+ yield event;
3745
+ }
3746
+ }
3652
3747
  function createClaudeCodeAdapter(deps = {}) {
3653
3748
  const queryFn = deps.queryFn ?? defaultQuery;
3654
3749
  return {
@@ -3664,20 +3759,15 @@ function createClaudeCodeAdapter(deps = {}) {
3664
3759
  );
3665
3760
  }
3666
3761
  const degraded = freshReason !== void 0 && freshReason !== "no_session";
3667
- if (!options.abortController) {
3668
- const sdkAbort = new AbortController();
3669
- if (signal.aborted) sdkAbort.abort();
3670
- else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
3671
- options.abortController = sdkAbort;
3672
- }
3673
- const prompt = buildQueryPrompt(req);
3674
- const iter = queryFn({ prompt, options });
3675
- return decodeSdkStream(iter, {
3762
+ return runWithResumeRecovery(
3763
+ queryFn,
3764
+ req,
3676
3765
  signal,
3677
- cwd: req.local.cwd,
3678
- resumedSessionId: resume,
3766
+ { options, resume, freshReason },
3767
+ deps.augmentOptions,
3768
+ deps.onWarn,
3679
3769
  degraded
3680
- });
3770
+ );
3681
3771
  }
3682
3772
  };
3683
3773
  }
@@ -3721,7 +3811,7 @@ var init = (sessionId, model) => ({
3721
3811
  subtype: "init",
3722
3812
  session_id: sessionId,
3723
3813
  mcp_servers: HEALTHY_MCP_INVENTORY.servers,
3724
- tools: ["mcp__cabane__sdk"],
3814
+ tools: ["mcp__cabane__query"],
3725
3815
  ...model ? { model } : {}
3726
3816
  });
3727
3817
  var assistantText = (text) => ({
@@ -4199,12 +4289,14 @@ function selectAdapter(registry, runtime) {
4199
4289
 
4200
4290
  // packages/agent-runtime/src/opencode/addendum.ts
4201
4291
  var OPENCODE_ADDENDUM = [
4202
- "Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
4203
- "`ask`, `reply_to`, `wake_me`, `send`, `skip_turn`; the ancillary tools are",
4204
- "`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
4205
- "`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
4206
- "the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
4207
- "workspace read is `cabane.files.read` inside an `sdk` program. If a tool ever appears with",
4292
+ "Every tool here goes by its plain name: the workspace tools are `query`, `mutate` and",
4293
+ "`describe`; the turn tools are `ask`, `reply_to`, `wake_me`, `send`, `skip_turn`; the",
4294
+ "ancillary tools are `list_workspaces`, `read_binary`, `upload`, `begin_upload`,",
4295
+ "`finalize_upload`, `mint_render_token`; and the host tools are plain verbs too (`bash`,",
4296
+ "`read`, `edit`). Mind the collision: a bare `read` or `edit` is the HOST tool, acting on the",
4297
+ "local machine \u2014 a workspace read is `query` with the operation name `files.read`. The",
4298
+ "collision runs the other way too: `read` is a host tool here AND `files.read` is an",
4299
+ "operation name, so keep the bare verb and the dotted name apart. If a tool ever appears with",
4208
4300
  "an `mcp__\u2026__` prefix, that prefix is not part of its name here. One more harness fact: you",
4209
4301
  "can interleave narration with tool calls, but only your final message is recorded as the",
4210
4302
  "turn's reply \u2014 write your closing answer last."
@@ -5173,15 +5265,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5173
5265
  // packages/agent-runtime/src/codex/addendum.ts
5174
5266
  var CODEX_ADDENDUM = [
5175
5267
  "Codex surfaces MCP tools under qualified names, and on some versions defers them. The",
5176
- "workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
5177
- "the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
5268
+ "workspace tools are `mcp__cabane__query`, `mcp__cabane__mutate` and",
5269
+ "`mcp__cabane__describe` \u2014 if they aren't in your visible tool list, locate them in",
5270
+ "the deferred-tool inventory and invoke those exact qualified names; never conclude the",
5271
+ "workspace is",
5178
5272
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
5179
5273
  "error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
5180
5274
  "`send` / `skip_turn`, and the ancillary tools",
5181
5275
  "`mcp__cabane__list_workspaces` /",
5182
5276
  "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
5183
5277
  "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
5184
- "`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
5278
+ "operation NAMES you pass to `query` / `mutate`. One more harness fact: you can interleave",
5279
+ "narration",
5185
5280
  "with tool calls, but only your final message is recorded as the turn's reply \u2014 write your",
5186
5281
  "closing answer last."
5187
5282
  ].join(" ");
@@ -5279,9 +5374,12 @@ function readUsage(ev) {
5279
5374
  const usage = asRecord2(ev.usage);
5280
5375
  if (!usage) return void 0;
5281
5376
  const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
5282
- const inputTokens = num(usage.input_tokens) + num(usage.cached_input_tokens);
5283
- const outputTokens = num(usage.output_tokens) + num(usage.reasoning_output_tokens);
5284
- return { inputTokens, outputTokens };
5377
+ return {
5378
+ inputTokens: num(usage.input_tokens),
5379
+ outputTokens: num(usage.output_tokens),
5380
+ cacheReadTokens: num(usage.cached_input_tokens),
5381
+ cacheCreationTokens: num(usage.cache_write_input_tokens)
5382
+ };
5285
5383
  }
5286
5384
  function toStatus(v) {
5287
5385
  return v === "completed" || v === "failed" ? v : "in_progress";
@@ -5843,24 +5941,14 @@ var errorItem = (id, message) => ({
5843
5941
  type: "item.completed",
5844
5942
  item: { id, type: "error", message }
5845
5943
  });
5846
- var SDK_SUCCESS = { ok: true, result: { source: "native" }, logs: "", truncated: false };
5847
- var SDK_FAILURE = {
5944
+ var OP_SUCCESS = { ok: true, result: { source: "native" } };
5945
+ var OP_FAILURE = {
5848
5946
  ok: false,
5849
- result: null,
5850
- preview: '{"rows":[1,2,3]}',
5851
- logs: "",
5852
- truncated: false,
5853
- error: { kind: "output_too_large", bytes: 169167, limit: 65536 },
5854
- warnings: [],
5855
- committed: {
5856
- calls: [
5857
- {
5858
- method: "conversations.update",
5859
- args: { id: "conv-1", opts: { title: "Renamed before failure" } }
5860
- }
5861
- ]
5862
- },
5863
- inFlight: null
5947
+ error: {
5948
+ kind: "output_too_large",
5949
+ code: "output_too_large",
5950
+ message: "files.read returned more than the result ceiling"
5951
+ }
5864
5952
  };
5865
5953
  var sessionEvent3 = (threadId, cwd = DIR2, degraded = false, promptFingerprint = fingerprintPrompt(PROMPT)) => ({
5866
5954
  type: "session",
@@ -5871,6 +5959,11 @@ var CODEX_CONFORMANCE_FIXTURES = [
5871
5959
  {
5872
5960
  // A plain text reply: the held agent message seals as the terminal final at
5873
5961
  // `turn.completed`; the usage counters ride the `result`.
5962
+ //
5963
+ // CT1283: the counters NEST — `cached_input_tokens` is a slice of
5964
+ // `input_tokens`, `reasoning_output_tokens` a slice of `output_tokens` — so
5965
+ // input is 100 (not 120) and output is 50 (not 60), with the cache read broken
5966
+ // out beside them. This fixture pinned the double-count before CT1283.
5874
5967
  name: "clean turn",
5875
5968
  request: makeRequest3(),
5876
5969
  nativeStream: [
@@ -5879,6 +5972,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
5879
5972
  turnCompleted({
5880
5973
  input_tokens: 100,
5881
5974
  cached_input_tokens: 20,
5975
+ cache_write_input_tokens: 5,
5882
5976
  output_tokens: 50,
5883
5977
  reasoning_output_tokens: 10
5884
5978
  })
@@ -5886,7 +5980,16 @@ var CODEX_CONFORMANCE_FIXTURES = [
5886
5980
  expected: [
5887
5981
  sessionEvent3(NEW_THREAD_ID),
5888
5982
  { type: "text", body: "Hello there.", terminal: true },
5889
- { type: "result", ok: true, usage: { inputTokens: 120, outputTokens: 60 } }
5983
+ {
5984
+ type: "result",
5985
+ ok: true,
5986
+ usage: {
5987
+ inputTokens: 100,
5988
+ outputTokens: 50,
5989
+ cacheReadTokens: 20,
5990
+ cacheCreationTokens: 5
5991
+ }
5992
+ }
5890
5993
  ]
5891
5994
  },
5892
5995
  {
@@ -5942,30 +6045,30 @@ var CODEX_CONFORMANCE_FIXTURES = [
5942
6045
  ]
5943
6046
  },
5944
6047
  {
5945
- // CT1211: recorded SDK success shape. Codex exposes the text compatibility
6048
+ // CT1211: recorded MCP success shape. Codex exposes the text compatibility
5946
6049
  // copy and the native object together; normalization must keep the object.
5947
6050
  name: "MCP success prefers native structured content",
5948
6051
  request: makeRequest3(),
5949
6052
  nativeStream: [
5950
6053
  threadStarted(NEW_THREAD_ID),
5951
6054
  toolFrame("item.started", {
5952
- id: "sdk-success",
6055
+ id: "query-success",
5953
6056
  type: "mcp_tool_call",
5954
6057
  server: "cabane",
5955
- tool: "sdk",
5956
- arguments: { code: 'return { source: "native" }' },
6058
+ tool: "query",
6059
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
5957
6060
  status: "in_progress"
5958
6061
  }),
5959
6062
  toolFrame("item.completed", {
5960
- id: "sdk-success",
6063
+ id: "query-success",
5961
6064
  type: "mcp_tool_call",
5962
6065
  server: "cabane",
5963
- tool: "sdk",
5964
- arguments: { code: 'return { source: "native" }' },
6066
+ tool: "query",
6067
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
5965
6068
  status: "completed",
5966
6069
  result: {
5967
- content: [{ type: "text", text: JSON.stringify(SDK_SUCCESS) }],
5968
- structured_content: SDK_SUCCESS
6070
+ content: [{ type: "text", text: JSON.stringify(OP_SUCCESS) }],
6071
+ structured_content: OP_SUCCESS
5969
6072
  }
5970
6073
  }),
5971
6074
  turnCompleted()
@@ -5974,20 +6077,20 @@ var CODEX_CONFORMANCE_FIXTURES = [
5974
6077
  sessionEvent3(NEW_THREAD_ID),
5975
6078
  {
5976
6079
  type: "tool",
5977
- id: "sdk-success",
5978
- name: "sdk",
6080
+ id: "query-success",
6081
+ name: "query",
5979
6082
  phase: "start",
5980
6083
  summary: "",
5981
- input: { code: 'return { source: "native" }' }
6084
+ input: { op: "files.read", args: { path: "/ABOUT.md" } }
5982
6085
  },
5983
6086
  {
5984
6087
  type: "tool",
5985
- id: "sdk-success",
5986
- name: "sdk",
6088
+ id: "query-success",
6089
+ name: "query",
5987
6090
  phase: "done",
5988
6091
  summary: "",
5989
- input: { code: 'return { source: "native" }' },
5990
- result: SDK_SUCCESS
6092
+ input: { op: "files.read", args: { path: "/ABOUT.md" } },
6093
+ result: OP_SUCCESS
5991
6094
  },
5992
6095
  { type: "result", ok: true }
5993
6096
  ]
@@ -5995,28 +6098,28 @@ var CODEX_CONFORMANCE_FIXTURES = [
5995
6098
  {
5996
6099
  // CT1211 incident shape: MCP `isError` keeps the failed phase, while the raw
5997
6100
  // camel-case structured payload survives instead of the generic `error` token.
5998
- name: "failed SDK call preserves structured receipt while remaining error phase",
6101
+ name: "failed MCP call preserves structured receipt while remaining error phase",
5999
6102
  request: makeRequest3(),
6000
6103
  nativeStream: [
6001
6104
  threadStarted(NEW_THREAD_ID),
6002
6105
  toolFrame("item.started", {
6003
- id: "sdk-failed",
6106
+ id: "query-failed",
6004
6107
  type: "mcp_tool_call",
6005
6108
  server: "cabane",
6006
- tool: "sdk",
6007
- arguments: { code: "return largeResult" },
6109
+ tool: "query",
6110
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6008
6111
  status: "in_progress"
6009
6112
  }),
6010
6113
  toolFrame("item.completed", {
6011
- id: "sdk-failed",
6114
+ id: "query-failed",
6012
6115
  type: "mcp_tool_call",
6013
6116
  server: "cabane",
6014
- tool: "sdk",
6015
- arguments: { code: "return largeResult" },
6117
+ tool: "query",
6118
+ arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6016
6119
  status: "failed",
6017
6120
  result: {
6018
- content: [{ type: "text", text: JSON.stringify(SDK_FAILURE) }],
6019
- structuredContent: SDK_FAILURE,
6121
+ content: [{ type: "text", text: JSON.stringify(OP_FAILURE) }],
6122
+ structuredContent: OP_FAILURE,
6020
6123
  isError: true
6021
6124
  },
6022
6125
  error: { message: "error" }
@@ -6027,20 +6130,20 @@ var CODEX_CONFORMANCE_FIXTURES = [
6027
6130
  sessionEvent3(NEW_THREAD_ID),
6028
6131
  {
6029
6132
  type: "tool",
6030
- id: "sdk-failed",
6031
- name: "sdk",
6133
+ id: "query-failed",
6134
+ name: "query",
6032
6135
  phase: "start",
6033
6136
  summary: "",
6034
- input: { code: "return largeResult" }
6137
+ input: { op: "files.read", args: { path: "/ABOUT.md" } }
6035
6138
  },
6036
6139
  {
6037
6140
  type: "tool",
6038
- id: "sdk-failed",
6039
- name: "sdk",
6141
+ id: "query-failed",
6142
+ name: "query",
6040
6143
  phase: "error",
6041
6144
  summary: "",
6042
- input: { code: "return largeResult" },
6043
- result: SDK_FAILURE
6145
+ input: { op: "files.read", args: { path: "/ABOUT.md" } },
6146
+ result: OP_FAILURE
6044
6147
  },
6045
6148
  { type: "result", ok: true }
6046
6149
  ]
@@ -7271,6 +7374,7 @@ var TurnCommitter = class {
7271
7374
  { err: err instanceof Error ? err.message : String(err), hook },
7272
7375
  "dispatcher: transcript callback failed"
7273
7376
  );
7377
+ deps.onCommitFailed?.(err);
7274
7378
  };
7275
7379
  const commit = {
7276
7380
  commitMessage: async ({ body, kind, seq }) => {
@@ -7335,6 +7439,7 @@ var TurnCommitter = class {
7335
7439
  { err: err instanceof Error ? err.message : String(err) },
7336
7440
  "dispatcher: progress-promotion commit failed"
7337
7441
  );
7442
+ deps.onCommitFailed?.(err);
7338
7443
  }
7339
7444
  });
7340
7445
  this.emit = sinkEmitter(this.pump.sink, this.onError);
@@ -7494,6 +7599,7 @@ var SILENT_MARKER_BODY = "(no reply)";
7494
7599
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7495
7600
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7496
7601
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7602
+ var DEFAULT_LEASE_RENEWAL_MS = 3e4;
7497
7603
  function checkoutState(cwd) {
7498
7604
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
7499
7605
  if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
@@ -7526,13 +7632,19 @@ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7526
7632
  "turn_already_ended",
7527
7633
  "turn_belongs_elsewhere"
7528
7634
  ]);
7529
- function leaseRefusal(err) {
7635
+ function apiErrorCode(err) {
7530
7636
  if (!(err instanceof ApiError)) return null;
7531
7637
  const body = err.body;
7532
- const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7638
+ return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7639
+ }
7640
+ function leaseRefusal(err) {
7641
+ const code = apiErrorCode(err);
7533
7642
  if (code && LEASE_REFUSALS.has(code)) return code;
7534
7643
  return null;
7535
7644
  }
7645
+ function isWriteFenceRefusal(err) {
7646
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
7647
+ }
7536
7648
  var ERROR_BODY_LOG_CAP = 2e3;
7537
7649
  function describeErrorBody(body) {
7538
7650
  if (body === void 0 || body === null) return void 0;
@@ -7878,6 +7990,7 @@ ${reason}`,
7878
7990
  if (pair.size === 0) this.aborts.delete(key);
7879
7991
  };
7880
7992
  let timeoutReason = null;
7993
+ let leaseLost = false;
7881
7994
  try {
7882
7995
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
7883
7996
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8025,6 +8138,8 @@ ${reason}`,
8025
8138
  let latestSessionState = request.session;
8026
8139
  let settledDiagnostics = null;
8027
8140
  let silentMarkerEmitted = false;
8141
+ let noteCommitFailed = () => {
8142
+ };
8028
8143
  const committer = new TurnCommitter({
8029
8144
  api: this.opts.api,
8030
8145
  workspaceId,
@@ -8050,7 +8165,9 @@ ${reason}`,
8050
8165
  replyState,
8051
8166
  // The ledger-derived owed reply, for the runtime's own declaration when
8052
8167
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8053
- owedReplyMessageId: turnContext.owedReplyMessageId ?? null
8168
+ owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8169
+ // CT1292: a commit that didn't land may mean this turn's lease is gone.
8170
+ onCommitFailed: (err) => noteCommitFailed(err)
8054
8171
  });
8055
8172
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8056
8173
  let turnControlIntentFetched = false;
@@ -8130,6 +8247,35 @@ ${reason}`,
8130
8247
  const totalTimer = setTimeout(() => fireTimeout("timeout_total"), totalTimeoutMs);
8131
8248
  totalTimer.unref?.();
8132
8249
  armIdle();
8250
+ const leaseRenewalMs = this.opts.leaseRenewalMs ?? DEFAULT_LEASE_RENEWAL_MS;
8251
+ let leaseCheckInFlight = false;
8252
+ const checkLease = async (trigger) => {
8253
+ if (leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8254
+ leaseCheckInFlight = true;
8255
+ try {
8256
+ const state = await this.opts.api.checkTurnLease(
8257
+ workspaceId,
8258
+ payload.conversationId,
8259
+ payload.agentId,
8260
+ turnId
8261
+ );
8262
+ if (state !== "ended") return;
8263
+ if (abortController.signal.aborted) return;
8264
+ leaseLost = true;
8265
+ turnLog.warn(
8266
+ { turnId, trigger },
8267
+ "dispatcher: this turn is no longer running server-side \u2014 aborting the loop"
8268
+ );
8269
+ abortController.abort();
8270
+ } finally {
8271
+ leaseCheckInFlight = false;
8272
+ }
8273
+ };
8274
+ const leaseTimer = setInterval(() => void checkLease("cadence"), leaseRenewalMs);
8275
+ leaseTimer.unref?.();
8276
+ noteCommitFailed = (err) => {
8277
+ if (isWriteFenceRefusal(err)) void checkLease("commit_refused");
8278
+ };
8133
8279
  try {
8134
8280
  for await (const event of adapter.runTurn(request, abortController.signal)) {
8135
8281
  transcript?.write(event);
@@ -8194,7 +8340,7 @@ ${reason}`,
8194
8340
  }
8195
8341
  if (abortController.signal.aborted) {
8196
8342
  okResult = false;
8197
- resultReason = timeoutReason ?? "cancelled";
8343
+ resultReason = leaseLost ? "lease_lost" : timeoutReason ?? "cancelled";
8198
8344
  }
8199
8345
  const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
8200
8346
  if (emptyResultReason) {
@@ -8259,7 +8405,12 @@ ${reason}`,
8259
8405
  } finally {
8260
8406
  if (idleTimer) clearTimeout(idleTimer);
8261
8407
  clearTimeout(totalTimer);
8262
- const userCancelled = abortController.signal.aborted && timeoutReason === null;
8408
+ clearInterval(leaseTimer);
8409
+ if (leaseLost) {
8410
+ resultReason = "lease_lost";
8411
+ okResult = false;
8412
+ }
8413
+ const userCancelled = abortController.signal.aborted && timeoutReason === null && !leaseLost;
8263
8414
  if (timeoutReason !== null) {
8264
8415
  resultReason = timeoutReason;
8265
8416
  okResult = false;
@@ -8301,7 +8452,7 @@ ${reason}`,
8301
8452
  if (turnResolvedConfig && Object.keys(turnResolvedConfig).length > 0) {
8302
8453
  body.resolvedConfig = turnResolvedConfig;
8303
8454
  }
8304
- if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !skipState.skipped) {
8455
+ if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !leaseLost && !skipState.skipped) {
8305
8456
  body.errorReason = resultReason.slice(0, 200);
8306
8457
  }
8307
8458
  if (okResult) {
@@ -8314,8 +8465,8 @@ ${reason}`,
8314
8465
  body.sessionWriteRejected = true;
8315
8466
  this.sessionWriteNotified.add(key);
8316
8467
  }
8317
- const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
8318
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8468
+ const outcome = skipState.skipped ? "skipped" : userCancelled || leaseLost ? "cancelled" : okResult ? "success" : "failure";
8469
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8319
8470
  settledDiagnostics = {
8320
8471
  outcome,
8321
8472
  resultReason: diagnosticReason,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.68",
3
+ "version": "0.6.70",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",