wtagent 0.3.0 → 0.4.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.
@@ -1,8 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { constants as fsConstants } from "node:fs";
3
+ import net from "node:net";
3
4
  import path from "node:path";
4
- import { randomUUID } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { isDeepStrictEqual } from "node:util";
5
7
  import { isPathInside } from "../policy/path-guard.js";
8
+ import { processStartTimeMs } from "../browser/cdp-state.js";
6
9
  import { replaceFileAtomic } from "../shared/atomic-write.js";
7
10
 
8
11
  const DIRECTORY_MODE = 0o700;
@@ -10,6 +13,330 @@ const FILE_MODE = 0o600;
10
13
  const NO_FOLLOW = process.platform === "win32"
11
14
  ? 0
12
15
  : (fsConstants.O_NOFOLLOW ?? 0);
16
+ const STATE_LOCK_FILE = ".wtagent-state.lock";
17
+ const persistenceQueues = new Map();
18
+
19
+ function stateRevision(state) {
20
+ return Number.isSafeInteger(state?.stateRevision)
21
+ && state.stateRevision >= 0
22
+ ? state.stateRevision
23
+ : 0;
24
+ }
25
+
26
+ function sessionStateConflict(message) {
27
+ const error = new Error(message);
28
+ error.code = "SESSION_STATE_CONFLICT";
29
+ return error;
30
+ }
31
+
32
+ function processIsAlive(pid) {
33
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
34
+ return false;
35
+ }
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch (error) {
40
+ return error.code === "EPERM";
41
+ }
42
+ }
43
+
44
+ const STATE_LOCK_MUTEX_PREFIX = "WTAGENT_STATE_LOCK_V1 ";
45
+
46
+ async function openPersistenceLockServer({ port = 0, token }) {
47
+ let responseToken = token;
48
+ const server = net.createServer((socket) => {
49
+ socket.end(`${STATE_LOCK_MUTEX_PREFIX}${responseToken}\n`);
50
+ });
51
+ server.unref();
52
+ await new Promise((resolve, reject) => {
53
+ server.once("error", reject);
54
+ server.once("listening", resolve);
55
+ server.listen({
56
+ host: "127.0.0.1",
57
+ port,
58
+ exclusive: true,
59
+ });
60
+ });
61
+ const address = server.address();
62
+ if (!address || typeof address === "string") {
63
+ await new Promise((resolve) => server.close(resolve));
64
+ throw new Error("Could not determine the WTAgent state-lock mutex port.");
65
+ }
66
+ let closed = false;
67
+ return {
68
+ port: address.port,
69
+ setToken(value) {
70
+ responseToken = value;
71
+ },
72
+ async close() {
73
+ if (closed) {
74
+ return;
75
+ }
76
+ closed = true;
77
+ await new Promise((resolve) => {
78
+ server.close(() => resolve());
79
+ server.closeAllConnections?.();
80
+ });
81
+ },
82
+ };
83
+ }
84
+
85
+ function persistenceLockMutexPorts(directory) {
86
+ const resolved = path.resolve(directory);
87
+ const ports = new Set();
88
+ for (let index = 0; ports.size < 5; index += 1) {
89
+ const digest = createHash("sha256")
90
+ .update(`${resolved}\0state-lock\0${index}`)
91
+ .digest();
92
+ ports.add(41_000 + (digest.readUInt16BE(0) % 20_000));
93
+ }
94
+ return [...ports];
95
+ }
96
+
97
+ async function acquirePersistenceLockMutex(
98
+ directory,
99
+ token,
100
+ { timeoutMs = 2_000 } = {},
101
+ ) {
102
+ const mutexPorts = persistenceLockMutexPorts(directory);
103
+ const majority = Math.floor(mutexPorts.length / 2) + 1;
104
+ const deadline = Date.now() + timeoutMs;
105
+ let lastError = null;
106
+
107
+ while (Date.now() < deadline) {
108
+ const offset = Math.floor(Math.random() * mutexPorts.length);
109
+ const orderedPorts = mutexPorts.map(
110
+ (_, index) => mutexPorts[(index + offset) % mutexPorts.length],
111
+ );
112
+ const servers = [];
113
+ try {
114
+ for (const port of orderedPorts) {
115
+ try {
116
+ servers.push(await openPersistenceLockServer({ port, token }));
117
+ } catch (error) {
118
+ if (error.code !== "EADDRINUSE") {
119
+ throw error;
120
+ }
121
+ lastError = error;
122
+ }
123
+ if (servers.length >= majority) {
124
+ let closed = false;
125
+ return {
126
+ mutexPorts,
127
+ heldMutexPorts: servers.map((server) => server.port),
128
+ async close() {
129
+ if (closed) {
130
+ return;
131
+ }
132
+ closed = true;
133
+ await Promise.all(servers.map((server) => server.close()));
134
+ },
135
+ };
136
+ }
137
+ }
138
+ } finally {
139
+ if (servers.length < majority) {
140
+ await Promise.all(servers.map((server) => server.close().catch(() => null)));
141
+ }
142
+ }
143
+ await new Promise((resolve) => setTimeout(resolve, 5 + Math.floor(Math.random() * 11)));
144
+ }
145
+
146
+ const error = new Error(
147
+ `Could not acquire the WTAgent session lock mutex: ${lastError?.message ?? "timeout"}`,
148
+ );
149
+ error.code = "SESSION_STATE_LOCKED";
150
+ throw error;
151
+ }
152
+
153
+ async function writePersistenceLockCandidate(candidatePath, {
154
+ token,
155
+ mutexPorts,
156
+ heldMutexPorts,
157
+ }) {
158
+ const handle = await fs.open(
159
+ candidatePath,
160
+ fsConstants.O_WRONLY
161
+ | fsConstants.O_CREAT
162
+ | fsConstants.O_EXCL
163
+ | NO_FOLLOW,
164
+ FILE_MODE,
165
+ );
166
+ try {
167
+ await handle.writeFile(`${JSON.stringify({
168
+ pid: process.pid,
169
+ token,
170
+ mutexPorts,
171
+ heldMutexPorts,
172
+ createdAt: new Date().toISOString(),
173
+ })}\n`, "utf8");
174
+ if (process.platform !== "win32") {
175
+ await handle.chmod(FILE_MODE);
176
+ }
177
+ await handle.sync();
178
+ } finally {
179
+ await handle.close();
180
+ }
181
+ }
182
+
183
+ async function readPersistenceLock(filePath) {
184
+ try {
185
+ await assertSafeFile(filePath);
186
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
187
+ } catch (error) {
188
+ if (error.code === "ENOENT" || error instanceof SyntaxError) {
189
+ return null;
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ async function acquirePersistenceLock(
196
+ directory,
197
+ { timeoutMs = 2_000 } = {},
198
+ ) {
199
+ const filePath = path.join(directory, STATE_LOCK_FILE);
200
+ const token = randomUUID();
201
+ const candidatePath = `${filePath}.${process.pid}.${token}.tmp`;
202
+ const deadline = Date.now() + timeoutMs;
203
+ const lockMutex = await acquirePersistenceLockMutex(
204
+ directory,
205
+ token,
206
+ { timeoutMs },
207
+ );
208
+ let acquired = false;
209
+
210
+ try {
211
+ await writePersistenceLockCandidate(candidatePath, {
212
+ token,
213
+ mutexPorts: lockMutex.mutexPorts,
214
+ heldMutexPorts: lockMutex.heldMutexPorts,
215
+ });
216
+ while (Date.now() < deadline) {
217
+ try {
218
+ // A fully initialized inode wins an uncontended lock with one atomic link.
219
+ await fs.link(candidatePath, filePath);
220
+ break;
221
+ } catch (error) {
222
+ if (error.code !== "EEXIST") {
223
+ throw error;
224
+ }
225
+ const existing = await readPersistenceLock(filePath);
226
+ if (!existing) {
227
+ const stillExists = await fs.stat(filePath)
228
+ .then(() => true)
229
+ .catch((statError) => {
230
+ if (statError.code === "ENOENT") {
231
+ return false;
232
+ }
233
+ throw statError;
234
+ });
235
+ if (!stillExists) {
236
+ continue;
237
+ }
238
+ const lockError = new Error(
239
+ `The WTAgent session lock at ${filePath} is malformed; refusing to replace it without a verifiable owner.`,
240
+ );
241
+ lockError.code = "SESSION_STATE_LOCKED";
242
+ throw lockError;
243
+ }
244
+
245
+ const existingPid = Number(existing.pid);
246
+ const validExisting = Number.isSafeInteger(existingPid)
247
+ && existingPid > 0
248
+ && typeof existing.token === "string"
249
+ && existing.token.length > 0
250
+ && Number.isFinite(Date.parse(existing.createdAt ?? ""));
251
+ if (!validExisting) {
252
+ const lockError = new Error(
253
+ `The WTAgent session lock at ${filePath} has no verifiable owner.`,
254
+ );
255
+ lockError.code = "SESSION_STATE_LOCKED";
256
+ throw lockError;
257
+ }
258
+
259
+ if (processIsAlive(existingPid)) {
260
+ const startedAt = await processStartTimeMs(existingPid)
261
+ .catch(() => null);
262
+ const lockCreatedAt = Date.parse(existing.createdAt);
263
+ const recycled = startedAt != null
264
+ && startedAt > lockCreatedAt + 2_000;
265
+ if (!recycled) {
266
+ const confirmed = await readPersistenceLock(filePath);
267
+ if (confirmed?.token !== existing.token) {
268
+ continue;
269
+ }
270
+ await new Promise((resolve) => setTimeout(resolve, 10));
271
+ continue;
272
+ }
273
+ }
274
+
275
+ const confirmed = await readPersistenceLock(filePath);
276
+ if (confirmed?.token !== existing.token) {
277
+ continue;
278
+ }
279
+ // The quorum mutex remains held across this atomic replacement, so every
280
+ // current-version stale reaper observes either the old or the new inode.
281
+ // Locks from an older format are safe to migrate once their owner is dead.
282
+ await replaceFileAtomic(candidatePath, filePath);
283
+ break;
284
+ }
285
+ }
286
+
287
+ const current = await readPersistenceLock(filePath);
288
+ if (current?.token !== token) {
289
+ const error = new Error(
290
+ `Could not acquire the WTAgent session lock at ${filePath}.`,
291
+ );
292
+ error.code = "SESSION_STATE_LOCKED";
293
+ throw error;
294
+ }
295
+ acquired = true;
296
+ let released = false;
297
+ return async () => {
298
+ if (released) {
299
+ return;
300
+ }
301
+ released = true;
302
+ try {
303
+ const latest = await readPersistenceLock(filePath);
304
+ if (latest?.token === token) {
305
+ await fs.rm(filePath, { force: true });
306
+ }
307
+ } finally {
308
+ await lockMutex.close();
309
+ }
310
+ };
311
+ } finally {
312
+ await fs.rm(candidatePath, { force: true }).catch(() => null);
313
+ if (!acquired) {
314
+ await lockMutex.close().catch(() => null);
315
+ }
316
+ }
317
+ }
318
+
319
+ function withPersistenceLock(directory, callback) {
320
+ const key = path.resolve(directory);
321
+ const previous = persistenceQueues.get(key) ?? Promise.resolve();
322
+ const operation = previous
323
+ .catch(() => null)
324
+ .then(async () => {
325
+ const release = await acquirePersistenceLock(key);
326
+ try {
327
+ return await callback();
328
+ } finally {
329
+ await release();
330
+ }
331
+ });
332
+ persistenceQueues.set(key, operation);
333
+ void operation.finally(() => {
334
+ if (persistenceQueues.get(key) === operation) {
335
+ persistenceQueues.delete(key);
336
+ }
337
+ }).catch(() => null);
338
+ return operation;
339
+ }
13
340
 
14
341
  async function chmodOwnerOnly(target, mode) {
15
342
  if (process.platform !== "win32") {
@@ -125,6 +452,76 @@ async function appendOwnerOnly(filePath, content) {
125
452
  }
126
453
  }
127
454
 
455
+ async function truncateOwnerOnly(filePath, length) {
456
+ await assertSafeFile(filePath);
457
+ const handle = await fs.open(
458
+ filePath,
459
+ fsConstants.O_WRONLY | NO_FOLLOW,
460
+ );
461
+ try {
462
+ const stats = await handle.stat();
463
+ if (!stats.isFile()) {
464
+ throw new Error(`Session path is not a regular file: ${filePath}`);
465
+ }
466
+ await handle.truncate(length);
467
+ if (process.platform !== "win32") {
468
+ await handle.chmod(FILE_MODE);
469
+ }
470
+ await handle.sync();
471
+ } finally {
472
+ await handle.close();
473
+ }
474
+ }
475
+
476
+ async function readJsonlRecords(filePath, {
477
+ allowMissing = false,
478
+ repairTrailing = false,
479
+ } = {}) {
480
+ let bytes;
481
+ try {
482
+ await assertSafeFile(filePath);
483
+ bytes = await fs.readFile(filePath);
484
+ } catch (error) {
485
+ if (allowMissing && error.code === "ENOENT") {
486
+ return { records: [], needsLeadingNewline: false };
487
+ }
488
+ throw error;
489
+ }
490
+
491
+ const raw = bytes.toString("utf8");
492
+ const terminalNewline = bytes.length === 0 || bytes.at(-1) === 0x0a;
493
+ const lines = raw.split(/\r?\n/);
494
+ const records = [];
495
+ for (let index = 0; index < lines.length; index += 1) {
496
+ const line = lines[index];
497
+ if (!line.trim()) {
498
+ continue;
499
+ }
500
+ try {
501
+ records.push(JSON.parse(line));
502
+ } catch (error) {
503
+ const trailingPartial = !terminalNewline && index === lines.length - 1;
504
+ if (!trailingPartial) {
505
+ throw error;
506
+ }
507
+ if (repairTrailing) {
508
+ const lastNewline = bytes.lastIndexOf(0x0a);
509
+ await truncateOwnerOnly(filePath, lastNewline < 0 ? 0 : lastNewline + 1);
510
+ }
511
+ return {
512
+ records,
513
+ needsLeadingNewline: false,
514
+ trailingPartial: true,
515
+ };
516
+ }
517
+ }
518
+ return {
519
+ records,
520
+ needsLeadingNewline: !terminalNewline && raw.trim().length > 0,
521
+ trailingPartial: false,
522
+ };
523
+ }
524
+
128
525
  function rolloutFileName(createdAt, sessionId) {
129
526
  const stamp = createdAt.replaceAll(":", "-");
130
527
  return `rollout-${stamp}-${sessionId}.jsonl`;
@@ -147,12 +544,20 @@ function sessionMetaRecord(state) {
147
544
  }
148
545
 
149
546
  export class AgentSession {
150
- constructor({ sessionsDir, directory, state, stateFileName = "session.json" }) {
547
+ constructor({
548
+ sessionsDir,
549
+ directory,
550
+ state,
551
+ stateFileName = "session.json",
552
+ persistedRevision = stateRevision(state),
553
+ }) {
151
554
  this.sessionsDir = sessionsDir;
152
555
  this.directory = directory;
153
556
  this.state = state;
154
557
  this.sessionIdentifier = state.sessionId;
155
558
  this.stateFileName = stateFileName;
559
+ this.persistedRevision = persistedRevision;
560
+ this.persistenceQueue = Promise.resolve();
156
561
  }
157
562
 
158
563
  static async create({ sessionsDir, tasksDir, task, projectRoot, mode, provider = "chatgpt" }) {
@@ -173,8 +578,13 @@ export class AgentSession {
173
578
  turn: 0,
174
579
  runCount: 0,
175
580
  conversationUrl: null,
581
+ conversationTargetId: null,
582
+ lastUserMessageId: null,
176
583
  lastAssistantMessageId: null,
584
+ pendingOutbound: null,
585
+ pendingAssistantTurn: null,
177
586
  activeMode: null,
587
+ stateRevision: 0,
178
588
  createdAt: now,
179
589
  updatedAt: now,
180
590
  rolloutFile: rolloutFileName(now, sessionId),
@@ -189,6 +599,7 @@ export class AgentSession {
189
599
  sessionsDir: root,
190
600
  directory,
191
601
  state,
602
+ persistedRevision: null,
192
603
  });
193
604
  await session.save();
194
605
  await appendOwnerOnly(
@@ -241,8 +652,13 @@ export class AgentSession {
241
652
  : (state.status ?? "idle");
242
653
  state.provider ??= "chatgpt";
243
654
  state.runCount ??= 0;
655
+ state.conversationTargetId ??= null;
656
+ state.lastUserMessageId ??= null;
244
657
  state.lastAssistantMessageId ??= null;
658
+ state.pendingOutbound ??= null;
659
+ state.pendingAssistantTurn ??= null;
245
660
  state.activeMode ??= null;
661
+ state.stateRevision = stateRevision(state);
246
662
  state.rolloutFile ??= "transcript.jsonl";
247
663
  state.lastMessage ??= state.finalMessage ?? null;
248
664
  state.completedTools ??= {};
@@ -254,6 +670,7 @@ export class AgentSession {
254
670
  directory,
255
671
  state,
256
672
  stateFileName,
673
+ persistedRevision: state.stateRevision,
257
674
  });
258
675
  }
259
676
 
@@ -312,16 +729,91 @@ export class AgentSession {
312
729
  }
313
730
  }
314
731
 
315
- async update(patch) {
316
- Object.assign(this.state, patch, { updatedAt: new Date().toISOString() });
317
- await this.save();
732
+ #queuePersistenceOperation(callback) {
733
+ const operation = this.persistenceQueue
734
+ .catch(() => null)
735
+ .then(() => withPersistenceLock(this.directory, callback));
736
+ this.persistenceQueue = operation;
737
+ return operation;
318
738
  }
319
739
 
320
- async save() {
740
+ async #readPersistedStateLocked() {
741
+ const statePath = path.join(this.directory, this.stateFileName);
742
+ try {
743
+ await assertSafeFile(statePath);
744
+ return JSON.parse(await fs.readFile(statePath, "utf8"));
745
+ } catch (error) {
746
+ if (error.code === "ENOENT") {
747
+ return null;
748
+ }
749
+ throw error;
750
+ }
751
+ }
752
+
753
+ async #assertPersistedRevisionLocked() {
321
754
  await this.validateDirectory();
755
+ const persisted = await this.#readPersistedStateLocked();
756
+ const expectedRevision = this.persistedRevision;
757
+ if (expectedRevision == null) {
758
+ if (persisted != null) {
759
+ throw sessionStateConflict(
760
+ "Session state appeared while creating its initial checkpoint.",
761
+ );
762
+ }
763
+ return;
764
+ }
765
+
766
+ const persistedId = persisted?.sessionId ?? persisted?.taskId ?? null;
767
+ if (
768
+ persisted == null
769
+ || persistedId !== this.sessionIdentifier
770
+ || stateRevision(persisted) !== expectedRevision
771
+ ) {
772
+ throw sessionStateConflict(
773
+ `Session state changed on disk before revision ${expectedRevision} could be saved.`,
774
+ );
775
+ }
776
+ }
777
+
778
+ async #saveSnapshotLocked(snapshot) {
779
+ await this.#assertPersistedRevisionLocked();
780
+ const nextRevision = (this.persistedRevision ?? 0) + 1;
781
+ snapshot.stateRevision = nextRevision;
322
782
  await writeJsonAtomic(
323
783
  path.join(this.directory, this.stateFileName),
324
- this.state,
784
+ snapshot,
785
+ );
786
+ this.persistedRevision = nextRevision;
787
+ this.state.stateRevision = nextRevision;
788
+ }
789
+
790
+ async #mutateState(callback) {
791
+ return await this.#queuePersistenceOperation(async () => {
792
+ await this.#assertPersistedRevisionLocked();
793
+ const nextState = structuredClone(this.state);
794
+ const result = await callback(nextState);
795
+ await this.#saveSnapshotLocked(nextState);
796
+ this.state = nextState;
797
+ return result;
798
+ });
799
+ }
800
+
801
+ async update(patch) {
802
+ const requested = structuredClone(patch);
803
+ await this.#mutateState((nextState) => {
804
+ Object.assign(nextState, requested, {
805
+ updatedAt: new Date().toISOString(),
806
+ });
807
+ });
808
+ }
809
+
810
+ async save() {
811
+ // Navigation callbacks and Runtime checkpoints can update the same Session
812
+ // concurrently. The instance queue preserves call order; the owner-only
813
+ // interprocess lock plus persisted revision prevents a separately loaded,
814
+ // stale AgentSession from replacing a newer checkpoint on disk.
815
+ return await this.#queuePersistenceOperation(
816
+ () => this.#saveSnapshotLocked(structuredClone(this.state)),
325
817
  );
