@cabane/companion 0.6.18 → 0.6.19

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 +139 -24
  2. package/dist/runtime.js +139 -24
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -4726,6 +4726,16 @@ function readMcpError(error) {
4726
4726
  }
4727
4727
 
4728
4728
  // packages/agent-runtime/src/codex/session.ts
4729
+ function fingerprintPrompt(prompt) {
4730
+ let fnv = 2166136261;
4731
+ let djb = 5381;
4732
+ for (let i = 0; i < prompt.length; i += 1) {
4733
+ const code = prompt.charCodeAt(i);
4734
+ fnv = Math.imul(fnv ^ code, 16777619);
4735
+ djb = Math.imul(djb, 33) + code | 0;
4736
+ }
4737
+ return `${prompt.length}.${(fnv >>> 0).toString(36)}.${(djb >>> 0).toString(36)}`;
4738
+ }
4729
4739
  function encodeSession3(state) {
4730
4740
  return JSON.stringify(state);
4731
4741
  }
@@ -4745,7 +4755,10 @@ function decideResume3(stored, currentCwd) {
4745
4755
  if (storedCwd !== (currentCwd ?? "")) {
4746
4756
  return { fresh: true, reason: "cwd_mismatch" };
4747
4757
  }
4748
- return { resume: state.threadId };
4758
+ return {
4759
+ resume: state.threadId,
4760
+ promptFingerprint: typeof state.promptFingerprint === "string" ? state.promptFingerprint : null
4761
+ };
4749
4762
  }
4750
4763
 
4751
4764
  // packages/agent-runtime/src/codex/loop.ts
