@timurproko/a1 0.2.7-dev.746 → 0.2.7-dev.767

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 (118) hide show
  1. package/bin/tab-launch-context.d.ts +6 -1
  2. package/bin/tab-launch-context.js +11 -2
  3. package/bin/ui.js +7 -3
  4. package/dist/app/session-shell/session-shell-root.d.ts +23 -0
  5. package/dist/app/session-shell/session-shell.js +112 -6
  6. package/dist/composition/owned-ui.d.ts +4 -1
  7. package/dist/composition/owned-ui.js +54 -8
  8. package/dist/contracts/owned-ui/session-backend.d.ts +1 -0
  9. package/dist/features/owned-ui/settings-app.js +8 -35
  10. package/dist/foundation/release/bootstrap.d.ts +1 -1
  11. package/dist/foundation/release/bootstrap.js +15 -4
  12. package/dist/foundation/release/dependency-certification.js +54 -19
  13. package/dist/foundation/release/progress-palette.generated.d.ts +2 -2
  14. package/dist/foundation/release/progress-palette.generated.js +2 -2
  15. package/dist/foundation/resident-tabs/bridge-client.d.ts +7 -2
  16. package/dist/foundation/resident-tabs/bridge-client.js +58 -16
  17. package/dist/foundation/resident-tabs/draft-checkpoint.d.ts +27 -0
  18. package/dist/foundation/resident-tabs/draft-checkpoint.js +145 -0
  19. package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +2 -0
  20. package/dist/foundation/resident-tabs/ensure-terminal-host.js +7 -1
  21. package/dist/foundation/resident-tabs/fault-injection.d.ts +8 -0
  22. package/dist/foundation/resident-tabs/fault-injection.js +26 -0
  23. package/dist/foundation/resident-tabs/index.d.ts +2 -1
  24. package/dist/foundation/resident-tabs/index.js +1 -1
  25. package/dist/foundation/resident-tabs/prompt-journal.d.ts +65 -0
  26. package/dist/foundation/resident-tabs/prompt-journal.js +214 -0
  27. package/dist/foundation/resident-tabs/recovery.d.ts +26 -0
  28. package/dist/foundation/resident-tabs/recovery.js +205 -0
  29. package/dist/foundation/resident-tabs/session-sync.d.ts +14 -0
  30. package/dist/foundation/resident-tabs/session-sync.js +37 -0
  31. package/dist/foundation/resident-tabs/settings.d.ts +9 -2
  32. package/dist/foundation/resident-tabs/settings.js +55 -7
  33. package/dist/foundation/session-writer-lock/index.d.ts +27 -0
  34. package/dist/foundation/session-writer-lock/index.js +197 -0
  35. package/dist/foundation/startup/startup-descriptor.js +2 -2
  36. package/dist/integrations/pi/components/resources/builtin-themes.d.ts +4 -4
  37. package/dist/integrations/pi/components/resources/builtin-themes.js +4 -4
  38. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
  39. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +17 -4
  40. package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
  41. package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
  42. package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
  43. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
  44. package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
  45. package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
  46. package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
  47. package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
  48. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
  49. package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
  50. package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
  51. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  52. package/dist/integrations/pi/components/upstream/components/session-footer.js +1 -1
  53. package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
  54. package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
  55. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
  56. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
  57. package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +2 -2
  58. package/dist/integrations/pi/components/upstream/components/status-indicator.js +3 -3
  59. package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
  60. package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
  61. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
  62. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
  63. package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +0 -2
  64. package/dist/integrations/pi/components/upstream/components/tool-execution.js +41 -20
  65. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
  66. package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
  67. package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
  68. package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
  69. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +2 -2
  70. package/dist/integrations/pi/components/upstream/history/editor-core.js +5 -5
  71. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
  72. package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
  73. package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
  74. package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
  75. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
  76. package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
  77. package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
  78. package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
  79. package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
  80. package/dist/integrations/pi/components/upstream/theme/theme-controller.d.ts +1 -1
  81. package/dist/integrations/pi/components/upstream/theme/theme.d.ts +3 -0
  82. package/dist/integrations/pi/components/upstream/theme/theme.js +24 -1
  83. package/dist/integrations/pi/engine/adapter.js +1 -0
  84. package/dist/integrations/pi/engine/pi-settings-metadata.json +2 -2
  85. package/dist/integrations/pi/engine/resources/changelog.json +4 -4
  86. package/dist/integrations/pi/engine/runtime-integration.js +102 -29
  87. package/dist/integrations/pi/engine/session-runtime.d.ts +0 -6
  88. package/dist/integrations/pi/engine/session-runtime.js +8 -3
  89. package/dist/integrations/pi/engine/session-selection.d.ts +3 -0
  90. package/dist/integrations/pi/engine/session-selection.js +45 -9
  91. package/dist/integrations/pi/engine/workflow-contexts.js +10 -1
  92. package/dist/integrations/pi/startup-public.js +152 -222
  93. package/dist/integrations/pi/startup-public.manifest.json +266 -177
  94. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
  95. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
  96. package/dist/native/darwin-arm64/manifest.json +1 -1
  97. package/dist/native/linux-x64/manifest.json +1 -1
  98. package/dist/native/win32-x64/manifest.json +2 -2
  99. package/dist/native/win32-x64/process-guardian.exe +0 -0
  100. package/dist/runtime-payload-inventory.json +5349 -6035
  101. package/dist/ui/components/line-input.d.ts +1 -1
  102. package/dist/ui/components/prompt-input.d.ts +7 -5
  103. package/dist/ui/components/prompt-input.js +7 -7
  104. package/dist/ui/settings/declarations.d.ts +38 -2
  105. package/dist/ui/settings/declarations.js +29 -2
  106. package/dist/ui/settings/index.d.ts +4 -2
  107. package/dist/ui/settings/index.js +2 -1
  108. package/dist/ui/settings/manager.d.ts +9 -3
  109. package/dist/ui/settings/manager.js +81 -29
  110. package/dist/ui/settings/migrations.d.ts +6 -0
  111. package/dist/ui/settings/migrations.js +25 -0
  112. package/dist/ui/settings/profile-document.d.ts +26 -0
  113. package/dist/ui/settings/profile-document.js +164 -0
  114. package/dist/ui/settings/sections.js +4 -0
  115. package/docs/architecture/resident-tabs-requirements.md +1 -234
  116. package/docs/architecture/resident-tabs.md +30 -10
  117. package/docs/openspec-archive-automation.md +7 -7
  118. package/package.json +6 -4
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, resolve } from "node:path";
2
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
4
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
5
5
  import { immutablePlatformPolicy } from "./immutable-platform.js";