326
818
  }
327
819
 
@@ -340,6 +832,31 @@ export class AgentSession {
340
832
  return event;
341
833
  }
342
834
 
835
+ async hasEvent(type) {
836
+ await this.validateDirectory();
837
+ const eventsPath = path.join(this.directory, "events.jsonl");
838
+ let raw;
839
+ try {
840
+ await assertSafeFile(eventsPath);
841
+ raw = await fs.readFile(eventsPath, "utf8");
842
+ } catch (error) {
843
+ if (error.code === "ENOENT") {
844
+ return false;
845
+ }
846
+ throw error;
847
+ }
848
+
849
+ for (const line of raw.split(/\r?\n/)) {
850
+ if (!line.trim()) {
851
+ continue;
852
+ }
853
+ if (JSON.parse(line).type === type) {
854
+ return true;
855
+ }
856
+ }
857
+ return false;
858
+ }
859
+
343
860
  async appendToolOutput(payload) {
344
861
  await this.validateDirectory();
345
862
  const record = {
@@ -354,44 +871,127 @@ export class AgentSession {
354
871
  return record;
355
872
  }
356
873
 
357
- // Appends one canonical transcript item directly to this conversation's
358
- // Codex rollout JSONL. The XML sent to ChatGPT Web is transport-only.
359
- async appendTranscriptItem(item, { kind = "response_item" } = {}) {
874
+ #queueTranscriptOperation(callback) {
875
+ return this.#queuePersistenceOperation(callback);
876
+ }
877
+
878
+ #transcriptPath() {
879
+ return path.join(this.directory, this.state.rolloutFile);
880
+ }
881
+
882
+ #validateTranscriptIdempotencyKey(idempotencyKey) {
883
+ if (
884
+ typeof idempotencyKey !== "string"
885
+ || idempotencyKey.length === 0
886
+ || idempotencyKey.length > 512
887
+ || /[\0\r\n]/.test(idempotencyKey)
888
+ ) {
889
+ throw new Error("A non-empty transcript idempotency key is required.");
890
+ }
891
+ }
892
+
893
+ async #appendTranscriptItemLocked(item, { kind = "response_item" } = {}) {
360
894
  await this.validateDirectory();