@@ -4768,13 +4781,20 @@ async function* decodeCodexStream(events, ctx) {
4768
4781
  if (ctx.signal.aborted) return;
4769
4782
  if (ev.type === "thread.started") {
4770
4783
  const threadId = readThreadId(ev);
4771
- if (!sessionEmitted && threadId && threadId !== ctx.resumedThreadId) {
4772
- sessionEmitted = true;
4773
- yield {
4774
- type: "session",
4775
- state: encodeSession3({ threadId, cwd: ctx.cwd ?? "" }),
4776
- ...ctx.degraded ? { degraded: true } : {}
4777
- };
4784
+ if (threadId && !sessionEmitted) {
4785
+ const state = encodeSession3({
4786
+ threadId,
4787
+ cwd: ctx.cwd ?? "",
4788
+ promptFingerprint: ctx.promptFingerprint ?? null
4789
+ });
4790
+ if (threadId !== ctx.resumedThreadId || state !== ctx.storedState) {
4791
+ sessionEmitted = true;
4792
+ yield {
4793
+ type: "session",
4794
+ state,
4795
+ ...ctx.degraded ? { degraded: true } : {}
4796
+ };
4797
+ }
4778
4798
  }
4779
4799
  continue;
4780
4800
  }
@@ -4897,7 +4917,7 @@ function codexToolPolicy(policy) {
4897
4917
  networkAccessEnabled: policy.web
4898
4918
  };
4899
4919
  }
4900
- var CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
4920
+ var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
4901
4921
  var codexDialectSchema = z11.object({
4902
4922
  modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
4903
4923
  }).loose();
@@ -4919,7 +4939,7 @@ var CABANE_MCP_SERVER3 = "cabane";
4919
4939
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4920
4940
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4921
4941
  var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
4922
- function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4942
+ function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
4923
4943
  const { policy, config } = req;
4924
4944
  const directory = req.local.cwd ?? "";
4925
4945
  const dialect = readCodexDialect(config.runtimeOptions);
@@ -4930,6 +4950,9 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4930
4950
  `[agent-runtime/codex] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 Codex will fall back to its own default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
4931
4951
  );
4932
4952
  }
4953
+ const promptFingerprint = fingerprintPrompt(req.systemPrompt);
4954
+ const threadHasThisPrompt = resumeThreadId !== null && threadPromptFingerprint !== null && threadPromptFingerprint === promptFingerprint;
4955
+ const promptRidesInput = baseInstructionsFile === null && !threadHasThisPrompt;
4933
4956
  return {
4934
4957
  resumeThreadId,
4935
4958
  directory,
@@ -4938,14 +4961,16 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4938
4961
  skipGitRepoCheck: true,
4939
4962
  ...dialect.modelReasoningEffort ? { modelReasoningEffort: dialect.modelReasoningEffort } : {},
4940
4963
  baseInstructionsFile,
4941
- input: buildInput(req, baseInstructionsFile !== null),
4964
+ input: buildInput(req, promptRidesInput),
4965
+ promptRidesInput,
4966
+ promptFingerprint,
4942
4967
  config: buildConfig(req, baseInstructionsFile)
4943
4968
  };
4944
4969
  }
4945
- function buildInput(req, promptRidesInstructionsFile) {
4970
+ function buildInput(req, promptRidesInput) {
4946
4971
  const userText = req.content.filter((b) => b.type === "text").map((b) => b.text).join("\n\n");
4947
4972
  const body = userText.trim().length > 0 ? userText : req.prompt;
4948
- if (promptRidesInstructionsFile) return body;
4973
+ if (!promptRidesInput) return body;
4949
4974
  const system = req.systemPrompt.trim();
4950
4975
  return system.length > 0 ? `${system}
4951
4976
 
@@ -5047,7 +5072,7 @@ function isStringRecord2(v) {
5047
5072
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
5048
5073
  }
5049
5074
 
5050
- // node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
5075
+ // node_modules/.pnpm/@openai+codex-sdk@0.147.0/node_modules/@openai/codex-sdk/dist/index.js
5051
5076
  import { promises as fs } from "fs";
5052
5077
  import os from "os";
5053
5078
  import path from "path";
@@ -5630,6 +5655,7 @@ function createCodexAdapter(deps = {}) {
5630
5655
  const directory = req.local.cwd ?? "";
5631
5656
  const decision = decideResume3(req.session, directory);
5632
5657
  const resumeThreadId = "resume" in decision ? decision.resume : null;
5658
+ const threadPromptFingerprint = "resume" in decision ? decision.promptFingerprint : null;
5633
5659
  if ("fresh" in decision && decision.reason === "cwd_mismatch") {
5634
5660
  deps.onWarn?.(
5635
5661
  "codex adapter: stored session directory no longer matches the current environment \u2014 starting a fresh thread (SJ527-analogue guard)",
@@ -5655,7 +5681,12 @@ function createCodexAdapter(deps = {}) {
5655
5681
  }
5656
5682
  }
5657
5683
  try {
5658
- const spec = buildRunSpec2(req, resumeThreadId, instructions?.path ?? null);
5684
+ const spec = buildRunSpec2(
5685
+ req,
5686
+ resumeThreadId,
5687
+ instructions?.path ?? null,
5688
+ threadPromptFingerprint
5689
+ );
5659
5690
  const result = await transport.run(spec, signal);
5660
5691
  if (signal.aborted) return;
5661
5692
  yield* decodeCodexStream(result.events, {
@@ -5663,6 +5694,14 @@ function createCodexAdapter(deps = {}) {
5663
5694
  cwd: req.local.cwd,
5664
5695
  resumedThreadId: resumeThreadId,
5665
5696
  degraded,
5697
+ // CT1075: which prompt the thread carries once this turn lands. If we sent
5698
+ // one, it's that one — a later copy is what the model reads, so a changed
5699
+ // prompt supersedes the older copy still sitting above it. If we withheld
5700
+ // it, the thread still carries whatever it did before. Read off the spec
5701
+ // rather than recomputed, so the recorded fact can't disagree with what
5702
+ // was actually sent.
5703
+ promptFingerprint: spec.promptRidesInput ? spec.promptFingerprint : threadPromptFingerprint,
5704
+ storedState: req.session,
5666
5705
  // CT601: Codex reports no model in-stream, so record what the thread was
5667
5706
  // started with (null on the default token). Same for the reasoning effort.
5668
5707
  resolvedModel: spec.model,
@@ -5697,9 +5736,10 @@ var COMPANION_POLICY2 = {
5697
5736
  uiPrompts: "never"
5698
5737
  };
5699
5738
  var DIR2 = "/env/here";
5739
+ var PROMPT = "system";
5700
5740
  function makeRequest3(overrides = {}) {
5701
5741
  return {
5702
- systemPrompt: "system",
5742
+ systemPrompt: PROMPT,
5703
5743
  prompt: "hi there",
5704
5744
  content: [{ type: "text", text: "hi there" }],
5705
5745
  // CT601: the resolved model + reasoning effort ride the terminal `result` off
@@ -5742,9 +5782,9 @@ var errorItem = (id, message) => ({
5742
5782
  type: "item.completed",
5743
5783
  item: { id, type: "error", message }
5744
5784
  });
5745
- var sessionEvent3 = (threadId, cwd = DIR2, degraded = false) => ({
5785
+ var sessionEvent3 = (threadId, cwd = DIR2, degraded = false, promptFingerprint = fingerprintPrompt(PROMPT)) => ({
5746
5786
  type: "session",
5747
- state: encodeSession3({ threadId, cwd }),
5787
+ state: encodeSession3({ threadId, cwd, promptFingerprint }),
5748
5788
  ...degraded ? { degraded: true } : {}
5749
5789
  });
5750
5790
  var CODEX_CONFORMANCE_FIXTURES = [
@@ -6202,23 +6242,91 @@ var CODEX_CONFORMANCE_FIXTURES = [
6202
6242
  {
6203
6243
  // Session resume: the stored state matches the current directory, so the adapter
6204
6244
  // resumes its thread id and `thread.started` echoes the SAME id — NO session
6205
- // event fires (the id didn't change). This is what carries cross-turn memory.
6245
+ // event fires (the id didn't change AND the state is unchanged). This is what
6246
+ // carries cross-turn memory.
6247
+ //
6248
+ // CT1075: the stored state also says the thread already holds the composed
6249
+ // prompt, so this turn withholds the preamble and the state it would write is
6250
+ // byte-identical to the stored one. The silence is now proof of BOTH: an id that
6251
+ // didn't change and a prompt that didn't need re-sending.
6206
6252
  name: "session resume (no re-emit on echo)",
6207
- request: makeRequest3({ session: encodeSession3({ threadId: "th1", cwd: DIR2 }) }),
6253
+ request: makeRequest3({
6254
+ session: encodeSession3({
6255
+ threadId: "th1",
6256
+ cwd: DIR2,
6257
+ promptFingerprint: fingerprintPrompt(PROMPT)
6258
+ })
6259
+ }),
6208
6260
  nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
6209
6261
  expected: [
6210
6262
  { type: "text", body: "Back again.", terminal: true },
6211
6263
  { type: "result", ok: true }
6212
6264
  ]
6213
6265
  },
6266
+ {
6267
+ // CT1075: a resumed thread the platform's state does NOT vouch for — a state
6268
+ // written before CT1075 existed, so it carries no `promptFingerprint` field. The
6269
+ // adapter reads the absence as "we can't say which prompt this thread holds",
6270
+ // sends the full prompt exactly as it did pre-CT1075, and re-emits the state with
6271
+ // the fingerprint recorded — on the SAME thread id, which pre-CT1075 emitted
6272
+ // nothing at all. That re-emit is the upgrade path: without it a long-lived
6273
+ // thread would re-send the whole prompt forever.
6274
+ //
6275
+ // This is the fixture that fails if the static half is withheld on a thread we
6276
+ // have no evidence about.
6277
+ name: "session resume with a pre-CT1075 state re-emits with the prompt fingerprint",
6278
+ request: makeRequest3({
6279
+ session: JSON.stringify({ threadId: "th1", cwd: DIR2 })
6280
+ }),
6281
+ nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
6282
+ expected: [
6283
+ sessionEvent3("th1"),
6284
+ { type: "text", body: "Back again.", terminal: true },
6285
+ { type: "result", ok: true }
6286
+ ]
6287
+ },
6288
+ {
6289
+ // CT1075 (review finding): the thread carries a DIFFERENT prompt than the one
6290
+ // this turn composed — a charter edit, a renamed agent, a redeployed prompt
6291
+ // body. The stored fingerprint doesn't match, so the new prompt is sent exactly
6292
+ // as on a fresh thread and the state is re-recorded against it.
6293
+ //
6294
+ // This is the fixture that fails if a stale prompt is treated as good enough. A
6295
+ // boolean flag would have passed the resume fixture above and silently withheld
6296
+ // every future charter edit from this thread.
6297
+ name: "session resume with a stale prompt fingerprint re-sends the prompt",
6298
+ request: makeRequest3({
6299
+ session: encodeSession3({
6300
+ threadId: "th1",
6301
+ cwd: DIR2,
6302
+ promptFingerprint: fingerprintPrompt("an older composed prompt")
6303
+ })
6304
+ }),
6305
+ nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
6306
+ expected: [
6307
+ sessionEvent3("th1"),
6308
+ { type: "text", body: "Back again.", terminal: true },
6309
+ { type: "result", ok: true }
6310
+ ]
6311
+ },
6214
6312
  {
6215
6313
  // Directory-mismatch degrade (the SJ527 analogue): the stored state was created
6216
6314
  // under a DIFFERENT directory, so the adapter refuses to resume and starts
6217
6315
  // fresh — `thread.started` reports a NEW id, and the emitted session event
6218
6316
  // records the CURRENT directory with `degraded: true` so the host rewinds the
6219
6317
  // catch-up mark.
6318
+ //
6319
+ // CT1075: the refused resume means the new thread is EMPTY, so the full prompt
6320
+ // goes in even though the stored state vouched for a copy — of a thread we're
6321
+ // no longer in. The emitted state records the fresh thread's own answer.
6220
6322
  name: "session directory-mismatch degrades to fresh",
6221
- request: makeRequest3({ session: encodeSession3({ threadId: "old", cwd: "/env/gone" }) }),
6323
+ request: makeRequest3({
6324
+ session: encodeSession3({
6325
+ threadId: "old",
6326
+ cwd: "/env/gone",
6327
+ promptFingerprint: fingerprintPrompt(PROMPT)
6328
+ })
6329
+ }),
6222
6330
  nativeStream: [
6223
6331
  threadStarted(NEW_THREAD_ID),
6224
6332
  agentMessage("m1", "Fresh start."),
@@ -6584,9 +6692,16 @@ function buildCompanionTurnRequest(params) {
6584
6692
  ...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
6585
6693
  // CT714: mount the turn-control surface ONLY when a real turn token backs
6586
6694
  // this turn — the surface admits `turn_token` auth exclusively, so a
6587
- // PAT-fallback bearer (older API / unresolved delegation) would be rejected
6588
- // there. Absent it, external adapters simply don't mount it that turn (the
6589
- // same graceful degrade as the rest of the OBO path).
6695
+ // PAT-fallback bearer would be rejected there. Absent it, external adapters
6696
+ // simply don't mount it that turn.
6697
+ //
6698
+ // CT1074: that fallback is now an OLD-API case only. The server mints a token
6699
+ // for every turn it dispatched, so "no token" no longer means "no human behind
6700
+ // the turn" — it means the API predates this change. The condition is right as
6701
+ // it stands and stays where the failure is honest (a rejected bearer is worse
6702
+ // than an unmounted server); what was wrong was upstream, where an undelegated
6703
+ // turn minted nothing and this silently dropped all five verbs on Codex and
6704
+ // opencode with no refusal anyone could see.
6590
6705
  ...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
6591
6706
  },
6592
6707
  local: {
package/dist/runtime.js CHANGED
@@ -4379,6 +4379,16 @@ function readMcpError(error) {
4379
4379
  }
4380
4380
 
4381
4381
  // packages/agent-runtime/src/codex/session.ts
4382
+ function fingerprintPrompt(prompt) {
4383
+ let fnv = 2166136261;
4384
+ let djb = 5381;
4385
+ for (let i = 0; i < prompt.length; i += 1) {
4386
+ const code = prompt.charCodeAt(i);
4387
+ fnv = Math.imul(fnv ^ code, 16777619);
4388
+ djb = Math.imul(djb, 33) + code | 0;
4389
+ }
4390
+ return `${prompt.length}.${(fnv >>> 0).toString(36)}.${(djb >>> 0).toString(36)}`;
4391
+ }
4382
4392
  function encodeSession3(state) {
4383
4393
  return JSON.stringify(state);
4384
4394
  }
@@ -4398,7 +4408,10 @@ function decideResume3(stored, currentCwd) {
4398
4408
  if (storedCwd !== (currentCwd ?? "")) {
4399
4409
  return { fresh: true, reason: "cwd_mismatch" };
4400
4410
  }
4401
- return { resume: state.threadId };
4411
+ return {
4412
+ resume: state.threadId,
4413
+ promptFingerprint: typeof state.promptFingerprint === "string" ? state.promptFingerprint : null
4414
+ };
4402
4415
  }
4403
4416
 
4404
4417
  // packages/agent-runtime/src/codex/loop.ts
@@ -4421,13 +4434,20 @@ async function* decodeCodexStream(events, ctx) {
4421
4434
  if (ctx.signal.aborted) return;
4422
4435
  if (ev.type === "thread.started") {
4423
4436
  const threadId = readThreadId(ev);
4424
- if (!sessionEmitted && threadId && threadId !== ctx.resumedThreadId) {
4425
- sessionEmitted = true;
4426
- yield {
4427
- type: "session",
4428
- state: encodeSession3({ threadId, cwd: ctx.cwd ?? "" }),
4429
- ...ctx.degraded ? { degraded: true } : {}
4430
- };
4437
+ if (threadId && !sessionEmitted) {
4438
+ const state = encodeSession3({
4439
+ threadId,
4440
+ cwd: ctx.cwd ?? "",
4441
+ promptFingerprint: ctx.promptFingerprint ?? null
4442
+ });
4443
+ if (threadId !== ctx.resumedThreadId || state !== ctx.storedState) {
4444
+ sessionEmitted = true;
4445
+ yield {
4446
+ type: "session",
4447
+ state,
4448
+ ...ctx.degraded ? { degraded: true } : {}
4449
+ };
4450
+ }
4431
4451
  }
4432
4452
  continue;
4433
4453
  }
@@ -4550,7 +4570,7 @@ function codexToolPolicy(policy) {
4550
4570
  networkAccessEnabled: policy.web
4551
4571
  };
4552
4572
  }
4553
- var CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
4573
+ var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
4554
4574
  var codexDialectSchema = z11.object({
4555
4575
  modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
4556
4576
  }).loose();
@@ -4572,7 +4592,7 @@ var CABANE_MCP_SERVER3 = "cabane";
4572
4592
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4573
4593
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4574
4594
  var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
4575
- function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4595
+ function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
4576
4596
  const { policy, config } = req;
4577
4597
  const directory = req.local.cwd ?? "";
4578
4598
  const dialect = readCodexDialect(config.runtimeOptions);
@@ -4583,6 +4603,9 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4583
4603
  `[agent-runtime/codex] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 Codex will fall back to its own default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
4584
4604
  );
4585
4605
  }
4606
+ const promptFingerprint = fingerprintPrompt(req.systemPrompt);
4607
+ const threadHasThisPrompt = resumeThreadId !== null && threadPromptFingerprint !== null && threadPromptFingerprint === promptFingerprint;
4608
+ const promptRidesInput = baseInstructionsFile === null && !threadHasThisPrompt;
4586
4609
  return {
4587
4610
  resumeThreadId,
4588
4611
  directory,
@@ -4591,14 +4614,16 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null) {
4591
4614
  skipGitRepoCheck: true,
4592
4615
  ...dialect.modelReasoningEffort ? { modelReasoningEffort: dialect.modelReasoningEffort } : {},
4593
4616
  baseInstructionsFile,
4594
- input: buildInput(req, baseInstructionsFile !== null),
4617
+ input: buildInput(req, promptRidesInput),
4618
+ promptRidesInput,
4619
+ promptFingerprint,
4595
4620
  config: buildConfig(req, baseInstructionsFile)
4596
4621
  };
4597
4622
  }
4598
- function buildInput(req, promptRidesInstructionsFile) {
4623
+ function buildInput(req, promptRidesInput) {
4599
4624
  const userText = req.content.filter((b) => b.type === "text").map((b) => b.text).join("\n\n");
4600
4625
  const body = userText.trim().length > 0 ? userText : req.prompt;
4601
- if (promptRidesInstructionsFile) return body;
4626
+ if (!promptRidesInput) return body;
4602
4627
  const system = req.systemPrompt.trim();
4603
4628
  return system.length > 0 ? `${system}
4604
4629
 
@@ -4700,7 +4725,7 @@ function isStringRecord2(v) {
4700
4725
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
4701
4726
  }
4702
4727
 
4703
- // node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
4728
+ // node_modules/.pnpm/@openai+codex-sdk@0.147.0/node_modules/@openai/codex-sdk/dist/index.js
4704
4729
  import { promises as fs } from "fs";
4705
4730
  import os from "os";
4706
4731
  import path from "path";
@@ -5283,6 +5308,7 @@ function createCodexAdapter(deps = {}) {
5283
5308
  const directory = req.local.cwd ?? "";
5284
5309
  const decision = decideResume3(req.session, directory);
5285
5310
  const resumeThreadId = "resume" in decision ? decision.resume : null;
5311
+ const threadPromptFingerprint = "resume" in decision ? decision.promptFingerprint : null;
5286
5312
  if ("fresh" in decision && decision.reason === "cwd_mismatch") {
5287
5313
  deps.onWarn?.(
5288
5314
  "codex adapter: stored session directory no longer matches the current environment \u2014 starting a fresh thread (SJ527-analogue guard)",
@@ -5308,7 +5334,12 @@ function createCodexAdapter(deps = {}) {
5308
5334
  }
5309
5335
  }
5310
5336
  try {
5311
- const spec = buildRunSpec2(req, resumeThreadId, instructions?.path ?? null);
5337
+ const spec = buildRunSpec2(
5338
+ req,
5339
+ resumeThreadId,
5340
+ instructions?.path ?? null,
5341
+ threadPromptFingerprint
5342
+ );
5312
5343
  const result = await transport.run(spec, signal);
5313
5344
  if (signal.aborted) return;
5314
5345
  yield* decodeCodexStream(result.events, {
@@ -5316,6 +5347,14 @@ function createCodexAdapter(deps = {}) {
5316
5347
  cwd: req.local.cwd,
5317
5348
  resumedThreadId: resumeThreadId,
5318
5349
  degraded,
5350
+ // CT1075: which prompt the thread carries once this turn lands. If we sent
5351
+ // one, it's that one — a later copy is what the model reads, so a changed
5352
+ // prompt supersedes the older copy still sitting above it. If we withheld
5353
+ // it, the thread still carries whatever it did before. Read off the spec
5354
+ // rather than recomputed, so the recorded fact can't disagree with what
5355
+ // was actually sent.
5356
+ promptFingerprint: spec.promptRidesInput ? spec.promptFingerprint : threadPromptFingerprint,
5357
+ storedState: req.session,
5319
5358
  // CT601: Codex reports no model in-stream, so record what the thread was
5320
5359
  // started with (null on the default token). Same for the reasoning effort.
5321
5360
  resolvedModel: spec.model,
@@ -5350,9 +5389,10 @@ var COMPANION_POLICY2 = {
5350
5389
  uiPrompts: "never"
5351
5390
  };
5352
5391
  var DIR2 = "/env/here";
5392
+ var PROMPT = "system";
5353
5393
  function makeRequest3(overrides = {}) {
5354
5394
  return {
5355
- systemPrompt: "system",
5395
+ systemPrompt: PROMPT,
5356
5396
  prompt: "hi there",
5357
5397
  content: [{ type: "text", text: "hi there" }],
5358
5398
  // CT601: the resolved model + reasoning effort ride the terminal `result` off
@@ -5395,9 +5435,9 @@ var errorItem = (id, message) => ({
5395
5435
  type: "item.completed",
5396
5436
  item: { id, type: "error", message }
5397
5437
  });
5398
- var sessionEvent3 = (threadId, cwd = DIR2, degraded = false) => ({
5438
+ var sessionEvent3 = (threadId, cwd = DIR2, degraded = false, promptFingerprint = fingerprintPrompt(PROMPT)) => ({
5399
5439
  type: "session",
5400
- state: encodeSession3({ threadId, cwd }),
5440
+ state: encodeSession3({ threadId, cwd, promptFingerprint }),
5401
5441
  ...degraded ? { degraded: true } : {}
5402
5442
  });
5403
5443
  var CODEX_CONFORMANCE_FIXTURES = [
@@ -5855,23 +5895,91 @@ var CODEX_CONFORMANCE_FIXTURES = [
5855
5895
  {
5856
5896
  // Session resume: the stored state matches the current directory, so the adapter
5857
5897
  // resumes its thread id and `thread.started` echoes the SAME id — NO session
5858
- // event fires (the id didn't change). This is what carries cross-turn memory.
5898
+ // event fires (the id didn't change AND the state is unchanged). This is what
5899
+ // carries cross-turn memory.
5900
+ //
5901
+ // CT1075: the stored state also says the thread already holds the composed
5902
+ // prompt, so this turn withholds the preamble and the state it would write is
5903
+ // byte-identical to the stored one. The silence is now proof of BOTH: an id that
5904
+ // didn't change and a prompt that didn't need re-sending.
5859
5905
  name: "session resume (no re-emit on echo)",
5860
- request: makeRequest3({ session: encodeSession3({ threadId: "th1", cwd: DIR2 }) }),
5906
+ request: makeRequest3({
5907
+ session: encodeSession3({
5908
+ threadId: "th1",
5909
+ cwd: DIR2,
5910
+ promptFingerprint: fingerprintPrompt(PROMPT)
5911
+ })
5912
+ }),
5861
5913
  nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
5862
5914
  expected: [
5863
5915
  { type: "text", body: "Back again.", terminal: true },
5864
5916
  { type: "result", ok: true }
5865
5917
  ]
5866
5918
  },
5919
+ {
5920
+ // CT1075: a resumed thread the platform's state does NOT vouch for — a state
5921
+ // written before CT1075 existed, so it carries no `promptFingerprint` field. The
5922
+ // adapter reads the absence as "we can't say which prompt this thread holds",
5923
+ // sends the full prompt exactly as it did pre-CT1075, and re-emits the state with
5924
+ // the fingerprint recorded — on the SAME thread id, which pre-CT1075 emitted
5925
+ // nothing at all. That re-emit is the upgrade path: without it a long-lived
5926
+ // thread would re-send the whole prompt forever.
5927
+ //
5928
+ // This is the fixture that fails if the static half is withheld on a thread we
5929
+ // have no evidence about.
5930
+ name: "session resume with a pre-CT1075 state re-emits with the prompt fingerprint",
5931
+ request: makeRequest3({
5932
+ session: JSON.stringify({ threadId: "th1", cwd: DIR2 })
5933
+ }),
5934
+ nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
5935
+ expected: [
5936
+ sessionEvent3("th1"),
5937
+ { type: "text", body: "Back again.", terminal: true },
5938
+ { type: "result", ok: true }
5939
+ ]
5940
+ },
5941
+ {
5942
+ // CT1075 (review finding): the thread carries a DIFFERENT prompt than the one
5943
+ // this turn composed — a charter edit, a renamed agent, a redeployed prompt
5944
+ // body. The stored fingerprint doesn't match, so the new prompt is sent exactly
5945
+ // as on a fresh thread and the state is re-recorded against it.
5946
+ //
5947
+ // This is the fixture that fails if a stale prompt is treated as good enough. A
5948
+ // boolean flag would have passed the resume fixture above and silently withheld
5949
+ // every future charter edit from this thread.
5950
+ name: "session resume with a stale prompt fingerprint re-sends the prompt",
5951
+ request: makeRequest3({
5952
+ session: encodeSession3({
5953
+ threadId: "th1",
5954
+ cwd: DIR2,
5955
+ promptFingerprint: fingerprintPrompt("an older composed prompt")
5956
+ })
5957
+ }),
5958
+ nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
5959
+ expected: [
5960
+ sessionEvent3("th1"),
5961
+ { type: "text", body: "Back again.", terminal: true },
5962
+ { type: "result", ok: true }
5963
+ ]
5964
+ },
5867
5965
  {
5868
5966
  // Directory-mismatch degrade (the SJ527 analogue): the stored state was created
5869
5967
  // under a DIFFERENT directory, so the adapter refuses to resume and starts
5870
5968
  // fresh — `thread.started` reports a NEW id, and the emitted session event
5871
5969
  // records the CURRENT directory with `degraded: true` so the host rewinds the
5872
5970
  // catch-up mark.
5971
+ //
5972
+ // CT1075: the refused resume means the new thread is EMPTY, so the full prompt
5973
+ // goes in even though the stored state vouched for a copy — of a thread we're
5974
+ // no longer in. The emitted state records the fresh thread's own answer.
5873
5975
  name: "session directory-mismatch degrades to fresh",
5874
- request: makeRequest3({ session: encodeSession3({ threadId: "old", cwd: "/env/gone" }) }),
5976
+ request: makeRequest3({
5977
+ session: encodeSession3({
5978
+ threadId: "old",
5979
+ cwd: "/env/gone",
5980
+ promptFingerprint: fingerprintPrompt(PROMPT)
5981
+ })
5982
+ }),
5875
5983
  nativeStream: [
5876
5984
  threadStarted(NEW_THREAD_ID),
5877
5985
  agentMessage("m1", "Fresh start."),
@@ -6237,9 +6345,16 @@ function buildCompanionTurnRequest(params) {
6237
6345
  ...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
6238
6346
  // CT714: mount the turn-control surface ONLY when a real turn token backs
6239
6347
  // this turn — the surface admits `turn_token` auth exclusively, so a
6240
- // PAT-fallback bearer (older API / unresolved delegation) would be rejected
6241
- // there. Absent it, external adapters simply don't mount it that turn (the
6242
- // same graceful degrade as the rest of the OBO path).
6348
+ // PAT-fallback bearer would be rejected there. Absent it, external adapters
6349
+ // simply don't mount it that turn.
6350
+ //
6351
+ // CT1074: that fallback is now an OLD-API case only. The server mints a token
6352
+ // for every turn it dispatched, so "no token" no longer means "no human behind
6353
+ // the turn" — it means the API predates this change. The condition is right as
6354
+ // it stands and stays where the failure is honest (a rejected bearer is worse
6355
+ // than an unmounted server); what was wrong was upstream, where an undelegated
6356
+ // turn minted nothing and this silently dropped all five verbs on Codex and
6357
+ // opencode with no refusal anyone could see.
6243
6358
  ...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
6244
6359
  },
6245
6360
  local: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.18",
3
+ "version": "0.6.19",
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",
@@ -28,7 +28,7 @@
28
28
  "prepublishOnly": "pnpm build"
29
29
  },
30
30
  "dependencies": {
31
- "@anthropic-ai/claude-agent-sdk": "0.3.226",
31
+ "@anthropic-ai/claude-agent-sdk": "0.3.233",
32
32
  "@hono/node-server": "1.19.14",
33
33
  "@inquirer/prompts": "7.10.1",
34
34
  "commander": "12.1.0",