6
6
  export const DEPENDENCY_CERTIFICATIONS_DIRECTORY = "dependency-certifications";
@@ -194,7 +194,8 @@ async function releasePublicationLock(path, token) {
194
194
  while (true) {
195
195
  try {
196
196
  if (!retired) {
197
- await assertPublicationLockOwner(path, token);
197
+ if (!await assertPublicationLockOwner(path, token))
198
+ return;
198
199
  await rename(path, released);
199
200
  retired = true;
200
201
  }
@@ -218,24 +219,58 @@ async function releasePublicationLock(path, token) {
218
219
  }
219
220
  /** Ownership uncertainty is never authority to retire a missing, linked, or replaced generation. */
220
221
  async function assertPublicationLockOwner(path, token) {
221
- try {
222
- const metadata = await lstat(path);
223
- if (!metadata.isDirectory() || metadata.isSymbolicLink() || await realpath(path) !== path)
224
- throw new Error("invalid lock directory");
225
- const ownerPath = resolve(path, "owner.json");
226
- const ownerMetadata = await lstat(ownerPath);
227
- if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink() || await realpath(ownerPath) !== ownerPath)
228
- throw new Error("invalid owner file");
229
- const owner = JSON.parse(await readFile(ownerPath, "utf8"));
230
- if (!owner || typeof owner !== "object" || !("pid" in owner) || !("token" in owner)
231
- || owner.pid !== process.pid || owner.token !== token)
232
- throw new Error("owner generation differs");
222
+ for (let attempt = 1;; attempt += 1) {
223
+ try {
224
+ const metadata = await lstat(path);
225
+ if (!metadata.isDirectory() || metadata.isSymbolicLink() || await realpath(path) !== path)
226
+ throw new Error("invalid lock directory");
227
+ const ownerPath = resolve(path, "owner.json");
228
+ const ownerMetadata = await lstat(ownerPath);
229
+ if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink() || await realpath(ownerPath) !== ownerPath)
230
+ throw new Error("invalid owner file");
231
+ const owner = JSON.parse(await readFile(ownerPath, "utf8"));
232
+ if (!owner || typeof owner !== "object" || !("pid" in owner) || !("token" in owner)
233
+ || owner.pid !== process.pid || owner.token !== token)
234
+ throw new Error("owner generation differs");
235
+ return true;
236
+ }
237
+ catch (error) {
238
+ // Platform: Windows can briefly hide a directory immediately after a successful rename
239
+ // when independent publishers are contending. Re-read the complete identity; never treat
240
+ // absence as ownership and never retire a generation whose token differs.
241
+ if (hasCode(error, "ENOENT") && attempt < 3) {
242
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 20 * attempt));
243
+ continue;
244
+ }
245
+ // Concurrency: a delayed abandoned-owner reclaimer can move a successor generation after
246
+ // reading the predecessor. If our exact token is now in a managed tombstone, no active
247
+ // generation belongs to this release call and returning without mutation is fail-safe.
248
+ if (hasCode(error, "ENOENT") && await publicationLockWasRetired(path, token))
249
+ return false;
250
+ if (isReleaseContention(error))
251
+ throw error;
252
+ throw new Error("publication lease release ownership could not be verified", { cause: error });
253
+ }
233
254
  }
234
- catch (error) {
235
- if (isReleaseContention(error))
236
- throw error;
237
- throw new Error("publication lease release ownership could not be verified", { cause: error });
255
+ }
256
+ async function publicationLockWasRetired(path, token) {
257
+ const directory = dirname(path);
258
+ const prefix = `${basename(path)}.`;
259
+ const entries = await readdir(directory, { withFileTypes: true });
260
+ if (entries.length > 256)
261
+ return false;
262
+ for (const entry of entries) {
263
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !entry.name.startsWith(prefix)
264
+ || (!entry.name.includes(".abandoned-") && !entry.name.includes(".released-")))
265
+ continue;
266
+ const ownerPath = resolve(directory, entry.name, "owner.json");
267
+ const owner = await readFile(ownerPath, "utf8")
268
+ .then(source => JSON.parse(source))
269
+ .catch(() => null);
270
+ if (owner?.pid === process.pid && owner.token === token)
271
+ return true;
238
272
  }
273
+ return false;
239
274
  }