895
+ const transcriptPath = this.#transcriptPath();
896
+ const state = await readJsonlRecords(transcriptPath, {
897
+ allowMissing: true,
898
+ repairTrailing: true,
899
+ });
361
900
  const record = {
362
901
  timestamp: new Date().toISOString(),
363
902
  type: kind,
364
- payload: item,
903
+ payload: JSON.parse(JSON.stringify(item)),
365
904
  };
366
905
  await appendOwnerOnly(
367
- path.join(this.directory, this.state.rolloutFile),
368
- `${JSON.stringify(record)}\n`,
906
+ transcriptPath,
907
+ `${state.needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)}\n`,
369
908
  );
370
909
  return record;
371
910
  }
372
911
 
373
- // Reads the conversation rollout as { meta, items } for portable exporters.
374
- async readTranscript() {
912
+ async #appendTranscriptItemOnceLocked(item, {
913
+ idempotencyKey,
914
+ kind = "response_item",
915
+ }) {
916
+ this.#validateTranscriptIdempotencyKey(idempotencyKey);
375
917
  await this.validateDirectory();
376
- const transcriptPath = path.join(this.directory, this.state.rolloutFile);
377
- let raw;
378
- try {
379
- await assertSafeFile(transcriptPath);
380
- raw = await fs.readFile(transcriptPath, "utf8");
381
- } catch (error) {
382
- if (error.code === "ENOENT") {
383
- return { meta: this.#transcriptMeta(), items: [] };
918
+ const transcriptPath = this.#transcriptPath();
919
+ const state = await readJsonlRecords(transcriptPath, {
920
+ allowMissing: true,
921
+ repairTrailing: true,
922
+ });
923
+ const payload = JSON.parse(JSON.stringify(item));
924
+ const matching = state.records.filter((record) => (
925
+ record?.wtagent?.idempotencyKey === idempotencyKey
926
+ ));
927
+ if (matching.length > 0) {
928
+ const identical = matching.every((record) => (
929
+ record.type === kind
930
+ && isDeepStrictEqual(record.payload ?? record.item, payload)
931
+ ));
932
+ if (!identical) {
933
+ throw new Error(
934
+ `Transcript idempotency key collision: ${idempotencyKey}`,
935
+ );
384
936
  }
385
- throw error;
937
+ return matching[0];
938
+ }
939
+
940
+ const record = {
941
+ timestamp: new Date().toISOString(),
942
+ type: kind,
943
+ wtagent: { idempotencyKey },
944
+ payload,
945
+ };
946
+ await appendOwnerOnly(
947
+ transcriptPath,
948
+ `${state.needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)}\n`,
949
+ );
950
+ return record;
951
+ }
952
+
953
+ // Appends one canonical transcript item directly to this conversation's
954
+ // Codex rollout JSONL. The XML sent to ChatGPT Web is transport-only.
955
+ async appendTranscriptItem(item, {
956
+ kind = "response_item",
957
+ idempotencyKey = null,
958
+ } = {}) {
959
+ if (idempotencyKey != null) {
960
+ return await this.appendTranscriptItemOnce(item, {
961
+ kind,
962
+ idempotencyKey,
963
+ });
386
964
  }
965
+ return await this.#queueTranscriptOperation(
966
+ () => this.#appendTranscriptItemLocked(item, { kind }),
967
+ );
968
+ }
969
+
970
+ async appendTranscriptItemOnce(item, {
971
+ idempotencyKey,
972
+ kind = "response_item",
973
+ } = {}) {
974
+ this.#validateTranscriptIdempotencyKey(idempotencyKey);
975
+ return await this.#queueTranscriptOperation(
976
+ () => this.#appendTranscriptItemOnceLocked(item, {
977
+ kind,
978
+ idempotencyKey,
979
+ }),
980
+ );
981
+ }
982
+
983
+ async #readTranscriptLocked() {
984
+ await this.validateDirectory();
985
+ const {
986
+ records,
987
+ trailingPartial = false,
988
+ } = await readJsonlRecords(this.#transcriptPath(), {
989
+ allowMissing: true,
990
+ });
387
991
 
