@timurproko/a1 0.2.6-dev.734 → 0.2.6

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 (45) hide show
  1. package/dist/app/session-shell/index.d.ts +2 -2
  2. package/dist/app/session-shell/index.js +1 -1
  3. package/dist/app/session-shell/session-shell-root.d.ts +9 -1
  4. package/dist/app/session-shell/session-shell-root.js +56 -47
  5. package/dist/app/session-shell/session-shell.d.ts +37 -9
  6. package/dist/app/session-shell/session-shell.js +266 -446
  7. package/dist/app/session-shell/shell-host.d.ts +105 -0
  8. package/dist/app/session-shell/shell-host.js +523 -0
  9. package/dist/cli/session-context.js +6 -3
  10. package/dist/composition/owned-ui.js +45 -25
  11. package/dist/contracts/owned-ui/index.d.ts +1 -1
  12. package/dist/contracts/owned-ui/session-presenters.d.ts +35 -8
  13. package/dist/features/launch/index.d.ts +1 -1
  14. package/dist/features/launch/index.js +1 -1
  15. package/dist/features/launch/session-context.d.ts +1 -0
  16. package/dist/features/launch/session-context.js +1 -0
  17. package/dist/features/owned-ui/resources/release-notes.json +4 -0
  18. package/dist/features/owned-ui/settings-app.js +3 -3
  19. package/dist/foundation/lifecycle/index.d.ts +1 -1
  20. package/dist/foundation/lifecycle/index.js +1 -1
  21. package/dist/foundation/lifecycle/session-repository-context.d.ts +8 -2
  22. package/dist/foundation/lifecycle/session-repository-context.js +200 -65
  23. package/dist/foundation/release/cohort-state.js +5 -2
  24. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +11 -0
  25. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +18 -2
  26. package/dist/native/darwin-arm64/manifest.json +1 -1
  27. package/dist/native/linux-x64/manifest.json +1 -1
  28. package/dist/native/win32-x64/manifest.json +2 -2
  29. package/dist/native/win32-x64/process-guardian.exe +0 -0
  30. package/dist/ui/components/value-menu.d.ts +6 -3
  31. package/dist/ui/components/value-menu.js +6 -5
  32. package/docs/architecture/boundaries.md +1 -1
  33. package/docs/architecture/evidence/terminal-host-spike/manual-one-pane-terminal-host.md +70 -0
  34. package/docs/architecture/evidence/terminal-host-spike/manual-two-by-two-basic-terminal-host.md +32 -0
  35. package/docs/architecture/evidence/terminal-host-spike/milestone-baseline.json +115 -0
  36. package/docs/architecture/evidence/terminal-host-spike/owned-ui-foundation-prerequisite.json +48 -0
  37. package/docs/architecture/evidence/terminal-host-spike/terminal-host-provenance.json +125 -0
  38. package/docs/architecture/evidence/terminal-host-spike/terminal-host-task-5-1.json +56 -0
  39. package/docs/architecture/evidence/terminal-host-spike/terminal-host-task-5-3.json +174 -0
  40. package/docs/architecture/evidence/terminal-host-spike/terminal-spike-acceptance-record.json +18 -0
  41. package/docs/architecture/terminal-host-proof-gate.md +1 -1
  42. package/docs/architecture/terminal-host-spike-evidence.md +1 -1
  43. package/docs/architecture/ui-reference-provenance.md +1 -1
  44. package/docs/releases/0.2.6.md +44 -0
  45. package/package.json +1 -1
@@ -13,52 +13,60 @@ const MAX_WORKTREES = 256;
13
13
  const GIT_TIMEOUT_MS = 5_000;
14
14
  const LOCK_TIMEOUT_MS = 2_000;
15
15
  const LOCK_RETRY_MS = 20;
16
+ /** Records without an agent id were written by a single-agent release and belong to its one agent. */
17
+ export const PRIMARY_SESSION_AGENT_ID = "primary";
18
+ const RECORD_FORMAT_VERSION = 2;
16
19
  /** Publish the exact live runtime that may acquire a delivery worktree. */
