@cabane/companion 0.6.117 → 0.6.119

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 +106 -49
  2. package/dist/runtime.js +106 -49
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3200,9 +3200,9 @@ var CabaneApi = class {
3200
3200
  // Commit one outgoing act (ask, withdraw_ask, wake_me, cancel_wake) at
3201
3201
  // invocation. The server records the effect and receipt in one transaction;
3202
3202
  // an ok:false response is a recorded refusal for the tool to report.
3203
- // Retries retain the caller's callId and recover the same receipt while the
3204
- // HTTP route admits the request. Currently that route rejects ended spans
3205
- // before receipt lookup: a 409 cannot prove an earlier attempt had no effect.
3203
+ // Retries retain the caller's callId and recover the same receipt, even
3204
+ // when the turn settled between attempts (CT1447): the route consults the
3205
+ // receipt before it refuses an ended turn, so a 4xx proves no attempt wrote.
3206
3206
  // The caller's signal bounds how long the model waits for the tool result.
3207
3207
  turnAct(workspaceId, turnId, act, signal) {
3208
3208
  return this.request(
@@ -4368,11 +4368,14 @@ var TurnPump = class {
4368
4368
  },
4369
4369
  onToolActivity: async (evt) => {
4370
4370
  if (opts.signal.aborted) return;
4371
- const existing = this.toolSeqByUseId.get(evt.toolUseId);
4372
- const seq = existing ?? opts.nextSeq();
4373
- if (existing === void 0) this.toolSeqByUseId.set(evt.toolUseId, seq);
4371
+ let call = this.toolCallByProviderId.get(evt.toolUseId);
4372
+ if (evt.phase === "start" || call === void 0) {
4373
+ call = { persistedId: crypto.randomUUID(), seq: opts.nextSeq() };
4374
+ this.toolCallByProviderId.set(evt.toolUseId, call);
4375
+ }
4376
+ const { persistedId, seq } = call;
4374
4377
  await opts.commit.reportTool({
4375
- toolUseId: evt.toolUseId,
4378
+ toolUseId: persistedId,
4376
4379
  toolName: evt.toolName,
4377
4380
  summary: evt.summary,
4378
4381
  input: evt.input,
@@ -4409,9 +4412,19 @@ var TurnPump = class {
4409
4412
  // The turn's final reply text, captured for the dashboard feed / logging.
4410
4413
  finalReplyBody = null;
4411
4414
  emittedFinalSource = "none";
4412
- // CT11: each tool's persisted seq so the `done`/`error` frame republishes the
4413
- // same seq the `start` row got (the host's upsert keeps the original seq).
4414
- toolSeqByUseId = /* @__PURE__ */ new Map();
4415
+ // CT1383: provider tool id → the persisted identity of its CURRENT call. The
4416
+ // provider's id is only a lookup key: it is unique within one adapter
4417
+ // invocation at best (Codex restarts at `item_0` on every invocation, and a
4418
+ // resumed turn keeps its turn id), so the row and the outbox entry are keyed
4419
+ // on what the pump mints — a host id for the host's `(turn_id, tool_use_id)`
4420
+ // upsert, and a seq for the outbox's `(turnId, seq)` file. Both are minted
4421
+ // together; minting only the id would let two calls overwrite each other's
4422
+ // queued body.
4423
+ //
4424
+ // In memory on purpose: it dies with the process, and so does the runtime.
4425
+ // After a crash no in-flight `done` awaits a pre-crash `start` — the resumed
4426
+ // invocation starts its calls afresh — so there is nothing to recover.
4427
+ toolCallByProviderId = /* @__PURE__ */ new Map();
4415
4428
  // The callback consumer the classification (via `sinkEmitter`) — and the
4416
4429
  // frozen committer test — drive. Each callback applies the choreography and
4417
4430
  // commits through the injected sink.
@@ -4806,11 +4819,7 @@ async function* decodeSdkStream(iter, ctx) {
4806
4819
  let usage;
4807
4820
  let lastRequestContextTokens;
4808
4821
  let resolvedModel;
4809
- let sawRejectedLimit = false;
4810
- let rateLimitResetIso;
4811
- let rateLimitType;
4812
- let authError;
4813
- let lastAssistantError;
4822
+ const signals = { sawRejectedLimit: false };
4814
4823
  try {
4815
4824
  for await (const msg of iter) {
4816
4825
  if (ctx.signal.aborted) return;
@@ -4833,12 +4842,14 @@ async function* decodeSdkStream(iter, ctx) {
4833
4842
  };
4834
4843
  }
4835
4844
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
4836
- resultReason = "session_start_failed";
4845
+ if (preInitToolActivity(msg) && ctx.startEvidence) ctx.startEvidence.toolActivity = true;
4846
+ const failure = classifyPreInitFrame(msg, signals);
4847
+ resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
4837
4848
  break;
4838
4849
  } else if (msg.type === "assistant") {
4839
4850
  const assistantErr = msg.error;
4840
4851
  if (typeof assistantErr === "string" && assistantErr.length > 0) {
4841
- lastAssistantError = assistantErr;
4852
+ signals.lastAssistantError = assistantErr;
4842
4853
  }
4843
4854
  const reqContext = readRequestContextTokens(msg);
4844
4855
  if (reqContext !== void 0) lastRequestContextTokens = reqContext;
@@ -4850,13 +4861,13 @@ async function* decodeSdkStream(iter, ctx) {
4850
4861
  } else if (msg.type === "rate_limit_event") {
4851
4862
  const info = msg.rate_limit_info;
4852
4863
  if (info?.status === "rejected") {
4853
- sawRejectedLimit = true;
4854
- rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
4855
- if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
4864
+ signals.sawRejectedLimit = true;
4865
+ signals.rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? signals.rateLimitResetIso;
4866
+ if (typeof info.rateLimitType === "string") signals.rateLimitType = info.rateLimitType;
4856
4867
  }
4857
4868
  } else if (msg.type === "auth_status") {
4858
4869
  const err = msg.error;
4859
- if (typeof err === "string" && err.length > 0) authError = err;
4870
+ if (typeof err === "string" && err.length > 0) signals.authError = err;
4860
4871
  } else if (msg.type === "result") {
4861
4872
  const origin = msg.origin;
4862
4873
  if (origin?.kind === "task-notification") continue;
@@ -4873,16 +4884,7 @@ async function* decodeSdkStream(iter, ctx) {
4873
4884
  ok = true;
4874
4885
  } else {
4875
4886
  const resultText = msg.result ?? "";
4876
- const terminalReason = msg.terminal_reason;
4877
- const errors = msg.errors;
4878
- const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
4879
- " "
4880
- );
4881
- const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
4882
- const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
4883
- kind: "usage_capped",
4884
- ...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
4885
- } : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
4887
+ const failure = classifyResultFailure(msg, signals);
4886
4888
  resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
4887
4889
  ok = false;
4888
4890
  }
@@ -4891,16 +4893,25 @@ async function* decodeSdkStream(iter, ctx) {
4891
4893
  }
4892
4894
  } catch (err) {
4893
4895
  if (ctx.signal.aborted) throw err;
4894
- const failure = classifyErrorText(err instanceof Error ? err.message : String(err));
4895
- if (!failure) throw err;
4896
+ const message = err instanceof Error ? err.message : String(err);
4897
+ const failure = mcpInventory.initReceived ? classifyErrorText(message) : classifyFailure(signals, { text: message });
4898
+ if (!failure && (mcpInventory.initReceived || ctx.resumedSessionId === null)) throw err;
4896
4899
  ok = false;
4897
- resultReason = encodeFailureReason(failure);
4900
+ if (failure) {
4901
+ resultReason = encodeFailureReason(failure);
4902
+ } else {
4903
+ resultReason = "session_start_failed";
4904
+ if (ctx.startEvidence) ctx.startEvidence.thrown = message.slice(0, 200);
4905
+ }
4898
4906
  sawResult = true;
4899
4907
  }
4900
4908
  if (ctx.signal.aborted) return;
4901
4909
  if (!workspaceProven) {
4902
4910
  ok = false;
4903
- resultReason ??= "session_start_failed";
4911
+ if (resultReason === void 0) {
4912
+ const failure = classifyFailure(signals, { text: "" });
4913
+ resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
4914
+ }
4904
4915
  }
4905
4916
  const heldFailure = !ok ? classifyRuntimeNoticeText(buffer.held) : null;
4906
4917
  const terminalFailure = !ok ? decodeFailureReason(resultReason) : null;
@@ -4935,6 +4946,42 @@ function provesWorkspaceTools(msg) {
4935
4946
  const tools = msg.tools;
4936
4947
  return Array.isArray(tools) && tools.includes("mcp__cabane__query");
4937
4948
  }
4949
+ function classifyFailure(s, frame) {
4950
+ const rejectedIsCap = s.sawRejectedLimit && (s.rateLimitResetIso !== void 0 || isSubscriptionWindow(s.rateLimitType));
4951
+ if (rejectedIsCap || frame.terminalReason === "blocking_limit")
4952
+ return {
4953
+ kind: "usage_capped",
4954
+ ...s.rateLimitResetIso ? { resetsAt: s.rateLimitResetIso } : {}
4955
+ };
4956
+ return classifyAssistantError(s.lastAssistantError) ?? classifyErrorText([s.authError, frame.text].filter(Boolean).join(" "));
4957
+ }
4958
+ function classifyResultFailure(msg, s) {
4959
+ const resultText = msg.result ?? "";
4960
+ const terminalReason = msg.terminal_reason;
4961
+ const errors = msg.errors;
4962
+ const text = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(" ");
4963
+ return classifyFailure(s, { terminalReason, text });
4964
+ }
4965
+ function classifyPreInitFrame(msg, s) {
4966
+ if (msg.type === "result") {
4967
+ const isError = msg.is_error === true;
4968
+ if (msg.subtype === "success" && !isError) return classifyFailure(s, { text: "" });
4969
+ return classifyResultFailure(msg, s);
4970
+ }
4971
+ if (msg.type !== "assistant") return classifyFailure(s, { text: "" });
4972
+ const error = msg.error;
4973
+ const signals = typeof error === "string" && error.length > 0 ? { ...s, lastAssistantError: error } : s;
4974
+ const text = contentBlocks(msg).flatMap((b) => b.type === "text" && typeof b.text === "string" ? [b.text] : []).join(" ");
4975
+ return classifyFailure(signals, { text });
4976
+ }
4977
+ function preInitToolActivity(msg) {
4978
+ const kind = msg.type === "assistant" ? "tool_use" : msg.type === "user" ? "tool_result" : null;
4979
+ return kind !== null && contentBlocks(msg).some((b) => b.type === kind);
4980
+ }
4981
+ function contentBlocks(msg) {
4982
+ const content = msg.message?.content;
4983
+ return Array.isArray(content) ? content : [];
4984
+ }
4938
4985
  function isSubscriptionWindow(value) {
4939
4986
  return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
4940
4987
  }
@@ -4994,15 +5041,18 @@ function attachAbortController(options, signal) {
4994
5041
  else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
4995
5042
  options.abortController = sdkAbort;
4996
5043
  }
4997
- function sdkEvents(queryFn, req, signal, built, degraded) {
5044
+ function sdkEvents(queryFn, req, signal, built, degraded, startEvidence) {
4998
5045
  attachAbortController(built.options, signal);
4999
5046
  const prompt = buildQueryPrompt(req);
5000
- const iter = queryFn({ prompt, options: built.options });
5047
+ const iter = {
5048
+ [Symbol.asyncIterator]: () => queryFn({ prompt, options: built.options })[Symbol.asyncIterator]()
5049
+ };
5001
5050
  return decodeSdkStream(iter, {
5002
5051
  signal,
5003
5052
  cwd: req.local.cwd,
5004
5053
  resumedSessionId: built.resume,
5005
- degraded
5054
+ degraded,
5055
+ ...startEvidence ? { startEvidence } : {}
5006
5056
  });
5007
5057
  }
5008
5058
  async function freshRequest(req, reason, hooks) {
@@ -5011,8 +5061,9 @@ async function freshRequest(req, reason, hooks) {
5011
5061
  }
5012
5062
  async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded, hooks) {
5013
5063
  let emitted = false;
5014
- for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
5015
- const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
5064
+ const evidence = { toolActivity: false };
5065
+ for await (const event of sdkEvents(queryFn, req, signal, initial, degraded, evidence)) {
5066
+ const recoverResume = initial.resume !== null && !emitted && !evidence.toolActivity && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
5016
5067
  if (recoverResume) {
5017
5068
  if (signal.aborted) return;
5018
5069
  onWarn?.(
@@ -5020,7 +5071,8 @@ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOpti
5020
5071
  {
5021
5072
  cwd: req.local.cwd,
5022
5073
  reason: "session_start_failed",
5023
- discardedSessionId: initial.resume
5074
+ discardedSessionId: initial.resume,
5075
+ ...evidence.thrown ? { thrown: evidence.thrown } : {}
5024
5076
  }
5025
5077
  );
5026
5078
  const fresh = await freshRequest(req, "session_start_failed", hooks);
@@ -8488,6 +8540,9 @@ async function performAct(acts, act) {
8488
8540
  AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
8489
8541
  );
8490
8542
  } catch (err) {
8543
+ if (err instanceof ApiError && err.status >= 500) {
8544
+ return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
8545
+ }
8491
8546
  if (err instanceof ApiError) {
8492
8547
  if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
8493
8548
  return toolError(
@@ -8496,14 +8551,16 @@ async function performAct(acts, act) {
8496
8551
  }
8497
8552
  return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
8498
8553
  }
8499
- const message = err instanceof Error ? err.message : String(err);
8500
- return toolError(
8501
- `Cabane could not be reached (${message}), so it is UNKNOWN whether this act was recorded \u2014 it may have committed with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
8502
- );
8554
+ return uncertainOutcome(err instanceof Error ? err.message : String(err));
8503
8555
  }
8504
8556
  if (!result.ok) return toolError(JSON.stringify(result));
8505
8557
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8506
8558
  }
8559
+ function uncertainOutcome(cause) {
8560
+ return toolError(
8561
+ `The outcome of this act could not be confirmed (${cause}). It may already have been performed, with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
8562
+ );
8563
+ }
8507
8564
  function createTurnControlMcpServer(acts, skipState) {
8508
8565
  const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
8509
8566
  const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
@@ -9920,13 +9977,13 @@ var TurnExecution = class {
9920
9977
  } else {
9921
9978
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
9922
9979
  let preparingStarted = false;
9923
- let preparingSeq = null;
9980
+ let preparing = null;
9924
9981
  const reportPreparing = (phase) => {
9925
- if (preparingSeq === null) preparingSeq = this.nextSeq();
9926
- const seq = preparingSeq;
9982
+ preparing ??= { id: `prepare:${randomUUID3()}`, seq: this.nextSeq() };
9983
+ const { id, seq } = preparing;
9927
9984
  void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, {
9928
9985
  turnId,
9929
- toolUseId: `prepare:${turnId}`,
9986
+ toolUseId: id,
9930
9987
  toolName: PREPARING_TOOL_NAME,
9931
9988
  summary: "",
9932
9989
  phase,
package/dist/runtime.js CHANGED
@@ -2534,9 +2534,9 @@ var CabaneApi = class {
2534
2534
  // Commit one outgoing act (ask, withdraw_ask, wake_me, cancel_wake) at
2535
2535
  // invocation. The server records the effect and receipt in one transaction;
2536
2536
  // an ok:false response is a recorded refusal for the tool to report.
2537
- // Retries retain the caller's callId and recover the same receipt while the
2538
- // HTTP route admits the request. Currently that route rejects ended spans
2539
- // before receipt lookup: a 409 cannot prove an earlier attempt had no effect.
2537
+ // Retries retain the caller's callId and recover the same receipt, even
2538
+ // when the turn settled between attempts (CT1447): the route consults the
2539
+ // receipt before it refuses an ended turn, so a 4xx proves no attempt wrote.
2540
2540
  // The caller's signal bounds how long the model waits for the tool result.
2541
2541
  turnAct(workspaceId, turnId, act, signal) {
2542
2542
  return this.request(
@@ -3781,11 +3781,14 @@ var TurnPump = class {
3781
3781
  },
3782
3782
  onToolActivity: async (evt) => {
3783
3783
  if (opts.signal.aborted) return;
3784
- const existing = this.toolSeqByUseId.get(evt.toolUseId);
3785
- const seq = existing ?? opts.nextSeq();
3786
- if (existing === void 0) this.toolSeqByUseId.set(evt.toolUseId, seq);
3784
+ let call = this.toolCallByProviderId.get(evt.toolUseId);
3785
+ if (evt.phase === "start" || call === void 0) {
3786
+ call = { persistedId: crypto.randomUUID(), seq: opts.nextSeq() };
3787
+ this.toolCallByProviderId.set(evt.toolUseId, call);
3788
+ }
3789
+ const { persistedId, seq } = call;
3787
3790
  await opts.commit.reportTool({
3788
- toolUseId: evt.toolUseId,
3791
+ toolUseId: persistedId,
3789
3792
  toolName: evt.toolName,
3790
3793
  summary: evt.summary,
3791
3794
  input: evt.input,
@@ -3822,9 +3825,19 @@ var TurnPump = class {
3822
3825
  // The turn's final reply text, captured for the dashboard feed / logging.
3823
3826
  finalReplyBody = null;
3824
3827
  emittedFinalSource = "none";
3825
- // CT11: each tool's persisted seq so the `done`/`error` frame republishes the
3826
- // same seq the `start` row got (the host's upsert keeps the original seq).
3827
- toolSeqByUseId = /* @__PURE__ */ new Map();
3828
+ // CT1383: provider tool id → the persisted identity of its CURRENT call. The
3829
+ // provider's id is only a lookup key: it is unique within one adapter
3830
+ // invocation at best (Codex restarts at `item_0` on every invocation, and a
3831
+ // resumed turn keeps its turn id), so the row and the outbox entry are keyed
3832
+ // on what the pump mints — a host id for the host's `(turn_id, tool_use_id)`
3833
+ // upsert, and a seq for the outbox's `(turnId, seq)` file. Both are minted
3834
+ // together; minting only the id would let two calls overwrite each other's
3835
+ // queued body.
3836
+ //
3837
+ // In memory on purpose: it dies with the process, and so does the runtime.
3838
+ // After a crash no in-flight `done` awaits a pre-crash `start` — the resumed
3839
+ // invocation starts its calls afresh — so there is nothing to recover.
3840
+ toolCallByProviderId = /* @__PURE__ */ new Map();
3828
3841
  // The callback consumer the classification (via `sinkEmitter`) — and the
3829
3842
  // frozen committer test — drive. Each callback applies the choreography and
3830
3843
  // commits through the injected sink.
@@ -4219,11 +4232,7 @@ async function* decodeSdkStream(iter, ctx) {
4219
4232
  let usage;
4220
4233
  let lastRequestContextTokens;
4221
4234
  let resolvedModel;
4222
- let sawRejectedLimit = false;
4223
- let rateLimitResetIso;
4224
- let rateLimitType;
4225
- let authError;
4226
- let lastAssistantError;
4235
+ const signals = { sawRejectedLimit: false };
4227
4236
  try {
4228
4237
  for await (const msg of iter) {
4229
4238
  if (ctx.signal.aborted) return;
@@ -4246,12 +4255,14 @@ async function* decodeSdkStream(iter, ctx) {
4246
4255
  };
4247
4256
  }
4248
4257
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
4249
- resultReason = "session_start_failed";
4258
+ if (preInitToolActivity(msg) && ctx.startEvidence) ctx.startEvidence.toolActivity = true;
4259
+ const failure = classifyPreInitFrame(msg, signals);
4260
+ resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
4250
4261
  break;
4251
4262
  } else if (msg.type === "assistant") {
4252
4263
  const assistantErr = msg.error;
4253
4264
  if (typeof assistantErr === "string" && assistantErr.length > 0) {
4254
- lastAssistantError = assistantErr;
4265
+ signals.lastAssistantError = assistantErr;
4255
4266
  }
4256
4267
  const reqContext = readRequestContextTokens(msg);
4257
4268
  if (reqContext !== void 0) lastRequestContextTokens = reqContext;
@@ -4263,13 +4274,13 @@ async function* decodeSdkStream(iter, ctx) {
4263
4274
  } else if (msg.type === "rate_limit_event") {
4264
4275
  const info = msg.rate_limit_info;
4265
4276
  if (info?.status === "rejected") {
4266
- sawRejectedLimit = true;
4267
- rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
4268
- if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
4277
+ signals.sawRejectedLimit = true;
4278
+ signals.rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? signals.rateLimitResetIso;
4279
+ if (typeof info.rateLimitType === "string") signals.rateLimitType = info.rateLimitType;
4269
4280
  }
4270
4281
  } else if (msg.type === "auth_status") {
4271
4282
  const err = msg.error;
4272
- if (typeof err === "string" && err.length > 0) authError = err;
4283
+ if (typeof err === "string" && err.length > 0) signals.authError = err;
4273
4284
  } else if (msg.type === "result") {
4274
4285
  const origin = msg.origin;
4275
4286
  if (origin?.kind === "task-notification") continue;
@@ -4286,16 +4297,7 @@ async function* decodeSdkStream(iter, ctx) {
4286
4297
  ok = true;
4287
4298
  } else {
4288
4299
  const resultText = msg.result ?? "";
4289
- const terminalReason = msg.terminal_reason;
4290
- const errors = msg.errors;
4291
- const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
4292
- " "
4293
- );
4294
- const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
4295
- const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
4296
- kind: "usage_capped",
4297
- ...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
4298
- } : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
4300
+ const failure = classifyResultFailure(msg, signals);
4299
4301
  resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
4300
4302
  ok = false;
4301
4303
  }
@@ -4304,16 +4306,25 @@ async function* decodeSdkStream(iter, ctx) {
4304
4306
  }
4305
4307
  } catch (err) {
4306
4308
  if (ctx.signal.aborted) throw err;
4307
- const failure = classifyErrorText(err instanceof Error ? err.message : String(err));
4308
- if (!failure) throw err;
4309
+ const message = err instanceof Error ? err.message : String(err);
4310
+ const failure = mcpInventory.initReceived ? classifyErrorText(message) : classifyFailure(signals, { text: message });
4311
+ if (!failure && (mcpInventory.initReceived || ctx.resumedSessionId === null)) throw err;
4309
4312
  ok = false;
4310
- resultReason = encodeFailureReason(failure);
4313
+ if (failure) {
4314
+ resultReason = encodeFailureReason(failure);
4315
+ } else {
4316
+ resultReason = "session_start_failed";
4317
+ if (ctx.startEvidence) ctx.startEvidence.thrown = message.slice(0, 200);
4318
+ }
4311
4319
  sawResult = true;
4312
4320
  }
4313
4321
  if (ctx.signal.aborted) return;
4314
4322
  if (!workspaceProven) {
4315
4323
  ok = false;
4316
- resultReason ??= "session_start_failed";
4324
+ if (resultReason === void 0) {
4325
+ const failure = classifyFailure(signals, { text: "" });
4326
+ resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
4327
+ }
4317
4328
  }
4318
4329
  const heldFailure = !ok ? classifyRuntimeNoticeText(buffer.held) : null;
4319
4330
  const terminalFailure = !ok ? decodeFailureReason(resultReason) : null;
@@ -4348,6 +4359,42 @@ function provesWorkspaceTools(msg) {
4348
4359
  const tools = msg.tools;
4349
4360
  return Array.isArray(tools) && tools.includes("mcp__cabane__query");
4350
4361
  }
4362
+ function classifyFailure(s, frame) {
4363
+ const rejectedIsCap = s.sawRejectedLimit && (s.rateLimitResetIso !== void 0 || isSubscriptionWindow(s.rateLimitType));
4364
+ if (rejectedIsCap || frame.terminalReason === "blocking_limit")
4365
+ return {
4366
+ kind: "usage_capped",
4367
+ ...s.rateLimitResetIso ? { resetsAt: s.rateLimitResetIso } : {}
4368
+ };
4369
+ return classifyAssistantError(s.lastAssistantError) ?? classifyErrorText([s.authError, frame.text].filter(Boolean).join(" "));
4370
+ }
4371
+ function classifyResultFailure(msg, s) {
4372
+ const resultText = msg.result ?? "";
4373
+ const terminalReason = msg.terminal_reason;
4374
+ const errors = msg.errors;
4375
+ const text = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(" ");
4376
+ return classifyFailure(s, { terminalReason, text });
4377
+ }
4378
+ function classifyPreInitFrame(msg, s) {
4379
+ if (msg.type === "result") {
4380
+ const isError = msg.is_error === true;
4381
+ if (msg.subtype === "success" && !isError) return classifyFailure(s, { text: "" });
4382
+ return classifyResultFailure(msg, s);
4383
+ }
4384
+ if (msg.type !== "assistant") return classifyFailure(s, { text: "" });
4385
+ const error = msg.error;
4386
+ const signals = typeof error === "string" && error.length > 0 ? { ...s, lastAssistantError: error } : s;
4387
+ const text = contentBlocks(msg).flatMap((b) => b.type === "text" && typeof b.text === "string" ? [b.text] : []).join(" ");
4388
+ return classifyFailure(signals, { text });
4389
+ }
4390
+ function preInitToolActivity(msg) {
4391
+ const kind = msg.type === "assistant" ? "tool_use" : msg.type === "user" ? "tool_result" : null;
4392
+ return kind !== null && contentBlocks(msg).some((b) => b.type === kind);
4393
+ }
4394
+ function contentBlocks(msg) {
4395
+ const content = msg.message?.content;
4396
+ return Array.isArray(content) ? content : [];
4397
+ }
4351
4398
  function isSubscriptionWindow(value) {
4352
4399
  return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
4353
4400
  }
@@ -4407,15 +4454,18 @@ function attachAbortController(options, signal) {
4407
4454
  else signal.addEventListener("abort", () => sdkAbort.abort(), { once: true });
4408
4455
  options.abortController = sdkAbort;
4409
4456
  }
4410
- function sdkEvents(queryFn, req, signal, built, degraded) {
4457
+ function sdkEvents(queryFn, req, signal, built, degraded, startEvidence) {
4411
4458
  attachAbortController(built.options, signal);
4412
4459
  const prompt = buildQueryPrompt(req);
4413
- const iter = queryFn({ prompt, options: built.options });
4460
+ const iter = {
4461
+ [Symbol.asyncIterator]: () => queryFn({ prompt, options: built.options })[Symbol.asyncIterator]()
4462
+ };
4414
4463
  return decodeSdkStream(iter, {
4415
4464
  signal,
4416
4465
  cwd: req.local.cwd,
4417
4466
  resumedSessionId: built.resume,
4418
- degraded
4467
+ degraded,
4468
+ ...startEvidence ? { startEvidence } : {}
4419
4469
  });
4420
4470
  }
4421
4471
  async function freshRequest(req, reason, hooks) {
@@ -4424,8 +4474,9 @@ async function freshRequest(req, reason, hooks) {
4424
4474
  }
4425
4475
  async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOptions, onWarn, degraded, hooks) {
4426
4476
  let emitted = false;
4427
- for await (const event of sdkEvents(queryFn, req, signal, initial, degraded)) {
4428
- const recoverResume = initial.resume !== null && !emitted && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
4477
+ const evidence = { toolActivity: false };
4478
+ for await (const event of sdkEvents(queryFn, req, signal, initial, degraded, evidence)) {
4479
+ const recoverResume = initial.resume !== null && !emitted && !evidence.toolActivity && event.type === "result" && !event.ok && event.reason === "session_start_failed" && event.mcpInventory?.initReceived === false;
4429
4480
  if (recoverResume) {
4430
4481
  if (signal.aborted) return;
4431
4482
  onWarn?.(
@@ -4433,7 +4484,8 @@ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOpti
4433
4484
  {
4434
4485
  cwd: req.local.cwd,
4435
4486
  reason: "session_start_failed",
4436
- discardedSessionId: initial.resume
4487
+ discardedSessionId: initial.resume,
4488
+ ...evidence.thrown ? { thrown: evidence.thrown } : {}
4437
4489
  }
4438
4490
  );
4439
4491
  const fresh = await freshRequest(req, "session_start_failed", hooks);
@@ -7911,6 +7963,9 @@ async function performAct(acts, act) {
7911
7963
  AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
7912
7964
  );
7913
7965
  } catch (err) {
7966
+ if (err instanceof ApiError && err.status >= 500) {
7967
+ return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
7968
+ }
7914
7969
  if (err instanceof ApiError) {
7915
7970
  if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
7916
7971
  return toolError(
@@ -7919,14 +7974,16 @@ async function performAct(acts, act) {
7919
7974
  }
7920
7975
  return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
7921
7976
  }
7922
- const message = err instanceof Error ? err.message : String(err);
7923
- return toolError(
7924
- `Cabane could not be reached (${message}), so it is UNKNOWN whether this act was recorded \u2014 it may have committed with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
7925
- );
7977
+ return uncertainOutcome(err instanceof Error ? err.message : String(err));
7926
7978
  }
7927
7979
  if (!result.ok) return toolError(JSON.stringify(result));
7928
7980
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7929
7981
  }
7982
+ function uncertainOutcome(cause) {
7983
+ return toolError(
7984
+ `The outcome of this act could not be confirmed (${cause}). It may already have been performed, with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
7985
+ );
7986
+ }
7930
7987
  function createTurnControlMcpServer(acts, skipState) {
7931
7988
  const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7932
7989
  const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
@@ -9343,13 +9400,13 @@ var TurnExecution = class {
9343
9400
  } else {
9344
9401
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
9345
9402
  let preparingStarted = false;
9346
- let preparingSeq = null;
9403
+ let preparing = null;
9347
9404
  const reportPreparing = (phase) => {
9348
- if (preparingSeq === null) preparingSeq = this.nextSeq();
9349
- const seq = preparingSeq;
9405
+ preparing ??= { id: `prepare:${randomUUID3()}`, seq: this.nextSeq() };
9406
+ const { id, seq } = preparing;
9350
9407
  void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, {
9351
9408
  turnId,
9352
- toolUseId: `prepare:${turnId}`,
9409
+ toolUseId: id,
9353
9410
  toolName: PREPARING_TOOL_NAME,
9354
9411
  summary: "",
9355
9412
  phase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.117",
3
+ "version": "0.6.119",
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",