@timurproko/a1 0.2.7-dev.1276 → 0.2.7-dev.1285

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 (180) hide show
  1. package/README.md +5 -1
  2. package/dist/app/session-shell/session-shell-root.d.ts +4 -68
  3. package/dist/app/session-shell/session-shell.js +5 -11
  4. package/dist/app/session-shell/shell-host.js +2 -2
  5. package/dist/app/session-shell/shell-options.d.ts +65 -0
  6. package/dist/app/session-shell/shell-options.js +1 -0
  7. package/dist/app/session-shell/workflow-router.d.ts +3 -1
  8. package/dist/app/session-shell/workflow-router.js +12 -0
  9. package/dist/cli/dispatch.js +27 -1
  10. package/dist/cli/packages.js +18 -2
  11. package/dist/cli/session-context.js +6 -2
  12. package/dist/composition/owned-ui.js +20 -8
  13. package/dist/composition/session-naming.js +1 -1
  14. package/dist/composition/session-runtime-registry.d.ts +2 -0
  15. package/dist/composition/session-runtime-registry.js +49 -9
  16. package/dist/contracts/agent-engine/package-ports.d.ts +10 -14
  17. package/dist/contracts/agent-engine/package-ports.js +5 -9
  18. package/dist/contracts/owned-ui/session-workflows.d.ts +6 -0
  19. package/dist/contracts/presentation/index.d.ts +1 -0
  20. package/dist/contracts/presentation/progress-palette.generated.d.ts +1 -1
  21. package/dist/contracts/presentation/progress-palette.generated.js +1 -1
  22. package/dist/contracts/presentation/progress-spinner.d.ts +7 -0
  23. package/dist/contracts/presentation/progress-spinner.js +1 -0
  24. package/dist/features/launch/session-context.d.ts +8 -0
  25. package/dist/features/launch/session-context.js +19 -10
  26. package/dist/features/owned-ui/reference-screen-app.js +9 -5
  27. package/dist/features/owned-ui/session-fork-prompt.d.ts +2 -1
  28. package/dist/features/owned-ui/session-fork-prompt.js +11 -5
  29. package/dist/features/owned-ui/settings-app.d.ts +1 -1
  30. package/dist/features/owned-ui/settings-app.js +14 -7
  31. package/dist/features/owned-ui/settings-spinner-preview.d.ts +9 -0
  32. package/dist/features/owned-ui/settings-spinner-preview.js +29 -0
  33. package/dist/features/session-naming/context.d.ts +17 -0
  34. package/dist/features/session-naming/context.js +39 -0
  35. package/dist/features/session-naming/controller.d.ts +1 -5
  36. package/dist/features/session-naming/controller.js +7 -4
  37. package/dist/features/session-naming/generate.d.ts +2 -1
  38. package/dist/features/session-naming/generate.js +14 -10
  39. package/dist/features/session-naming/index.d.ts +1 -1
  40. package/dist/foundation/lifecycle/index.d.ts +3 -1
  41. package/dist/foundation/lifecycle/index.js +3 -1
  42. package/dist/foundation/lifecycle/session-claim-lock.d.ts +2 -2
  43. package/dist/foundation/lifecycle/session-claim-lock.js +36 -10
  44. package/dist/foundation/lifecycle/session-context-retry.d.ts +2 -0
  45. package/dist/foundation/lifecycle/session-context-retry.js +17 -0
  46. package/dist/foundation/lifecycle/session-repository-context.d.ts +7 -1
  47. package/dist/foundation/lifecycle/session-repository-context.js +105 -39
  48. package/dist/foundation/lifecycle/session-worktree-entry.d.ts +3 -0
  49. package/dist/foundation/lifecycle/session-worktree-entry.js +87 -0
  50. package/dist/foundation/release/automatic-update.js +2 -2
  51. package/dist/foundation/release/bootstrap.js +19 -3
  52. package/dist/foundation/release/release-gc.js +20 -5
  53. package/dist/foundation/release/release-state.d.ts +12 -3
  54. package/dist/foundation/release/release-state.js +55 -24
  55. package/dist/foundation/release/release-use.d.ts +10 -0
  56. package/dist/foundation/release/release-use.js +70 -0
  57. package/dist/foundation/release/update-activation.js +2 -2
  58. package/dist/foundation/release/update-recovery.js +2 -2
  59. package/dist/foundation/release/update.js +1 -1
  60. package/dist/foundation/release/warmup.js +8 -9
  61. package/dist/foundation/session-writer-lock/index.d.ts +2 -2
  62. package/dist/foundation/session-writer-lock/index.js +82 -12
  63. package/dist/foundation/startup/startup-descriptor.js +2 -2
  64. package/dist/foundation/terminal-cleanup/fatal-diagnostic.d.ts +2 -0
  65. package/dist/foundation/terminal-cleanup/fatal-diagnostic.js +59 -0
  66. package/dist/foundation/terminal-cleanup/fatal-exit.js +1 -29
  67. package/dist/integrations/pi/components/resources/builtin-themes.d.ts +2 -2
  68. package/dist/integrations/pi/components/resources/builtin-themes.js +2 -2
  69. package/dist/integrations/pi/components/share-operation-dialog.d.ts +2 -1
  70. package/dist/integrations/pi/components/share-operation-dialog.js +13 -5
  71. package/dist/integrations/pi/components/shell-footer-status.d.ts +2 -1
  72. package/dist/integrations/pi/components/shell-footer-status.js +15 -8
  73. package/dist/integrations/pi/components/shell-session-info-reference.js +11 -8
  74. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
  75. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +1 -1
  76. package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
  77. package/dist/integrations/pi/components/upstream/components/auth-url.d.ts +1 -1
  78. package/dist/integrations/pi/components/upstream/components/auth-url.js +1 -1
  79. package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
  80. package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
  81. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
  82. package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
  83. package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
  84. package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
  85. package/dist/integrations/pi/components/upstream/components/login-dialog.js +1 -1
  86. package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
  87. package/dist/integrations/pi/components/upstream/components/oauth-selector.d.ts +1 -1
  88. package/dist/integrations/pi/components/upstream/components/oauth-selector.js +1 -1
  89. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
  90. package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
  91. package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
  92. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  93. package/dist/integrations/pi/components/upstream/components/session-footer.js +14 -8
  94. package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
  95. package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
  96. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
  97. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
  98. package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +1 -1
  99. package/dist/integrations/pi/components/upstream/components/status-indicator.js +1 -1
  100. package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
  101. package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
  102. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
  103. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
  104. package/dist/integrations/pi/components/upstream/components/tool-execution.js +1 -1
  105. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
  106. package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
  107. package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
  108. package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
  109. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +1 -1
  110. package/dist/integrations/pi/components/upstream/history/editor-core.js +1 -1
  111. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
  112. package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
  113. package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
  114. package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
  115. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
  116. package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
  117. package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
  118. package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
  119. package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
  120. package/dist/integrations/pi/components/upstream/theme/theme.js +1 -1
  121. package/dist/integrations/pi/engine/adapter.d.ts +3 -1
  122. package/dist/integrations/pi/engine/adapter.js +24 -6
  123. package/dist/integrations/pi/engine/changelog.js +5 -2
  124. package/dist/integrations/pi/engine/delivery-retention.d.ts +7 -0
  125. package/dist/integrations/pi/engine/delivery-retention.js +66 -0
  126. package/dist/integrations/pi/engine/event-delivery.d.ts +17 -1
  127. package/dist/integrations/pi/engine/event-delivery.js +25 -3
  128. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -1
  129. package/dist/integrations/pi/engine/extension-ui-binding.js +45 -0
  130. package/dist/integrations/pi/engine/host-ports.d.ts +2 -0
  131. package/dist/integrations/pi/engine/host.d.ts +3 -0
  132. package/dist/integrations/pi/engine/host.js +2 -0
  133. package/dist/integrations/pi/engine/package-integration.js +19 -4
  134. package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -1
  135. package/dist/integrations/pi/engine/pending-delivery.js +51 -18
  136. package/dist/integrations/pi/engine/resources/changelog.json +4 -4
  137. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -0
  138. package/dist/integrations/pi/engine/runtime-integration.js +58 -9
  139. package/dist/integrations/pi/engine/session-cwd-recovery.d.ts +11 -0
  140. package/dist/integrations/pi/engine/session-cwd-recovery.js +18 -0
  141. package/dist/integrations/pi/engine/session-runtime.d.ts +3 -0
  142. package/dist/integrations/pi/engine/session-runtime.js +2 -0
  143. package/dist/integrations/pi/engine/session-selection.d.ts +3 -2
  144. package/dist/integrations/pi/engine/session-selection.js +25 -5
  145. package/dist/integrations/pi/engine/transcript-image-assets.d.ts +3 -0
  146. package/dist/integrations/pi/engine/transcript-image-assets.js +65 -14
  147. package/dist/integrations/pi/engine/view-state.d.ts +14 -0
  148. package/dist/integrations/pi/engine/view-state.js +17 -0
  149. package/dist/integrations/pi/engine/workflow-runner.d.ts +2 -0
  150. package/dist/integrations/pi/engine/workflow-runner.js +47 -26
  151. package/dist/integrations/pi/engine/worktree-tools.d.ts +4 -0
  152. package/dist/integrations/pi/engine/worktree-tools.js +26 -0
  153. package/dist/integrations/pi/session-presenters/components.d.ts +2 -1
  154. package/dist/integrations/pi/session-presenters/components.js +5 -3
  155. package/dist/integrations/pi/session-presenters/presenters.d.ts +3 -1
  156. package/dist/integrations/pi/session-presenters/presenters.js +1 -1
  157. package/dist/integrations/pi/startup-public.d.ts +1 -1
  158. package/dist/integrations/pi/startup-public.js +16 -20
  159. package/dist/integrations/pi/startup-public.manifest.json +29 -29
  160. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
  161. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
  162. package/dist/product-identity.json +1 -1
  163. package/dist/runtime-payload-inventory.json +5027 -5027
  164. package/dist/ui/components/index.d.ts +1 -1
  165. package/dist/ui/components/index.js +1 -1
  166. package/dist/ui/components/progress-status.d.ts +7 -0
  167. package/dist/ui/components/progress-status.js +21 -0
  168. package/dist/ui/components/value-menu.d.ts +1 -1
  169. package/dist/ui/components/value-menu.js +4 -2
  170. package/dist/ui/settings/declarations.d.ts +16 -4
  171. package/dist/ui/settings/declarations.js +13 -4
  172. package/docs/architecture/internal-naming.md +1 -1
  173. package/docs/architecture/project-structure.md +7 -11
  174. package/docs/features/session-exports.md +1 -1
  175. package/docs/features/session-naming.md +8 -6
  176. package/docs/features/worktrees.md +38 -0
  177. package/docs/local-worktree-cleanup.md +27 -4
  178. package/docs/openspec-archive-automation.md +30 -4
  179. package/docs/worktree-link-recovery.md +2 -2
  180. package/package.json +5 -5