388
992
  const items = [];
389
993
  let storedMeta = null;
390
- for (const line of raw.split(/\r?\n/)) {
391
- if (!line.trim()) {
392
- continue;
393
- }
394
- const record = JSON.parse(line);
994
+ for (const record of records) {
395
995
  if (record.type === "session_meta") {
396
996
  storedMeta = record.payload ?? null;
397
997
  continue;
@@ -400,10 +1000,32 @@ export class AgentSession {
400
1000
  items.push({
401
1001
  timestamp: record.timestamp,
402
1002
  item: record.payload ?? record.item,
1003
+ ...(record.wtagent?.idempotencyKey
1004
+ ? { idempotencyKey: record.wtagent.idempotencyKey }
1005
+ : {}),
403
1006
  });
404
1007
  }
405
1008
  }
406
- return { meta: this.#transcriptMeta(storedMeta), items };
1009
+ return {
1010
+ meta: this.#transcriptMeta(storedMeta),
1011
+ items,
1012
+ trailingPartial,
1013
+ };
1014
+ }
1015
+
1016
+ // Reads the conversation rollout as { meta, items } for portable exporters.
1017
+ async readTranscript({ allowTrailingPartial = false } = {}) {
1018
+ const transcript = await this.#queueTranscriptOperation(
1019
+ () => this.#readTranscriptLocked(),
1020
+ );
1021
+ if (transcript.trailingPartial && !allowTrailingPartial) {
1022
+ const error = new Error(
1023
+ "The canonical transcript ends with an incomplete JSONL record from an interrupted write.",
1024
+ );
1025
+ error.code = "TRANSCRIPT_INCOMPLETE";
1026
+ throw error;
1027
+ }
1028
+ return transcript;
407
1029
  }
408
1030
 
409
1031
  #transcriptMeta(storedMeta = null) {
@@ -419,6 +1041,230 @@ export class AgentSession {
419
1041
  };
420
1042
  }
