chati-dev 4.5.15 → 4.5.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +2 -2
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +278 -24
  43. package/framework/orchestrator/chati.md +250 -68
  44. package/framework/schemas/session.schema.json +21 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +157 -56
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +91 -23
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +235 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/intelligence/timeline.js +3 -1
  91. package/src/orchestrator/browser-runtime.js +44 -13
  92. package/src/orchestrator/cli.js +1616 -151
  93. package/src/orchestrator/clickup-projection.js +43 -1
  94. package/src/orchestrator/clickup-runtime.js +355 -39
  95. package/src/orchestrator/doctor.js +87 -4
  96. package/src/orchestrator/index.js +16 -0
  97. package/src/orchestrator/planning-runtime.js +20 -2
  98. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  99. package/src/orchestrator/rail-evidence-authority.js +147 -0
  100. package/src/orchestrator/rail-execution-evidence.js +45 -0
  101. package/src/orchestrator/rail-runtime.js +836 -56
  102. package/src/orchestrator/release-runtime.js +65 -6
  103. package/src/orchestrator/review-runtime.js +186 -41
  104. package/src/orchestrator/runtime-installation-v2.js +236 -20
  105. package/src/orchestrator/session-manager.js +1249 -52
  106. package/src/terminal/adapters/claude-adapter.js +3 -1
  107. package/src/terminal/adapters/codex-adapter.js +2 -0
  108. package/src/terminal/adapters/grok-adapter.js +7 -4
  109. package/src/terminal/handoff-parser.js +19 -1
  110. package/src/terminal/prompt-builder.js +10 -1
  111. package/src/terminal/provider-preflight.js +36 -3
  112. package/src/terminal/rail-execution-worktree.js +213 -0
  113. package/src/terminal/rail-prompts.js +169 -0
  114. package/src/terminal/rail-readonly-workspace.js +324 -0
  115. package/src/terminal/run-agent.js +434 -16
  116. package/src/terminal/run-parallel.js +5 -0
  117. package/src/terminal/run-rail-adjudication.js +323 -0
  118. package/src/terminal/run-rail-review.js +291 -0
  119. package/src/terminal/run-rail-rework.js +325 -0
  120. package/src/terminal/run-rail-task.js +380 -0
  121. package/src/terminal/run-team.js +5 -0
  122. package/src/terminal/spawner.js +1225 -77
  123. package/src/utils/schema-validator.js +5 -0
  124. package/src/wizard/index.js +3 -2
@@ -6,36 +6,391 @@
6
6
  */
7
7
 
8
8
  import * as yaml from 'js-yaml';
9
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
10
- import { join, dirname, resolve } from 'path';
11
- import lockfile from 'proper-lockfile';
9
+ import {
10
+ closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
11
+ openSync, readSync, realpathSync, renameSync, rmSync, rmdirSync,
12
+ unlinkSync, writeFileSync, readFileSync,
13
+ } from 'fs';
14
+ import { basename, join, dirname, resolve } from 'path';
15
+ import { randomUUID } from 'crypto';
16
+ import { hostname } from 'node:os';
12
17
 
13
18
  const SESSION_FILE = '.chati/session.yaml';