240
275
  function isReleaseContention(error) {
241
276
  return ["EPERM", "EACCES", "EBUSY"].some(code => hasCode(error, code));
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated by scripts/pi/sync-progress-palette.ts from @earendil-works/pi-coding-agent 1.1.0
3
- * (MIT), commit abe508e1b89912adde45528136c3221eb69acdd7. Do not edit directly.
2
+ * Generated by scripts/pi/sync-progress-palette.ts from @earendil-works/pi-coding-agent 1.2.0
3
+ * (MIT), commit 06a466935536f8c9bcacb2d5efc0fc021be1bdff. Do not edit directly.
4
4
  */
5
5
  export declare const SHARED_PROGRESS_ACCENT_ANSI = "\u001B[38;2;167;152;215m";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated by scripts/pi/sync-progress-palette.ts from @earendil-works/pi-coding-agent 1.1.0
3
- * (MIT), commit abe508e1b89912adde45528136c3221eb69acdd7. Do not edit directly.
2
+ * Generated by scripts/pi/sync-progress-palette.ts from @earendil-works/pi-coding-agent 1.2.0
3
+ * (MIT), commit 06a466935536f8c9bcacb2d5efc0fc021be1bdff. Do not edit directly.
4
4
  */
5
5
  export const SHARED_PROGRESS_ACCENT_ANSI = "\u001b[38;2;167;152;215m";
@@ -1,4 +1,4 @@
1
- export type ResidentTabStatus = "starting" | "working" | "idle";
1
+ export type ResidentTabStatus = "starting" | "working" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
2
2
  export interface TabBridgeCredentials {
3
3
  readonly tabId: string;
4
4
  readonly token: string;
@@ -20,6 +20,9 @@ export interface TabBridgeOptions {
20
20
  readonly startIdentity?: string;
21
21
  readonly build?: string;
22
22
  readonly connect?: (endpoint: string) => BridgeTransport;
23
+ readonly heartbeatIntervalMs?: number;
24
+ readonly setInterval?: (callback: () => void, milliseconds: number) => unknown;
25
+ readonly clearInterval?: (timer: unknown) => void;
23
26
  }
24
27
  /** Metadata-only advisory bridge. Terminal input and transcript bytes have no API here. */
25
28
  export declare class TabBridgeClient {
@@ -28,9 +31,11 @@ export declare class TabBridgeClient {
28
31
  get available(): boolean;
29
32
  captureAttachment(): TabAttachment | null;
30
33
  start(timeoutMs?: number): Promise<boolean>;
31
- status(state: ResidentTabStatus): void;
34
+ status(state: ResidentTabStatus, degraded?: boolean): void;
35
+ degraded(degraded: boolean): void;
32
36
  session(file: string | null, name: string | null): void;
33
37
  requestDetach(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
38
+ requestRestart(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
34
39
  close(): void;
35
40
  }
36
41
  export {};
@@ -6,6 +6,7 @@ export class TabBridgeClient {
6
6
  #transport = null;
7
7
  #connected = false;
8
8
  #available = true;
9
+ #features = new Set();
9
10
  #sequence = 0;
10
11
  #request = 0;
11
12
  #attachment = null;
@@ -13,6 +14,7 @@ export class TabBridgeClient {
13
14
  #retry = null;
14
15
  #latestStatus = null;
15
16
  #latestSession = null;
17
+ #heartbeat = null;
16
18
  #pending = new Map();
17
19
  #startWaiter = null;
18
20
  constructor(options) { this.#options = options; }
@@ -53,7 +55,8 @@ export class TabBridgeClient {
53
55
  this.#startWaiter = finish;
54
56
  transport.once("connect", () => {
55
57
  this.#send("hello", {
56
- role: "bridge", generation: 1, build: this.#options.build ?? "a1", features: ["bridge-status", "bridge-detach"],
58
+ role: "bridge", generation: 1, build: this.#options.build ?? "a1",
59
+ features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "recovery-journal"],
57
60
  token: [...Buffer.from(this.#options.credentials.token, "base64")], tabId: this.#options.credentials.tabId,
58
61
  pid: this.#options.pid ?? process.pid, startIdentity: this.#options.startIdentity ?? `${process.pid}:node`,
59
62
  }, ++this.#request);
@@ -63,31 +66,36 @@ export class TabBridgeClient {
63
66
  timer.unref();
64
67
  });
65
68
  }
66
- status(state) {
67
- this.#latestStatus = state;
68
- this.#send("bridge.status", { seq: ++this.#sequence, state });
69
+ status(state, degraded = false) {
70
+ this.#latestStatus = { state, degraded };
71
+ const recoveryState = state === "unresponsive" || state === "stalled" || state === "crashed" || state === "restarting" || state === "failed";
72
+ if (recoveryState && !this.#supports("recovery-status"))
73
+ return;
74
+ this.#send("bridge.status", {
75
+ seq: ++this.#sequence,
76
+ state,
77
+ ...(degraded && this.#supports("recovery-status") ? { degraded: true } : {}),
78
+ });
79
+ }
80
+ degraded(degraded) {
81
+ this.status(this.#latestStatus?.state ?? "idle", degraded);
69
82
  }
70
83
  session(file, name) {
71
84
  this.#latestSession = { file, name };
72
85
  this.#send("bridge.session", { file, name });
73
86
  }
74
- async requestDetach(origin = this.captureAttachment(), timeoutMs = 1_000) {
75
- if (!this.available || origin === null)
76
- return false;
77
- const requestId = ++this.#request;
78
- const result = new Promise(resolvePromise => this.#pending.set(requestId, resolvePromise));
79
- this.#send("bridge.request", { action: "detach", clientId: origin.clientId, controllerRevision: origin.controllerRevision }, requestId);
80
- const timeout = new Promise(resolvePromise => {
81
- const timer = setTimeout(() => { this.#pending.delete(requestId); resolvePromise(false); }, timeoutMs);
82
- timer.unref();
83
- });
84
- return await Promise.race([result, timeout]);
87
+ requestDetach(origin = this.captureAttachment(), timeoutMs = 1_000) {
88
+ return this.#requestAction("detach", origin, timeoutMs);
89
+ }
90
+ requestRestart(origin = this.captureAttachment(), timeoutMs = 1_000) {
91
+ return this.#requestAction("restart", origin, timeoutMs);
85
92
  }
86
93
  close() {
87
94
  this.#closed = true;
88
95
  if (this.#retry !== null)
89
96
  clearTimeout(this.#retry);
90
97
  this.#retry = null;
98
+ this.#stopHeartbeat();
91
99
  const transport = this.#transport;
92
100
  this.#transport = null;
93
101
  transport?.destroy();
@@ -105,11 +113,15 @@ export class TabBridgeClient {
105
113
  }
106
114
  #receive(message) {
107
115
  if (message.type === "welcome" && message.body["generation"] === 1) {
116
+ const advertised = message.body["features"];
117
+ this.#features = new Set(Array.isArray(advertised) ? advertised.filter((feature) => typeof feature === "string") : []);
108
118
  this.#connected = true;
109
119
  this.#available = true;
110
120
  this.#startWaiter?.(true);
121
+ if (this.#supports("bridge-heartbeat"))
122
+ this.#startHeartbeat();
111
123
  if (this.#latestStatus !== null)
112
- this.#send("bridge.status", { seq: ++this.#sequence, state: this.#latestStatus });
124
+ this.status(this.#latestStatus.state, this.#latestStatus.degraded);
113
125
  if (this.#latestSession !== null)
114
126
  this.#send("bridge.session", this.#latestSession);
115
127
  return;
@@ -133,10 +145,40 @@ export class TabBridgeClient {
133
145
  }
134
146
  }
135
147
  }
148
+ async #requestAction(action, origin, timeoutMs) {
149
+ if (!this.available || origin === null || (action === "restart" && !this.#supports("bridge-restart")))
150
+ return false;
151
+ const requestId = ++this.#request;
152
+ const result = new Promise(resolvePromise => this.#pending.set(requestId, resolvePromise));
153
+ this.#send("bridge.request", { action, clientId: origin.clientId, controllerRevision: origin.controllerRevision }, requestId);
154
+ const timeout = new Promise(resolvePromise => {
155
+ const timer = setTimeout(() => { this.#pending.delete(requestId); resolvePromise(false); }, timeoutMs);
156
+ timer.unref();
157
+ });
158
+ return await Promise.race([result, timeout]);
159
+ }
160
+ #startHeartbeat() {
161
+ if (this.#heartbeat !== null)
162
+ return;
163
+ const schedule = this.#options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
164
+ this.#heartbeat = schedule(() => this.#send("bridge.heartbeat", { seq: ++this.#sequence }), this.#options.heartbeatIntervalMs ?? 5_000);
165
+ this.#heartbeat.unref?.();
166
+ }
167
+ #stopHeartbeat() {
168
+ if (this.#heartbeat === null)
169
+ return;
170
+ (this.#options.clearInterval ?? (timer => clearInterval(timer)))(this.#heartbeat);
171
+ this.#heartbeat = null;
172
+ }
173
+ #supports(feature) {
174
+ return this.#features.has("crash-recovery") || this.#features.has(feature);
175
+ }
136
176
  #fail(retry = true) {
137
177
  const transport = this.#transport;
178
+ this.#stopHeartbeat();
138
179
  this.#available = false;
139
180
  this.#connected = false;
181
+ this.#features.clear();
140
182
  this.#attachment = null;
141
183
  this.#transport = null;
142
184
  transport?.destroy();
@@ -0,0 +1,27 @@
1
+ import { ResidentTabFaultInjection } from "./fault-injection.js";
2
+ export interface DraftCheckpointState {
3
+ readonly text: string;
4
+ readonly savedAt: string;
5
+ }
6
+ export interface DraftCheckpointOptions {
7
+ readonly intervalMs?: number;
8
+ readonly maximumDirtyMs?: number;
9
+ readonly faults?: ResidentTabFaultInjection;
10
+ readonly now?: () => number;
11
+ readonly onDegraded?: (degraded: boolean) => void;
12
+ readonly write?: (path: string, text: string) => Promise<void>;
13
+ readonly move?: (from: string, to: string) => Promise<void>;
14
+ readonly schedule?: (callback: () => void | Promise<void>, delayMs: number) => unknown;
15
+ readonly cancel?: (timer: unknown) => void;
16
+ }
17
+ /** Periodic rather than inactivity-debounced: continuous input still checkpoints every interval. */
18
+ export declare class DraftCheckpointer {
19
+ #private;
20
+ readonly path: string;
21
+ constructor(path: string, options?: DraftCheckpointOptions);
22
+ get degraded(): boolean;
23
+ change(text: string): void;
24
+ restore(): Promise<DraftCheckpointState | null>;
25
+ flush(): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { ResidentTabFaultInjection } from "./fault-injection.js";
5
+ /** Periodic rather than inactivity-debounced: continuous input still checkpoints every interval. */
6
+ export class DraftCheckpointer {
7
+ path;
8
+ #intervalMs;
9
+ #maximumDirtyMs;
10
+ #faults;
11
+ #now;
12
+ #onDegraded;
13
+ #write;
14
+ #move;
15
+ #scheduleTimer;
16
+ #cancelTimer;
17
+ #text = "";
18
+ #dirtySince = null;
19
+ #timer = null;
20
+ #degradedTimer = null;
21
+ #writing = null;
22
+ #closed = false;
23
+ #degraded = false;
24
+ constructor(path, options = {}) {
25
+ this.path = path;
26
+ this.#intervalMs = options.intervalMs ?? 500;
27
+ this.#maximumDirtyMs = options.maximumDirtyMs ?? 1_000;
28
+ this.#faults = options.faults ?? new ResidentTabFaultInjection();
29
+ this.#now = options.now ?? Date.now;
30
+ this.#onDegraded = options.onDegraded ?? (() => { });
31
+ this.#write = options.write ?? (async (target, text) => writeFile(target, text, { encoding: "utf8", mode: 0o600, flag: "wx" }));
32
+ this.#move = options.move ?? rename;
33
+ this.#scheduleTimer = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
34
+ this.#cancelTimer = options.cancel ?? (timer => clearTimeout(timer));
35
+ }
36
+ get degraded() { return this.#degraded; }
37
+ change(text) {
38
+ if (this.#closed)
39
+ return;
40
+ this.#text = text;
41
+ this.#dirtySince ??= this.#now();
42
+ this.#schedule();
43
+ this.#armDegradedDeadline();
44
+ }
45
+ async restore() {
46
+ try {
47
+ const value = JSON.parse(await readFile(this.path, "utf8"));
48
+ if (typeof value !== "object" || value === null || typeof value.text !== "string"
49
+ || typeof value.savedAt !== "string")
50
+ return null;
51
+ return value;
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
55
+ return null;
56
+ throw error;
57
+ }
58
+ }
59
+ async flush() {
60
+ if (this.#timer !== null) {
61
+ this.#cancelTimer(this.#timer);
62
+ this.#timer = null;
63
+ }
64
+ await this.#writing;
65
+ if (this.#dirtySince !== null)
66
+ await this.#checkpoint();
67
+ }
68
+ async close() {
69
+ await this.flush();
70
+ this.#closed = true;
71
+ }
72
+ #armDegradedDeadline() {
73
+ if (this.#degradedTimer !== null || this.#dirtySince === null || this.#closed)
74
+ return;
75
+ const delay = Math.max(0, this.#maximumDirtyMs - (this.#now() - this.#dirtySince));
76
+ this.#degradedTimer = this.#scheduleTimer(() => {
77
+ this.#degradedTimer = null;
78
+ if (this.#dirtySince !== null && this.#now() - this.#dirtySince >= this.#maximumDirtyMs) {
79
+ this.#setDegraded(true);
80
+ }
81
+ else if (this.#dirtySince !== null) {
82
+ this.#armDegradedDeadline();
83
+ }
84
+ }, delay);
85
+ }
86
+ #clearDegradedDeadline() {
87
+ if (this.#degradedTimer !== null)
88
+ this.#cancelTimer(this.#degradedTimer);
89
+ this.#degradedTimer = null;
90
+ }
91
+ #schedule() {
92
+ if (this.#timer !== null || this.#writing !== null || this.#closed)
93
+ return;
94
+ const age = this.#dirtySince === null ? 0 : this.#now() - this.#dirtySince;
95
+ if (age > this.#maximumDirtyMs)
96
+ this.#setDegraded(true);
97
+ this.#timer = this.#scheduleTimer(async () => {
98
+ this.#timer = null;
99
+ this.#writing = this.#checkpoint().finally(() => {
100
+ this.#writing = null;
101
+ if (this.#dirtySince !== null)
102
+ this.#schedule();
103
+ });
104
+ await this.#writing.catch(() => undefined);
105
+ }, this.#intervalMs);
106
+ }
107
+ async #checkpoint() {
108
+ if (this.#dirtySince === null)
109
+ return;
110
+ const snapshot = this.#text;
111
+ const temporary = `${this.path}.tmp-${process.pid}-${randomUUID()}`;
112
+ try {
113
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
114
+ if (process.platform !== "win32")
115
+ await chmod(dirname(this.path), 0o700);
116
+ this.#faults.at("draft-during-temp-write");
117
+ await this.#write(temporary, `${JSON.stringify({ text: snapshot, savedAt: new Date(this.#now()).toISOString() })}\n`);
118
+ this.#faults.at("draft-before-rename");
119
+ await this.#move(temporary, this.path);
120
+ this.#faults.at("draft-after-rename");
121
+ if (process.platform !== "win32")
122
+ await chmod(this.path, 0o600);
123
+ if (this.#text === snapshot)
124
+ this.#dirtySince = null;
125
+ else
126
+ this.#dirtySince = this.#now();
127
+ this.#clearDegradedDeadline();
128
+ if (this.#dirtySince !== null)
129
+ this.#armDegradedDeadline();
130
+ this.#setDegraded(false);
131
+ }
132
+ catch (error) {
133
+ await unlink(temporary).catch(() => undefined);
134
+ this.#clearDegradedDeadline();
135
+ this.#setDegraded(true);
136
+ throw error;
137
+ }
138
+ }
139
+ #setDegraded(value) {
140
+ if (this.#degraded === value)
141
+ return;
142
+ this.#degraded = value;
143
+ this.#onDegraded(value);
144
+ }
145
+ }
@@ -21,6 +21,8 @@ export interface EnsureTerminalHostOptions {
21
21
  readonly platform?: NodeJS.Platform;
22
22
  readonly architecture?: string;
23
23
  readonly timeoutMs?: number;
24
+ readonly maxConcurrentStarts?: number;
25
+ readonly unresponsiveRestartSeconds?: number;
24
26
  readonly run?: (executable: string, arguments_: readonly string[], environment: NodeJS.ProcessEnv, timeoutMs: number) => Promise<HostCommandResult>;
25
27
  }
26
28
  export declare function resolveTerminalHostPath(releaseRoot: string, environment?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, architecture?: string): string;
@@ -42,7 +42,13 @@ export async function ensureTerminalHost(options) {
42
42
  return { status: "fallback", reason: "incompatible generation" };
43
43
  if (existing?.outcome !== "not-running" && probe.code !== 3)
44
44
  return { status: "fallback", reason: classifiedReason(existing?.reason, "host probe failed") };
45
- const start = await run(executable, ["server", "--detach", ...common], environment, timeoutMs);
45
+ const maximum = Math.max(1, Math.min(8, Math.trunc(options.maxConcurrentStarts ?? 2)));
46
+ const unresponsiveRestartSeconds = Math.max(30, Math.min(600, Math.trunc(options.unresponsiveRestartSeconds ?? 120)));
47
+ const start = await run(executable, [
48
+ "server", "--detach", ...common,
49
+ "--max-concurrent-starts", String(maximum),
50
+ "--unresponsive-restart-seconds", String(unresponsiveRestartSeconds),
51
+ ], environment, timeoutMs);
46
52
  const started = parseResult(start.stdout);
47
53
  if (start.code !== 0)
48
54
  return { status: "fallback", reason: classifiedReason(started?.reason, "launch not verified") };
@@ -0,0 +1,8 @@
1
+ export declare const RESIDENT_TAB_FAULT_ENVIRONMENT: readonly ["A1_TABS_FAULT_CRASH_POINT", "A1_TABS_HERMETIC_TEST"];
2
+ export declare function residentTabFaultEnvironment(point: string, hermetic?: boolean): NodeJS.ProcessEnv;
3
+ /** Test-only crash points are inert unless the caller explicitly marks a hermetic process. */
4
+ export declare class ResidentTabFaultInjection {
5
+ #private;
6
+ constructor(environment?: NodeJS.ProcessEnv, crash?: (point: string) => never);
7
+ at(point: string): void;
8
+ }
@@ -0,0 +1,26 @@
1
+ const CRASH_POINT = "A1_TABS_FAULT_CRASH_POINT";
2
+ const HERMETIC = "A1_TABS_HERMETIC_TEST";
3
+ export const RESIDENT_TAB_FAULT_ENVIRONMENT = Object.freeze([CRASH_POINT, HERMETIC]);
4
+ export function residentTabFaultEnvironment(point, hermetic = true) {
5
+ return Object.fromEntries(hermetic ? [[CRASH_POINT, point], [HERMETIC, "1"]] : [[CRASH_POINT, point]]);
6
+ }
7
+ /** Test-only crash points are inert unless the caller explicitly marks a hermetic process. */
8
+ export class ResidentTabFaultInjection {
9
+ #point;
10
+ #crash;
11
+ constructor(environment = process.env, crash = point => {
12
+ process.stderr.write(`resident tab injected crash: ${point}\n`);
13
+ process.abort();
14
+ }) {
15
+ const point = environment[CRASH_POINT];
16
+ if (point !== undefined && environment[HERMETIC] !== "1") {
17
+ throw new Error(`${CRASH_POINT} is available only in a hermetic resident-tab test`);
18
+ }
19
+ this.#point = point;
20
+ this.#crash = crash;
21
+ }
22
+ at(point) {
23
+ if (this.#point === point)
24
+ this.#crash(point);
25
+ }
26
+ }
@@ -1,5 +1,6 @@
1
1
  export { ensureTerminalHost, resolveTerminalHostPath } from "./ensure-terminal-host.js";
2
2
  export type { EnsureTerminalHostOptions, EnsureTerminalHostResult, TerminalHostFallbackReason } from "./ensure-terminal-host.js";
3
- export { readResidentTabsSetting } from "./settings.js";
3
+ export { readResidentTabLaunchSettings, readResidentTabsSetting } from "./settings.js";
4
+ export type { ResidentTabLaunchSettings } from "./settings.js";
4
5
  export { selectResidentHost } from "./routing.js";
5
6
  export type { ResidentHostSelectionOptions } from "./routing.js";
@@ -1,3 +1,3 @@
1
1
  export { ensureTerminalHost, resolveTerminalHostPath } from "./ensure-terminal-host.js";
2
- export { readResidentTabsSetting } from "./settings.js";
2
+ export { readResidentTabLaunchSettings, readResidentTabsSetting } from "./settings.js";
3
3
  export { selectResidentHost } from "./routing.js";
@@ -0,0 +1,65 @@
1
+ import { ResidentTabFaultInjection } from "./fault-injection.js";
2
+ export interface SubmittedPromptRecord {
3
+ readonly type: "submitted";
4
+ readonly id: string;
5
+ readonly sessionId: string;
6
+ readonly sessionFile: string | null;
7
+ readonly incarnation: number;
8
+ readonly fileExisted: boolean;
9
+ readonly kind: "prompt" | "steer" | "follow-up";
10
+ readonly text: string;
11
+ readonly images?: readonly unknown[];
12
+ readonly at: string;
13
+ }
14
+ export interface CorrelatedPromptRecord {
15
+ readonly type: "correlated";
16
+ readonly id: string;
17
+ readonly entryId: string;
18
+ }
19
+ export interface SettledPromptRecord {
20
+ readonly type: "settled";
21
+ readonly id: string;
22
+ readonly entryId: string;
23
+ }
24
+ export interface RetiredPromptRecord {
25
+ readonly type: "retired";
26
+ readonly id: string;
27
+ }
28
+ export type PromptJournalRecord = SubmittedPromptRecord | CorrelatedPromptRecord | SettledPromptRecord | RetiredPromptRecord;
29
+ export interface PromptSubmission {
30
+ readonly sessionId: string;
31
+ readonly sessionFile: string | null;
32
+ readonly incarnation: number;
33
+ readonly fileExisted: boolean;
34
+ readonly kind: SubmittedPromptRecord["kind"];
35
+ readonly text: string;
36
+ readonly images?: readonly unknown[];
37
+ }
38
+ export interface SessionJournalEntry {
39
+ readonly entryId: string;
40
+ readonly text: string;
41
+ readonly settled: boolean;
42
+ }
43
+ export interface PendingPrompt {
44
+ readonly submitted: SubmittedPromptRecord;
45
+ readonly correlatedEntryId?: string;
46
+ }
47
+ export declare class PromptJournal {
48
+ #private;
49
+ readonly path: string;
50
+ constructor(path: string, options?: {
51
+ readonly faults?: ResidentTabFaultInjection;
52
+ readonly maximumBytes?: number;
53
+ });
54
+ initialize(): Promise<void>;
55
+ submit(submission: PromptSubmission): Promise<SubmittedPromptRecord>;
56
+ adopt(record: SubmittedPromptRecord): Promise<boolean>;
57
+ correlate(id: string, entryId: string): Promise<void>;
58
+ settle(id: string, entryId: string): Promise<void>;
59
+ retire(id: string): Promise<void>;
60
+ read(): Promise<readonly PromptJournalRecord[]>;
61
+ pending(sessionEntries?: readonly SessionJournalEntry[]): Promise<readonly PendingPrompt[]>;
62
+ compactIfRetired(): Promise<boolean>;
63
+ }
64
+ export declare function classifyMissingSession(record: SubmittedPromptRecord, sessionExists: boolean): "available" | "first-turn" | "missing-transcript";
65
+ export declare function removeJournalTemporary(path: string): Promise<void>;