421
1043
 
1044
+ async commitPendingOutboundHandoff({ outboundId, handoff = {} }) {
1045
+ if (typeof outboundId !== "string" || outboundId.length === 0) {
1046
+ throw new Error("A pending outbound ID is required for assistant handoff.");
1047
+ }
1048
+ return await this.#queuePersistenceOperation(async () => {
1049
+ // Hold one interprocess lock from the persisted-revision check through all
1050
+ // transcript appends and the state swap. A competing writer can neither
1051
+ // supersede the outbound between those steps nor inherit transcript items
1052
+ // from a handoff whose compare-and-swap failed.
1053
+ await this.#assertPersistedRevisionLocked();
1054
+ const existingHandoff = this.state.pendingAssistantTurn;
1055
+ if (
1056
+ this.state.pendingOutbound?.outboundId !== outboundId
1057
+ && existingHandoff?.sourceOutboundId === outboundId
1058
+ ) {
1059
+ return structuredClone(existingHandoff);
1060
+ }
1061
+ if (this.state.pendingOutbound?.outboundId !== outboundId) {
1062
+ throw new Error(
1063
+ `Pending outbound changed before assistant handoff: ${outboundId}`,
1064
+ );
1065
+ }
1066
+
1067
+ const transcriptItems = Array.isArray(
1068
+ this.state.pendingOutbound.transcriptItems,
1069
+ )
1070
+ ? structuredClone(this.state.pendingOutbound.transcriptItems)
1071
+ : [];
1072
+ for (let index = 0; index < transcriptItems.length; index += 1) {
1073
+ const entry = transcriptItems[index];
1074
+ const item = entry?.item ?? entry;
1075
+ const kind = entry?.item
1076
+ ? (entry.kind ?? "response_item")
1077
+ : "response_item";
1078
+ await this.#appendTranscriptItemOnceLocked(item, {
1079
+ kind,
1080
+ idempotencyKey: `outbound:${outboundId}:transcript:${index}`,
1081
+ });
1082
+ }
1083
+
1084
+ const currentOutbound = this.state.pendingOutbound;
1085
+ if (currentOutbound?.outboundId !== outboundId) {
1086
+ throw new Error(
1087
+ `Pending outbound changed while committing assistant handoff: ${outboundId}`,
1088
+ );
1089
+ }
1090
+ const now = new Date().toISOString();
1091
+ const requested = structuredClone(handoff);
1092
+ const pendingAssistantTurn = {
1093
+ ...requested,
1094
+ version: 1,
1095
+ handoffId: `outbound:${outboundId}`,
1096
+ sourceOutboundId: outboundId,
1097
+ outboundKind: currentOutbound.kind ?? "runtime_message",
1098
+ runtimeTurn: Number.isSafeInteger(requested.runtimeTurn)
1099
+ ? requested.runtimeTurn
1100
+ : this.state.turn,
1101
+ status: "waiting",
1102
+ conversationUrl: requested.conversationUrl
1103
+ ?? this.state.conversationUrl
1104
+ ?? null,
1105
+ conversationTargetId: requested.conversationTargetId
1106
+ ?? this.state.conversationTargetId
1107
+ ?? null,
1108
+ userMessageId: requested.userMessageId ?? null,
1109
+ userTurn: requested.userTurn ?? null,
1110
+ assistantBaseline: requested.assistantBaseline ?? null,
1111
+ pendingToolAcknowledgement:
1112
+ requested.pendingToolAcknowledgement ?? null,
1113
+ createdAt: requested.createdAt ?? now,
1114
+ updatedAt: now,
1115
+ };
1116
+ const nextState = {
1117
+ ...structuredClone(this.state),
1118
+ pendingOutbound: null,
1119
+ pendingAssistantTurn,
1120
+ conversationUrl: pendingAssistantTurn.conversationUrl,
1121
+ conversationTargetId: pendingAssistantTurn.conversationTargetId,
1122
+ ...(pendingAssistantTurn.userMessageId
1123
+ ? { lastUserMessageId: pendingAssistantTurn.userMessageId }
1124
+ : {}),
1125
+ updatedAt: now,
1126
+ };
1127
+ await this.#saveSnapshotLocked(nextState);
1128
+ this.state = nextState;
1129
+ return structuredClone(pendingAssistantTurn);
1130
+ });
1131
+ }
1132
+
1133
+ async refreshPendingAssistantTurn(handoffId, patch = {}) {
1134
+ const requested = structuredClone(patch);
1135
+ const protectedKeys = new Set([
1136
+ "version",
1137
+ "handoffId",
1138
+ "sourceOutboundId",
1139
+ "outboundKind",
1140
+ "runtimeTurn",
1141
+ "status",
1142
+ "createdAt",
1143
+ "rawResponse",
1144
+ "responseHash",
1145
+ ]);
1146
+ if (Object.keys(requested).some((key) => protectedKeys.has(key))) {
1147
+ throw new Error("Assistant handoff refresh contains protected fields.");
1148
+ }
1149
+ return await this.#mutateState((nextState) => {
1150
+ const current = nextState.pendingAssistantTurn;
1151
+ if (!current || current.handoffId !== handoffId) {
1152
+ throw new Error(
1153
+ `Pending assistant handoff changed before refresh: ${handoffId}`,
1154
+ );
1155
+ }
1156
+ if (!["waiting", "complete"].includes(current.status)) {
1157
+ throw new Error(
1158
+ `Invalid pending assistant handoff status: ${String(current.status)}`,
1159
+ );
1160
+ }
1161
+ const now = new Date().toISOString();
1162
+ const refreshed = {
1163
+ ...current,
1164
+ ...requested,
1165
+ updatedAt: now,
1166
+ };
1167
+ nextState.pendingAssistantTurn = refreshed;
1168
+ if (refreshed.conversationUrl) {
1169
+ nextState.conversationUrl = refreshed.conversationUrl;
1170
+ }
1171
+ if (refreshed.conversationTargetId) {
1172
+ nextState.conversationTargetId = refreshed.conversationTargetId;
1173
+ }
1174
+ if (refreshed.userMessageId) {
1175
+ nextState.lastUserMessageId = refreshed.userMessageId;
1176
+ }
1177
+ nextState.updatedAt = now;
1178
+ return structuredClone(refreshed);
1179
+ });
1180
+ }
1181
+
1182
+ async completePendingAssistantTurn({
1183
+ handoffId,
1184
+ assistantMessageId = null,
1185
+ assistantTurn = null,
1186
+ rawResponse,
1187
+ completedAt = new Date().toISOString(),
1188
+ }) {
1189
+ if (typeof rawResponse !== "string") {
1190
+ throw new Error("A raw assistant response is required for completion.");
1191
+ }
1192
+ const responseHash = createHash("sha256")
1193
+ .update(rawResponse)
1194
+ .digest("hex");
1195
+ const completion = {
1196
+ assistantMessageId,
1197
+ assistantTurn,
1198
+ rawResponse,
1199
+ responseHash,
1200
+ };
1201
+ return await this.#mutateState((nextState) => {
1202
+ const current = nextState.pendingAssistantTurn;
1203
+ if (!current || current.handoffId !== handoffId) {
1204
+ throw new Error(
1205
+ `Pending assistant handoff changed before completion: ${handoffId}`,
1206
+ );
1207
+ }
1208
+ if (current.status === "complete") {
1209
+ const existing = {
1210
+ assistantMessageId: current.assistantMessageId ?? null,
1211
+ assistantTurn: current.assistantTurn ?? null,
1212
+ rawResponse: current.rawResponse,
1213
+ responseHash: current.responseHash,
1214
+ };
1215
+ if (!isDeepStrictEqual(existing, completion)) {
1216
+ throw new Error(
1217
+ `Assistant handoff completion collision: ${handoffId}`,
1218
+ );
1219
+ }
1220
+ return structuredClone(current);
1221
+ }
1222
+ if (current.status !== "waiting") {
1223
+ throw new Error(
1224
+ `Invalid pending assistant handoff status: ${String(current.status)}`,
1225
+ );
1226
+ }
1227
+
1228
+ const completed = {
1229
+ ...current,
1230
+ ...completion,
1231
+ status: "complete",
1232
+ completedAt,
1233
+ updatedAt: completedAt,
1234
+ };
1235
+ nextState.pendingAssistantTurn = completed;
1236
+ // Null is authoritative: retaining an older ID could falsely correlate stale
1237
+ // history after a provider completed a reply without exposing identity.
1238
+ nextState.lastAssistantMessageId = assistantMessageId;
1239
+ nextState.updatedAt = completedAt;
1240
+ return structuredClone(completed);
1241
+ });
1242
+ }
1243
+
1244
+ async clearPendingAssistantTurn(handoffId, patch = {}) {
1245
+ const requested = structuredClone(patch);
1246
+ const protectedKeys = new Set([
1247
+ "pendingAssistantTurn",
1248
+ "pendingOutbound",
1249
+ "sessionId",
1250
+ ]);
1251
+ if (Object.keys(requested).some((key) => protectedKeys.has(key))) {
1252
+ throw new Error("Assistant handoff terminal patch contains protected state.");
1253
+ }
1254
+ await this.#mutateState((nextState) => {
1255
+ const current = nextState.pendingAssistantTurn;
1256
+ if (!current || current.handoffId !== handoffId) {
1257
+ throw new Error(
1258
+ `Pending assistant handoff changed before clearing: ${handoffId}`,
1259
+ );
1260
+ }
1261
+ Object.assign(nextState, requested, {
1262
+ pendingAssistantTurn: null,
1263
+ updatedAt: new Date().toISOString(),
1264
+ });
1265
+ });
1266
+ }
1267
+
422
1268
  getToolResult(fingerprint) {
423
1269
  return this.state.completedTools[fingerprint]?.result ?? null;
424
1270
  }