19
+ const SESSION_MAX_BYTES = 4 * 1024 * 1024;
20
+ const SESSION_LOCK_OWNER_MAX_BYTES = 16 * 1024;
21
+ const SESSION_LOCK_STALE_AFTER_MS = 30_000;
22
+ const SESSION_TEST_HOOKS = Symbol.for('chati.session-manager.security-test-hooks');
23
+ const UNSAFE_SESSION_LOCK_OWNER = Symbol('unsafe-session-lock-owner');
24
+
25
+ function assertSessionWritableSize(value) {
26
+ if (Buffer.byteLength(JSON.stringify(value)) > SESSION_MAX_BYTES) {
27
+ throw new Error('session.yaml exceeds the 4 MiB safety limit');
28
+ }
29
+ }
30
+
31
+ function runSecurityTestHook(name, detail) {
32
+ const hook = globalThis[SESSION_TEST_HOOKS]?.[name];
33
+ if (typeof hook === 'function') hook(detail);
34
+ }
35
+
36
+ function sameFileIdentity(left, right) {
37
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
38
+ }
39
+
40
+ function sameFileSnapshot(left, right) {
41
+ return sameFileIdentity(left, right)
42
+ && left.mode === right.mode
43
+ && left.uid === right.uid
44
+ && left.gid === right.gid
45
+ && left.nlink === right.nlink
46
+ && left.size === right.size
47
+ && left.mtimeMs === right.mtimeMs
48
+ && left.ctimeMs === right.ctimeMs;
49
+ }
50
+
51
+ function sameLockOwnerSnapshot(left, right) {
52
+ return sameFileIdentity(left, right)
53
+ && left.mode === right.mode
54
+ && left.uid === right.uid
55
+ && left.gid === right.gid
56
+ && left.nlink === right.nlink
57
+ && left.size === right.size
58
+ && left.mtimeNs === right.mtimeNs
59
+ && left.ctimeNs === right.ctimeNs;
60
+ }
61
+
62
+ function openDirectoryNoFollow(directoryPath) {
63
+ const fd = openSync(
64
+ directoryPath,
65
+ constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
66
+ );
67
+ const stat = fstatSync(fd);
68
+ if (!stat.isDirectory()) {
69
+ closeSync(fd);
70
+ throw new Error('The .chati path must be a real directory');
71
+ }
72
+ return { fd, stat };
73
+ }
74
+
75
+ function assertDirectoryIdentity(directoryPath, expected) {
76
+ let opened;
77
+ try {
78
+ opened = openDirectoryNoFollow(directoryPath);
79
+ if (!sameFileIdentity(opened.stat, expected)) {
80
+ throw new Error('The .chati directory changed during the session operation');
81
+ }
82
+ } finally {
83
+ if (opened) closeSync(opened.fd);
84
+ }
85
+ }
86
+
87
+ function openRegularFileNoFollow(path, { allowMissing = false } = {}) {
88
+ let fd;
89
+ try {
90
+ fd = openSync(
91
+ path,
92
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
93
+ );
94
+ } catch (err) {
95
+ if (allowMissing && err.code === 'ENOENT') return null;
96
+ throw err;
97
+ }
98
+ try {
99
+ const stat = fstatSync(fd);
100
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('session.yaml must be a regular file');
101
+ if (stat.size > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
102
+ return { fd, stat };
103
+ } catch (err) {
104
+ closeSync(fd);
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ function assertPathIdentity(path, expected, { allowMissing = false } = {}) {
110
+ const opened = openRegularFileNoFollow(path, { allowMissing });
111
+ if (!opened) {
112
+ if (expected !== null) throw new Error('session.yaml changed during the session operation');
113
+ return;
114
+ }
115
+ try {
116
+ if (expected === null || !sameFileSnapshot(opened.stat, expected)) {
117
+ throw new Error('session.yaml changed during the session operation');
118
+ }
119
+ } finally {
120
+ closeSync(opened.fd);
121
+ }
122
+ }
123
+
124
+ function readRegularFileBounded(opened) {
125
+ const buffer = Buffer.allocUnsafe(SESSION_MAX_BYTES + 1);
126
+ let offset = 0;
127
+ while (offset < buffer.length) {
128
+ const bytesRead = readSync(opened.fd, buffer, offset, buffer.length - offset, null);
129
+ if (bytesRead === 0) break;
130
+ offset += bytesRead;
131
+ }
132
+ if (offset > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
133
+ return buffer.subarray(0, offset).toString('utf8');
134
+ }
14
135
 
15
136
  /**
16
- * Atomic write helper. Writes `content` to a sibling `.tmp` file and then
17
- * renames it into place. If the process is killed mid-write the original
18
- * remains intact. Same-FS rename on POSIX is atomic; on Windows it's
19
- * best-effort. Mirrors the pattern used by `chati.dev/hooks/prism-engine.js`
20
- * `writeBackSession` to keep both hook and CLI write paths consistent.
137
+ * Atomic write helper. On Linux, all destination operations use the pinned
138
+ * directory descriptor through /proc/self/fd so an ancestor replacement cannot
139
+ * redirect the final rename. Other platforms retain the canonical path plus
140
+ * immediate identity checks before and after the rename.
21
141
  */
22
- function writeFileAtomic(path, content, encoding = 'utf-8') {
23
- const tmp = `${path}.tmp`;
24
- writeFileSync(tmp, content, encoding);
25
- renameSync(tmp, path);
142
+ function writeFileAtomic(path, content, encoding = 'utf-8', {
143
+ expectedDestinationIdentity,
144
+ expectedDirectoryIdentity,
145
+ } = {}) {
146
+ if (Buffer.byteLength(String(content), encoding) > SESSION_MAX_BYTES) {
147
+ throw new Error('session.yaml exceeds the 4 MiB safety limit');
148
+ }
149
+ const directoryPath = dirname(path);
150
+ let directory;
151
+ let destination;
152
+ let tmp;
153
+ let tmpFd;
154
+ let tmpIdentity;
155
+ try {
156
+ directory = openDirectoryNoFollow(directoryPath);
157
+ if (expectedDirectoryIdentity && !sameFileIdentity(directory.stat, expectedDirectoryIdentity)) {
158
+ throw new Error('The .chati directory changed during the session operation');
159
+ }
160
+ const directoryReference = process.platform === 'linux'
161
+ ? `/proc/self/fd/${directory.fd}`
162
+ : directoryPath;
163
+ destination = join(directoryReference, basename(path));
164
+ tmp = join(directoryReference, `${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
165
+ const expectedDestination = expectedDestinationIdentity === undefined
166
+ ? (() => {
167
+ const opened = openRegularFileNoFollow(destination, { allowMissing: true });
168
+ if (!opened) return null;
169
+ try { return opened.stat; } finally { closeSync(opened.fd); }
170
+ })()
171
+ : expectedDestinationIdentity;
172
+ tmpFd = openSync(
173
+ tmp,
174
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
175
+ 0o600,
176
+ );
177
+ tmpIdentity = fstatSync(tmpFd);
178
+ if (!tmpIdentity.isFile() || tmpIdentity.nlink !== 1) throw new Error('Session temporary must be a regular file');
179
+ writeFileSync(tmpFd, content, { encoding });
180
+ fsyncSync(tmpFd);
181
+
182
+ runSecurityTestHook('beforeAtomicRename', { path, tmp });
183
+ assertDirectoryIdentity(directoryPath, directory.stat);
184
+ assertPathIdentity(destination, expectedDestination, { allowMissing: true });
185
+ const currentTmp = lstatSync(tmp);
186
+ if (currentTmp.isSymbolicLink() || !currentTmp.isFile() || !sameFileIdentity(currentTmp, tmpIdentity)) {
187
+ throw new Error('Session temporary changed before atomic rename');
188
+ }
189
+ runSecurityTestHook('beforeAtomicRenameCommit', { path, tmp });
190
+ assertDirectoryIdentity(directoryPath, directory.stat);
191
+ assertPathIdentity(destination, expectedDestination, { allowMissing: true });
192
+ renameSync(tmp, destination);
193
+ fsyncSync(directory.fd);
194
+ assertDirectoryIdentity(directoryPath, directory.stat);
195
+ } finally {
196
+ if (tmpFd !== undefined) closeSync(tmpFd);
197
+ try {
198
+ const currentTmp = lstatSync(tmp);
199
+ if (tmpIdentity && currentTmp.isFile() && !currentTmp.isSymbolicLink()
200
+ && sameFileIdentity(currentTmp, tmpIdentity)) rmSync(tmp, { force: true });
201
+ } catch { /* leave an unverified temporary untouched */ }
202
+ if (directory) closeSync(directory.fd);
203
+ }
204
+ }
205
+
206
+ function readSessionDocumentRecord(sessionPath) {
207
+ const directoryPath = dirname(sessionPath);
208
+ let directory;
209
+ let opened;
210
+ try {
211
+ directory = openDirectoryNoFollow(directoryPath);
212
+ runSecurityTestHook('beforeReadOpen', { sessionPath });
213
+ opened = openRegularFileNoFollow(sessionPath);
214
+ runSecurityTestHook('afterReadOpen', { sessionPath, fd: opened.fd });
215
+ const raw = readRegularFileBounded(opened);
216
+ runSecurityTestHook('afterReadContent', { sessionPath, fd: opened.fd });
217
+ const finalStat = fstatSync(opened.fd);
218
+ if (!sameFileSnapshot(opened.stat, finalStat)) {
219
+ throw new Error('session.yaml changed during the session operation');
220
+ }
221
+ const session = yaml.load(raw);
222
+ if (!session || typeof session !== 'object' || Array.isArray(session)) throw new Error('Invalid session document');
223
+ assertDirectoryIdentity(directoryPath, directory.stat);
224
+ assertPathIdentity(sessionPath, opened.stat);
225
+ return { session, identity: opened.stat, directoryIdentity: directory.stat };
226
+ } finally {
227
+ if (opened) closeSync(opened.fd);
228
+ if (directory) closeSync(directory.fd);
229
+ }
230
+ }
231
+
232
+ function readSessionDocument(sessionPath) {
233
+ return readSessionDocumentRecord(sessionPath).session;
234
+ }
235
+
236
+ function readRuntimeSessionDocument(sessionPath) {
237
+ let releaseLock = null;
238
+ try {
239
+ try {
240
+ releaseLock = waitForSessionWriteLock(sessionPath);
241
+ } catch (err) {
242
+ // A genuinely read-only project cannot create the sibling lock directory
243
+ // and cannot be mutated by this process. Preserve read-only inspection
244
+ // while retaining the descriptor and path-identity checks.
245
+ if (!['EACCES', 'EROFS'].includes(err.code)) throw err;
246
+ }
247
+ return readSessionDocument(sessionPath);
248
+ } finally {
249
+ if (releaseLock) releaseLock();
250
+ }
251
+ }
252
+
253
+ function readSessionDocumentRecordIfPresent(sessionPath) {
254
+ try {
255
+ return readSessionDocumentRecord(sessionPath);
256
+ } catch (err) {
257
+ if (err.code === 'ENOENT') return null;
258
+ throw err;
259
+ }
260
+ }
261
+
262
+ function sessionLockOwner(lockPath) {
263
+ const ownerPath = join(lockPath, 'owner.json');
264
+ let descriptor;
265
+ try {
266
+ descriptor = openSync(ownerPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
267
+ const stat = fstatSync(descriptor, { bigint: true });
268
+ if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(SESSION_LOCK_OWNER_MAX_BYTES)) {
269
+ return UNSAFE_SESSION_LOCK_OWNER;
270
+ }
271
+ runSecurityTestHook('afterSessionLockOwnerStat', { path: ownerPath });
272
+ const bytes = Buffer.allocUnsafe(SESSION_LOCK_OWNER_MAX_BYTES + 1);
273
+ let total = 0;
274
+ while (total < bytes.length) {
275
+ const bytesRead = readSync(descriptor, bytes, total, bytes.length - total, null);
276
+ if (bytesRead === 0) break;
277
+ total += bytesRead;
278
+ }
279
+ const after = fstatSync(descriptor, { bigint: true });
280
+ if (total > SESSION_LOCK_OWNER_MAX_BYTES || !sameLockOwnerSnapshot(stat, after)) {
281
+ return UNSAFE_SESSION_LOCK_OWNER;
282
+ }
283
+ let owner;
284
+ try { owner = JSON.parse(bytes.subarray(0, total).toString('utf8')); }
285
+ catch { return null; }
286
+ return owner && typeof owner.token === 'string' ? owner : null;
287
+ } catch (error) { return error?.code === 'ENOENT' ? null : UNSAFE_SESSION_LOCK_OWNER; }
288
+ finally { if (descriptor !== undefined) closeSync(descriptor); }
289
+ }
290
+
291
+ function localProcessAlive(pid) {
292
+ if (!Number.isInteger(pid) || pid <= 0) return false;
293
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
294
+ }
295
+
296
+ function localProcessIdentity(pid) {
297
+ if (process.platform !== 'linux' || !Number.isInteger(pid) || pid <= 0) return null;
298
+ try {
299
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
300
+ const commandEnd = stat.lastIndexOf(')');
301
+ if (commandEnd < 0) return null;
302
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
303
+ const startTime = fields[19]; // field 22; fields[0] is field 3 (state)
304
+ const bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
305
+ if (!startTime || !bootId) return null;
306
+ return `linux-proc-v1:${bootId}:${startTime}`;
307
+ } catch { return null; }
308
+ }
309
+
310
+ function sessionLockAgeMs(lockStat) {
311
+ return Math.max(0, Date.now() - lockStat.mtimeMs);
312
+ }
313
+
314
+ function recoverAbandonedSessionLock(lockPath) {
315
+ let initial;
316
+ try { initial = lstatSync(lockPath); } catch (error) { return error?.code === 'ENOENT'; }
317
+ if (initial.isSymbolicLink() || !initial.isDirectory()) return false;
318
+ const owner = sessionLockOwner(lockPath);
319
+ if (owner === UNSAFE_SESSION_LOCK_OWNER) return UNSAFE_SESSION_LOCK_OWNER;
320
+ const staleByAge = sessionLockAgeMs(initial) >= SESSION_LOCK_STALE_AFTER_MS;
321
+ if (owner?.hostname && owner.hostname !== hostname() && !staleByAge) return false;
322
+ if (owner?.hostname === hostname() && localProcessAlive(owner.pid)) {
323
+ const observedIdentity = localProcessIdentity(owner.pid);
324
+ if (owner.process_identity && observedIdentity === owner.process_identity) return false;
325
+ // Legacy owners and platforms without a stable process start identity get
326
+ // a bounded lease instead of trusting a potentially recycled PID forever.
327
+ if ((!owner.process_identity || !observedIdentity) && !staleByAge) return false;
328
+ }
329
+ if (!owner && !staleByAge) return false;
330
+ const electionPath = join(lockPath, 'recovery.json');
331
+ let electionFd;
332
+ try {
333
+ electionFd = openSync(electionPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
334
+ writeFileSync(electionFd, JSON.stringify({ pid: process.pid, hostname: hostname(), token: randomUUID() }));
335
+ fsyncSync(electionFd);
336
+ } catch {
337
+ if (electionFd !== undefined) closeSync(electionFd);
338
+ return false;
339
+ }
340
+ const quarantine = `${lockPath}.stale.${process.pid}.${randomUUID()}`;
341
+ try {
342
+ const current = lstatSync(lockPath);
343
+ const currentOwner = sessionLockOwner(lockPath);
344
+ if (currentOwner === UNSAFE_SESSION_LOCK_OWNER) return UNSAFE_SESSION_LOCK_OWNER;
345
+ if (!sameFileIdentity(initial, current) || currentOwner?.token !== owner?.token) return false;
346
+ renameSync(lockPath, quarantine);
347
+ try { unlinkSync(join(quarantine, 'owner.json')); } catch { /* owner may be absent */ }
348
+ unlinkSync(join(quarantine, 'recovery.json'));
349
+ rmdirSync(quarantine);
350
+ return true;
351
+ } catch { return false; }
352
+ finally { if (electionFd !== undefined) closeSync(electionFd); }
26
353
  }
27
354
 
28
355
  function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
29
356
  const startedAt = Date.now();
357
+ const lockPath = `${sessionPath}.lock`;
358
+ const parentPath = dirname(lockPath);
359
+ let unsafeLockObserved = false;
30
360
  while (true) {
31
361
  try {
32
- return lockfile.lockSync(sessionPath, {
33
- realpath: false,
34
- stale: 30_000,
35
- update: 10_000,
36
- });
362
+ mkdirSync(lockPath, { mode: 0o700 });
363
+ const token = randomUUID();
364
+ const ownerPath = join(lockPath, 'owner.json');
365
+ const ownerFd = openSync(ownerPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
366
+ try {
367
+ writeFileSync(ownerFd, JSON.stringify({
368
+ schema_version: 2,
369
+ pid: process.pid,
370
+ hostname: hostname(),
371
+ process_identity: localProcessIdentity(process.pid),
372
+ token,
373
+ acquired_at: new Date().toISOString(),
374
+ }));
375
+ fsyncSync(ownerFd);
376
+ } finally { closeSync(ownerFd); }
377
+ const lockDirFd = openSync(lockPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
378
+ try { fsyncSync(lockDirFd); } finally { closeSync(lockDirFd); }
379
+ const parentFd = openSync(parentPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
380
+ try { fsyncSync(parentFd); } finally { closeSync(parentFd); }
381
+ return () => {
382
+ const current = sessionLockOwner(lockPath);
383
+ if (current?.token !== token) return;
384
+ unlinkSync(ownerPath);
385
+ rmdirSync(lockPath);
386
+ const releaseParentFd = openSync(parentPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
387
+ try { fsyncSync(releaseParentFd); } finally { closeSync(releaseParentFd); }
388
+ };
37
389
  } catch (err) {
38
- if (err.code !== 'ELOCKED') throw err;
390
+ if (err.code !== 'EEXIST') throw err;
391
+ const recovery = unsafeLockObserved ? false : recoverAbandonedSessionLock(lockPath);
392
+ if (recovery === UNSAFE_SESSION_LOCK_OWNER) unsafeLockObserved = true;
393
+ else if (recovery) continue;
39
394
  if (Date.now() - startedAt >= maxWaitMs) {
40
395
  throw new Error(`Timed out waiting for session write lock after ${maxWaitMs}ms`, { cause: err });
41
396
  }
@@ -47,17 +402,161 @@ function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
47
402
  function findSessionProjectDir(startDir) {
48
403
  let current = resolve(startDir);
49
404
  while (true) {
50
- if (existsSync(join(current, SESSION_FILE))) return current;
405
+ const chatiDir = join(current, '.chati');
406
+ if (existsSync(chatiDir) && !lstatSync(chatiDir).isSymbolicLink() && lstatSync(chatiDir).isDirectory()
407
+ && existsSync(join(current, SESSION_FILE))
408
+ && !lstatSync(join(current, SESSION_FILE)).isSymbolicLink()
409
+ && lstatSync(join(current, SESSION_FILE)).isFile()) return current;
51
410
  const parent = dirname(current);
52
411
  if (parent === current) return null;
53
412
  current = parent;
54
413
  }
55
414
  }
56
415
 
416
+ function validateSessionPath(projectDir, { createDirectory = false, requireFile = false } = {}) {
417
+ const projectRoot = realpathSync(resolve(projectDir));
418
+ const requestedSessionPath = join(projectRoot, SESSION_FILE);
419
+ const sessionDir = dirname(requestedSessionPath);
420
+ let dirStat = null;
421
+ try {
422
+ dirStat = lstatSync(sessionDir);
423
+ } catch (err) {
424
+ if (err.code !== 'ENOENT') throw err;
425
+ }
426
+ if (dirStat) {
427
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
428
+ throw new Error('The .chati path must be a real directory');
429
+ }
430
+ } else if (createDirectory) {
431
+ mkdirSync(sessionDir, { recursive: true });
432
+ } else {
433
+ throw new Error('Session directory not found');
434
+ }
435
+ const realSessionDir = realpathSync(sessionDir);
436
+ if (dirname(realSessionDir) !== projectRoot || realSessionDir !== join(projectRoot, '.chati')) {
437
+ throw new Error('The .chati directory must remain inside the project');
438
+ }
439
+ const openedDirectory = openDirectoryNoFollow(sessionDir);
440
+ closeSync(openedDirectory.fd);
441
+ const sessionPath = join(realSessionDir, 'session.yaml');
442
+ let fileStat = null;
443
+ try {
444
+ fileStat = lstatSync(sessionPath);
445
+ } catch (err) {
446
+ if (err.code !== 'ENOENT') throw err;
447
+ }
448
+ if (fileStat) {
449
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
450
+ throw new Error('session.yaml must be a regular file');
451
+ }
452
+ } else if (requireFile) {
453
+ throw new Error('Session file not found');
454
+ }
455
+ return sessionPath;
456
+ }
457
+
458
+ /**
459
+ * Read canonical session state without migration or write-back. Authorization
460
+ * and routing consumers use this surface so invalid, symlinked, oversized or
461
+ * non-regular session files fail closed under the same checks as mutations.
462
+ */
463
+ export function readSessionSafe(projectDir) {
464
+ let sessionPath;
465
+ let releaseLock = null;
466
+ try {
467
+ sessionPath = validateSessionPath(projectDir, { requireFile: true });
468
+ // Security consumers read under the same lock used by atomic writers. This
469
+ // avoids false failures during a legitimate rename without retrying an
470
+ // attacker-controlled replacement after an identity check has failed.
471
+ releaseLock = waitForSessionWriteLock(sessionPath);
472
+ const record = readSessionDocumentRecord(sessionPath);
473
+ releaseLock();
474
+ releaseLock = null;
475
+ return { loaded: true, missing: false, session: record.session, path: sessionPath, error: null };
476
+ } catch (err) {
477
+ const missing = err.code === 'ENOENT'
478
+ || err.message === 'Session directory not found'
479
+ || err.message === 'Session file not found';
480
+ return {
481
+ loaded: false,
482
+ missing,
483
+ session: null,
484
+ path: sessionPath || join(resolve(projectDir), SESSION_FILE),
485
+ error: err.message,
486
+ };
487
+ } finally {
488
+ if (releaseLock) {
489
+ try { releaseLock(); } catch { /* the read already fails closed above */ }
490
+ }
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Persist the installer-generated initial document through the same lock and
496
+ * atomic writer used by the runtime. Existing session state is always
497
+ * preserved, including an inactive pre-init session created by an earlier
498
+ * installation.
499
+ */
500
+ export function initializeSessionDocument(projectDir, yamlContent) {
501
+ let sessionPath;
502
+ let releaseLock = null;
503
+ let outcome;
504
+ try {
505
+ const parsed = yaml.load(yamlContent);
506
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
507
+ throw new Error('Invalid initial session document');
508
+ }
509
+ sessionPath = validateSessionPath(projectDir, { createDirectory: true });
510
+ releaseLock = waitForSessionWriteLock(sessionPath);
511
+ const existing = readSessionDocumentRecordIfPresent(sessionPath);
512
+ if (existing) {
513
+ outcome = { created: false, preserved: true, session: existing.session, path: sessionPath };
514
+ } else {
515
+ const directory = openDirectoryNoFollow(dirname(sessionPath));
516
+ try {
517
+ writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
518
+ expectedDestinationIdentity: null,
519
+ expectedDirectoryIdentity: directory.stat,
520
+ });
521
+ } finally {
522
+ closeSync(directory.fd);
523
+ }
524
+ outcome = { created: true, preserved: false, session: parsed, path: sessionPath };
525
+ }
526
+ } catch (err) {
527
+ outcome = {
528
+ created: false,
529
+ preserved: false,
530
+ session: null,
531
+ path: sessionPath || join(projectDir, SESSION_FILE),
532
+ error: `Failed to initialize session document: ${err.message}`,
533
+ };
534
+ } finally {
535
+ if (releaseLock) {
536
+ try {
537
+ releaseLock();
538
+ } catch (err) {
539
+ outcome = {
540
+ created: false,
541
+ preserved: false,
542
+ session: null,
543
+ path: sessionPath,
544
+ error: `Session initialization lock was compromised: ${err.message}`,
545
+ };
546
+ }
547
+ }
548
+ }
549
+ return outcome;
550
+ }
551
+
57
552
  /**
58
553
  * Default session template.
59
554
  */
60
555
  const CURRENT_SCHEMA_VERSION = '1.2';
556
+ const V2_SESSION_AGENTS = new Set([
557
+ 'greenfield-wu', 'brownfield-wu', 'brief', 'detail', 'architect', 'ux',
558
+ 'qa-planning', 'phases', 'tasks',
559
+ ]);
61
560
 
62
561
  const DEFAULT_SESSION = {
63
562
  schema_version: CURRENT_SCHEMA_VERSION,
@@ -106,6 +605,12 @@ const DEFAULT_SESSION = {
106
605
  // context_last_bracket: last computed bracket — telemetry + /chati status.
107
606
  active_model: null,
108
607
  active_provider: null,
608
+ // The harness is the CLI/UI currently orchestrating the session. It is not
609
+ // the provider selected for the routed task.
610
+ active_harness: null,
611
+ session_status: 'inactive',
612
+ current_execution_binding: null,
613
+ pending_interaction: null,
109
614
  context_tokens_used: 0,
110
615
  context_window_tokens: null,
111
616
  context_last_bracket: null,
@@ -156,15 +661,22 @@ const DEFAULT_SESSION = {
156
661
  * @returns {{ created: boolean, session: object, path: string }}
157
662
  */
158
663
  export function initSession(projectDir, options = {}) {
159
- const sessionPath = join(projectDir, SESSION_FILE);
160
- const dir = dirname(sessionPath);
161
-
162
- if (!existsSync(dir)) {
163
- mkdirSync(dir, { recursive: true });
664
+ let sessionPath;
665
+ try {
666
+ sessionPath = validateSessionPath(projectDir, { createDirectory: true });
667
+ } catch (err) {
668
+ return { created: false, session: null, path: join(projectDir, SESSION_FILE), error: err.message };
164
669
  }
165
670
 
671
+ const sessionAgents = Object.fromEntries(
672
+ Object.entries(DEFAULT_SESSION.agents)
673
+ .filter(([agent]) => options.runtimeVersion !== 2 || V2_SESSION_AGENTS.has(agent))
674
+ .map(([agent, state]) => [agent, { ...state }]),
675
+ );
166
676
  const session = {
167
677
  ...DEFAULT_SESSION,
678
+ runtime_version: options.runtimeVersion === 2 ? 2 : 1,
679
+ agents: sessionAgents,
168
680
  mode: options.mode || 'discover',
169
681
  project: {
170
682
  name: options.projectName || '',
@@ -179,27 +691,69 @@ export function initSession(projectDir, options = {}) {
179
691
  providers_enabled: options.providersEnabled || [],
180
692
  };
181
693
 
694
+ let releaseLock = null;
695
+ let outcome;
182
696
  try {
183
- const yamlContent = yaml.dump(session, {
184
- lineWidth: -1,
185
- noRefs: true,
186
- });
187
-
188
- writeFileAtomic(sessionPath, yamlContent);
189
-
190
- return {
191
- created: true,
192
- session,
193
- path: sessionPath,
194
- };
697
+ releaseLock = waitForSessionWriteLock(sessionPath);
698
+ const existingRecord = readSessionDocumentRecordIfPresent(sessionPath);
699
+ if (existingRecord) {
700
+ const existing = existingRecord.session;
701
+ const hasConcurrentProgress = existing && typeof existing === 'object' && !Array.isArray(existing) && (
702
+ existing.pending_interaction
703
+ || (Array.isArray(existing.completed_agents) && existing.completed_agents.length > 0)
704
+ || (existing.mode && existing.mode !== 'discover')
705
+ || (existing.current_agent && existing.current_agent !== '')
706
+ );
707
+ if (hasConcurrentProgress) {
708
+ outcome = {
709
+ created: true,
710
+ preserved: true,
711
+ session: existing,
712
+ path: sessionPath,
713
+ };
714
+ } else {
715
+ const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
716
+ writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
717
+ expectedDestinationIdentity: existingRecord.identity,
718
+ expectedDirectoryIdentity: existingRecord.directoryIdentity,
719
+ });
720
+ outcome = { created: true, session, path: sessionPath };
721
+ }
722
+ } else {
723
+ const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
724
+ const directory = openDirectoryNoFollow(dirname(sessionPath));
725
+ try {
726
+ writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
727
+ expectedDestinationIdentity: null,
728
+ expectedDirectoryIdentity: directory.stat,
729
+ });
730
+ } finally {
731
+ closeSync(directory.fd);
732
+ }
733
+ outcome = { created: true, session, path: sessionPath };
734
+ }
195
735
  } catch (err) {
196
- return {
736
+ outcome = {
197
737
  created: false,
198
738
  session: null,
199
739
  path: sessionPath,
200
740
  error: `Failed to create session: ${err.message}`,
201
741
  };
742
+ } finally {
743
+ if (releaseLock) {
744
+ try {
745
+ releaseLock();
746
+ } catch (err) {
747
+ outcome = {
748
+ created: false,
749
+ session: null,
750
+ path: sessionPath,
751
+ error: `Session initialization lock was compromised: ${err.message}`,
752
+ };
753
+ }
754
+ }
202
755
  }
756
+ return outcome;
203
757
  }
204
758
 
205
759
  /**
@@ -237,8 +791,24 @@ export function migrateSession(session) {
237
791
  });
238
792
  }
239
793
 
794
+ // Seed additive runtime fields even when the schema version is current.
795
+ // Patch releases can therefore repair installed v1.2 sessions safely.
796
+ let seededRuntimeFields = false;
797
+ const runtimeDefaults = {
798
+ active_harness: null,
799
+ session_status: 'inactive',
800
+ current_execution_binding: null,
801
+ pending_interaction: null,
802
+ };
803
+ for (const [key, value] of Object.entries(runtimeDefaults)) {
804
+ if (!(key in session)) {
805
+ session[key] = value;
806
+ seededRuntimeFields = true;
807
+ }
808
+ }
809
+
240
810
  if (session.schema_version === CURRENT_SCHEMA_VERSION) {
241
- return { migrated: normalizedLegacySelections, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
811
+ return { migrated: normalizedLegacySelections || seededRuntimeFields, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
242
812
  }
243
813
 
244
814
  // v0 (no schema_version) → v1.0 → v1.1 → v1.2
@@ -356,19 +926,21 @@ function inferLegacyProvider(model) {
356
926
  * @returns {{ loaded: boolean, session: object|null, error: string|null, migrated?: boolean }}
357
927
  */
358
928
  export function loadSession(projectDir) {
359
- const sessionPath = join(projectDir, SESSION_FILE);
360
-
361
- if (!existsSync(sessionPath)) {
929
+ let sessionPath;
930
+ try {
931
+ sessionPath = validateSessionPath(projectDir, { requireFile: true });
932
+ } catch (err) {
362
933
  return {
363
934
  loaded: false,
364
935
  session: null,
365
- error: 'Session file not found',
936
+ error: err.message,
366
937
  };
367
938
  }
368
-
369
939
  try {
370
- const content = readFileSync(sessionPath, 'utf-8');
371
- let session = yaml.load(content);
940
+ // Canonical runtime reads participate in the same cross-process lock as
941
+ // atomic writers. Retrying unlocked reads can exhaust every attempt while
942
+ // another process replaces session.yaml under scheduler pressure.
943
+ let session = readRuntimeSessionDocument(sessionPath);
372
944
 
373
945
  // Run migration if needed
374
946
  const migration = migrateSession(session);
@@ -436,21 +1008,221 @@ export function updateSession(projectDir, updates) {
436
1008
  });
437
1009
  }
438
1010
 
1011
+ /**
1012
+ * Atomically transition conversation ownership and lifecycle state. Returning
1013
+ * the prior values lets callers record timeline evidence without performing a
1014
+ * race-prone read before the canonical mutation.
1015
+ */
1016
+ export function transitionSessionLifecycle(projectDir, {
1017
+ activeHarness,
1018
+ sessionStatus,
1019
+ } = {}) {
1020
+ let previous = null;
1021
+ const persisted = mutateSession(projectDir, (session) => {
1022
+ previous = {
1023
+ active_harness: session.active_harness || null,
1024
+ session_status: session.session_status || 'inactive',
1025
+ current_agent: session.current_agent || '',
1026
+ mode: session.mode || 'discover',
1027
+ execution_mode: session.execution_mode || 'interactive',
1028
+ language: session.language || 'en',
1029
+ };
1030
+ if (activeHarness !== undefined) session.active_harness = activeHarness;
1031
+ if (sessionStatus !== undefined) session.session_status = sessionStatus;
1032
+ return session;
1033
+ });
1034
+ return { ...persisted, previous };
1035
+ }
1036
+
1037
+ /** Restore a lifecycle transition only while it still owns the canonical state. */
1038
+ export function rollbackSessionLifecycle(projectDir, {
1039
+ expectedActiveHarness,
1040
+ expectedSessionStatus,
1041
+ previous,
1042
+ } = {}) {
1043
+ let outcome = { rolledBack: false };
1044
+ const persisted = mutateSession(projectDir, (session) => {
1045
+ const activeHarness = session.active_harness || null;
1046
+ const sessionStatus = session.session_status || 'inactive';
1047
+ if (activeHarness !== expectedActiveHarness || sessionStatus !== expectedSessionStatus) {
1048
+ outcome = {
1049
+ rolledBack: false,
1050
+ code: 'SESSION_ROLLBACK_CONFLICT',
1051
+ error: 'Canonical lifecycle changed before projection rollback.',
1052
+ };
1053
+ return session;
1054
+ }
1055
+ session.active_harness = previous?.active_harness ?? null;
1056
+ session.session_status = previous?.session_status || 'inactive';
1057
+ outcome = { rolledBack: true };
1058
+ return session;
1059
+ });
1060
+ return { ...persisted, ...outcome };
1061
+ }
1062
+
1063
+ function cloneSessionValue(value) {
1064
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
1065
+ }
1066
+
1067
+ function snapshotAgentDispatch(session, agentNames) {
1068
+ return {
1069
+ current_agent: session.current_agent || '',
1070
+ has_current_execution_binding: Object.hasOwn(session, 'current_execution_binding'),
1071
+ current_execution_binding: cloneSessionValue(session.current_execution_binding),
1072
+ agents: Object.fromEntries(agentNames.map((agent) => [agent, {
1073
+ present: Object.hasOwn(session.agents || {}, agent),
1074
+ value: cloneSessionValue(session.agents?.[agent]),
1075
+ }])),
1076
+ };
1077
+ }
1078
+
1079
+ /** Atomically mark a legacy execution active while retaining exact rollback state. */
1080
+ export function activateAgentExecutionState(projectDir, {
1081
+ agent,
1082
+ binding,
1083
+ markInProgress = true,
1084
+ } = {}) {
1085
+ if (!agent) return { saved: false, activated: false, error: 'Agent activation requires an agent' };
1086
+ let previous = null;
1087
+ const persisted = mutateSession(projectDir, (session) => {
1088
+ session.agents = session.agents || {};
1089
+ previous = snapshotAgentDispatch(session, [agent]);
1090
+ session.current_agent = agent;
1091
+ if (binding) session.current_execution_binding = cloneSessionValue(binding);
1092
+ if (markInProgress) {
1093
+ session.agents[agent] = {
1094
+ ...(session.agents[agent] || {}),
1095
+ status: 'in_progress',
1096
+ };
1097
+ }
1098
+ return session;
1099
+ });
1100
+ return { ...persisted, activated: persisted.saved, previous };
1101
+ }
1102
+
1103
+ /**
1104
+ * Roll back an agent dispatch only if every exact execution identity still
1105
+ * owns the canonical state. This prevents compensation from erasing a newer
1106
+ * CLI's successful mutation.
1107
+ */
1108
+ export function rollbackAgentDispatch(projectDir, {
1109
+ expectedAgent,
1110
+ claims = null,
1111
+ previous,
1112
+ } = {}) {
1113
+ let outcome = { rolledBack: false };
1114
+ const persisted = mutateSession(projectDir, (session) => {
1115
+ if (!previous || session.current_agent !== expectedAgent) {
1116
+ outcome = {
1117
+ rolledBack: false,
1118
+ code: 'SESSION_ROLLBACK_CONFLICT',
1119
+ error: 'Canonical agent state changed before projection rollback.',
1120
+ };
1121
+ return session;
1122
+ }
1123
+ if (claims) {
1124
+ for (const [agent, claimId] of Object.entries(claims)) {
1125
+ if (!claimId || session.agents?.[agent]?.execution_claim_id !== claimId) {
1126
+ outcome = {
1127
+ rolledBack: false,
1128
+ code: 'SESSION_ROLLBACK_CONFLICT',
1129
+ error: `Execution claim for ${agent} changed before projection rollback.`,
1130
+ };
1131
+ return session;
1132
+ }
1133
+ }
1134
+ } else if (session.agents?.[expectedAgent]?.status !== 'in_progress'
1135
+ && previous.agents?.[expectedAgent]?.value?.status !== session.agents?.[expectedAgent]?.status) {
1136
+ outcome = {
1137
+ rolledBack: false,
1138
+ code: 'SESSION_ROLLBACK_CONFLICT',
1139
+ error: `Agent ${expectedAgent} changed before projection rollback.`,
1140
+ };
1141
+ return session;
1142
+ }
1143
+
1144
+ session.current_agent = previous.current_agent;
1145
+ if (previous.has_current_execution_binding) {
1146
+ session.current_execution_binding = cloneSessionValue(previous.current_execution_binding);
1147
+ } else {
1148
+ delete session.current_execution_binding;
1149
+ }
1150
+ session.agents = session.agents || {};
1151
+ for (const [agent, snapshot] of Object.entries(previous.agents || {})) {
1152
+ if (snapshot.present) session.agents[agent] = cloneSessionValue(snapshot.value);
1153
+ else delete session.agents[agent];
1154
+ }
1155
+ outcome = { rolledBack: true };
1156
+ return session;
1157
+ });
1158
+ return { ...persisted, ...outcome };
1159
+ }
1160
+
1161
+ export function updateContextUsage(projectDir, {
1162
+ delta = 0,
1163
+ contextWindowTokens,
1164
+ detectedModel = null,
1165
+ routedProvider = null,
1166
+ } = {}) {
1167
+ const normalizedDelta = Math.max(0, Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 0);
1168
+ const normalizedWindow = Math.max(1, Number.isFinite(Number(contextWindowTokens))
1169
+ ? Math.trunc(Number(contextWindowTokens))
1170
+ : 200_000);
1171
+ const persisted = mutateSession(projectDir, (session) => {
1172
+ const current = Math.max(0, Number.isFinite(Number(session.context_tokens_used))
1173
+ ? Math.trunc(Number(session.context_tokens_used))
1174
+ : 0);
1175
+ const tokensUsed = Math.min(normalizedWindow, current + normalizedDelta);
1176
+ const remainingPercent = Math.max(0, Math.round((1 - tokensUsed / normalizedWindow) * 100));
1177
+ const bracket = remainingPercent < 25
1178
+ ? 'CRITICAL'
1179
+ : remainingPercent < 40
1180
+ ? 'DEPLETED'
1181
+ : remainingPercent < 60
1182
+ ? 'MODERATE'
1183
+ : 'FRESH';
1184
+ session.context_tokens_used = tokensUsed;
1185
+ session.context_window_tokens = normalizedWindow;
1186
+ session.context_last_bracket = bracket;
1187
+ if (detectedModel && (session.current_execution_binding?.model || !session.active_model)) {
1188
+ session.active_model = detectedModel;
1189
+ }
1190
+ if (routedProvider) session.active_provider = routedProvider;
1191
+ if ((detectedModel || session.active_model) && !session.model_at_last_audit) {
1192
+ session.model_at_last_audit = detectedModel || session.active_model;
1193
+ }
1194
+ return session;
1195
+ });
1196
+ if (!persisted.saved) return persisted;
1197
+ return {
1198
+ ...persisted,
1199
+ tokensUsed: persisted.session.context_tokens_used,
1200
+ contextWindowTokens: persisted.session.context_window_tokens,
1201
+ bracket: persisted.session.context_last_bracket,
1202
+ };
1203
+ }
1204
+
439
1205
  function mutateSession(projectDir, mutator) {
440
- const sessionPath = join(projectDir, SESSION_FILE);
441
- if (!existsSync(sessionPath)) return { saved: false, error: 'Session file not found' };
1206
+ let sessionPath;
1207
+ try {
1208
+ sessionPath = validateSessionPath(projectDir, { requireFile: true });
1209
+ } catch (err) {
1210
+ return { saved: false, error: err.message };
1211
+ }
442
1212
  let releaseLock = null;
443
1213
  let outcome;
444
1214
  try {
445
1215
  releaseLock = waitForSessionWriteLock(sessionPath);
446
- const session = yaml.load(readFileSync(sessionPath, 'utf-8'));
447
- if (!session || typeof session !== 'object' || Array.isArray(session)) {
448
- throw new Error('Invalid session document');
449
- }
1216
+ const record = readSessionDocumentRecord(sessionPath);
1217
+ const session = record.session;
450
1218
  migrateSession(session);
451
1219
  const nextSession = mutator(session) || session;
1220
+ assertSessionWritableSize(nextSession);
452
1221
  const yamlContent = yaml.dump(nextSession, { lineWidth: -1, noRefs: true });
453
- writeFileAtomic(sessionPath, yamlContent);
1222
+ writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
1223
+ expectedDestinationIdentity: record.identity,
1224
+ expectedDirectoryIdentity: record.directoryIdentity,
1225
+ });
454
1226
  outcome = { saved: true, session: nextSession };
455
1227
  } catch (err) {
456
1228
  outcome = { saved: false, error: `Failed to update session: ${err.message}` };
@@ -466,6 +1238,38 @@ function mutateSession(projectDir, mutator) {
466
1238
  return outcome;
467
1239
  }
468
1240
 
1241
+ /**
1242
+ * Runs a projection against the latest canonical session while holding the
1243
+ * same cross-process lock used by mutations. The callback may update derived
1244
+ * harness files, but cannot replace session.yaml itself.
1245
+ */
1246
+ export function withSessionProjectionLock(projectDir, projector) {
1247
+ if (typeof projector !== 'function') return { projected: false, error: 'Session projector must be a function' };
1248
+ let sessionPath;
1249
+ try {
1250
+ sessionPath = validateSessionPath(projectDir, { requireFile: true });
1251
+ } catch (err) {
1252
+ return { projected: false, error: err.message, ...(err.code ? { code: err.code } : {}) };
1253
+ }
1254
+ let releaseLock = null;
1255
+ let outcome;
1256
+ try {
1257
+ releaseLock = waitForSessionWriteLock(sessionPath);
1258
+ const session = readSessionDocumentRecord(sessionPath).session;
1259
+ migrateSession(session);
1260
+ outcome = { projected: true, session, result: projector(session) };
1261
+ } catch (err) {
1262
+ outcome = { projected: false, error: `Failed to project session: ${err.message}`, ...(err.code ? { code: err.code } : {}) };
1263
+ } finally {
1264
+ if (releaseLock) {
1265
+ try { releaseLock(); } catch (err) {
1266
+ outcome = { projected: false, error: `Session projection lock was compromised: ${err.message}` };
1267
+ }
1268
+ }
1269
+ }
1270
+ return outcome;
1271
+ }
1272
+
469
1273
  /**
470
1274
  * Append the exact provider/model binding used for an execution.
471
1275
  * A short cross-process lock prevents parallel agents from overwriting each
@@ -492,6 +1296,393 @@ export function recordModelSelection(projectDir, selection) {
492
1296
  });
493
1297
  }
494
1298
 
1299
+ /** Persist an interactive question so another harness resumes it verbatim. */
1300
+ export function recordPendingInteraction(projectDir, interaction) {
1301
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1302
+ if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
1303
+ if (!interaction?.agent || !interaction?.question) {
1304
+ return { saved: false, error: 'Pending interaction requires agent and question' };
1305
+ }
1306
+ return mutateSession(sessionProjectDir, (session) => {
1307
+ const previous = session.pending_interaction;
1308
+ const taskId = interaction.taskId || `${interaction.agent}-task`;
1309
+ const binding = {
1310
+ provider: interaction.provider || null,
1311
+ provider_id: interaction.providerId || null,
1312
+ model: interaction.model || null,
1313
+ reasoning_configuration: interaction.reasoningConfiguration || null,
1314
+ catalog_snapshot_ref: interaction.catalogSnapshotRef || null,
1315
+ };
1316
+ const sameRelay = previous?.agent === interaction.agent && previous?.task_id === taskId;
1317
+ const relayAdvanced = sameRelay && interaction.advanceRelay === true;
1318
+ const interactionId = (!sameRelay || relayAdvanced || !previous?.interaction_id)
1319
+ ? randomUUID()
1320
+ : previous.interaction_id;
1321
+ const now = interaction.timestamp || new Date().toISOString();
1322
+ session.current_agent = interaction.agent;
1323
+ session.current_execution_binding = binding;
1324
+ session.agents = session.agents || {};
1325
+ const existingAgent = session.agents[interaction.agent] || {};
1326
+ session.agents[interaction.agent] = {
1327
+ ...existingAgent,
1328
+ status: 'waiting_for_user',
1329
+ interaction_id: interactionId,
1330
+ };
1331
+ delete session.agents[interaction.agent].execution_claim_id;
1332
+ delete session.agents[interaction.agent].execution_claim_state;
1333
+ delete session.agents[interaction.agent].execution_claim_started_at;
1334
+ delete session.agents[interaction.agent].execution_claim_expires_at;
1335
+ session.pending_interaction = {
1336
+ interaction_id: interactionId,
1337
+ status: 'waiting_for_user',
1338
+ agent: interaction.agent,
1339
+ task_id: taskId,
1340
+ language: session.language || 'en',
1341
+ question: interaction.question,
1342
+ relay_cycles: relayAdvanced ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
1343
+ execution_binding: binding,
1344
+ created_at: sameRelay ? previous.created_at : now,
1345
+ updated_at: now,
1346
+ };
1347
+ return session;
1348
+ });
1349
+ }
1350
+
1351
+ /** Atomically reserve one or more agent executions before returning commands. */
1352
+ export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
1353
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1354
+ if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
1355
+ const requested = executions.filter((entry) => entry?.agent);
1356
+ if (requested.length === 0) return { saved: true, claimed: false, claims: {} };
1357
+ let outcome = { claimed: false, claims: {}, previous: null };
1358
+ const now = claim.timestamp ? new Date(claim.timestamp) : new Date();
1359
+ const leaseMs = Math.max(60_000, Number(claim.leaseMs) || 30 * 60 * 1000);
1360
+ const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1361
+ const result = mutateSession(sessionProjectDir, (session) => {
1362
+ session.agents = session.agents || {};
1363
+ for (const entry of requested) {
1364
+ const current = session.agents[entry.agent] || {};
1365
+ if (current.status === 'completed' || session.completed_agents?.includes(entry.agent)) {
1366
+ outcome = {
1367
+ claimed: false,
1368
+ code: 'AGENT_ALREADY_COMPLETED',
1369
+ error: `Agent ${entry.agent} is already recorded as completed.`,
1370
+ agent: entry.agent,
1371
+ };
1372
+ return session;
1373
+ }
1374
+ if (current.status === 'waiting_for_user') {
1375
+ outcome = {
1376
+ claimed: false,
1377
+ code: 'AGENT_WAITING_FOR_USER',
1378
+ error: `Agent ${entry.agent} has a pending user interaction.`,
1379
+ agent: entry.agent,
1380
+ };
1381
+ return session;
1382
+ }
1383
+ if (current.status === 'awaiting_advance' || current.execution_claim_state === 'awaiting_advance') {
1384
+ outcome = {
1385
+ claimed: false,
1386
+ code: 'AGENT_AWAITING_ADVANCE',
1387
+ error: `Agent ${entry.agent} finished its routed process and is waiting for canonical advance.`,
1388
+ agent: entry.agent,
1389
+ };
1390
+ return session;
1391
+ }
1392
+ const expiry = Date.parse(current.execution_claim_expires_at || '');
1393
+ const liveClaim = current.status === 'in_progress'
1394
+ && current.execution_claim_id
1395
+ && Number.isFinite(expiry)
1396
+ && expiry > now.getTime();
1397
+ if (liveClaim) {
1398
+ outcome = {
1399
+ claimed: false,
1400
+ code: 'AGENT_EXECUTION_IN_FLIGHT',
1401
+ error: `Agent ${entry.agent} already has an active execution claim.`,
1402
+ agent: entry.agent,
1403
+ };
1404
+ return session;
1405
+ }
1406
+ }
1407
+ outcome.previous = snapshotAgentDispatch(session, requested.map(({ agent }) => agent));
1408
+ for (const entry of requested) {
1409
+ const claimId = randomUUID();
1410
+ session.agents[entry.agent] = {
1411
+ ...(session.agents[entry.agent] || {}),
1412
+ status: 'in_progress',
1413
+ execution_claim_id: claimId,
1414
+ execution_claim_state: 'reserved',
1415
+ execution_claim_started_at: now.toISOString(),
1416
+ execution_claim_expires_at: expiresAt,
1417
+ };
1418
+ outcome.claims[entry.agent] = claimId;
1419
+ }
1420
+ session.current_agent = requested[0].agent;
1421
+ if (requested[0].binding) session.current_execution_binding = requested[0].binding;
1422
+ outcome.claimed = true;
1423
+ return session;
1424
+ });
1425
+ return { ...result, ...outcome };
1426
+ }
1427
+
1428
+ /** Atomically consume a reserved execution command exactly once. */
1429
+ export function startAgentExecution(projectDir, match = {}) {
1430
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1431
+ if (!sessionProjectDir) return { saved: false, started: false, error: 'Session file not found in working directory or its parents' };
1432
+ let outcome = { started: false };
1433
+ const result = mutateSession(sessionProjectDir, (session) => {
1434
+ const agent = session.agents?.[match.agent];
1435
+ const expired = Date.parse(agent?.execution_claim_expires_at || '') <= Date.now();
1436
+ if (!agent || agent.execution_claim_id !== match.claimId || agent.execution_claim_state !== 'reserved' || expired) {
1437
+ outcome = {
1438
+ started: false,
1439
+ code: 'AGENT_EXECUTION_CLAIM_INVALID',
1440
+ error: `Execution claim for ${match.agent || 'unknown'} is missing, stale, or already consumed.`,
1441
+ };
1442
+ return session;
1443
+ }
1444
+ agent.execution_claim_state = 'running';
1445
+ agent.execution_started_at = match.timestamp || new Date().toISOString();
1446
+ outcome = { started: true };
1447
+ return session;
1448
+ });
1449
+ return { ...result, ...outcome };
1450
+ }
1451
+
1452
+ /** Keep a successful routed result durable until the canonical advance records completion. */
1453
+ export function holdAgentExecutionForAdvance(projectDir, match = {}) {
1454
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1455
+ if (!sessionProjectDir) return { saved: false, held: false, error: 'Session file not found in working directory or its parents' };
1456
+ let outcome = { held: false };
1457
+ const result = mutateSession(sessionProjectDir, (session) => {
1458
+ const agent = session.agents?.[match.agent];
1459
+ if (!agent) {
1460
+ outcome = { held: false, code: 'AGENT_EXECUTION_NOT_FOUND', error: `Agent ${match.agent || 'unknown'} is not present in the canonical session.` };
1461
+ return session;
1462
+ }
1463
+ if (match.claimId && agent.execution_claim_id !== match.claimId) {
1464
+ outcome = { held: false, code: 'AGENT_EXECUTION_CLAIM_INVALID', error: `Execution claim for ${match.agent} does not own this result.` };
1465
+ return session;
1466
+ }
1467
+ agent.status = 'awaiting_advance';
1468
+ agent.execution_claim_state = 'awaiting_advance';
1469
+ agent.execution_finished_at = match.timestamp || new Date().toISOString();
1470
+ delete agent.execution_claim_expires_at;
1471
+ delete agent.execution_started_at;
1472
+ outcome = { held: true };
1473
+ return session;
1474
+ });
1475
+ return { ...result, ...outcome };
1476
+ }
1477
+
1478
+ /** Release an exact execution claim after completion or controlled failure. */
1479
+ export function releaseAgentExecution(projectDir, match = {}) {
1480
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1481
+ if (!sessionProjectDir) return { saved: false, released: false, error: 'Session file not found in working directory or its parents' };
1482
+ let released = false;
1483
+ const result = mutateSession(sessionProjectDir, (session) => {
1484
+ const agent = session.agents?.[match.agent];
1485
+ if (!agent || agent.execution_claim_id !== match.claimId) return session;
1486
+ if (agent.execution_claim_state === 'awaiting_advance' || agent.status === 'awaiting_advance') return session;
1487
+ if (agent.status === 'in_progress') agent.status = 'pending';
1488
+ delete agent.execution_claim_id;
1489
+ delete agent.execution_claim_state;
1490
+ delete agent.execution_claim_started_at;
1491
+ delete agent.execution_claim_expires_at;
1492
+ delete agent.execution_started_at;
1493
+ released = true;
1494
+ return session;
1495
+ });
1496
+ return { ...result, released };
1497
+ }
1498
+
1499
+ /** Cancel commands that were reserved but never started when a session exits. */
1500
+ export function releaseReservedAgentExecutions(projectDir) {
1501
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1502
+ if (!sessionProjectDir) return { saved: false, released: 0, error: 'Session file not found in working directory or its parents' };
1503
+ let released = 0;
1504
+ const result = mutateSession(sessionProjectDir, (session) => {
1505
+ for (const agent of Object.values(session.agents || {})) {
1506
+ if (agent.execution_claim_state !== 'reserved') continue;
1507
+ if (agent.status === 'in_progress') agent.status = 'pending';
1508
+ delete agent.execution_claim_id;
1509
+ delete agent.execution_claim_state;
1510
+ delete agent.execution_claim_started_at;
1511
+ delete agent.execution_claim_expires_at;
1512
+ released += 1;
1513
+ }
1514
+ return session;
1515
+ });
1516
+ return { ...result, released };
1517
+ }
1518
+
1519
+ /** Add an identity to pending interactions created by older runtime versions. */
1520
+ export function ensurePendingInteractionIdentity(projectDir, fallbackBinding = null) {
1521
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1522
+ if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
1523
+ let interactionId = null;
1524
+ const result = mutateSession(sessionProjectDir, (session) => {
1525
+ if (!session.pending_interaction) return session;
1526
+ const existingId = session.pending_interaction.interaction_id;
1527
+ interactionId = isSafeInteractionId(existingId) ? existingId : randomUUID();
1528
+ session.pending_interaction.interaction_id = interactionId;
1529
+ if (!session.pending_interaction.execution_binding && fallbackBinding) {
1530
+ session.pending_interaction.execution_binding = {
1531
+ provider: fallbackBinding.provider || null,
1532
+ provider_id: fallbackBinding.provider_id || null,
1533
+ model: fallbackBinding.model || null,
1534
+ reasoning_configuration: fallbackBinding.reasoning_configuration || null,
1535
+ catalog_snapshot_ref: fallbackBinding.catalog_snapshot_ref || null,
1536
+ };
1537
+ }
1538
+ return session;
1539
+ });
1540
+ return { ...result, interactionId };
1541
+ }
1542
+
1543
+ /** Restrict interaction identities to path-inert tokens before file derivation. */
1544
+ export function isSafeInteractionId(value) {
1545
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
1546
+ }
1547
+
1548
+ /** Atomically reserve a pending relay before any provider process is started. */
1549
+ export function claimPendingInteraction(projectDir, claim = {}) {
1550
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1551
+ if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
1552
+ let outcome = { allowed: true, claimed: false };
1553
+ const result = mutateSession(sessionProjectDir, (session) => {
1554
+ const pending = session.pending_interaction;
1555
+ if (!pending) {
1556
+ if (claim.interactionId || (typeof claim.response === 'string' && claim.response.trim())) {
1557
+ outcome = {
1558
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_NOT_FOUND',
1559
+ error: 'The pending interaction no longer exists. Refusing a stale relay command.',
1560
+ };
1561
+ }
1562
+ return session;
1563
+ }
1564
+ if (pending.status !== 'waiting_for_user') {
1565
+ outcome = {
1566
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_ALREADY_CLAIMED',
1567
+ error: `Pending interaction ${pending.interaction_id || 'unknown'} is already being relayed.`,
1568
+ };
1569
+ return session;
1570
+ }
1571
+ const reasoningEscalation = claim.reasoningEscalation === true;
1572
+ if ((pending.relay_cycles || 1) <= 3 && reasoningEscalation) {
1573
+ outcome = {
1574
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_ESCALATION_NOT_REQUIRED',
1575
+ error: 'Reasoning escalation is only allowed after three unresolved relay cycles.',
1576
+ };
1577
+ return session;
1578
+ }
1579
+ if ((pending.relay_cycles || 1) > 3 && !reasoningEscalation) {
1580
+ outcome = {
1581
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_RELAY_LIMIT',
1582
+ error: `Pending interaction reached the maximum of 3 relay cycles and requires reasoning escalation.`,
1583
+ };
1584
+ return session;
1585
+ }
1586
+ const response = typeof claim.response === 'string' ? claim.response.trim() : '';
1587
+ if (!response) {
1588
+ outcome = {
1589
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_REQUIRES_RESPONSE',
1590
+ error: `Agent ${pending.agent} is waiting for the user's response.`,
1591
+ };
1592
+ return session;
1593
+ }
1594
+ const binding = reasoningEscalation ? {
1595
+ provider: claim.provider || null,
1596
+ provider_id: claim.providerId || null,
1597
+ model: claim.model || null,
1598
+ reasoning_configuration: claim.reasoningConfiguration || null,
1599
+ catalog_snapshot_ref: claim.catalogSnapshotRef || null,
1600
+ } : (pending.execution_binding || {});
1601
+ const mismatches = [
1602
+ ['interaction_id', pending.interaction_id, claim.interactionId],
1603
+ ['agent', pending.agent, claim.agent],
1604
+ ['task_id', pending.task_id, claim.taskId],
1605
+ ['provider', binding.provider || null, claim.provider || null],
1606
+ ['provider_id', binding.provider_id || null, claim.providerId || null],
1607
+ ['model', binding.model || null, claim.model || null],
1608
+ ['reasoning_configuration', binding.reasoning_configuration || null, claim.reasoningConfiguration || null],
1609
+ ['catalog_snapshot_ref', binding.catalog_snapshot_ref || null, claim.catalogSnapshotRef || null],
1610
+ ].filter(([, expected, actual]) => expected !== actual);
1611
+ if (mismatches.length > 0) {
1612
+ outcome = {
1613
+ allowed: false, claimed: false, code: 'PENDING_INTERACTION_BINDING_MISMATCH',
1614
+ error: `Pending interaction binding mismatch: ${mismatches.map(([field]) => field).join(', ')}.`,
1615
+ };
1616
+ return session;
1617
+ }
1618
+ const claimId = randomUUID();
1619
+ session.pending_interaction = {
1620
+ ...pending,
1621
+ status: 'relay_in_flight',
1622
+ ...(reasoningEscalation ? { escalation_execution_binding: binding } : {}),
1623
+ relay_claim_id: claimId,
1624
+ relay_started_at: claim.timestamp || new Date().toISOString(),
1625
+ relay_lease_expires_at: claim.leaseExpiresAt || null,
1626
+ };
1627
+ outcome = { allowed: true, claimed: true, claimId };
1628
+ return session;
1629
+ });
1630
+ return { ...result, ...outcome };
1631
+ }
1632
+
1633
+ /** Restore a claimed relay after a controlled provider or contract failure. */
1634
+ export function releasePendingInteraction(projectDir, match = {}) {
1635
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1636
+ if (!sessionProjectDir) return { saved: false, released: false, error: 'Session file not found in working directory or its parents' };
1637
+ let released = false;
1638
+ let settled = false;
1639
+ const result = mutateSession(sessionProjectDir, (session) => {
1640
+ const pending = session.pending_interaction;
1641
+ if (!pending) {
1642
+ settled = true;
1643
+ return session;
1644
+ }
1645
+ if (pending.status === 'waiting_for_user' && (!match.interactionId || pending.interaction_id === match.interactionId)) {
1646
+ released = true;
1647
+ settled = true;
1648
+ return session;
1649
+ }
1650
+ if (pending.status !== 'relay_in_flight') return session;
1651
+ if (match.claimId && pending.relay_claim_id !== match.claimId) return session;
1652
+ if (match.interactionId && pending.interaction_id !== match.interactionId) return session;
1653
+ session.pending_interaction = { ...pending, status: 'waiting_for_user' };
1654
+ delete session.pending_interaction.relay_claim_id;
1655
+ delete session.pending_interaction.relay_started_at;
1656
+ delete session.pending_interaction.relay_lease_expires_at;
1657
+ released = true;
1658
+ settled = true;
1659
+ return session;
1660
+ });
1661
+ return { ...result, released, settled };
1662
+ }
1663
+
1664
+ /** Clear a pending interaction only when the caller owns that relay. */
1665
+ export function clearPendingInteraction(projectDir, match = {}) {
1666
+ const sessionProjectDir = findSessionProjectDir(projectDir);
1667
+ if (!sessionProjectDir) return { saved: false, cleared: false, error: 'Session file not found in working directory or its parents' };
1668
+ let cleared = false;
1669
+ const result = mutateSession(sessionProjectDir, (session) => {
1670
+ const pending = session.pending_interaction;
1671
+ if (!pending) return session;
1672
+ if (match.agent && pending.agent !== match.agent) return session;
1673
+ if (match.taskId && pending.task_id !== match.taskId) return session;
1674
+ if (match.interactionId && pending.interaction_id !== match.interactionId) return session;
1675
+ if (match.claimId && pending.relay_claim_id !== match.claimId) return session;
1676
+ session.pending_interaction = null;
1677
+ if (session.agents?.[pending.agent]?.status === 'waiting_for_user') {
1678
+ session.agents[pending.agent].status = 'in_progress';
1679
+ }
1680
+ cleared = true;
1681
+ return session;
1682
+ });
1683
+ return { ...result, cleared };
1684
+ }
1685
+
495
1686
  /**
496
1687
  * Record a mode transition in session history.
497
1688
  * @param {string} projectDir
@@ -540,6 +1731,12 @@ export function recordAgentCompletion(projectDir, completion) {
540
1731
  session.agents[agent].status = status;
541
1732
  session.agents[agent].score = score || 0;
542
1733
  session.agents[agent].completed_at = new Date().toISOString();
1734
+ delete session.agents[agent].execution_claim_id;
1735
+ delete session.agents[agent].execution_claim_state;
1736
+ delete session.agents[agent].execution_claim_started_at;
1737
+ delete session.agents[agent].execution_claim_expires_at;
1738
+ delete session.agents[agent].execution_started_at;
1739
+ delete session.agents[agent].execution_finished_at;
543
1740
 
544
1741
  // Add to completed agents list
545
1742
  session.completed_agents = session.completed_agents || [];