@@ -1,21 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
2
+ import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
5
5
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
6
6
  export const RELEASE_STATE_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
7
7
  const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
8
- /** How long a release stays on disk after its approval, so processes started from it keep their files. */
8
+ /** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
9
9
  export const RELEASE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
10
10
  /**
11
11
  * Age-based retention. A release stays while a selector names it (active, its rollback, or a
12
12
  * candidate still being certified), while an unfinished update may return to it, while the
13
13
  * reconciling command runs from it, or while its approval is younger than the retention window.
14
- * Nothing asks which processes are running: a session that outlives the window and two updates
15
- * can lose its release files, which is the documented trade-off.
14
+ * Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
16
15
  */
17
16
  export function planProtectedReleases(state, inputs = {}) {
18
17
  const protectedIds = new Set();
18
+ for (const claim of Object.values(state.uses ?? {}))
19
+ if (releaseUseIsLive(claim))
20
+ protectedIds.add(claim.releaseId);
19
21
  for (const id of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
20
22
  if (id !== null)
21
23
  protectedIds.add(id);
@@ -28,7 +30,7 @@ export function planProtectedReleases(state, inputs = {}) {
28
30
  const now = inputs.now ?? Date.now();
29
31
  const window = inputs.retentionMs ?? RELEASE_RETENTION_MS;
30
32
  for (const release of Object.values(state.releases)) {
31
- if (withinRetention(release, now, window))
33
+ if (withinRetention(release, now, window) || (release.approval === "approved" && release.lifetimeClaims !== true))
32
34
  protectedIds.add(release.releaseId);
33
35
  }
34
36
  const protectedReleaseIds = [...protectedIds].sort();
@@ -104,6 +106,7 @@ export class ReleaseStateStore {
104
106
  packageVersion: release.packageVersion,
105
107
  contentDigest: release.contentDigest,
106
108
  ...(release.launchContract === undefined ? {} : { launchContract: release.launchContract }),
109
+ ...(release.files.some(file => file.path === "dist/foundation/release/release-use.js") ? { lifetimeClaims: true } : {}),
107
110
  approval: "candidate",
108
111
  materializedAt: new Date().toISOString(),
109
112
  certifiedAt: null,
@@ -363,7 +366,7 @@ async function acquireLock(path) {
363
366
  while (true) {
364
367
  try {
365
368
  const lock = await open(path, "wx", 0o600);
366
- await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
369
+ await lock.writeFile(JSON.stringify({ pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() }));
367
370
  await lock.sync();
368
371
  return lock;
369
372
  }
@@ -382,31 +385,35 @@ async function acquireLock(path) {
382
385
  }
383
386
  }
384
387
  async function reclaimAbandonedLock(path) {
385
- let abandoned = false;
388
+ let owner;
386
389
  try {
387
- const value = JSON.parse(await readFile(path, "utf8"));
388
- if (typeof value.pid === "number") {
389
- try {
390
- process.kill(value.pid, 0);
391
- }
392
- catch (error) {
393
- abandoned = !(error instanceof Error && "code" in error && error.code === "EPERM");
394
- }
390
+ owner = await readFile(path, "utf8");
391
+ const value = JSON.parse(owner);
392
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0)
393
+ return false;
394
+ try {
395
+ process.kill(value.pid, 0);
396
+ return false;
397
+ }
398
+ catch (error) {
399
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
400
+ return false;
395
401
  }
396
402
  }
397
403
  catch {
398
- const metadata = await stat(path).catch(() => null);
399
- abandoned = metadata !== null && Date.now() - metadata.mtimeMs > 10_000;
404
+ return false;
405
+ } // Security: unknown ownership never becomes safe merely through age.
406
+ const gate = `${path}.reclaim`;
407
+ try {
408
+ await mkdir(gate);
400
409
  }
401
- if (!abandoned)
410
+ catch {
402
411
  return false;
403
- const quarantine = `${path}.abandoned`;
404
- const oldQuarantine = await stat(quarantine).catch(() => null);
405
- if (oldQuarantine && Date.now() - oldQuarantine.mtimeMs > 10_000)
406
- await rm(quarantine, { force: true });
412
+ }
407
413
  try {
408
- // Concurrency: the fixed destination lets exactly one waiter claim the stale lock;
409
- // another waiter cannot accidentally rename a newly acquired replacement.
414
+ if (await readFile(path, "utf8") !== owner)
415
+ return false;
416
+ const quarantine = `${path}.abandoned-${randomUUID()}`;
410
417
  await rename(path, quarantine);
411
418
  await rm(quarantine, { force: true });
412
419
  return true;
@@ -414,6 +421,9 @@ async function reclaimAbandonedLock(path) {
414
421
  catch {
415
422
  return false;
416
423
  }
424
+ finally {
425
+ await rm(gate, { recursive: true, force: true });
426
+ }
417
427
  }
418
428
  function normalizeState(value) {
419
429
  if (!value || typeof value !== "object")
@@ -424,6 +434,8 @@ function normalizeState(value) {
424
434
  const cleanup = state.cleanup;
425
435
  return {
426
436
  ...state,
437
+ // Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
438
+ ...(state.schema === "a1-release-cohort-v1" ? { schema: RELEASE_STATE_SCHEMA } : {}),
427
439
  cleanup: cleanup
428
440
  ? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
429
441
  : { pending: {}, diagnostics: [], workerRuns: [] },
@@ -436,6 +448,13 @@ function validateState(state) {
436
448
  || !Array.isArray(state.cleanup.workerRuns)) {
437
449
  throw new Error("invalid release state");
438
450
  }
451
+ if (state.uses !== undefined && (state.uses === null || typeof state.uses !== "object" || Array.isArray(state.uses)))
452
+ throw new Error("invalid release use claims");
453
+ for (const [token, claim] of Object.entries(state.uses ?? {})) {
454
+ if (!/^[a-f0-9-]{36}$/.test(token) || !claim || !RELEASE_ID_PATTERN.test(claim.releaseId)
455
+ || !Number.isSafeInteger(claim.pid) || claim.pid <= 0 || !["launching", "running"].includes(claim.phase))
456
+ throw new Error("invalid release use claim");
457
+ }
439
458
  for (const run of state.cleanup.workerRuns) {
440
459
  if (!run || typeof run.runId !== "string" || run.runId.length === 0
441
460
  || !["scheduled", "running", "completed", "blocked", "continued", "failed", "skipped"].includes(run.status)
@@ -473,6 +492,18 @@ function validateTransition(current, next) {
473
492
  if (next.revision !== current.revision)
474
493
  throw new Error("release revision is controlled by the state store");
475
494
  }
495
+ export function releaseUseIsLive(claim) {
496
+ if (claim.phase === "launching")
497
+ return true;
498
+ // Security: PID reuse only over-retains: no live/uncertain process identity ever proves a claim dead.
499
+ try {
500
+ process.kill(claim.pid, 0);
501
+ return true;
502
+ }
503
+ catch (error) {
504
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
505
+ }
506
+ }
476
507
  function boundedError(error) {
477
508
  return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 1_000);
478
509
  }
@@ -0,0 +1,10 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ export declare function spawnReleaseEntry(entry: string, args: readonly string[], options: SpawnOptions): Promise<ChildProcess & {
3
+ releaseSettled: Promise<void>;
4
+ }>;
5
+ /** A durable launch reservation closes selection/collection and spawn/handoff races. */
6
+ export declare function acquireReleaseUse(dataDir: string, releaseId: string): Promise<{
7
+ track(child: ChildProcess): Promise<void>;
8
+ started(pid: number): Promise<void>;
9
+ release(): Promise<void>;
10
+ }>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { basename, dirname, resolve } from "node:path";
5
+ import { resolveProductPaths } from "../lifecycle/index.js";
6
+ import { ReleaseStateStore, releaseUseIsLive } from "./release-state.js";
7
+ /** Mutable installed entries are not GC-managed; immutable helper processes need their own claim. */
8
+ async function claimReleaseEntry(entry, environment) {
9
+ const dataDir = resolveProductPaths(environment).dataDir;
10
+ const root = await realpath(dirname(dirname(resolve(entry))));
11
+ const storeRoot = await realpath(resolve(dataDir, "releases")).catch(() => undefined);
12
+ if (dirname(root) !== storeRoot)
13
+ return undefined;
14
+ return acquireReleaseUse(dataDir, basename(root));
15
+ }
16
+ export async function spawnReleaseEntry(entry, args, options) {
17
+ const use = await claimReleaseEntry(entry, options.env ?? process.env);
18
+ try {
19
+ const child = spawn(process.execPath, [entry, ...args], options);
20
+ const releaseSettled = use?.track(child) ?? Promise.resolve();
21
+ // Concurrency: detached callers need no waiter; awaited callers still observe release failures.
22
+ void releaseSettled.catch(() => undefined);
23
+ return Object.assign(child, { releaseSettled });
24
+ }
25
+ catch (error) {
26
+ await use?.release();
27
+ throw error;
28
+ }
29
+ }
30
+ /** A durable launch reservation closes selection/collection and spawn/handoff races. */
31
+ export async function acquireReleaseUse(dataDir, releaseId) {
32
+ const store = new ReleaseStateStore(dataDir);
33
+ const token = randomUUID();
34
+ await store.update(state => {
35
+ if (!state.releases[releaseId])
36
+ throw new Error("Selected release is no longer available for launch");
37
+ const uses = Object.fromEntries(Object.entries(state.uses ?? {}).filter(([, claim]) => releaseUseIsLive(claim)));
38
+ return { ...state, uses: { ...uses, [token]: { releaseId, pid: process.pid, phase: "launching" } } };
39
+ });
40
+ let released = false;
41
+ return {
42
+ track(child) {
43
+ let handoff = Promise.resolve();
44
+ child.once("spawn", () => { handoff = this.started(child.pid).catch(() => undefined); });
45
+ return new Promise((resolve, reject) => {
46
+ child.once("close", () => { void handoff.then(() => this.release()).then(resolve, reject); });
47
+ });
48
+ },
49
+ async started(pid) {
50
+ if (!Number.isSafeInteger(pid) || pid <= 0 || released)
51
+ throw new Error("Invalid release process handoff");
52
+ await store.update(state => {
53
+ if (!state.uses?.[token])
54
+ throw new Error("Release launch reservation was lost");
55
+ const claim = { releaseId, pid, phase: "running" };
56
+ return { ...state, uses: { ...state.uses, [token]: claim } };
57
+ });
58
+ },
59
+ async release() {
60
+ if (released)
61
+ return;
62
+ await store.update(state => {
63
+ const uses = { ...state.uses };
64
+ delete uses[token];
65
+ return { ...state, uses };
66
+ });
67
+ released = true;
68
+ },
69
+ };
70
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnReleaseEntry } from "./release-use.js";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -77,7 +77,7 @@ export async function readActivationContracts(packageRoot, read = path => readFi
77
77
  */
78
78
  export async function delegateActivation(request, callbacks) {
79
79
  const entry = resolve(request.packageRoot, UPDATE_ACTIVATION_ENTRY);
80
- const child = spawn(process.execPath, [entry, "--data-dir", request.dataDir, "--target-version", request.targetVersion], {
80
+ const child = await spawnReleaseEntry(entry, ["--data-dir", request.dataDir, "--target-version", request.targetVersion], {
81
81
  env: request.environment,
82
82
  stdio: ["ignore", "pipe", "pipe"],
83
83
  windowsHide: true,
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { spawn } from "node:child_process";
2
+ import { spawnReleaseEntry } from "./release-use.js";
3
3
  import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -303,7 +303,7 @@ export async function cleanupUpdateRecoveryCapsules(dataDir) {
303
303
  }
304
304
  }
305
305
  async function spawnRecoveryWorker(entry, manifestPath, environment) {
306
- const child = spawn(process.execPath, [entry, "--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
306
+ const child = await spawnReleaseEntry(entry, ["--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
307
307
  await new Promise((resolvePromise, rejectPromise) => {
308
308
  child.once("spawn", resolvePromise);
309
309
  child.once("error", rejectPromise);
@@ -164,7 +164,7 @@ export function renderUpdateProgressBar(percent, accent = SHARED_PROGRESS_ACCENT
164
164
  // percentage retain the update meter's neutral treatment.
165
165
  const gray = "\u001b[38;2;128;128;128m";
166
166
  const track = "\u001b[38;2;102;102;102m";
167
- return `${accent}${"━".repeat(filled)}${track}${"─".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
167
+ return `${accent}${"━".repeat(filled)}${track}${"━".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
168
168
  }
169
169
  function updateProgressAccent(environment) {
170
170
  return earlyAccentPalette(resolveEarlyAccentColor(environment), earlyTerminalAppearance(environment), earlyTerminalColorMode(environment)).accent;
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnReleaseEntry } from "./release-use.js";
2
2
  import { resolveReleaseEntryPoint } from "./release-store.js";
3
3
  import { releaseEnvironment } from "./bootstrap.js";
4
4
  import { launchContractTarget, launchEnvironmentKeys } from "../launch-context/index.js";
@@ -11,15 +11,13 @@ function warmupDiagnostics(text) {
11
11
  /** Import the exact immutable startup graph in a terminal-free bounded child process. */
12
12
  export async function warmMaterializedRelease(release, environment, timeoutMs = 30_000) {
13
13
  const entry = await resolveReleaseEntryPoint(release, "bin/warmup.js");
14
+ const child = await spawnReleaseEntry(entry, [], {
15
+ env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
16
+ stdio: ["ignore", "ignore", "pipe"],
17
+ windowsHide: true,
18
+ });
14
19
  await new Promise((resolvePromise, rejectPromise) => {
15
- // Rationale: the child still gets no terminal, but a discarded failure reason leaves an
16
- // update reporting only an exit status. Its own diagnostics are read and bounded so the
17
- // transaction journal records why the graph could not be imported.
18
- const child = spawn(process.execPath, [entry], {
19
- env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
20
- stdio: ["ignore", "ignore", "pipe"],
21
- windowsHide: true,
22
- });
20
+ // Rationale: bounded diagnostics explain graph-import failures without attaching a terminal.
23
21
  let diagnostics = "";
24
22
  child.stderr?.setEncoding("utf8");
25
23
  child.stderr?.on("data", chunk => { diagnostics = `${diagnostics}${chunk}`.slice(-WARMUP_DIAGNOSTIC_LIMIT); });
@@ -44,4 +42,5 @@ export async function warmMaterializedRelease(release, environment, timeoutMs =
44
42
  }, timeoutMs);
45
43
  timer.unref?.();
46
44
  });
45
+ await child.releaseSettled;
47
46
  }
@@ -20,8 +20,8 @@ export interface SessionWriterHandle {
20
20
  release(): void;
21
21
  }
22
22
  /**
23
- * A crashed writer is not released by the kernel; its lock becomes stale once its holder stops
24
- * refreshing it. The bound keeps resume after a crash a matter of seconds.
23
+ * Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
24
+ * Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
25
25
  */
26
26
  export declare const SESSION_WRITER_STALE_MS = 10000;
27
27
  /**
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, utimesSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import lockfile from "proper-lockfile";
6
6
  /** Reports that a canonical session identity already has a live writer. */
7
7
  export class SessionWriterConflict extends Error {
@@ -15,8 +15,8 @@ export class SessionWriterConflict extends Error {
15
15
  }
16
16
  }
17
17
  /**
18
- * A crashed writer is not released by the kernel; its lock becomes stale once its holder stops
19
- * refreshing it. The bound keeps resume after a crash a matter of seconds.
18
+ * Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
19
+ * Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
20
20
  */
21
21
  export const SESSION_WRITER_STALE_MS = 10_000;
22
22
  let backendOverride;
@@ -35,15 +35,37 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
35
35
  };
36
36
  const acquireAuthority = (path) => {
37
37
  mkdirSync(root, { recursive: true, mode: 0o700 });
38
- // Rationale: the lock directory is the authority; no target file exists or is needed.
39
- return lockfile.lockSync(path, {
40
- realpath: false,
41
- lockfilePath: path,
42
- stale: staleMs,
43
- // Rationale: a missed refresh means this process stalled; the next writer takes over, and the
44
- // stalled one must not crash for it.
45
- onCompromised: () => { },
38
+ // Compatibility: the nonempty owner record also prevents legacy heartbeat-only clients from reclaiming
39
+ // a stalled live writer: proper-lockfile removes directories non-recursively.
40
+ reclaimDeadWriter(path);
41
+ const release = lockfile.lockSync(path, {
42
+ realpath: false, lockfilePath: path, stale: staleMs,
43
+ onCompromised: () => { throw new Error("Session writer ownership was lost; execution has been stopped."); },
46
44
  });
45
+ const owner = { pid: process.pid, token: randomUUID() };
46
+ const stamp = statSync(path).mtime;
47
+ try {
48
+ writeFileSync(join(path, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 });
49
+ // Concurrency: creating the owner record must not masquerade as a foreign heartbeat.
50
+ utimesSync(path, stamp, stamp);
51
+ }
52
+ catch (error) {
53
+ try {
54
+ unlinkSync(join(path, "owner.json"));
55
+ release();
56
+ }
57
+ catch { /* Security: retain uncertain ownership. */ }
58
+ throw error;
59
+ }
60
+ return () => {
61
+ if (readWriterOwner(path)?.token !== owner.token)
62
+ throw new Error("Session writer ownership was lost");
63
+ // Concurrency: a legacy contender must not reclaim the short empty-directory release interval.
64
+ const now = new Date();
65
+ utimesSync(path, now, now);
66
+ unlinkSync(join(path, "owner.json"));
67
+ release();
68
+ };
47
69
  };
48
70
  return {
49
71
  acquire(requested) {
@@ -92,6 +114,54 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
92
114
  },
93
115
  };
94
116
  }
117
+ function readWriterOwner(path) {
118
+ try {
119
+ const source = readFileSync(join(path, "owner.json"), "utf8");
120
+ if (source.length > 1024)
121
+ return null;
122
+ const value = JSON.parse(source);
123
+ return Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.token === "string"
124
+ && /^[a-f0-9-]{36}$/.test(value.token) ? value : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ function writerAbsent(pid) {
131
+ try {
132
+ process.kill(pid, 0);
133
+ return false;
134
+ }
135
+ catch (error) {
136
+ return error instanceof Error && "code" in error && error.code === "ESRCH";
137
+ }
138
+ }
139
+ function reclaimDeadWriter(path) {
140
+ if (!existsSync(path))
141
+ return;
142
+ const owner = readWriterOwner(path);
143
+ // Security: age and malformed/legacy records cannot prove that a writer is absent.
144
+ if (owner === null || !writerAbsent(owner.pid))
145
+ throw new Error("Session writer is live or unverifiable");
146
+ const gate = join(path, ".reclaim");
147
+ mkdirSync(gate);
148
+ try {
149
+ if (readWriterOwner(path)?.token !== owner.token || !writerAbsent(owner.pid))
150
+ throw new Error("Session writer changed during recovery");
151
+ const now = new Date();
152
+ utimesSync(path, now, now);
153
+ unlinkSync(join(path, "owner.json"));
154
+ rmdirSync(gate);
155
+ rmdirSync(path);
156
+ }
157
+ catch (error) {
158
+ try {
159
+ rmdirSync(gate);
160
+ }
161
+ catch { /* Security: a partial recovery remains conservatively blocked. */ }
162
+ throw error;
163
+ }
164
+ }
95
165
  function backend() {
96
166
  return backendOverride ?? (cachedBackend ??= lockfileBackend());
97
167
  }
@@ -10,7 +10,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
10
10
  ],
11
11
  "generatedArtifact": {
12
12
  "path": "dist/integrations/pi/startup-public.js",
13
- "sha256": "e6a12f0ec3c5f2ada12bb29f8a02584f0724b37d107c29608b6629a4196cca13"
13
+ "sha256": "c1fca0ab0fe9d078a80faac1dad7d9f719cfc553645e38c2244a9c9ad381c205"
14
14
  },
15
15
  "dependencyLayerIdentity": {
16
16
  "source": "A1_RELEASE_LAYERS",
@@ -19,7 +19,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
19
19
  "compileCacheNamespace": {
20
20
  "strategy": "node-abi-version-and-ordered-layer-sha256-v1"
21
21
  },
22
- "identity": "1823698969cb8a551539b6351a7ba7b0756121369e6f9bf898347080d7892ece"
22
+ "identity": "3a54e70a22a20fa71dc2916cff394d4b06a3359677b3423002ff5b2dfbdccd70"
23
23
  });
24
24
 
25
25
  export function loadDeclaredStartupGraph() {
@@ -0,0 +1,2 @@
1
+ /** Loaded only on a failure; persistence must never impede terminal restoration. */
2
+ export declare function writeFatalDiagnostic(directory: string, origin: string, error: unknown, releaseId?: string): Promise<string | null>;
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /** Loaded only on a failure; persistence must never impede terminal restoration. */
5
+ export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
6
+ try {
7
+ // Security: only known classifications and code locations are retained, never private messages or objects.
8
+ const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
9
+ const primary = cleanupFailed ? error.cause : error;
10
+ const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
11
+ const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
12
+ const locations = stack.split("\n").slice(1).flatMap(line => {
13
+ const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
14
+ return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
15
+ }).slice(0, 16);
16
+ const delivery = deliveryFailure(primary);
17
+ const record = {
18
+ timestamp: new Date().toISOString(),
19
+ origin: ["uncaughtException", "unhandledRejection", "entry", "delivery"].includes(origin) ? origin : "entry",
20
+ ...(delivery === null ? {} : { deliveryFailure: delivery }),
21
+ classification, cleanupFailed, locations, node: process.version, platform: process.platform,
22
+ releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
23
+ };
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const name = `${origin === "delivery" ? "delivery" : "fatal"}-${Date.now()}-${randomUUID()}.json`;
26
+ const path = join(directory, name);
27
+ const text = JSON.stringify(record, null, 2);
28
+ if (Buffer.byteLength(text) > 16 * 1024)
29
+ return null;
30
+ await writeFile(path, text, { flag: "wx", mode: 0o600 });
31
+ const names = (await readdir(directory)).filter(file => /^(?:fatal|delivery)-\d+-[a-f0-9-]+\.json$/.test(file))
32
+ .sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
33
+ await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
34
+ return path;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function deliveryFailure(error) {
41
+ if (!(error instanceof Error) || !("deliveryFailure" in error))
42
+ return null;
43
+ const value = error.deliveryFailure;
44
+ if (!value || typeof value !== "object")
45
+ return null;
46
+ const record = value;
47
+ if (!["event-count", "retained-bytes", "listener"].includes(String(record.reason))
48
+ || !["snapshot", "session-view", "transcript-block", "session-lifecycle", "status", "editor-state", "terminal-surface",
49
+ "agent-run-started", "agent-run-settled", "assistant-message-completed", "command-outcome", "diagnostic"].includes(String(record.event)))
50
+ return null;
51
+ const result = { reason: String(record.reason), event: String(record.event) };
52
+ for (const key of ["generation", "pending", "bytes"]) {
53
+ const number = record[key];
54
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0)
55
+ return null;
56
+ result[key] = number;
57
+ }
58
+ return result;
59
+ }
@@ -1,36 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
3
1
  import { writeSync } from "node:fs";
4
- import { join } from "node:path";
5
2
  import { boundedCleanup, restoreProcessTerminal } from "./terminal-reset.js";
6
3
  export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
7
4
  try {
8
- // Security: only known classifications and code locations are retained. Error messages,
9
- // function names, arbitrary paths, and request objects are never persisted.
10
- const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
11
- const primary = cleanupFailed ? error.cause : error;
12
- const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
13
- const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
14
- const locations = stack.split("\n").slice(1).flatMap(line => {
15
- const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
16
- return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
17
- }).slice(0, 16);
18
- const record = {
19
- timestamp: new Date().toISOString(),
20
- origin: ["uncaughtException", "unhandledRejection", "entry"].includes(origin) ? origin : "entry",
21
- classification, cleanupFailed, locations, node: process.version, platform: process.platform,
22
- releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
23
- };
24
- await mkdir(directory, { recursive: true, mode: 0o700 });
25
- const name = `fatal-${Date.now()}-${randomUUID()}.json`;
26
- const path = join(directory, name);
27
- const text = JSON.stringify(record, null, 2);
28
- if (Buffer.byteLength(text) > 16 * 1024)
29
- return null;
30
- await writeFile(path, text, { flag: "wx", mode: 0o600 });
31
- const names = (await readdir(directory)).filter(file => /^fatal-\d+-[a-f0-9-]+\.json$/.test(file)).sort().reverse();
32
- await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
33
- return path;
5
+ return await (await import("./fatal-diagnostic.js")).writeFatalDiagnostic(directory, origin, error, releaseId);
34
6
  }
35
7
  catch {
36
8
  return null;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.1 (MIT),
3
- * commit dd30101cfc5b0609411dd80b6f93adbc14db14cf.
2
+ * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.2 (MIT),
3
+ * commit b4afdddf44e059c67ccf5097e5317d913521b470.
4
4
  * Upstream dark.json SHA-256: e1f96982f8cb7d2a48eca7db03c9cb282cb1e14a243680cd855638ecd6a251b6
5
5
  * Upstream light.json SHA-256: 2dee6a4be7f765e7fd7728166b7ebf8c29e4ce313f8ec6b38ae50699e3abc2f3
6
6
  * Modifications: removed upstream schema/export metadata; runtime color data is unchanged.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.1 (MIT),
3
- * commit dd30101cfc5b0609411dd80b6f93adbc14db14cf.
2
+ * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.2 (MIT),
3
+ * commit b4afdddf44e059c67ccf5097e5317d913521b470.
4
4
  * Upstream dark.json SHA-256: e1f96982f8cb7d2a48eca7db03c9cb282cb1e14a243680cd855638ecd6a251b6
5
5
  * Upstream light.json SHA-256: 2dee6a4be7f765e7fd7728166b7ebf8c29e4ce313f8ec6b38ae50699e3abc2f3
6
6
  * Modifications: removed upstream schema/export metadata; runtime color data is unchanged.
@@ -1,8 +1,9 @@
1
+ import type { ProgressSpinnerSource } from "../../../contracts/presentation/index.js";
1
2
  import { type PiShellComponentPort, type PiShellEditorOptions } from "./shell-shared-facade.js";
2
3
  export interface PiShellOperationLoaderPort extends PiShellComponentPort {
3
4
  readonly signal: AbortSignal;
4
5
  /** Relabels the running operation; the pinned comparison loader keeps its first label. */
5
6
  readonly setMessage?: (message: string) => void;
6
7
  }
7
- export declare function createPiShellShareOperationDialog(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string): PiShellOperationLoaderPort;
8
+ export declare function createPiShellShareOperationDialog(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string, spinner?: ProgressSpinnerSource): PiShellOperationLoaderPort;
8
9
  export declare function createPiShellOperationLoader(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string): PiShellOperationLoaderPort;