@cabane/companion 0.6.17 → 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.
- package/dist/cli.js +151 -26
- package/dist/runtime.js +151 -26
- 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 {
|
|
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 (
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
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 = ["
|
|
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,
|
|
4964
|
+
input: buildInput(req, promptRidesInput),
|
|
4965
|
+
promptRidesInput,
|
|
4966
|
+
promptFingerprint,
|
|
4942
4967
|
config: buildConfig(req, baseInstructionsFile)
|
|
4943
4968
|
};
|
|
4944
4969
|
}
|
|
4945
|
-
function buildInput(req,
|
|
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 (
|
|
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.
|
|
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(
|
|
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:
|
|
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,11 +6242,69 @@ 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
|
|
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({
|
|
6253
|
+
request: makeRequest3({
|
|
6254
|
+
session: encodeSession3({
|
|
6255
|
+
threadId: "th1",
|
|
6256
|
+
cwd: DIR2,
|
|
6257
|
+
promptFingerprint: fingerprintPrompt(PROMPT)
|
|
6258
|
+
})
|
|
6259
|
+
}),
|
|
6260
|
+
nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
|
|
6261
|
+
expected: [
|
|
6262
|
+
{ type: "text", body: "Back again.", terminal: true },
|
|
6263
|
+
{ type: "result", ok: true }
|
|
6264
|
+
]
|
|
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
|
+
}),
|
|
6208
6305
|
nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
|
|
6209
6306
|
expected: [
|
|
6307
|
+
sessionEvent3("th1"),
|
|
6210
6308
|
{ type: "text", body: "Back again.", terminal: true },
|
|
6211
6309
|
{ type: "result", ok: true }
|
|
6212
6310
|
]
|
|
@@ -6217,8 +6315,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
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({
|
|
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
|
|
6588
|
-
//
|
|
6589
|
-
//
|
|
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: {
|
|
@@ -7199,7 +7314,12 @@ var Dispatcher = class {
|
|
|
7199
7314
|
// clear is durable (CT93 outbox, last-writer-wins per pair), so a terminal
|
|
7200
7315
|
// failure here just logs and the server age-sweep backstops it.
|
|
7201
7316
|
async concludeBeforeRun(payload, turnLog, startedAt, reason, errorReason) {
|
|
7202
|
-
const body = {
|
|
7317
|
+
const body = {
|
|
7318
|
+
activeRunStartedAt: null,
|
|
7319
|
+
// CT1046: this dispatch is over before it ran — retire its evidence so it
|
|
7320
|
+
// can't authorize a turn-token mint afterwards.
|
|
7321
|
+
settledMessageId: payload.messageId
|
|
7322
|
+
};
|
|
7203
7323
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
7204
7324
|
try {
|
|
7205
7325
|
await this.opts.api.setActiveRun(
|
|
@@ -7959,7 +8079,12 @@ ${reason}`,
|
|
|
7959
8079
|
// CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
|
|
7960
8080
|
// off this turn id, so the settle PATCH must carry it — the start PATCH
|
|
7961
8081
|
// isn't enough. Always sent (every settle closes a real turn).
|
|
7962
|
-
turnId
|
|
8082
|
+
turnId,
|
|
8083
|
+
// CT1046: and the trigger this turn answered, so the server retires the
|
|
8084
|
+
// dispatch that authorized it. Always sent, unlike `lastSeenMessageId`
|
|
8085
|
+
// below (which is a success-only catch-up advance): a failed or cancelled
|
|
8086
|
+
// turn is just as over, and its evidence must die with it.
|
|
8087
|
+
settledMessageId: payload.messageId
|
|
7963
8088
|
};
|
|
7964
8089
|
if (turnUsage) {
|
|
7965
8090
|
body.usage = turnUsage;
|
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 {
|
|
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 (
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
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 = ["
|
|
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,
|
|
4617
|
+
input: buildInput(req, promptRidesInput),
|
|
4618
|
+
promptRidesInput,
|
|
4619
|
+
promptFingerprint,
|
|
4595
4620
|
config: buildConfig(req, baseInstructionsFile)
|
|
4596
4621
|
};
|
|
4597
4622
|
}
|
|
4598
|
-
function buildInput(req,
|
|
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 (
|
|
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.
|
|
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(
|
|
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:
|
|
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,11 +5895,69 @@ 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
|
|
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({
|
|
5906
|
+
request: makeRequest3({
|
|
5907
|
+
session: encodeSession3({
|
|
5908
|
+
threadId: "th1",
|
|
5909
|
+
cwd: DIR2,
|
|
5910
|
+
promptFingerprint: fingerprintPrompt(PROMPT)
|
|
5911
|
+
})
|
|
5912
|
+
}),
|
|
5913
|
+
nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
|
|
5914
|
+
expected: [
|
|
5915
|
+
{ type: "text", body: "Back again.", terminal: true },
|
|
5916
|
+
{ type: "result", ok: true }
|
|
5917
|
+
]
|
|
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
|
+
}),
|
|
5861
5958
|
nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
|
|
5862
5959
|
expected: [
|
|
5960
|
+
sessionEvent3("th1"),
|
|
5863
5961
|
{ type: "text", body: "Back again.", terminal: true },
|
|
5864
5962
|
{ type: "result", ok: true }
|
|
5865
5963
|
]
|
|
@@ -5870,8 +5968,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
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({
|
|
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
|
|
6241
|
-
//
|
|
6242
|
-
//
|
|
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: {
|
|
@@ -6852,7 +6967,12 @@ var Dispatcher = class {
|
|
|
6852
6967
|
// clear is durable (CT93 outbox, last-writer-wins per pair), so a terminal
|
|
6853
6968
|
// failure here just logs and the server age-sweep backstops it.
|
|
6854
6969
|
async concludeBeforeRun(payload, turnLog, startedAt, reason, errorReason) {
|
|
6855
|
-
const body = {
|
|
6970
|
+
const body = {
|
|
6971
|
+
activeRunStartedAt: null,
|
|
6972
|
+
// CT1046: this dispatch is over before it ran — retire its evidence so it
|
|
6973
|
+
// can't authorize a turn-token mint afterwards.
|
|
6974
|
+
settledMessageId: payload.messageId
|
|
6975
|
+
};
|
|
6856
6976
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
6857
6977
|
try {
|
|
6858
6978
|
await this.opts.api.setActiveRun(
|
|
@@ -7612,7 +7732,12 @@ ${reason}`,
|
|
|
7612
7732
|
// CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
|
|
7613
7733
|
// off this turn id, so the settle PATCH must carry it — the start PATCH
|
|
7614
7734
|
// isn't enough. Always sent (every settle closes a real turn).
|
|
7615
|
-
turnId
|
|
7735
|
+
turnId,
|
|
7736
|
+
// CT1046: and the trigger this turn answered, so the server retires the
|
|
7737
|
+
// dispatch that authorized it. Always sent, unlike `lastSeenMessageId`
|
|
7738
|
+
// below (which is a success-only catch-up advance): a failed or cancelled
|
|
7739
|
+
// turn is just as over, and its evidence must die with it.
|
|
7740
|
+
settledMessageId: payload.messageId
|
|
7616
7741
|
};
|
|
7617
7742
|
if (turnUsage) {
|
|
7618
7743
|
body.usage = turnUsage;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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",
|