@parall/codex-agent 1.44.0 → 1.46.0
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/app-server-process.d.ts +9 -0
- package/dist/app-server-process.d.ts.map +1 -0
- package/dist/app-server-process.js +58 -0
- package/dist/app-server-protocol.d.ts +19 -0
- package/dist/app-server-protocol.d.ts.map +1 -0
- package/dist/app-server-protocol.js +42 -0
- package/dist/dispatch.d.ts +88 -5
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +364 -196
- package/dist/index.js +27 -11
- package/dist/instructions-refresh.d.ts +136 -0
- package/dist/instructions-refresh.d.ts.map +1 -0
- package/dist/instructions-refresh.js +244 -0
- package/dist/jsonrpc-client.d.ts +49 -1
- package/dist/jsonrpc-client.d.ts.map +1 -1
- package/dist/jsonrpc-client.js +77 -5
- package/dist/legacy-workspace-config-migration.d.ts +112 -0
- package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
- package/dist/legacy-workspace-config-migration.js +229 -0
- package/dist/server-requests.d.ts +10 -0
- package/dist/server-requests.d.ts.map +1 -0
- package/dist/server-requests.js +39 -0
- package/dist/session-manager.d.ts +12 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +53 -3
- package/dist/turn-sink.d.ts +26 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +45 -0
- package/dist/workspace.d.ts +23 -25
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +138 -138
- package/package.json +5 -5
- package/src/app-server-process.ts +59 -0
- package/src/app-server-protocol.ts +46 -0
- package/src/dispatch.ts +426 -204
- package/src/index.ts +35 -10
- package/src/instructions-refresh.ts +367 -0
- package/src/jsonrpc-client.ts +109 -7
- package/src/legacy-workspace-config-migration.ts +296 -0
- package/src/server-requests.ts +40 -0
- package/src/session-manager.ts +74 -6
- package/src/turn-sink.ts +54 -0
- package/src/workspace.ts +155 -155
package/dist/jsonrpc-client.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/** JSON-RPC 2.0 spec code for "Method not found". */
|
|
2
|
+
export const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
3
|
+
/**
|
|
4
|
+
* Rejection error that preserves the JSON-RPC error object's code (and data),
|
|
5
|
+
* so callers can branch on protocol-level conditions — e.g. method-not-found
|
|
6
|
+
* on an older CLI — without matching on server message text, whose wording
|
|
7
|
+
* shifts between codex versions.
|
|
8
|
+
*/
|
|
9
|
+
export class JsonRpcError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
data;
|
|
12
|
+
constructor(code, message, data) {
|
|
13
|
+
super(message || 'JSON-RPC error');
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.data = data;
|
|
16
|
+
this.name = 'JsonRpcError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
1
19
|
/** Maximum time to wait for a JSON-RPC response before rejecting. */
|
|
2
20
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
3
21
|
/**
|
|
@@ -16,6 +34,7 @@ export class JsonRpcStdioClient {
|
|
|
16
34
|
pending = new Map();
|
|
17
35
|
buffer = '';
|
|
18
36
|
onNotification = null;
|
|
37
|
+
onServerRequest = null;
|
|
19
38
|
disposed = false;
|
|
20
39
|
constructor(proc, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, killProcess) {
|
|
21
40
|
this.proc = proc;
|
|
@@ -30,10 +49,23 @@ export class JsonRpcStdioClient {
|
|
|
30
49
|
setNotificationHandler(handler) {
|
|
31
50
|
this.onNotification = handler;
|
|
32
51
|
}
|
|
33
|
-
|
|
52
|
+
setServerRequestHandler(handler) {
|
|
53
|
+
this.onServerRequest = handler;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `options.timeoutMs` overrides the client-wide budget for this request.
|
|
57
|
+
* `options.lethalTimeout: false` makes a timeout reject WITHOUT killing the
|
|
58
|
+
* subprocess — for best-effort side requests (e.g. the compaction watcher's
|
|
59
|
+
* turn/interrupt) where "no answer" must not translate into a delayed
|
|
60
|
+
* subprocess kill landing on an unrelated later dispatch. Default (true)
|
|
61
|
+
* keeps the existing assume-hung semantics.
|
|
62
|
+
*/
|
|
63
|
+
sendRequest(method, params, options) {
|
|
34
64
|
if (this.disposed) {
|
|
35
65
|
return Promise.reject(new Error('JSON-RPC client disposed'));
|
|
36
66
|
}
|
|
67
|
+
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
|
|
68
|
+
const lethalTimeout = options?.lethalTimeout ?? true;
|
|
37
69
|
const id = this.nextId++;
|
|
38
70
|
const request = { jsonrpc: '2.0', id, method, params };
|
|
39
71
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -42,9 +74,11 @@ export class JsonRpcStdioClient {
|
|
|
42
74
|
if (!pending)
|
|
43
75
|
return;
|
|
44
76
|
this.pending.delete(id);
|
|
45
|
-
reject(new Error(`JSON-RPC request "${method}" timed out after ${
|
|
46
|
-
|
|
47
|
-
|
|
77
|
+
reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
|
|
78
|
+
if (lethalTimeout) {
|
|
79
|
+
this.killUnhealthy(new Error(`request "${method}" timed out; subprocess assumed hung`));
|
|
80
|
+
}
|
|
81
|
+
}, timeoutMs);
|
|
48
82
|
this.pending.set(id, { resolve, reject, timer });
|
|
49
83
|
});
|
|
50
84
|
this.writeFrame(request);
|
|
@@ -114,21 +148,59 @@ export class JsonRpcStdioClient {
|
|
|
114
148
|
this.pending.delete(message.id);
|
|
115
149
|
clearTimeout(pending.timer);
|
|
116
150
|
if (message.error) {
|
|
117
|
-
pending.reject(new
|
|
151
|
+
pending.reject(new JsonRpcError(message.error.code, message.error.message, message.error.data));
|
|
118
152
|
}
|
|
119
153
|
else {
|
|
120
154
|
pending.resolve(message.result);
|
|
121
155
|
}
|
|
122
156
|
return;
|
|
123
157
|
}
|
|
158
|
+
if (isServerRequest(message)) {
|
|
159
|
+
this.answerServerRequest(message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
124
162
|
if (isNotification(message)) {
|
|
125
163
|
this.onNotification?.(message.method, message.params);
|
|
126
164
|
}
|
|
127
165
|
}
|
|
166
|
+
answerServerRequest(request) {
|
|
167
|
+
let answer;
|
|
168
|
+
try {
|
|
169
|
+
answer = this.onServerRequest?.(request.method, request.params);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
this.writeFrame({
|
|
173
|
+
jsonrpc: '2.0',
|
|
174
|
+
id: request.id,
|
|
175
|
+
error: { code: -32603, message: `server request handler failed: ${String(err)}` },
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (answer !== undefined) {
|
|
180
|
+
// undefined → null: a dropped `result` member would produce a frame
|
|
181
|
+
// that is neither a success nor an error response.
|
|
182
|
+
this.writeFrame({
|
|
183
|
+
jsonrpc: '2.0',
|
|
184
|
+
id: request.id,
|
|
185
|
+
result: answer.result === undefined ? null : answer.result,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.writeFrame({
|
|
190
|
+
jsonrpc: '2.0',
|
|
191
|
+
id: request.id,
|
|
192
|
+
error: { code: -32601, message: `unsupported server request: ${request.method}` },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
128
195
|
}
|
|
129
196
|
function isResponse(m) {
|
|
130
197
|
return !!m && typeof m === 'object' && 'id' in m && ('result' in m || 'error' in m);
|
|
131
198
|
}
|
|
199
|
+
// A server→client request carries BOTH `method` and `id` (and no
|
|
200
|
+
// result/error). It must be answered — see ServerRequestHandler.
|
|
201
|
+
function isServerRequest(m) {
|
|
202
|
+
return !!m && typeof m === 'object' && 'method' in m && 'id' in m;
|
|
203
|
+
}
|
|
132
204
|
function isNotification(m) {
|
|
133
205
|
return !!m && typeof m === 'object' && 'method' in m && !('id' in m);
|
|
134
206
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export declare function legacyWorkspaceConfigPath(workspaceDir: string): string;
|
|
2
|
+
export declare function migrationSentinelPath(workspaceDir: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Byte-exact reconstruction of the retired serializer. Verified identical across
|
|
5
|
+
* every released bridge that wrote this file (v1.37.0 … v1.44.0 — the line is
|
|
6
|
+
* byte-for-byte the same in all of them), so a single reconstruction covers the
|
|
7
|
+
* whole legacy fleet. The deletion gate compares raw bytes against this: it must
|
|
8
|
+
* never drift.
|
|
9
|
+
*/
|
|
10
|
+
export declare function legacyWorkspaceConfigToml(prompt: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The PRE-OVERWRITE `.parall/system-prompt.md`. The retired bridge wrote the
|
|
13
|
+
* reference copy and the config from the SAME string in the same call, so a byte
|
|
14
|
+
* match against `legacyWorkspaceConfigToml(prompt)` is what identifies our own
|
|
15
|
+
* artifact.
|
|
16
|
+
*
|
|
17
|
+
* `absent` (ENOENT — the bridge never bootstrapped this workspace) and
|
|
18
|
+
* `unreadable` (it is there, but an I/O error hid it) both mean "cannot prove
|
|
19
|
+
* ownership, so do not delete" — but they are different facts, and reporting the
|
|
20
|
+
* second as the first tells the operator the wrong reason for a preserved file.
|
|
21
|
+
*/
|
|
22
|
+
export type AuthorshipProof = {
|
|
23
|
+
kind: 'present';
|
|
24
|
+
prompt: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'absent';
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'unreadable';
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* What the filesystem says about the legacy config. Gathered by the executor;
|
|
32
|
+
* the classifier below sees nothing else — no fs, no clock, no env.
|
|
33
|
+
*/
|
|
34
|
+
export type LegacyConfigFacts = {
|
|
35
|
+
/** lstat of the legacy path — never a stat: a symlink must not be followed. `null` = ENOENT. */
|
|
36
|
+
entry: {
|
|
37
|
+
isPlainFile: boolean;
|
|
38
|
+
hardLinks: number;
|
|
39
|
+
} | null;
|
|
40
|
+
/** Raw bytes of the legacy file. `null` = not a plain file, or unreadable. */
|
|
41
|
+
rawContent: string | null;
|
|
42
|
+
proof: AuthorshipProof;
|
|
43
|
+
};
|
|
44
|
+
export type LegacyConfigVerdict = {
|
|
45
|
+
action: 'none';
|
|
46
|
+
} | {
|
|
47
|
+
action: 'remove';
|
|
48
|
+
} | {
|
|
49
|
+
action: 'preserve';
|
|
50
|
+
reason: PreserveReason;
|
|
51
|
+
};
|
|
52
|
+
export type PreserveReason =
|
|
53
|
+
/** Symlink (a dotfiles arrangement) or a directory — unlink-by-path would sever the operator's link. */
|
|
54
|
+
'not-a-plain-file'
|
|
55
|
+
/** Extra hard links: the same inode is reachable from a path we know nothing about. */
|
|
56
|
+
| 'extra-hard-links'
|
|
57
|
+
/** No `.parall/system-prompt.md` from a previous boot — no proof can exist. */
|
|
58
|
+
| 'no-authorship-proof'
|
|
59
|
+
/** The proof copy exists but could not be read, so ownership cannot be evaluated. */
|
|
60
|
+
| 'authorship-proof-unreadable'
|
|
61
|
+
/** Could not read the legacy config's own bytes, so the proof cannot be applied. */
|
|
62
|
+
| 'config-unreadable'
|
|
63
|
+
/** Bytes differ from what the retired bridge would have written (operator-authored, comments, hand-edited). */
|
|
64
|
+
| 'content-mismatch';
|
|
65
|
+
/**
|
|
66
|
+
* Pure. Decides the fate of the legacy config from filesystem facts alone.
|
|
67
|
+
*
|
|
68
|
+
* The only path to `remove` is: plain regular file + exactly one hard link + a
|
|
69
|
+
* pre-overwrite reference copy exists + raw bytes are EXACTLY the retired
|
|
70
|
+
* serializer's output for that reference prompt. Every other combination
|
|
71
|
+
* preserves — including value-level near-misses (an operator file carrying the
|
|
72
|
+
* same `developer_instructions` value plus a comment would be destroyed together
|
|
73
|
+
* with the comment by a value-level compare).
|
|
74
|
+
*/
|
|
75
|
+
export declare function classifyLegacyWorkspaceConfig(facts: LegacyConfigFacts): LegacyConfigVerdict;
|
|
76
|
+
export type MigrationClaim = 'claimed' | 'already-claimed';
|
|
77
|
+
/**
|
|
78
|
+
* Atomically take the one-way claim. `wx` makes this a single filesystem
|
|
79
|
+
* operation, so concurrent boots cannot both win.
|
|
80
|
+
*
|
|
81
|
+
* THROWS if the claim cannot be persisted (EACCES, EIO, EROFS …). That is
|
|
82
|
+
* deliberate and load-bearing: the caller runs this BEFORE overwriting
|
|
83
|
+
* `.parall/system-prompt.md`, so a boot that cannot record its claim must die
|
|
84
|
+
* with the legacy evidence still intact rather than proceed to manufacture a
|
|
85
|
+
* prompt that a later boot would mistake for that evidence.
|
|
86
|
+
*/
|
|
87
|
+
export declare function claimLegacyWorkspaceConfigMigration(workspaceDir: string): MigrationClaim;
|
|
88
|
+
/**
|
|
89
|
+
* The migration lifecycle: claim, then — only if we won the claim — read the
|
|
90
|
+
* proof and clean up.
|
|
91
|
+
*
|
|
92
|
+
* `readProof` is a THUNK, not a value, and that is the whole point. Reading the
|
|
93
|
+
* proof is not free of side effects: an unreadable `.parall/system-prompt.md`
|
|
94
|
+
* warns. Passed as an already-evaluated argument, that warning fires before the
|
|
95
|
+
* claim is even checked — so an already-claimed later boot, or a concurrent
|
|
96
|
+
* loser, would emit a migration warning about a decision it is not making. The
|
|
97
|
+
* thunk makes "only the winner touches the proof" a property of the type rather
|
|
98
|
+
* than of the caller's evaluation order.
|
|
99
|
+
*
|
|
100
|
+
* The caller still owns `.parall/system-prompt.md` and must hand over the
|
|
101
|
+
* PRE-OVERWRITE copy: the winner evaluates the thunk here, before anything
|
|
102
|
+
* writes a new prompt.
|
|
103
|
+
*
|
|
104
|
+
* A boot that does not win the claim returns silently — it must not delete, it
|
|
105
|
+
* must not read, and it must not warn: the winner may be deleting the very file
|
|
106
|
+
* it would warn about, and a stale warning about a file that is already gone is
|
|
107
|
+
* exactly the misleading diagnostic this module exists to avoid.
|
|
108
|
+
*/
|
|
109
|
+
export declare function runLegacyWorkspaceConfigMigration(workspaceDir: string, readProof: () => AuthorshipProof, log?: {
|
|
110
|
+
warn: (msg: string) => void;
|
|
111
|
+
}): void;
|
|
112
|
+
//# sourceMappingURL=legacy-workspace-config-migration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"legacy-workspace-config-migration.d.ts","sourceRoot":"","sources":["../src/legacy-workspace-config-migration.ts"],"names":[],"mappings":"AAqDA,wBAAgB,yBAAyB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3B;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,gGAAgG;IAChG,KAAK,EAAE;QAAE,WAAW,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC1D,8EAA8E;IAC9E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,KAAK,EAAE,eAAe,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,GACpB;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,cAAc,CAAA;CAAE,CAAC;AAEnD,MAAM,MAAM,cAAc;AACxB,wGAAwG;AACtG,kBAAkB;AACpB,uFAAuF;GACrF,kBAAkB;AACpB,+EAA+E;GAC7E,qBAAqB;AACvB,qFAAqF;GACnF,6BAA6B;AAC/B,oFAAoF;GAClF,mBAAmB;AACrB,+GAA+G;GAC7G,kBAAkB,CAAC;AAEvB;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,iBAAiB,GAAG,mBAAmB,CAc3F;AA6BD,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,iBAAiB,CAAC;AAE3D;;;;;;;;;GASG;AACH,wBAAgB,mCAAmC,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc,CAYxF;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iCAAiC,CAC/C,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,eAAe,EAChC,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAGN"}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* One-shot migration for workspaces last bootstrapped by a pre-protocol-delivery
|
|
5
|
+
* bridge.
|
|
6
|
+
*
|
|
7
|
+
* Those generations (every released bridge up to and including v1.44.0) injected
|
|
8
|
+
* the system prompt by writing `<workspace>/.codex/config.toml`
|
|
9
|
+
* (`developer_instructions`) and marking the workspace trusted in the operator's
|
|
10
|
+
* global codex config. Instructions now ride the app-server's per-thread
|
|
11
|
+
* `developerInstructions` param, so a leftover bridge-authored file would DOUBLE
|
|
12
|
+
* the instructions on any workspace codex still considers trusted.
|
|
13
|
+
*
|
|
14
|
+
* Deleting a file in someone's workspace is the destructive direction, so the
|
|
15
|
+
* gate is fail-closed: we remove ONLY what is provably the retired bridge's own
|
|
16
|
+
* artifact, and preserve (with a warning) everything else. A duplicated prompt
|
|
17
|
+
* is a degraded agent; a deleted operator file is lost work.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS IS ONE-SHOT AND NOT MERELY IDEMPOTENT. The authorship proof is
|
|
20
|
+
* `.parall/system-prompt.md`, and the bridge REWRITES that file on every boot.
|
|
21
|
+
* So it is legacy-era evidence exactly once: on the first protocol-delivery boot,
|
|
22
|
+
* before anything overwrites it. A gate that merely re-ran each boot would, from
|
|
23
|
+
* boot 2 on, be comparing the legacy config against a prompt THIS bridge wrote —
|
|
24
|
+
* evidence it manufactured itself. That is not hypothetical: prompt assembly is
|
|
25
|
+
* deterministic, so a file the first boot explicitly preserved as "not provably
|
|
26
|
+
* ours" can match on the next boot and be silently deleted, contradicting the
|
|
27
|
+
* warning the operator was just given. Fail-closed has to hold across the whole
|
|
28
|
+
* migration lifecycle, not per function call.
|
|
29
|
+
*
|
|
30
|
+
* Hence a versioned, one-way CLAIM (`.parall/legacy-workspace-config-migration.v1`),
|
|
31
|
+
* created atomically (`wx`) BEFORE any prompt write:
|
|
32
|
+
* - exactly one boot ever wins the claim — it alone may delete;
|
|
33
|
+
* - every later boot (and every concurrent loser) skips, silently, forever;
|
|
34
|
+
* - a claim that cannot be persisted FAILS the boot before the proof is
|
|
35
|
+
* overwritten, so a retry still has real evidence;
|
|
36
|
+
* - a boot that dies mid-migration leaves the claim behind, so the file is
|
|
37
|
+
* preserved forever rather than re-judged against fabricated evidence.
|
|
38
|
+
*
|
|
39
|
+
* The whole module is dead code once no workspace can still hold a pre-#1866
|
|
40
|
+
* artifact — sunset conditions in
|
|
41
|
+
* docs/tech-debt/codex-legacy-workspace-config-shim.md.
|
|
42
|
+
*/
|
|
43
|
+
/** Relative location of the retired bridge's workspace config. */
|
|
44
|
+
const LEGACY_CONFIG_RELPATH = ['.codex', 'config.toml'];
|
|
45
|
+
/**
|
|
46
|
+
* The one-way claim. Versioned: a future migration gets its own sentinel rather
|
|
47
|
+
* than reusing (or re-arming) this one.
|
|
48
|
+
*/
|
|
49
|
+
const MIGRATION_SENTINEL_RELPATH = ['.parall', 'legacy-workspace-config-migration.v1'];
|
|
50
|
+
export function legacyWorkspaceConfigPath(workspaceDir) {
|
|
51
|
+
return path.join(workspaceDir, ...LEGACY_CONFIG_RELPATH);
|
|
52
|
+
}
|
|
53
|
+
export function migrationSentinelPath(workspaceDir) {
|
|
54
|
+
return path.join(workspaceDir, ...MIGRATION_SENTINEL_RELPATH);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Byte-exact reconstruction of the retired serializer. Verified identical across
|
|
58
|
+
* every released bridge that wrote this file (v1.37.0 … v1.44.0 — the line is
|
|
59
|
+
* byte-for-byte the same in all of them), so a single reconstruction covers the
|
|
60
|
+
* whole legacy fleet. The deletion gate compares raw bytes against this: it must
|
|
61
|
+
* never drift.
|
|
62
|
+
*/
|
|
63
|
+
export function legacyWorkspaceConfigToml(prompt) {
|
|
64
|
+
return `developer_instructions = """\n${prompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Pure. Decides the fate of the legacy config from filesystem facts alone.
|
|
68
|
+
*
|
|
69
|
+
* The only path to `remove` is: plain regular file + exactly one hard link + a
|
|
70
|
+
* pre-overwrite reference copy exists + raw bytes are EXACTLY the retired
|
|
71
|
+
* serializer's output for that reference prompt. Every other combination
|
|
72
|
+
* preserves — including value-level near-misses (an operator file carrying the
|
|
73
|
+
* same `developer_instructions` value plus a comment would be destroyed together
|
|
74
|
+
* with the comment by a value-level compare).
|
|
75
|
+
*/
|
|
76
|
+
export function classifyLegacyWorkspaceConfig(facts) {
|
|
77
|
+
const { entry, rawContent, proof } = facts;
|
|
78
|
+
if (!entry)
|
|
79
|
+
return { action: 'none' };
|
|
80
|
+
if (!entry.isPlainFile)
|
|
81
|
+
return { action: 'preserve', reason: 'not-a-plain-file' };
|
|
82
|
+
if (entry.hardLinks !== 1)
|
|
83
|
+
return { action: 'preserve', reason: 'extra-hard-links' };
|
|
84
|
+
if (proof.kind === 'absent')
|
|
85
|
+
return { action: 'preserve', reason: 'no-authorship-proof' };
|
|
86
|
+
if (proof.kind === 'unreadable') {
|
|
87
|
+
return { action: 'preserve', reason: 'authorship-proof-unreadable' };
|
|
88
|
+
}
|
|
89
|
+
if (rawContent === null)
|
|
90
|
+
return { action: 'preserve', reason: 'config-unreadable' };
|
|
91
|
+
if (rawContent !== legacyWorkspaceConfigToml(proof.prompt)) {
|
|
92
|
+
return { action: 'preserve', reason: 'content-mismatch' };
|
|
93
|
+
}
|
|
94
|
+
return { action: 'remove' };
|
|
95
|
+
}
|
|
96
|
+
const PRESERVE_DETAIL = {
|
|
97
|
+
'not-a-plain-file': 'it is a symlink or directory, not a plain file the bridge could have written',
|
|
98
|
+
'extra-hard-links': 'the file has more than one hard link, so another path shares this inode',
|
|
99
|
+
'no-authorship-proof': 'this workspace has no .parall/system-prompt.md from a previous boot, so nothing here can be proven ours',
|
|
100
|
+
'authorship-proof-unreadable': 'the .parall/system-prompt.md authorship proof exists but could not be read, so ownership cannot be evaluated',
|
|
101
|
+
'config-unreadable': 'its own bytes could not be read, so authorship cannot be proven',
|
|
102
|
+
'content-mismatch': 'its bytes are not what the retired bridge would have written',
|
|
103
|
+
};
|
|
104
|
+
const SENTINEL_BODY = `Parall codex bridge — one-shot workspace migration record (v1)
|
|
105
|
+
|
|
106
|
+
The legacy workspace-config cleanup has been CLAIMED for this workspace. Only the
|
|
107
|
+
boot that created this file was allowed to remove a bridge-authored
|
|
108
|
+
.codex/config.toml, and only under a byte-exact authorship proof.
|
|
109
|
+
|
|
110
|
+
While this file exists the bridge will NEVER auto-remove .codex/config.toml again.
|
|
111
|
+
Deleting this file does not safely re-arm the migration: the evidence it relied on
|
|
112
|
+
(.parall/system-prompt.md as written by a pre-protocol-delivery bridge) has since
|
|
113
|
+
been overwritten by this bridge, so a re-run could mistake a prompt it wrote itself
|
|
114
|
+
for legacy evidence and delete a file that is not ours.
|
|
115
|
+
|
|
116
|
+
If a stale .codex/config.toml is still present, remove it by hand.
|
|
117
|
+
`;
|
|
118
|
+
/**
|
|
119
|
+
* Atomically take the one-way claim. `wx` makes this a single filesystem
|
|
120
|
+
* operation, so concurrent boots cannot both win.
|
|
121
|
+
*
|
|
122
|
+
* THROWS if the claim cannot be persisted (EACCES, EIO, EROFS …). That is
|
|
123
|
+
* deliberate and load-bearing: the caller runs this BEFORE overwriting
|
|
124
|
+
* `.parall/system-prompt.md`, so a boot that cannot record its claim must die
|
|
125
|
+
* with the legacy evidence still intact rather than proceed to manufacture a
|
|
126
|
+
* prompt that a later boot would mistake for that evidence.
|
|
127
|
+
*/
|
|
128
|
+
export function claimLegacyWorkspaceConfigMigration(workspaceDir) {
|
|
129
|
+
const sentinel = migrationSentinelPath(workspaceDir);
|
|
130
|
+
// The module owns its own sentinel, directory included — a caller that has not
|
|
131
|
+
// created `.parall` yet must still get a real claim, not an ENOENT throw.
|
|
132
|
+
fs.mkdirSync(path.dirname(sentinel), { recursive: true });
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(sentinel, SENTINEL_BODY, { flag: 'wx' });
|
|
135
|
+
return 'claimed';
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err.code === 'EEXIST')
|
|
139
|
+
return 'already-claimed';
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The migration lifecycle: claim, then — only if we won the claim — read the
|
|
145
|
+
* proof and clean up.
|
|
146
|
+
*
|
|
147
|
+
* `readProof` is a THUNK, not a value, and that is the whole point. Reading the
|
|
148
|
+
* proof is not free of side effects: an unreadable `.parall/system-prompt.md`
|
|
149
|
+
* warns. Passed as an already-evaluated argument, that warning fires before the
|
|
150
|
+
* claim is even checked — so an already-claimed later boot, or a concurrent
|
|
151
|
+
* loser, would emit a migration warning about a decision it is not making. The
|
|
152
|
+
* thunk makes "only the winner touches the proof" a property of the type rather
|
|
153
|
+
* than of the caller's evaluation order.
|
|
154
|
+
*
|
|
155
|
+
* The caller still owns `.parall/system-prompt.md` and must hand over the
|
|
156
|
+
* PRE-OVERWRITE copy: the winner evaluates the thunk here, before anything
|
|
157
|
+
* writes a new prompt.
|
|
158
|
+
*
|
|
159
|
+
* A boot that does not win the claim returns silently — it must not delete, it
|
|
160
|
+
* must not read, and it must not warn: the winner may be deleting the very file
|
|
161
|
+
* it would warn about, and a stale warning about a file that is already gone is
|
|
162
|
+
* exactly the misleading diagnostic this module exists to avoid.
|
|
163
|
+
*/
|
|
164
|
+
export function runLegacyWorkspaceConfigMigration(workspaceDir, readProof, log) {
|
|
165
|
+
if (claimLegacyWorkspaceConfigMigration(workspaceDir) === 'already-claimed')
|
|
166
|
+
return;
|
|
167
|
+
cleanupLegacyWorkspaceConfig(workspaceDir, readProof(), log);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Filesystem execution for the one boot that holds the claim. Reads the facts,
|
|
171
|
+
* asks the classifier, applies the verdict.
|
|
172
|
+
*
|
|
173
|
+
* Never throws: a workspace that cannot be migrated must still boot. Every
|
|
174
|
+
* non-ENOENT problem warns.
|
|
175
|
+
*/
|
|
176
|
+
function cleanupLegacyWorkspaceConfig(workspaceDir, proof, log) {
|
|
177
|
+
const configPath = legacyWorkspaceConfigPath(workspaceDir);
|
|
178
|
+
let entry;
|
|
179
|
+
try {
|
|
180
|
+
entry = fs.lstatSync(configPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// Only ENOENT means "nothing to clean up". Anything else (EACCES, EIO, …)
|
|
184
|
+
// leaves an unverifiable file behind — surface it rather than silently
|
|
185
|
+
// treating it as absent.
|
|
186
|
+
if (err.code !== 'ENOENT') {
|
|
187
|
+
log?.warn(`could not inspect legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const isPlainFile = entry.isFile();
|
|
192
|
+
let rawContent = null;
|
|
193
|
+
if (isPlainFile) {
|
|
194
|
+
try {
|
|
195
|
+
rawContent = fs.readFileSync(configPath, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// Read failure → rawContent stays null → the classifier preserves
|
|
199
|
+
// ('config-unreadable'). Log the cause here; the verdict warning follows.
|
|
200
|
+
log?.warn(`could not read legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const verdict = classifyLegacyWorkspaceConfig({
|
|
204
|
+
entry: { isPlainFile, hardLinks: entry.nlink },
|
|
205
|
+
rawContent,
|
|
206
|
+
proof,
|
|
207
|
+
});
|
|
208
|
+
if (verdict.action === 'none')
|
|
209
|
+
return;
|
|
210
|
+
if (verdict.action === 'remove') {
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(configPath);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
// ENOENT here means the file went away between our lstat and this unlink
|
|
216
|
+
// — a second bridge booting the same workspace (rapid daemon respawn) got
|
|
217
|
+
// there first. That is exactly the end state we wanted, so it is not a
|
|
218
|
+
// failure: warning "could not remove" about a file that is already gone
|
|
219
|
+
// would send the operator after nothing. Concurrent migrations converge.
|
|
220
|
+
if (err.code === 'ENOENT')
|
|
221
|
+
return;
|
|
222
|
+
log?.warn(`could not remove legacy workspace codex config ${configPath}: ${String(err)}`);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
log?.warn(`leaving ${configPath} in place — ${PRESERVE_DETAIL[verdict.reason]}, so it is not provably ` +
|
|
227
|
+
"the retired bridge's own artifact. Codex loads it for trusted workspaces IN ADDITION to " +
|
|
228
|
+
'the platform instructions; remove it manually if it is a stale artifact.');
|
|
229
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the response payload for a server request, or `undefined` when the
|
|
3
|
+
* method is not one we can meaningfully answer (the JSON-RPC client then
|
|
4
|
+
* replies -32601, which also unblocks the app-server). Own-property lookup
|
|
5
|
+
* only — a method named like an Object.prototype member (`toString`,
|
|
6
|
+
* `constructor`) must fall through to -32601, not return an inherited
|
|
7
|
+
* function that would serialize into a result-less frame.
|
|
8
|
+
*/
|
|
9
|
+
export declare function answerServerRequest(method: string): unknown | undefined;
|
|
10
|
+
//# sourceMappingURL=server-requests.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-requests.d.ts","sourceRoot":"","sources":["../src/server-requests.ts"],"names":[],"mappings":"AA6BA;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAEvE"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers for codex app-server server→client requests.
|
|
3
|
+
*
|
|
4
|
+
* The bridge runs headless with `approvalPolicy: "never"`, so approval-shaped
|
|
5
|
+
* requests should not occur — but the protocol allows the app-server to send
|
|
6
|
+
* them (e.g. a thread that fell back to `on-request`), and an unanswered
|
|
7
|
+
* request parks its turn until the dispatch deadline. Every known
|
|
8
|
+
* approval-shaped method is answered with an explicit denial; unknown methods
|
|
9
|
+
* get a method-not-found error from the JSON-RPC layer (`undefined` here).
|
|
10
|
+
*
|
|
11
|
+
* Wire shapes verified against codex-rs 0.144.1
|
|
12
|
+
* (`app-server-protocol/src/protocol/common.rs` server_request_definitions):
|
|
13
|
+
* - v2 `item/commandExecution/requestApproval` / `item/fileChange/requestApproval`
|
|
14
|
+
* respond `{ decision }` with camelCase variants — `decline` denies but lets
|
|
15
|
+
* the turn continue (vs `cancel`, which also interrupts the turn).
|
|
16
|
+
* - legacy v1 `execCommandApproval` / `applyPatchApproval` (SendUserTurn-era,
|
|
17
|
+
* unused by this bridge) respond `{ decision }` with snake_case
|
|
18
|
+
* ReviewDecision values.
|
|
19
|
+
*/
|
|
20
|
+
const APPROVAL_DENIALS = {
|
|
21
|
+
'item/commandExecution/requestApproval': { decision: 'decline' },
|
|
22
|
+
'item/fileChange/requestApproval': { decision: 'decline' },
|
|
23
|
+
// NOT listed: `item/permissions/requestApproval` — its response shape is a
|
|
24
|
+
// permission GRANT (no deny variant), so denial is correctly expressed by
|
|
25
|
+
// the -32601 error fallback.
|
|
26
|
+
execCommandApproval: { decision: 'denied' },
|
|
27
|
+
applyPatchApproval: { decision: 'denied' },
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Returns the response payload for a server request, or `undefined` when the
|
|
31
|
+
* method is not one we can meaningfully answer (the JSON-RPC client then
|
|
32
|
+
* replies -32601, which also unblocks the app-server). Own-property lookup
|
|
33
|
+
* only — a method named like an Object.prototype member (`toString`,
|
|
34
|
+
* `constructor`) must fall through to -32601, not return an inherited
|
|
35
|
+
* function that would serialize into a result-less frame.
|
|
36
|
+
*/
|
|
37
|
+
export function answerServerRequest(method) {
|
|
38
|
+
return Object.hasOwn(APPROVAL_DENIALS, method) ? APPROVAL_DENIALS[method] : undefined;
|
|
39
|
+
}
|
|
@@ -13,10 +13,22 @@ export declare class CodexSessionManager {
|
|
|
13
13
|
private readonly stateFilePath;
|
|
14
14
|
private readonly logger?;
|
|
15
15
|
private readonly threadIds;
|
|
16
|
+
private readonly effectiveInstructionsShas;
|
|
16
17
|
constructor(mainSessionKey: string, stateFilePath: string, logger?: Logger | undefined);
|
|
17
18
|
isMain(sessionKey: string): boolean;
|
|
18
19
|
getThreadId(sessionKey: string): string | undefined;
|
|
19
20
|
recordThreadId(sessionKey: string, threadId: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Record a freshly-STARTED thread together with the sha of the
|
|
23
|
+
* instructions baked into it — one atomic state-file write. Two separate
|
|
24
|
+
* writes (thread id, then sha) would leave a crash window whose survivor
|
|
25
|
+
* looks exactly like a pre-tracking legacy file, and the reconcile path
|
|
26
|
+
* deliberately does NOT compact legacy state — a stale thread would then
|
|
27
|
+
* be recorded as carrying instructions it never saw.
|
|
28
|
+
*/
|
|
29
|
+
recordStartedThread(sessionKey: string, threadId: string, effectiveSha: string | undefined): void;
|
|
30
|
+
getEffectiveInstructionsSha(sessionKey: string): string | undefined;
|
|
31
|
+
recordEffectiveInstructionsSha(sessionKey: string, sha: string): void;
|
|
20
32
|
createForkSessionKey(): ForkSessionHandle;
|
|
21
33
|
cleanupFork(sessionKey: string): void;
|
|
22
34
|
/** Clear the main thread id when app-server rejects it (stale / deleted). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAuB5D,KAAK,MAAM,GAAG;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,mBAAmB;IAK5B,QAAQ,CAAC,cAAc,EAAE,MAAM;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAN1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IACvD,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAA6B;gBAG5D,cAAc,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,MAAM,YAAA;IAKlC,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAInC,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInD,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAcnD;;;;;;;OAOG;IACH,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS;IAY1F,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInE,8BAA8B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM;IAO9D,oBAAoB,IAAI,iBAAiB;IAOzC,WAAW,CAAC,UAAU,EAAE,MAAM;IAK9B,6EAA6E;IAC7E,eAAe;IAYf,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,OAAO;CAuBhB"}
|
package/dist/session-manager.js
CHANGED
|
@@ -11,6 +11,7 @@ export class CodexSessionManager {
|
|
|
11
11
|
stateFilePath;
|
|
12
12
|
logger;
|
|
13
13
|
threadIds = new Map();
|
|
14
|
+
effectiveInstructionsShas = new Map();
|
|
14
15
|
constructor(mainSessionKey, stateFilePath, logger) {
|
|
15
16
|
this.mainSessionKey = mainSessionKey;
|
|
16
17
|
this.stateFilePath = stateFilePath;
|
|
@@ -24,9 +25,45 @@ export class CodexSessionManager {
|
|
|
24
25
|
return this.threadIds.get(sessionKey);
|
|
25
26
|
}
|
|
26
27
|
recordThreadId(sessionKey, threadId) {
|
|
28
|
+
// An effective-instructions sha is a fact about one specific thread.
|
|
29
|
+
// Recording a DIFFERENT thread id under the same session invalidates it;
|
|
30
|
+
// re-recording the same id (the resume path) must preserve it, or every
|
|
31
|
+
// bridge restart would trigger a redundant refresh.
|
|
32
|
+
if (this.threadIds.get(sessionKey) !== threadId) {
|
|
33
|
+
this.effectiveInstructionsShas.delete(sessionKey);
|
|
34
|
+
}
|
|
35
|
+
this.threadIds.set(sessionKey, threadId);
|
|
36
|
+
if (sessionKey === this.mainSessionKey) {
|
|
37
|
+
this.persist();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Record a freshly-STARTED thread together with the sha of the
|
|
42
|
+
* instructions baked into it — one atomic state-file write. Two separate
|
|
43
|
+
* writes (thread id, then sha) would leave a crash window whose survivor
|
|
44
|
+
* looks exactly like a pre-tracking legacy file, and the reconcile path
|
|
45
|
+
* deliberately does NOT compact legacy state — a stale thread would then
|
|
46
|
+
* be recorded as carrying instructions it never saw.
|
|
47
|
+
*/
|
|
48
|
+
recordStartedThread(sessionKey, threadId, effectiveSha) {
|
|
27
49
|
this.threadIds.set(sessionKey, threadId);
|
|
50
|
+
if (effectiveSha) {
|
|
51
|
+
this.effectiveInstructionsShas.set(sessionKey, effectiveSha);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
this.effectiveInstructionsShas.delete(sessionKey);
|
|
55
|
+
}
|
|
56
|
+
if (sessionKey === this.mainSessionKey) {
|
|
57
|
+
this.persist();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
getEffectiveInstructionsSha(sessionKey) {
|
|
61
|
+
return this.effectiveInstructionsShas.get(sessionKey);
|
|
62
|
+
}
|
|
63
|
+
recordEffectiveInstructionsSha(sessionKey, sha) {
|
|
64
|
+
this.effectiveInstructionsShas.set(sessionKey, sha);
|
|
28
65
|
if (sessionKey === this.mainSessionKey) {
|
|
29
|
-
this.persist(
|
|
66
|
+
this.persist();
|
|
30
67
|
}
|
|
31
68
|
}
|
|
32
69
|
createForkSessionKey() {
|
|
@@ -37,10 +74,12 @@ export class CodexSessionManager {
|
|
|
37
74
|
}
|
|
38
75
|
cleanupFork(sessionKey) {
|
|
39
76
|
this.threadIds.delete(sessionKey);
|
|
77
|
+
this.effectiveInstructionsShas.delete(sessionKey);
|
|
40
78
|
}
|
|
41
79
|
/** Clear the main thread id when app-server rejects it (stale / deleted). */
|
|
42
80
|
clearMainThread() {
|
|
43
81
|
this.threadIds.delete(this.mainSessionKey);
|
|
82
|
+
this.effectiveInstructionsShas.delete(this.mainSessionKey);
|
|
44
83
|
try {
|
|
45
84
|
fs.rmSync(this.stateFilePath, { force: true });
|
|
46
85
|
}
|
|
@@ -56,6 +95,10 @@ export class CodexSessionManager {
|
|
|
56
95
|
typeof parsed.threadId === 'string' &&
|
|
57
96
|
parsed.threadId.trim()) {
|
|
58
97
|
this.threadIds.set(this.mainSessionKey, parsed.threadId.trim());
|
|
98
|
+
if (typeof parsed.effectiveInstructionsSha === 'string' &&
|
|
99
|
+
parsed.effectiveInstructionsSha.trim()) {
|
|
100
|
+
this.effectiveInstructionsShas.set(this.mainSessionKey, parsed.effectiveInstructionsSha.trim());
|
|
101
|
+
}
|
|
59
102
|
}
|
|
60
103
|
}
|
|
61
104
|
catch (error) {
|
|
@@ -64,7 +107,10 @@ export class CodexSessionManager {
|
|
|
64
107
|
}
|
|
65
108
|
}
|
|
66
109
|
}
|
|
67
|
-
persist(
|
|
110
|
+
persist() {
|
|
111
|
+
const threadId = this.threadIds.get(this.mainSessionKey);
|
|
112
|
+
if (!threadId)
|
|
113
|
+
return;
|
|
68
114
|
// Atomic write: truncate-in-place risks leaving a half-written / zero-byte
|
|
69
115
|
// state file if the process dies between `open(O_TRUNC)` and the final
|
|
70
116
|
// fsync. Since this file is the sole source of cross-restart `resume`
|
|
@@ -74,7 +120,11 @@ export class CodexSessionManager {
|
|
|
74
120
|
try {
|
|
75
121
|
fs.mkdirSync(path.dirname(this.stateFilePath), { recursive: true });
|
|
76
122
|
const tmpPath = `${this.stateFilePath}.tmp`;
|
|
77
|
-
|
|
123
|
+
const state = { runtimeKey: this.mainSessionKey, threadId };
|
|
124
|
+
const effectiveSha = this.effectiveInstructionsShas.get(this.mainSessionKey);
|
|
125
|
+
if (effectiveSha)
|
|
126
|
+
state.effectiveInstructionsSha = effectiveSha;
|
|
127
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
|
|
78
128
|
fs.renameSync(tmpPath, this.stateFilePath);
|
|
79
129
|
}
|
|
80
130
|
catch (error) {
|