@@ -428,79 +1274,87 @@ export class AgentSession {
428
1274
  }
429
1275
 
430
1276
  async claimSideEffectTool(identity) {
431
- const existing = this.getSideEffectTool(identity.operationKey);
432
- if (existing) {
433
- throw new Error(
434
- `Tool operation is already claimed: ${identity.operationKey}`,
435
- );
436
- }
1277
+ const requested = structuredClone(identity);
1278
+ await this.#mutateState((nextState) => {
1279
+ const existing = nextState.sideEffectTools[requested.operationKey];
1280
+ if (existing) {
1281
+ throw new Error(
1282
+ `Tool operation is already claimed: ${requested.operationKey}`,
1283
+ );
1284
+ }
437
1285
 
438
- const claimedAt = new Date().toISOString();
439
- this.state.sideEffectTools[identity.operationKey] = {
440
- ...identity,
441
- status: "running",
442
- claimedAt,
443
- updatedAt: claimedAt,
444
- result: null,
445
- };
446
- await this.save();
1286
+ const claimedAt = new Date().toISOString();
1287
+ nextState.sideEffectTools[requested.operationKey] = {
1288
+ ...requested,
1289
+ status: "running",
1290
+ claimedAt,
1291
+ updatedAt: claimedAt,
1292
+ result: null,
1293
+ };
1294
+ });
447
1295
  return await this.appendEvent("tool.claimed", {
448
- identity,
1296
+ identity: requested,
449
1297
  });
