chati-dev 4.5.16 → 4.5.28

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 (122) 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 +1 -1
  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 +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -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 +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  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 +247 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. 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,
@@ -162,15 +661,22 @@ const DEFAULT_SESSION = {
162
661
  * @returns {{ created: boolean, session: object, path: string }}
163
662
  */
164
663
  export function initSession(projectDir, options = {}) {
165
- const sessionPath = join(projectDir, SESSION_FILE);
166
- const dir = dirname(sessionPath);
167
-
168
- if (!existsSync(dir)) {
169
- 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 };
170
669
  }
171
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
+ );
172
676
  const session = {
173
677
  ...DEFAULT_SESSION,
678
+ runtime_version: options.runtimeVersion === 2 ? 2 : 1,
679
+ agents: sessionAgents,
174
680
  mode: options.mode || 'discover',
175
681
  project: {
176
682
  name: options.projectName || '',
@@ -185,27 +691,69 @@ export function initSession(projectDir, options = {}) {
185
691
  providers_enabled: options.providersEnabled || [],
186
692
  };
187
693
 
694
+ let releaseLock = null;
695
+ let outcome;
188
696
  try {
189
- const yamlContent = yaml.dump(session, {
190
- lineWidth: -1,
191
- noRefs: true,
192
- });
193
-
194
- writeFileAtomic(sessionPath, yamlContent);
195
-
196
- return {
197
- created: true,
198
- session,
199
- path: sessionPath,
200
- };
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
+ }
201
735
  } catch (err) {
202
- return {
736
+ outcome = {
203
737
  created: false,
204
738
  session: null,
205
739
  path: sessionPath,
206
740
  error: `Failed to create session: ${err.message}`,
207
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
+ }
208
755
  }
756
+ return outcome;
209
757
  }
210
758
 
211
759
  /**
@@ -378,19 +926,21 @@ function inferLegacyProvider(model) {
378
926
  * @returns {{ loaded: boolean, session: object|null, error: string|null, migrated?: boolean }}
379
927
  */
380
928
  export function loadSession(projectDir) {
381
- const sessionPath = join(projectDir, SESSION_FILE);
382
-
383
- if (!existsSync(sessionPath)) {
929
+ let sessionPath;
930
+ try {
931
+ sessionPath = validateSessionPath(projectDir, { requireFile: true });
932
+ } catch (err) {
384
933
  return {
385
934
  loaded: false,
386
935
  session: null,
387
- error: 'Session file not found',
936
+ error: err.message,
388
937
  };
389
938
  }
390
-
391
939
  try {
392
- const content = readFileSync(sessionPath, 'utf-8');
393
- 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);
394
944
 
395
945
  // Run migration if needed
396
946
  const migration = migrateSession(session);
@@ -458,21 +1008,221 @@ export function updateSession(projectDir, updates) {
458
1008
  });
459
1009
  }
460
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
+
461
1205
  function mutateSession(projectDir, mutator) {
462
- const sessionPath = join(projectDir, SESSION_FILE);
463
- 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
+ }
464
1212
  let releaseLock = null;
465
1213
  let outcome;
466
1214
  try {
467
1215
  releaseLock = waitForSessionWriteLock(sessionPath);
468
- const session = yaml.load(readFileSync(sessionPath, 'utf-8'));
469
- if (!session || typeof session !== 'object' || Array.isArray(session)) {
470
- throw new Error('Invalid session document');
471
- }
1216
+ const record = readSessionDocumentRecord(sessionPath);
1217
+ const session = record.session;
472
1218
  migrateSession(session);
473
1219
  const nextSession = mutator(session) || session;
1220
+ assertSessionWritableSize(nextSession);
474
1221
  const yamlContent = yaml.dump(nextSession, { lineWidth: -1, noRefs: true });
475
- writeFileAtomic(sessionPath, yamlContent);
1222
+ writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
1223
+ expectedDestinationIdentity: record.identity,
1224
+ expectedDirectoryIdentity: record.directoryIdentity,
1225
+ });
476
1226
  outcome = { saved: true, session: nextSession };
477
1227
  } catch (err) {
478
1228
  outcome = { saved: false, error: `Failed to update session: ${err.message}` };
@@ -488,6 +1238,38 @@ function mutateSession(projectDir, mutator) {
488
1238
  return outcome;
489
1239
  }
490
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
+
491
1273
  /**
492
1274
  * Append the exact provider/model binding used for an execution.
493
1275
  * A short cross-process lock prevents parallel agents from overwriting each
@@ -532,21 +1314,32 @@ export function recordPendingInteraction(projectDir, interaction) {
532
1314
  catalog_snapshot_ref: interaction.catalogSnapshotRef || null,
533
1315
  };
534
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;
535
1321
  const now = interaction.timestamp || new Date().toISOString();
536
1322
  session.current_agent = interaction.agent;
537
1323
  session.current_execution_binding = binding;
538
1324
  session.agents = session.agents || {};
1325
+ const existingAgent = session.agents[interaction.agent] || {};
539
1326
  session.agents[interaction.agent] = {
540
- ...(session.agents[interaction.agent] || {}),
1327
+ ...existingAgent,
541
1328
  status: 'waiting_for_user',
1329
+ interaction_id: interactionId,
542
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;
543
1335
  session.pending_interaction = {
1336
+ interaction_id: interactionId,
544
1337
  status: 'waiting_for_user',
545
1338
  agent: interaction.agent,
546
1339
  task_id: taskId,
547
1340
  language: session.language || 'en',
548
1341
  question: interaction.question,
549
- relay_cycles: sameRelay ? (previous.relay_cycles || 1) + 1 : 1,
1342
+ relay_cycles: relayAdvanced ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
550
1343
  execution_binding: binding,
551
1344
  created_at: sameRelay ? previous.created_at : now,
552
1345
  updated_at: now,
@@ -555,6 +1348,319 @@ export function recordPendingInteraction(projectDir, interaction) {
555
1348
  });
556
1349
  }
557
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
+
558
1664
  /** Clear a pending interaction only when the caller owns that relay. */
559
1665
  export function clearPendingInteraction(projectDir, match = {}) {
560
1666
  const sessionProjectDir = findSessionProjectDir(projectDir);
@@ -565,6 +1671,8 @@ export function clearPendingInteraction(projectDir, match = {}) {
565
1671
  if (!pending) return session;
566
1672
  if (match.agent && pending.agent !== match.agent) return session;
567
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;
568
1676
  session.pending_interaction = null;
569
1677
  if (session.agents?.[pending.agent]?.status === 'waiting_for_user') {
570
1678
  session.agents[pending.agent].status = 'in_progress';
@@ -623,6 +1731,12 @@ export function recordAgentCompletion(projectDir, completion) {
623
1731
  session.agents[agent].status = status;
624
1732
  session.agents[agent].score = score || 0;
625
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;
626
1740
 
627
1741
  // Add to completed agents list
628
1742
  session.completed_agents = session.completed_agents || [];