17
20
  export async function registerSessionRepositoryRuntime(identity, runtimeId, runtimeIdentity, options = {}) {
18
21
  const header = await readSessionHeader(identity);
19
22
  assertRuntimeId(runtimeId);
23
+ const agentId = requireAgentId(options);
20
24
  assertProcessIdentity(runtimeIdentity);
21
- await withClaimLock(options, async () => {
22
- await writeRuntimeRecord(header, runtimeId, runtimeIdentity, options);
25
+ await withClaimLock({ ...options, runtimeIdentity }, async () => {
26
+ await writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options);
23
27
  });
24
28
  }
25
- /** Revalidate a durable association and acquire its live claim for this runtime. */
29
+ /** Revalidate a durable association and acquire its live claim for this runtime's agent; sibling agents' claims stay. */
26
30
  export async function activateSessionRepositoryContext(identity, runtimeId, runtimeIdentity, options = {}) {
27
31
  const header = await readSessionHeader(identity);
28
32
  assertRuntimeId(runtimeId);
33
+ const agentId = requireAgentId(options);
29
34
  assertProcessIdentity(runtimeIdentity);
30
- return await withClaimLock(options, async () => {
31
- await writeRuntimeRecord(header, runtimeId, runtimeIdentity, options);
35
+ const owner = { runtimeId, agentId };
36
+ return await withClaimLock({ ...options, runtimeIdentity }, async () => {
37
+ await writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options);
32
38
  const record = await readContextRecord(header, options);
33
39
  if (record === null) {
34
- await releaseClaimsOwnedBy(runtimeId, null, options);
40
+ await releaseClaimsOwnedBy(owner, null, options);
35
41
  return null;
36
42
  }
37
43
  const selected = await validateContextRecord(header, record, options.signal).catch(() => null);
38
44
  if (selected === null) {
39
- await releaseClaimsOwnedBy(runtimeId, null, options);
45
+ await releaseClaimsOwnedBy(owner, null, options);
40
46
  return null;
41
47
  }
42
- const status = await claimAvailability(selected, runtimeId, options);
48
+ const status = await claimAvailability(selected, owner, options);
43
49
  if (status !== "available" && status !== "current") {
44
- await releaseClaimsOwnedBy(runtimeId, null, options);
50
+ await releaseClaimsOwnedBy(owner, null, options);
45
51
  return null;
46
52
  }
47
- await writeClaim(header, selected, runtimeId, runtimeIdentity, options);
48
- await releaseClaimsOwnedBy(runtimeId, selected.gitDir, options);
53
+ await writeClaim(header, selected, owner, runtimeIdentity, options);
54
+ await releaseClaimsOwnedBy(owner, selected.gitDir, options);
49
55
  return { cwd: selected.root, branch: requireBranch(selected) };
50
56
  });
51
57
  }
52
- /** Release only this exact runtime generation's cooperative editing claims. */
58
+ /** Release every agent's cooperative editing claims held by this exact runtime generation. */
53
59
  export async function releaseSessionRepositoryRuntime(runtimeId, runtimeIdentity, options = {}) {
54
60
  assertRuntimeId(runtimeId);
55
61
  assertProcessIdentity(runtimeIdentity);
56
- await withClaimLock(options, async () => {
57
- const runtime = await readRuntimeRecord(runtimeId, options);
58
- if (runtime === null || runtime.pid !== runtimeIdentity.pid || runtime.startIdentity !== runtimeIdentity.startIdentity)
62
+ await withClaimLock({ ...options, runtimeIdentity }, async () => {
63
+ const runtimes = (await readRuntimeRecordsOf(runtimeId, options))
64
+ .filter(({ record }) => record.pid === runtimeIdentity.pid && record.startIdentity === runtimeIdentity.startIdentity);
65
+ if (runtimes.length === 0)
59
66
  return;
60
- await releaseClaimsOwnedBy(runtimeId, null, options);
61
- await rm(runtimeRecordPath(runtimeId, options), { force: true });
67
+ await releaseRuntimeClaims(runtimeId, options);
68
+ for (const { path } of runtimes)
69
+ await rm(path, { force: true });
62
70
  });
63
71
  }
64
72
  /** Persist one explicit same-repository worktree association after atomically claiming it. */
@@ -69,27 +77,27 @@ export async function setSessionRepositoryContext(identity, worktreePath, option
69
77
  if (!samePath(startup.commonDir, selected.commonDir)) {
70
78
  throw contextError("associated worktree belongs to a different Git repository", "worktree-foreign-repository");
71
79
  }
72
- const runtimeId = requireRuntimeId(options);
80
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
73
81
  return await withClaimLock(options, async () => {
74
- const runtime = await requireLiveRuntime(header, runtimeId, options);
75
- const status = await claimAvailability(selected, runtimeId, options);
82
+ const runtime = await requireLiveRuntime(header, owner, options);
83
+ const status = await claimAvailability(selected, owner, options);
76
84
  if (status === "busy")
77
85
  throw contextError("worktree is active in another session", "worktree-active-in-another-session");
78
86
  if (status === "unverifiable")
79
87
  throw contextError("worktree ownership cannot be verified", "worktree-owner-unverifiable");
80
88
  const prior = await readContextRecord(header, options);
81
- await writeClaim(header, selected, runtimeId, processIdentityOf(runtime), options);
89
+ await writeClaim(header, selected, owner, processIdentityOf(runtime), options);
82
90
  try {
83
91
  await writeContextRecord(header, startup, selected, options);
84
92
  }
85
93
  catch (error) {
86
94
  if (status !== "current")
87
- await removeMatchingClaim(selected, runtimeId, options);
95
+ await removeMatchingClaim(selected, owner, options);
88
96
  throw error;
89
97
  }
90
- await releaseClaimsOwnedBy(runtimeId, selected.gitDir, options);
98
+ await releaseClaimsOwnedBy(owner, selected.gitDir, options);
91
99
  if (prior !== null && !samePath(prior.worktreeGitDir, selected.gitDir)) {
92
- await removeClaimByGitDir(prior.worktreeCommonDir, prior.worktreeGitDir, runtimeId, options);
100
+ await removeClaimByGitDir(prior.worktreeCommonDir, prior.worktreeGitDir, owner, options);
93
101
  }
94
102
  return { cwd: selected.root, branch: requireBranch(selected) };
95
103
  });
@@ -101,11 +109,11 @@ export async function clearSessionRepositoryContext(identity, options = {}) {
101
109
  await rm(contextRecordPath(header, options), { force: true });
102
110
  return;
103
111
  }
104
- const runtimeId = requireRuntimeId(options);
112
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
105
113
  await withClaimLock(options, async () => {
106
- await requireLiveRuntime(header, runtimeId, options);
114
+ await requireLiveRuntime(header, owner, options);
107
115
  await rm(contextRecordPath(header, options), { force: true });
108
- await releaseClaimsOwnedBy(runtimeId, null, options);
116
+ await releaseClaimsOwnedBy(owner, null, options);
109
117
  });
110
118
  }
111
119
  /** Read and revalidate a persisted association; malformed or stale state fails closed to absence. */
@@ -126,8 +134,8 @@ export async function readSessionRepositoryContext(identity, options = {}) {
126
134
  /** List same-repository worktrees without acquiring, releasing, or repairing a claim. */
127
135
  export async function listSessionRepositoryWorktrees(identity, options = {}) {
128
136
  const header = await readSessionHeader(identity);
129
- const runtimeId = requireRuntimeId(options);
130
- await requireLiveRuntime(header, runtimeId, options);
137
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
138
+ await requireLiveRuntime(header, owner, options);
131
139
  const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
132
140
  const worktrees = await listGitWorktrees(startup.root, startup.commonDir, options.signal);
133
141
  if (worktrees.length > MAX_WORKTREES)
@@ -136,19 +144,17 @@ export async function listSessionRepositoryWorktrees(identity, options = {}) {
136
144
  const entries = [];
137
145
  for (const selected of worktrees) {
138
146
  if (selected.branch === null) {
139
- entries.push({ cwd: selected.root, branch: "(detached)", status: "unverifiable" });
147
+ entries.push({ cwd: selected.root, branch: "(detached)", status: "unverifiable", agentId: null });
140
148
  continue;
141
149
  }
142
- entries.push({
143
- cwd: selected.root,
144
- branch: selected.branch,
145
- status: contended ? "unverifiable" : await claimAvailability(selected, runtimeId, options),
146
- });
150
+ const status = contended ? "unverifiable" : await claimAvailability(selected, owner, options);
151
+ const claim = status === "current" || status === "busy" ? await readClaim(selected, options) : null;
152
+ entries.push({ cwd: selected.root, branch: selected.branch, status, agentId: claim?.agentId ?? null });
147
153
  }
148
154
  return entries.sort((left, right) => pathKey(left.cwd).localeCompare(pathKey(right.cwd)));
149
155
  }
150
- async function requireLiveRuntime(header, runtimeId, options) {
151
- const runtime = await readRuntimeRecord(runtimeId, options);
156
+ async function requireLiveRuntime(header, owner, options) {
157
+ const runtime = await readRuntimeRecord(owner, options);
152
158
  if (runtime === null || runtime.sessionId !== header.id || runtime.sessionFile !== header.file) {
153
159
  throw contextError("active session runtime is unavailable", "session-runtime-unavailable");
154
160
  }
@@ -165,14 +171,15 @@ async function requireLiveRuntime(header, runtimeId, options) {
165
171
  }
166
172
  return runtime;
167
173
  }
168
- async function claimAvailability(selected, runtimeId, options) {
174
+ async function claimAvailability(selected, owner, options) {
169
175
  const claim = await readClaim(selected, options);
170
176
  if (claim === undefined)
171
177
  return "available";
172
178
  if (claim === null || !claimMatchesWorktree(claim, selected))
173
179
  return "unverifiable";
174
- if (claim.runtimeId === runtimeId)
180
+ if (ownedBy(claim, owner))
175
181
  return "current";
182
+ // Invariant: a sibling agent of this runtime is live by construction, so its claim reads busy below.
176
183
  const inspection = requireInspector(options);
177
184
  let observed;
178
185
  try {
@@ -184,8 +191,8 @@ async function claimAvailability(selected, runtimeId, options) {
184
191
  const live = observed !== null && observed.pid === claim.pid && observed.startIdentity === claim.startIdentity;
185
192
  return live ? "busy" : "available";
186
193
  }
187
- async function writeRuntimeRecord(header, runtimeId, runtimeIdentity, options) {
188
- const existing = await readRuntimeRecord(runtimeId, options);
194
+ async function writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options) {
195
+ const existing = await readRuntimeRecord({ runtimeId, agentId }, options);
189
196
  if (existing !== null && (existing.pid !== runtimeIdentity.pid || existing.startIdentity !== runtimeIdentity.startIdentity)) {
190
197
  const inspection = requireInspector(options);
191
198
  let observed;
@@ -201,14 +208,16 @@ async function writeRuntimeRecord(header, runtimeId, runtimeIdentity, options) {
201
208
  }
202
209
  const record = {
203
210
  schema: RUNTIME_SCHEMA,
211
+ formatVersion: RECORD_FORMAT_VERSION,
204
212
  runtimeId,
213
+ agentId,
205
214
  sessionId: header.id,
206
215
  sessionFile: header.file,
207
216
  pid: runtimeIdentity.pid,
208
217
  startIdentity: runtimeIdentity.startIdentity,
209
218
  updatedAt: new Date().toISOString(),
210
219
  };
211
- await atomicWrite(runtimeRecordPath(runtimeId, options), record);
220
+ await atomicWrite(runtimeRecordPath({ runtimeId, agentId }, options), record);
212
221
  }
213
222
  async function writeContextRecord(header, startup, selected, options) {
214
223
  const record = {
@@ -224,10 +233,12 @@ async function writeContextRecord(header, startup, selected, options) {
224
233
  };
225
234
  await atomicWrite(contextRecordPath(header, options), record);
226
235
  }
227
- async function writeClaim(header, selected, runtimeId, runtimeIdentity, options) {
236
+ async function writeClaim(header, selected, owner, runtimeIdentity, options) {
228
237
  const claim = {
229
238
  schema: CLAIM_SCHEMA,
230
- runtimeId,
239
+ formatVersion: RECORD_FORMAT_VERSION,
240
+ runtimeId: owner.runtimeId,
241
+ agentId: owner.agentId,
231
242
  sessionId: header.id,
232
243
  worktreeRoot: selected.root,
233
244
  worktreeCommonDir: selected.commonDir,
@@ -241,8 +252,34 @@ async function writeClaim(header, selected, runtimeId, runtimeIdentity, options)
241
252
  async function readContextRecord(header, options) {
242
253
  return await readJsonRecord(contextRecordPath(header, options), isContextRecord);
243
254
  }
244
- async function readRuntimeRecord(runtimeId, options) {
245
- return await readJsonRecord(runtimeRecordPath(runtimeId, options), isRuntimeRecord);
255
+ async function readRuntimeRecord(owner, options) {
256
+ const record = await readRuntimeRecordAt(runtimeRecordPath(owner, options));
257
+ return record !== null && ownedBy(record, owner) ? record : null;
258
+ }
259
+ async function readRuntimeRecordAt(path) {
260
+ const stored = await readJsonRecord(path, isStoredRuntimeRecord);
261
+ return stored === null ? null : { ...stored, formatVersion: RECORD_FORMAT_VERSION, agentId: stored.agentId ?? PRIMARY_SESSION_AGENT_ID };
262
+ }
263
+ /** Every agent's runtime record of one runtime, with the file that holds it. */
264
+ async function readRuntimeRecordsOf(runtimeId, options) {
265
+ const directory = runtimesDirectory(options);
266
+ const names = await readdir(directory).catch(() => []);
267
+ if (names.length > MAX_WORKTREES * 4)
268
+ throw contextError("session runtime store exceeds its bounded limit", "session-runtime-limit");
269
+ const records = [];
270
+ for (const name of names) {
271
+ if (!name.endsWith(".json"))
272
+ continue;
273
+ const path = join(directory, name);
274
+ const record = await readRuntimeRecordAt(path);
275
+ if (record?.runtimeId === runtimeId)
276
+ records.push({ path, record });
277
+ }
278
+ return records;
279
+ }
280
+ async function readClaimRecordAt(path) {
281
+ const stored = await readJsonRecord(path, isStoredClaimRecord);
282
+ return stored === null ? null : normalizeClaim(stored);
246
283
  }
247
284
  /** Undefined means absent; null means present but invalid. */
248
285
  async function readClaim(selected, options) {
@@ -252,7 +289,7 @@ async function readClaim(selected, options) {
252
289
  if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
253
290
  return null;
254
291
  const value = JSON.parse((await readFile(path, { signal: options.signal })).toString("utf8"));
255
- return isClaimRecord(value) ? value : null;
292
+ return isStoredClaimRecord(value) ? normalizeClaim(value) : null;
256
293
  }
257
294
  catch (error) {
258
295
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
@@ -274,7 +311,15 @@ async function validateContextRecord(header, record, signal) {
274
311
  throw new Error("associated worktree identity changed");
275
312
  return selected;
276
313
  }
277
- async function releaseClaimsOwnedBy(runtimeId, keepGitDir, options) {
314
+ /** Release one agent's claims except the one it keeps; sibling agents of the runtime are untouched. */
315
+ async function releaseClaimsOwnedBy(owner, keepGitDir, options) {
316
+ await releaseClaimsWhere(claim => ownedBy(claim, owner) && (keepGitDir === null || !samePath(claim.worktreeGitDir, keepGitDir)), options);
317
+ }
318
+ /** Release every agent's claims of one runtime; only process exit does this. */
319
+ async function releaseRuntimeClaims(runtimeId, options) {
320
+ await releaseClaimsWhere(claim => claim.runtimeId === runtimeId, options);
321
+ }
322
+ async function releaseClaimsWhere(release, options) {
278
323
  const directory = claimsDirectory(options);
279
324
  const names = await readdir(directory).catch(() => []);
280
325
  if (names.length > MAX_WORKTREES * 4)
@@ -283,19 +328,18 @@ async function releaseClaimsOwnedBy(runtimeId, keepGitDir, options) {
283
328
  if (!name.endsWith(".json"))
284
329
  continue;
285
330
  const path = join(directory, name);
286
- const claim = await readJsonRecord(path, isClaimRecord);
287
- if (claim?.runtimeId === runtimeId && (keepGitDir === null || !samePath(claim.worktreeGitDir, keepGitDir))) {
331
+ const claim = await readClaimRecordAt(path);
332
+ if (claim !== null && release(claim))
288
333
  await rm(path, { force: true });
289
- }
290
334
  }
291
335
  }
292
- async function removeMatchingClaim(selected, runtimeId, options) {
293
- await removeClaimByGitDir(selected.commonDir, selected.gitDir, runtimeId, options);
336
+ async function removeMatchingClaim(selected, owner, options) {
337
+ await removeClaimByGitDir(selected.commonDir, selected.gitDir, owner, options);
294
338
  }
295
- async function removeClaimByGitDir(commonDir, gitDir, runtimeId, options) {
339
+ async function removeClaimByGitDir(commonDir, gitDir, owner, options) {
296
340
  const path = claimRecordPath(commonDir, gitDir, options);
297
- const claim = await readJsonRecord(path, isClaimRecord);
298
- if (claim?.runtimeId === runtimeId)
341
+ const claim = await readClaimRecordAt(path);
342
+ if (claim !== null && ownedBy(claim, owner))
299
343
  await rm(path, { force: true });
300
344
  }
301
345
  async function readSessionHeader(identity) {
@@ -401,21 +445,30 @@ async function withClaimLock(options, operation) {
401
445
  await mkdir(directory, { recursive: true, mode: 0o700 });
402
446
  const path = join(directory, "mutation.lock");
403
447
  const deadline = Date.now() + (options.lockTimeoutMs ?? LOCK_TIMEOUT_MS);
448
+ const holder = await lockHolderIdentity(options);
404
449
  let handle;
405
- while (handle === undefined) {
450
+ for (let attempt = 0; handle === undefined; attempt++) {
406
451
  try {
407
452
  handle = await open(path, "wx", 0o600);
408
453
  }
409
454
  catch (error) {
410
455
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
411
456
  throw error;
457
+ if (attempt === 0 && await evictDeadLockHolder(path, options))
458
+ continue;
412
459
  if (Date.now() >= deadline)
413
460
  throw contextError("worktree claim mutation is busy", "worktree-claim-busy");
414
461
  await new Promise(resolvePromise => setTimeout(resolvePromise, LOCK_RETRY_MS));
415
462
  }
416
463
  }
417
464
  try {
418
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`);
465
+ const record = {
466
+ pid: process.pid,
467
+ ...(holder === null ? {} : { startIdentity: holder.startIdentity }),
468
+ acquiredAt: new Date().toISOString(),
469
+ nonce: randomUUID(),
470
+ };
471
+ await handle.writeFile(`${JSON.stringify(record)}\n`);
419
472
  return await operation();
420
473
  }
421
474
  finally {
@@ -423,6 +476,59 @@ async function withClaimLock(options, operation) {
423
476
  await rm(path, { force: true }).catch(() => undefined);
424
477
  }
425
478
  }
479
+ const lockHolderIdentities = new WeakMap();
480
+ /** This process's identity as recorded in the lock; null leaves the lock unevictable, never wrongly evictable. */
481
+ async function lockHolderIdentity(options) {
482
+ if (options.runtimeIdentity?.pid === process.pid)
483
+ return options.runtimeIdentity;
484
+ const inspection = options.inspectProcess;
485
+ if (inspection === undefined)
486
+ return null;
487
+ let identity = lockHolderIdentities.get(inspection);
488
+ if (identity === undefined) {
489
+ identity = inspection(process.pid)
490
+ .then(observed => observed !== null && observed.pid === process.pid ? observed : null)
491
+ .catch(() => null);
492
+ lockHolderIdentities.set(inspection, identity);
493
+ }
494
+ return await identity;
495
+ }
496
+ /**
497
+ * Evict the mutation lock only when its recorded holder is proven dead: the pid is gone or now names a process
498
+ * with another start identity. A lock without a holder identity, or a holder that cannot be inspected, is kept.
499
+ */
500
+ async function evictDeadLockHolder(path, options) {
501
+ const inspection = options.inspectProcess;
502
+ if (inspection === undefined)
503
+ return false;
504
+ const holder = await readJsonRecord(path, isMutationLockRecord);
505
+ if (holder === null || holder.startIdentity === undefined || holder.pid === process.pid)
506
+ return false;
507
+ let observed;
508
+ try {
509
+ observed = await inspection(holder.pid);
510
+ }
511
+ catch {
512
+ return false;
513
+ }
514
+ if (observed !== null && observed.pid === holder.pid && observed.startIdentity === holder.startIdentity)
515
+ return false;
516
+ const evicted = `${path}.${randomUUID()}.evicted`;
517
+ try {
518
+ await rename(path, evicted);
519
+ }
520
+ catch {
521
+ return false;
522
+ }
523
+ const moved = await readJsonRecord(evicted, isMutationLockRecord);
524
+ if (moved?.nonce !== holder.nonce) {
525
+ // Concurrency: another waiter evicted first and a live holder took the lock since; hand it back untouched.
526
+ await rename(evicted, path).catch(() => undefined);
527
+ return false;
528
+ }
529
+ await rm(evicted, { force: true });
530
+ return true;
531
+ }
426
532
  async function atomicWrite(path, value) {
427
533
  const directory = dirname(path);
428
534
  await mkdir(directory, { recursive: true, mode: 0o700 });
@@ -455,8 +561,13 @@ function contextRecordPath(header, options) {
455
561
  function claimStoreRoot(options) {
456
562
  return join(options.dataDir ?? resolveProductPaths().dataDir, PRODUCT_IDENTITY.state.sessionWorktreeClaimDirectory);
457
563
  }
458
- function runtimeRecordPath(runtimeId, options) {
459
- return join(claimStoreRoot(options), "runtimes", `${digest(runtimeId)}.json`);
564
+ function runtimesDirectory(options) {
565
+ return join(claimStoreRoot(options), "runtimes");
566
+ }
567
+ // Compatibility: the primary agent keeps the single-agent file name, so a version-1 runtime record is its record.
568
+ function runtimeRecordPath(owner, options) {
569
+ const key = owner.agentId === PRIMARY_SESSION_AGENT_ID ? owner.runtimeId : `${owner.runtimeId}\0${owner.agentId}`;
570
+ return join(runtimesDirectory(options), `${digest(key)}.json`);
460
571
  }
461
572
  function claimsDirectory(options) {
462
573
  return join(claimStoreRoot(options), "worktrees");
@@ -472,6 +583,15 @@ function requireRuntimeId(options) {
472
583
  assertRuntimeId(runtimeId);
473
584
  return runtimeId;
474
585
  }
586
+ function requireAgentId(options) {
587
+ const agentId = options.agentId ?? PRIMARY_SESSION_AGENT_ID;
588
+ if (!validSessionId(agentId))
589
+ throw contextError("session agent identity is invalid", "session-agent-invalid");
590
+ return agentId;
591
+ }
592
+ function ownedBy(record, owner) {
593
+ return record.runtimeId === owner.runtimeId && record.agentId === owner.agentId;
594
+ }
475
595
  function requireInspector(options) {
476
596
  if (options.inspectProcess === undefined)
477
597
  throw contextError("native process identity inspection is unavailable", "process-inspection-unavailable");
@@ -520,19 +640,34 @@ function isContextRecord(value) {
520
640
  && validPath(value.worktreeRoot) && validPath(value.worktreeCommonDir) && validPath(value.worktreeGitDir)
521
641
  && typeof value.updatedAt === "string";
522
642
  }
523
- function isRuntimeRecord(value) {
524
- return isRecord(value) && value.schema === RUNTIME_SCHEMA && validSessionId(value.runtimeId) && validSessionId(value.sessionId)
525
- && validPath(value.sessionFile) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
643
+ function isStoredRuntimeRecord(value) {
644
+ return isRecord(value) && value.schema === RUNTIME_SCHEMA && validRecordVersion(value) && validSessionId(value.runtimeId)
645
+ && validSessionId(value.sessionId) && validPath(value.sessionFile) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
526
646
  && typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
527
647
  && !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
528
648
  }
529
- function isClaimRecord(value) {
530
- return isRecord(value) && value.schema === CLAIM_SCHEMA && validSessionId(value.runtimeId) && validSessionId(value.sessionId)
649
+ function isStoredClaimRecord(value) {
650
+ return isRecord(value) && value.schema === CLAIM_SCHEMA && validRecordVersion(value) && validSessionId(value.runtimeId)
651
+ && validSessionId(value.sessionId)
531
652
  && validPath(value.worktreeRoot) && validPath(value.worktreeCommonDir) && validPath(value.worktreeGitDir)
532
653
  && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
533
654
  && typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
534
655
  && !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
535
656
  }
657
+ function isMutationLockRecord(value) {
658
+ return isRecord(value) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
659
+ && (value.startIdentity === undefined || (typeof value.startIdentity === "string" && value.startIdentity.length > 0
660
+ && value.startIdentity.length <= 512 && !value.startIdentity.includes("\0")))
661
+ && typeof value.acquiredAt === "string" && typeof value.nonce === "string" && value.nonce.length > 0;
662
+ }
663
+ function validRecordVersion(value) {
664
+ if (value.formatVersion === undefined)
665
+ return value.agentId === undefined;
666
+ return value.formatVersion === RECORD_FORMAT_VERSION && validSessionId(value.agentId);
667
+ }
668
+ function normalizeClaim(stored) {
669
+ return { ...stored, formatVersion: RECORD_FORMAT_VERSION, agentId: stored.agentId ?? PRIMARY_SESSION_AGENT_ID };
670
+ }
536
671
  function isRecord(value) {
537
672
  return typeof value === "object" && value !== null && !Array.isArray(value);
538
673
  }
@@ -402,9 +402,12 @@ async function acquireLock(path) {
402
402
  return lock;
403
403
  }
404
404
  catch (error) {
405
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
405
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
406
+ // Platform: Windows reports a lock file another holder is still deleting as EPERM or EACCES.
407
+ const releasing = process.platform === "win32" && (code === "EPERM" || code === "EACCES");
408
+ if (code !== "EEXIST" && !releasing)
406
409
  throw error;
407
- if (await reclaimAbandonedLock(path))
410
+ if (code === "EEXIST" && await reclaimAbandonedLock(path))
408
411
  continue;
409
412
  if (Date.now() >= deadline)
410
413
  throw error;
@@ -1,6 +1,11 @@
1
1
  import type { PiTuiTerminalPort } from "./contracts.js";
2
2
  export declare const PINNED_PI_TUI_DAMAGE_GRAMMAR = "@earendil-works/pi-tui@1.1.0:tui-alt-screen-one-write-v1";
3
3
  export interface PiTuiDamageFrameDescriptor {
4
+ /**
5
+ * Frame ids are compared only within one presentation epoch; absent means the current epoch. A
6
+ * presenter switch starts a new epoch, because each presenter numbers its own frames.
7
+ */
8
+ readonly epoch?: number;
4
9
  readonly frameId: number;
5
10
  readonly width: number;
6
11
  readonly height: number;
@@ -64,6 +69,12 @@ export declare class DamageAwareTerminalAdapter implements PiTuiTerminalPort {
64
69
  get lastDecision(): PiTuiDamageDecision;
65
70
  get hyperlinkCleanupPending(): boolean;
66
71
  get canvasBackgroundAnsi(): string | null;
72
+ get presentationEpoch(): number;
73
+ /**
74
+ * Starts a new presentation epoch: the remembered cells and the armed frame are discarded and frame ids
75
+ * restart, so the next frame is painted in full rather than reconciled against another presenter's.
76
+ */
77
+ invalidatePresentation(): void;
67
78
  /** Invalidates remembered terminal cells before the shell forces one complete canvas repaint. */
68
79
  setCanvasBackground(ansi: string | null): boolean;
69
80
  /**
@@ -23,6 +23,7 @@ export class DamageAwareTerminalAdapter {
23
23
  #cleanedRevision = 0;
24
24
  #recoveryRevision = 0;
25
25
  #epoch = 0;
26
+ #presentationEpoch = 0;
26
27
  #armed;
27
28
  #lastConsumedFrameId = 0;
28
29
  #cacheWidth = 0;
@@ -44,6 +45,17 @@ export class DamageAwareTerminalAdapter {
44
45
  get lastDecision() { return this.#decision; }
45
46
  get hyperlinkCleanupPending() { return this.#cleanupRevision > this.#cleanedRevision; }
46
47
  get canvasBackgroundAnsi() { return this.#canvasBackgroundAnsi; }
48
+ get presentationEpoch() { return this.#presentationEpoch; }
49
+ /**
50
+ * Starts a new presentation epoch: the remembered cells and the armed frame are discarded and frame ids
51
+ * restart, so the next frame is painted in full rather than reconciled against another presenter's.
52
+ */
53
+ invalidatePresentation() {
54
+ this.#presentationEpoch += 1;
55
+ this.#armed = undefined;
56
+ this.#lastConsumedFrameId = 0;
57
+ this.#invalidatePresentation();
58
+ }
47
59
  /** Invalidates remembered terminal cells before the shell forces one complete canvas repaint. */
48
60
  setCanvasBackground(ansi) {
49
61
  if (this.#canvasBackgroundAnsi === ansi)
@@ -108,7 +120,7 @@ export class DamageAwareTerminalAdapter {
108
120
  const armed = this.#armed;
109
121
  this.#armed = undefined;
110
122
  const parsed = parsePinnedFullscreenWrite(data);
111
- if (armed !== undefined && armed.descriptor.frameId <= this.#lastConsumedFrameId) {
123
+ if (armed !== undefined && this.#isStale(armed.descriptor)) {
112
124
  // Concurrency: a recognized obsolete frame must never repaint newer cells,
113
125
  // acknowledge cleanup, or become the reference for a subsequent differential.
114
126
  this.#decision = this.#decide(armed, parsed);
@@ -189,7 +201,7 @@ export class DamageAwareTerminalAdapter {
189
201
  shiftRows: descriptor.verticalShiftRows,
190
202
  paintedRows: [],
191
203
  };
192
- if (descriptor.frameId <= this.#lastConsumedFrameId)
204
+ if (this.#isStale(descriptor))
193
205
  return { ...base, reason: "stale-frame" };
194
206
  this.#lastConsumedFrameId = descriptor.frameId;
195
207
  if (this.hyperlinkCleanupPending)
@@ -265,6 +277,10 @@ export class DamageAwareTerminalAdapter {
265
277
  paintedRows: [...painted].sort((left, right) => left - right),
266
278
  };
267
279
  }
280
+ #isStale(descriptor) {
281
+ return (descriptor.epoch ?? this.#presentationEpoch) !== this.#presentationEpoch
282
+ || descriptor.frameId <= this.#lastConsumedFrameId;
283
+ }
268
284
  #rememberFrame(descriptor, parsed, raw) {
269
285
  if (raw.includes("\u001b[2J") || raw.includes("\u001b[?1049") || raw.includes("\u001b_G") || raw.includes("\u001bPq")) {
270
286
  this.#invalidateRows();
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-10-10T06:00:13.890Z",
8
+ "builtAt": "2026-10-10T17:42:20.005Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "51b0b2dc1c9ae577f2586c4217066929278726c2c15308d9144eb6f538e118ff",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-10-10T06:00:04.832Z",
8
+ "builtAt": "2026-10-10T17:42:10.255Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "ef62679e83bc1fad3d38f9f21675d9256f08d20e67fd4791571032115ca1b0e9",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-10-10T06:00:51.067Z",
8
+ "builtAt": "2026-10-10T17:42:54.136Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "e9c6d7e4596a7cd2634bb74b67925499fe6817cbfb667b574d27ff1b7cb33d93",
11
+ "sha256": "bdafa323144718cc73b6e5a970608fd58420ec343f6fa7fac76aeb216edae485",
12
12
  "size": 288256
13
13
  },
14
14
  "provenance": {
@@ -1,8 +1,8 @@
1
1
  import type { UiTheme } from "./theme.js";
2
2
  /**
3
3
  * The menu a value opens: anchored to the row it was opened from, marking what
4
- * is in effect, highlighting nothing until something is picked, and flipping
5
- * above its anchor only when it would otherwise run off the bottom.
4
+ * is in effect, highlighting nothing until something is picked, and laid over
5
+ * its anchor so the value in effect stays where it was read.
6
6
  */
7
7
  export interface ValueMenuState {
8
8
  /** Values offered, as they should read. */
@@ -37,7 +37,10 @@ export interface ValueMenuLayout {
37
37
  /** Columns reserved at the right edge, such as a scrollbar rail. */
38
38
  readonly reservedRight: number;
39
39
  }
40
- /** Where the menu is placed: below its anchor where there is room, else above. */
40
+ /**
41
+ * Where the menu is placed: over its anchor, so the value in effect sits on the very
42
+ * row it was read from, shifted only as far as the body edges require.
43
+ */
41
44
  export declare function valueMenuFrame(state: ValueMenuState, anchor: ValueMenuAnchor, layout: ValueMenuLayout): ValueMenuFrame;
42
45
  /** Draws the menu over the rows behind it, leaving them otherwise untouched. */
43
46
  export declare function renderValueMenu(lines: readonly string[], state: ValueMenuState, frame: ValueMenuFrame, theme: UiTheme): readonly string[];