450
1298
  }
451
1299
 
452
1300
  async recordToolResult(fingerprint, result, { identity = null } = {}) {
453
- const completedAt = new Date().toISOString();
454
- this.state.completedTools[fingerprint] = {
455
- completedAt,
456
- result,
457
- };
458
-
459
- if (identity) {
460
- const existing = this.getSideEffectTool(identity.operationKey);
461
- if (!existing || existing.fingerprint !== identity.fingerprint) {
462
- throw new Error(
463
- `Tool operation changed before completion: ${identity.operationKey}`,
464
- );
465
- }
466
- this.state.sideEffectTools[identity.operationKey] = {
467
- ...existing,
468
- status: "completed",
1301
+ const storedResult = structuredClone(result);
1302
+ const storedIdentity = identity ? structuredClone(identity) : null;
1303
+ await this.#mutateState((nextState) => {
1304
+ const completedAt = new Date().toISOString();
1305
+ nextState.completedTools[fingerprint] = {
469
1306
  completedAt,
470
- updatedAt: completedAt,
471
- result,
1307
+ result: storedResult,
472
1308
  };
473
- }
474
1309
 
475
- this.state.pendingToolResult = result;
476
- await this.save();
1310
+ if (storedIdentity) {
1311
+ const existing = nextState.sideEffectTools[storedIdentity.operationKey];
1312
+ if (!existing || existing.fingerprint !== storedIdentity.fingerprint) {
1313
+ throw new Error(
1314
+ `Tool operation changed before completion: ${storedIdentity.operationKey}`,
1315
+ );
1316
+ }
1317
+ nextState.sideEffectTools[storedIdentity.operationKey] = {
1318
+ ...existing,
1319
+ status: "completed",
1320
+ completedAt,
1321
+ updatedAt: completedAt,
1322
+ result: storedResult,
1323
+ };
1324
+ }
1325
+
1326
+ nextState.pendingToolResult = storedResult;
1327
+ });
477
1328
  return await this.appendEvent("tool.completed", {
478
1329
  fingerprint,
479
- result,
1330
+ result: storedResult,
480
1331
  });
481
1332
  }
482
1333
 
483
1334
  async markSideEffectToolUnknown(identity, result) {
484
- const existing = this.getSideEffectTool(identity.operationKey);
485
- if (!existing || existing.fingerprint !== identity.fingerprint) {
486
- throw new Error(
487
- `Tool operation changed before unknown completion: ${identity.operationKey}`,
488
- );
489
- }
1335
+ const storedIdentity = structuredClone(identity);
1336
+ const storedResult = structuredClone(result);
1337
+ await this.#mutateState((nextState) => {
1338
+ const existing = nextState.sideEffectTools[storedIdentity.operationKey];
1339
+ if (!existing || existing.fingerprint !== storedIdentity.fingerprint) {
1340
+ throw new Error(
1341
+ `Tool operation changed before unknown completion: ${storedIdentity.operationKey}`,
1342
+ );
1343
+ }
490
1344
 
491
- const unknownAt = new Date().toISOString();
492
- this.state.sideEffectTools[identity.operationKey] = {
493
- ...existing,
494
- status: "unknown",
495
- unknownAt,
496
- updatedAt: unknownAt,
497
- result,
498
- };
499
- this.state.pendingToolResult = result;
500
- await this.save();
1345
+ const unknownAt = new Date().toISOString();
1346
+ nextState.sideEffectTools[storedIdentity.operationKey] = {
1347
+ ...existing,
1348
+ status: "unknown",
1349
+ unknownAt,
1350
+ updatedAt: unknownAt,
1351
+ result: storedResult,
1352
+ };
1353
+ nextState.pendingToolResult = storedResult;
1354
+ });
501
1355
  return await this.appendEvent("tool.completion_unknown", {
502
- identity,
503
- result,
1356
+ identity: storedIdentity,
1357
+ result: storedResult,
504
1358
  });
505
1359
  }
506
1360
 
@@ -509,9 +1363,6 @@ export class AgentSession {
509
1363
  }
510
1364
 
511
1365
  async clearPendingToolResult() {
512
- if (this.state.pendingToolResult == null) {
513
- return;
514
- }
515
1366
  await this.update({ pendingToolResult: null });
516
1367
  }
517
1368
 
@@ -526,42 +1377,46 @@ export class AgentSession {
526
1377
  path: file.path ?? null,
527
1378
  }));
528
1379
  }
529
- this.state.followUps.push(item);
530
- this.state.lastError = null;
531
- await this.save();
1380
+ await this.#mutateState((nextState) => {
1381
+ nextState.followUps.push(structuredClone(item));
1382
+ nextState.lastError = null;
1383
+ });
532
1384
  await this.appendEvent("session.instruction_added", item);
533
1385
  }
534
1386
 
535
1387
  async recoverInterruptedSideEffects() {
536
- for (const [operationKey, entry] of Object.entries(this.state.sideEffectTools)) {
537
- if (entry.status !== "running") {
538
- continue;
1388
+ await this.#mutateState((nextState) => {
1389
+ for (const [operationKey, entry] of Object.entries(
1390
+ nextState.sideEffectTools,
1391
+ )) {
1392
+ if (entry.status !== "running") {
1393
+ continue;
1394
+ }
1395
+ const result = {
1396
+ callId: entry.callId,
1397
+ name: entry.name,
1398
+ operationSignature: entry.requestSignature,
1399
+ ok: false,
1400
+ message:
1401
+ "This tool operation may have started before the local process was interrupted. "
1402
+ + "Its completion is unknown, so it will not be replayed automatically. "
1403
+ + "Inspect local state before issuing a deliberate follow-up operation.",
1404
+ meta: {
1405
+ completionUnknown: true,
1406
+ recoverable: true,
1407
+ },
1408
+ };
1409
+ const now = new Date().toISOString();
1410
+ nextState.sideEffectTools[operationKey] = {
1411
+ ...entry,
1412
+ status: "unknown",
1413
+ unknownAt: now,
1414
+ updatedAt: now,
1415
+ result,
1416
+ };
1417
+ nextState.pendingToolResult ??= result;
539
1418
  }
540
- const result = {
541
- callId: entry.callId,
542
- name: entry.name,
543
- operationSignature: entry.requestSignature,
544
- ok: false,
545
- message:
546
- "This tool operation may have started before the local process was interrupted. "
547
- + "Its completion is unknown, so it will not be replayed automatically. "
548
- + "Inspect local state before issuing a deliberate follow-up operation.",
549
- meta: {
550
- completionUnknown: true,
551
- recoverable: true,
552
- },
553
- };
554
- const now = new Date().toISOString();
555
- this.state.sideEffectTools[operationKey] = {
556
- ...entry,
557
- status: "unknown",
558
- unknownAt: now,
559
- updatedAt: now,
560
- result,
561
- };
562
- this.state.pendingToolResult ??= result;
563
- }
564
- await this.save();
1419
+ });
565
1420
  }
566
1421
  }
567
1422