@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
@@ -15,4 +15,9 @@ export function assignFreshSessionRuntimeId(
15
15
  export function consumeTabLaunchContext(
16
16
  arguments_: readonly string[],
17
17
  environment: Record<string, string | undefined>,
18
- ): { readonly uiArguments: string[]; readonly tabCredentials: TabLaunchCredentials | undefined };
18
+ ): {
19
+ readonly uiArguments: string[];
20
+ readonly tabCredentials: TabLaunchCredentials | undefined;
21
+ readonly tabDataDir?: string;
22
+ readonly tabIncarnation?: number;
23
+ };
@@ -28,9 +28,18 @@ export function consumeTabLaunchContext(arguments_, environment) {
28
28
  const tabArgument = uiArguments.indexOf("--tab");
29
29
  const tabMode = tabArgument !== -1;
30
30
  if (tabMode) uiArguments.splice(tabArgument, 1);
31
+ const dataArgument = uiArguments.indexOf("--tab-data-dir");
32
+ const tabDataDir = dataArgument === -1 ? undefined : uiArguments[dataArgument + 1];
33
+ if (dataArgument !== -1) uiArguments.splice(dataArgument, 2);
34
+ const incarnationArgument = uiArguments.indexOf("--tab-incarnation");
35
+ const tabIncarnation = incarnationArgument === -1 ? undefined : Number(uiArguments[incarnationArgument + 1]);
36
+ if (incarnationArgument !== -1) uiArguments.splice(incarnationArgument, 2);
31
37
  const [tabId, token, endpoint] = TAB_ENVIRONMENT_NAMES.map(name => environment[name]);
32
38
  for (const name of TAB_ENVIRONMENT_NAMES) delete environment[name];
33
39
  if (!tabMode) return { uiArguments, tabCredentials: undefined };
34
- if (!tabId || !token || !endpoint) throw new Error("A1 tab mode requires bridge credentials");
35
- return { uiArguments, tabCredentials: { tabId, token, endpoint } };
40
+ if (!tabId || !token || !endpoint || !tabDataDir || tabDataDir.includes("\0")
41
+ || typeof tabIncarnation !== "number" || !Number.isSafeInteger(tabIncarnation) || tabIncarnation < 1) {
42
+ throw new Error("A1 tab mode requires bridge credentials, recovery directory, and incarnation");
43
+ }
44
+ return { uiArguments, tabCredentials: { tabId, token, endpoint }, tabDataDir, tabIncarnation };
36
45
  }
package/bin/ui.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Security: this helper has no A1, Pi, tool, or extension dependency and erases credentials before those modules load.
4
4
  const { assignFreshSessionRuntimeId, consumeTabLaunchContext, formatResidentFallbackNotice } = await import("./tab-launch-context.js");
5
- const { uiArguments, tabCredentials } = consumeTabLaunchContext(process.argv.slice(2), process.env);
5
+ const { uiArguments, tabCredentials, tabDataDir, tabIncarnation } = consumeTabLaunchContext(process.argv.slice(2), process.env);
6
6
 
7
7
  // Invariant: one terminal module identity per process, decided before any A1 or Pi module loads.
8
8
  const { installPinnedPiTuiResolver } = await import("./module-resolver.js");
@@ -22,11 +22,13 @@ if (profile === "a1") {
22
22
  }
23
23
  const { installFatalExit } = await import("../dist/foundation/terminal-cleanup/fatal-exit.js");
24
24
  const { resolveProductPaths } = await import("../dist/foundation/lifecycle/paths.js");
25
- const { join } = await import("node:path");
25
+ const { dirname, join } = await import("node:path");
26
26
  /** @type {{ dispose(): void | Promise<unknown> } | undefined} */
27
27
  let runningApplication;
28
28
  const fatal = profile === "a1" ? installFatalExit({
29
- directory: join(resolveProductPaths().runtimeDir, "crashes"),
29
+ directory: tabDataDir === undefined
30
+ ? join(resolveProductPaths().runtimeDir, "crashes")
31
+ : join(dirname(tabDataDir), "diagnostics"),
30
32
  ...(launchContext.releaseId === undefined ? {} : { releaseId: launchContext.releaseId }),
31
33
  dispose: () => runningApplication?.dispose(),
32
34
  }) : undefined;
@@ -59,6 +61,8 @@ const sessionSelection = parseSessionSelection(uiArguments);
59
61
  Promise.resolve().then(async () => {
60
62
  const tab = tabCredentials === undefined ? undefined : {
61
63
  bridge: new (await import("../dist/foundation/resident-tabs/bridge-client.js")).TabBridgeClient({ credentials: tabCredentials }),
64
+ dataDir: /** @type {string} */ (tabDataDir),
65
+ incarnation: /** @type {number} */ (tabIncarnation),
62
66
  };
63
67
  if (sessionSelection && profile !== "a1") throw new Error("session selection requires the normal A1 profile");
64
68
  return runSelectedInteractiveRuntime(profile, {
@@ -46,6 +46,29 @@ export interface OwnedUiShellEngineOptions {
46
46
  /** Resident tab quit routes detach the outer client without stopping this engine session. */
47
47
  readonly residentTab?: {
48
48
  requestDetach(): Promise<boolean>;
49
+ requestRestart?(): Promise<boolean>;
50
+ reportStatus?(status: "working" | "idle" | "stalled"): void;
51
+ readonly stallNoticeSeconds?: number;
52
+ admit?(input: {
53
+ readonly sessionId: string;
54
+ readonly sessionFile: string | null;
55
+ readonly kind: "prompt" | "steer" | "follow-up";
56
+ readonly text: string;
57
+ readonly images?: readonly unknown[];
58
+ }): Promise<{
59
+ readonly id: string;
60
+ }>;
61
+ dispatched?(): void;
62
+ correlated?(submissionId: string, entryId: string): Promise<void>;
63
+ checkpoint?(text: string): void;
64
+ gracefulStop?(sessionFile: string | null): Promise<void>;
65
+ readonly recovered?: {
66
+ readonly prompts: readonly {
67
+ readonly id: string;
68
+ readonly text: string;
69
+ }[];
70
+ readonly draft: string | null;
71
+ };
49
72
  readonly unavailableNotice?: string;
50
73
  };
51
74
  }
@@ -54,6 +54,9 @@ export class OwnedUiSessionPresenter {
54
54
  #routeHost;
55
55
  #startupRoute;
56
56
  #startupRouteTimer;
57
+ #residentProgressTimer;
58
+ #residentWorking = false;
59
+ #residentStalled = false;
57
60
  #dialogHandle;
58
61
  #sequence = 0;
59
62
  #editorRevision = 0;
@@ -204,6 +207,7 @@ export class OwnedUiSessionPresenter {
204
207
  onDequeue: () => this.restoreQueuedInput(),
205
208
  onEditorChange: text => {
206
209
  this.#editorRevision++;
210
+ this.#residentTabOptions?.checkpoint?.(text);
207
211
  promptSuggestionController?.abortPending();
208
212
  if (text.length === 0) {
209
213
  // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
@@ -265,6 +269,7 @@ export class OwnedUiSessionPresenter {
265
269
  // Invariant: the presenter owns its root (theme subscription and helper clients) and disposes it once the
266
270
  // terminal no longer renders it.
267
271
  constructed.push(() => this.root.dispose());
272
+ this.#restoreResidentInput();
268
273
  const initialPiSettings = this.backend.settings.snapshot();
269
274
  this.#programStatus = new ProgramStatusReporter(status => this.#terminal.setProgramStatus(status), () => this.view().status.footer?.sessionName ?? undefined);
270
275
  this.#reloadPresentation = reloadPresentation;
@@ -476,6 +481,7 @@ export class OwnedUiSessionPresenter {
476
481
  this.#settleStoppedLifecycle();
477
482
  return;
478
483
  }
484
+ this.#observeResidentProgress(event);
479
485
  // Performance: a streamed chunk names one block, and touching only that block is what keeps the
480
486
  // cost of a chunk the same in a long session as in a new one. Everything else
481
487
  // resynchronizes the view, which is cheap next to re-reading the transcript.
@@ -781,15 +787,38 @@ export class OwnedUiSessionPresenter {
781
787
  const type = this.view().lifecycle === "busy" ? "steer" : "prompt";
782
788
  this.#rememberInput(this.root.omitUnsentPromptImages(displayInput).trim(), type);
783
789
  this.root.resumeViewportFollowing();
784
- const finishSending = prepared.images.length === 0 ? () => { } : this.root.beginImageSubmissionStatus();
790
+ const command = {
791
+ type,
792
+ correlationId: this.#correlation(type),
793
+ sessionId: this.backend.identity.sessionId,
794
+ text: input,
795
+ ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
796
+ };
797
+ let submission;
785
798
  try {
786
- return await this.#execute({
787
- type,
788
- correlationId: this.#correlation(type),
789
- sessionId: this.backend.identity.sessionId,
799
+ submission = await this.#residentTabOptions?.admit?.({
800
+ sessionId: command.sessionId,
801
+ sessionFile: this.#currentSessionFile(),
802
+ kind: type,
790
803
  text: input,
791
804
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
792
- }, displayInput);
805
+ });
806
+ }
807
+ catch {
808
+ if (this.root.editor.getText().length === 0)
809
+ this.root.editor.setText(displayInput);
810
+ const message = "Prompt was not sent because its recovery journal could not be synchronized.";
811
+ this.#reportSubmissionError(undefined, message);
812
+ return rejected(message);
813
+ }
814
+ const finishSending = prepared.images.length === 0 ? () => { } : this.root.beginImageSubmissionStatus();
815
+ try {
816
+ const result = await this.#execute(command, displayInput);
817
+ if (submission !== undefined && result.outcome !== "rejected") {
818
+ this.#residentTabOptions?.dispatched?.();
819
+ await this.#residentTabOptions?.correlated?.(submission.id, command.correlationId).catch(() => undefined);
820
+ }
821
+ return result;
793
822
  }
794
823
  finally {
795
824
  finishSending();
@@ -1183,9 +1212,75 @@ export class OwnedUiSessionPresenter {
1183
1212
  this.#resolveStoppedLifecycle();
1184
1213
  }
1185
1214
  }
1215
+ #restoreResidentInput() {
1216
+ const recovered = this.#residentTabOptions?.recovered;
1217
+ if (recovered === undefined)
1218
+ return;
1219
+ const prompts = recovered.prompts.map(prompt => prompt.text).filter(Boolean);
1220
+ if (prompts.length > 0) {
1221
+ this.root.editor.setText(prompts[0]);
1222
+ if (recovered.draft)
1223
+ this.root.editor.addToHistory(recovered.draft);
1224
+ for (const additional of prompts.slice(1))
1225
+ this.root.editor.addToHistory(additional);
1226
+ this.root.appendWorkflowStatus(recovered.draft
1227
+ ? `Recovered an unfinished prompt; the prior draft and ${Math.max(0, prompts.length - 1)} additional prompt${prompts.length === 2 ? "" : "s"} are in editor history.`
1228
+ : `Recovered an unfinished prompt${prompts.length > 1 ? `; ${prompts.length - 1} additional prompts are in editor history` : ""}.`);
1229
+ }
1230
+ else if (recovered.draft) {
1231
+ this.root.editor.setText(recovered.draft);
1232
+ this.root.appendWorkflowStatus("Recovered the editor draft from the previous tab process.");
1233
+ }
1234
+ }
1235
+ #currentSessionFile() {
1236
+ const direct = this.backend.identity.currentSessionFile?.();
1237
+ if (direct !== undefined && direct !== null)
1238
+ return direct;
1239
+ const resume = this.backend.identity.currentSessionResumeMetadata();
1240
+ return resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`;
1241
+ }
1186
1242
  #residentTab() {
1187
1243
  return this.#residentTabOptions;
1188
1244
  }
1245
+ #observeResidentProgress(event) {
1246
+ const resident = this.#residentTabOptions;
1247
+ const seconds = resident?.stallNoticeSeconds;
1248
+ if (resident === undefined || seconds === undefined)
1249
+ return;
1250
+ if (event.type === "session-lifecycle") {
1251
+ this.#residentWorking = event.lifecycle === "busy";
1252
+ if (!this.#residentWorking) {
1253
+ if (this.#residentProgressTimer !== undefined)
1254
+ clearTimeout(this.#residentProgressTimer);
1255
+ this.#residentProgressTimer = undefined;
1256
+ this.#residentStalled = false;
1257
+ resident.reportStatus?.("idle");
1258
+ return;
1259
+ }
1260
+ }
1261
+ else if (!this.#residentWorking || ![
1262
+ "transcript-block", "session-view", "assistant-message-completed", "agent-run-started",
1263
+ ].includes(event.type)) {
1264
+ return;
1265
+ }
1266
+ if (this.#residentStalled) {
1267
+ this.#residentStalled = false;
1268
+ resident.reportStatus?.("working");
1269
+ }
1270
+ if (this.#residentProgressTimer !== undefined)
1271
+ clearTimeout(this.#residentProgressTimer);
1272
+ this.#residentProgressTimer = setTimeout(() => {
1273
+ this.#residentProgressTimer = undefined;
1274
+ if (this.#disposed || !this.#residentWorking)
1275
+ return;
1276
+ this.#residentStalled = true;
1277
+ resident.reportStatus?.("stalled");
1278
+ const minutes = Math.max(1, Math.round(seconds / 60));
1279
+ this.root.appendWorkflowStatus(`No progress for ${minutes} min — Esc interrupts · /restart-tab restarts`);
1280
+ this.#terminal.requestRender();
1281
+ }, seconds * 1_000);
1282
+ this.#residentProgressTimer.unref?.();
1283
+ }
1189
1284
  async runWorkflow(request) {
1190
1285
  if (request.command === "quit")
1191
1286
  return this.shutdown();
@@ -1443,6 +1538,9 @@ export class OwnedUiSessionPresenter {
1443
1538
  if (this.#startupRouteTimer !== undefined)
1444
1539
  clearTimeout(this.#startupRouteTimer);
1445
1540
  this.#startupRouteTimer = undefined;
1541
+ if (this.#residentProgressTimer !== undefined)
1542
+ clearTimeout(this.#residentProgressTimer);
1543
+ this.#residentProgressTimer = undefined;
1446
1544
  });
1447
1545
  attempt(() => this.#promptSuggestions?.dispose());
1448
1546
  attempt(() => this.#unsubscribePromptSuggestions());
@@ -1479,6 +1577,8 @@ export class OwnedUiSessionPresenter {
1479
1577
  }
1480
1578
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1481
1579
  await boundedCleanup(() => pasteCleanup).catch(error => settled.push(error));
1580
+ await boundedCleanup(() => this.#residentTabOptions?.gracefulStop?.(this.#currentSessionFile()) ?? Promise.resolve())
1581
+ .catch(error => settled.push(error));
1482
1582
  await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => settled.push(error));
1483
1583
  return settled;
1484
1584
  };
@@ -1668,6 +1768,12 @@ export class OwnedUiSessionPresenter {
1668
1768
  const argument = separator < 0 ? "" : body.slice(separator + 1).trimStart();
1669
1769
  if (this.#routeHost?.claims(name))
1670
1770
  return this.#openOwnedRoute(name);
1771
+ if (name === "restart-tab" && this.#residentTabOptions?.requestRestart !== undefined) {
1772
+ const accepted = await this.#residentTabOptions.requestRestart().catch(() => false);
1773
+ this.root.appendWorkflowStatus(accepted ? "Restarting resident tab…" : "Resident tab restart is unavailable.");
1774
+ this.#terminal.requestRender();
1775
+ return accepted ? { outcome: "completed", diagnostic: null } : rejected("resident tab restart is unavailable");
1776
+ }
1671
1777
  if (this.#isWorkflowRoute(name))
1672
1778
  return this.runWorkflow({ command: name, argument });
1673
1779
  if (this.#skillsCollapsed()) {
@@ -11,6 +11,7 @@ import type { AgentSettingsPort } from "../contracts/agent-engine/index.js";
11
11
  import type { OwnedUiPromptSuggestionGeneratorPort, UiAccentColor } from "../contracts/owned-ui/index.js";
12
12
  import type { ReleaseNoteCatalog } from "../features/owned-ui/release-notes.js";
13
13
  import type { TabBridgeClient } from "../foundation/resident-tabs/bridge-client.js";
14
+ import { ResidentTabRecovery } from "../foundation/resident-tabs/recovery.js";
14
15
  /**
15
16
  * The session backend composition wires: the owned-UI port, the prompt-suggestion generator, and
16
17
  * the agent settings port. `settingsPort` stays outside the owned-UI contract because its type
@@ -59,6 +60,8 @@ export interface OwnedUiCompositionOptions {
59
60
  /** Private credentials were consumed by the entry before composition or extensions loaded. */
60
61
  readonly tab?: {
61
62
  readonly bridge: TabBridgeClient;
63
+ readonly dataDir: string;
64
+ readonly incarnation: number;
62
65
  };
63
66
  /** One prompt-adjacent notice explaining why an opted-in resident launch fell back. */
64
67
  readonly residentFallbackNotice?: string;
@@ -79,4 +82,4 @@ export declare function residentStartupPresentation(resident: boolean, fallbackN
79
82
  };
80
83
  export declare function composeOwnedUiApplication(options?: OwnedUiCompositionOptions): Promise<OwnedUiApplicationPort>;
81
84
  export declare function composeOwnedUi(options?: OwnedUiCompositionOptions): Promise<OwnedUiComposition>;
82
- export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backend: Pick<ComposedSessionBackend, "identity" | "session">): Promise<() => void>;
85
+ export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backend: Pick<ComposedSessionBackend, "identity" | "session">, recovery?: Pick<ResidentTabRecovery, "settled"> | null): Promise<() => void>;
@@ -4,7 +4,7 @@ import { PRODUCT_IDENTITY } from "../product-identity.js";
4
4
  import { PromptHistoryService } from "../features/prompt-history/service.js";
5
5
  import { PromptImageSidecar } from "../features/prompt-history/image-sidecar.js";
6
6
  import { resolvePromptHistoryPath } from "../features/prompt-history/paths.js";
7
- import { resolvePromptHistoryDataDir } from "../features/launch/profile-paths.js";
7
+ import { resolveLaunchProfilePaths, resolvePromptHistoryDataDir } from "../features/launch/profile-paths.js";
8
8
  import { resolveProductPaths } from "../foundation/lifecycle/paths.js";
9
9
  import { applyConfiguredPiTheme, getAvailablePiThemes, setPiAccentColor, setPiPackageBorderProjectionEnabled } from "../integrations/pi/components/upstream/theme/theme.js";
10
10
  import { createPiEngineHost } from "../integrations/pi/engine/host.js";
@@ -18,6 +18,7 @@ import { createPiTerminalBridge } from "../integrations/pi/tui-runtime/presentat
18
18
  import { createOwnedRouteHost } from "./settings-route-host.js";
19
19
  import { renderPiShellChangelogLines } from "../integrations/pi/components/shell-presenters-info.js";
20
20
  import { nativeHyperlinkStyle } from "../ui/components/spans.js";
21
+ import { ResidentTabRecovery } from "../foundation/resident-tabs/recovery.js";
21
22
  const SESSION_RUNTIME_REFRESH_MS = 30_000;
22
23
  export function residentStartupPresentation(resident, fallbackNotice) {
23
24
  if (resident)
@@ -87,13 +88,16 @@ export async function composeOwnedUi(options = {}) {
87
88
  sessionRuntimeRefreshTimer.unref();
88
89
  };
89
90
  const productPaths = resolveProductPaths();
91
+ const profilePaths = resolveLaunchProfilePaths();
90
92
  // Rationale: the manager reads the engine's settings port lazily, after the session exists.
91
93
  let backend;
92
94
  const settings = options.profileId === undefined
93
95
  ? null
94
96
  : new OwnedSettingsManager({
95
- configDir: resolveProductPaths().configDir,
97
+ profileDir: profilePaths.agentProfile,
96
98
  profileId: options.profileId,
99
+ namespace: PRODUCT_IDENTITY.commandName,
100
+ legacyConfigDir: productPaths.configDir,
97
101
  agentProvider: () => backend.settingsPort(),
98
102
  hiddenAgentSettingIds: ["fullscreenWheelScrollLines"],
99
103
  agentSettingLabelOverrides: { fullscreenCopyOnSelect: "Copy on select" },
@@ -126,7 +130,17 @@ export async function composeOwnedUi(options = {}) {
126
130
  throw error;
127
131
  }
128
132
  const tabBridge = options.tab?.bridge ?? null;
129
- const unsubscribeTabBridge = tabBridge === null ? () => { } : await bindResidentTabBridge(tabBridge, backend);
133
+ let recoveryDegraded = false;
134
+ let presentRecoveryDegraded = (_degraded) => { };
135
+ const tabRecovery = options.tab === undefined ? null : new ResidentTabRecovery(options.tab.dataDir, {
136
+ onDegraded: degraded => {
137
+ recoveryDegraded = degraded;
138
+ options.tab.bridge.degraded(degraded);
139
+ presentRecoveryDegraded(degraded);
140
+ },
141
+ });
142
+ const recoveredTabInput = await tabRecovery?.initialize(currentBackendSessionFile(backend), backend.identity.sessionId);
143
+ const unsubscribeTabBridge = tabBridge === null ? () => { } : await bindResidentTabBridge(tabBridge, backend, tabRecovery);
130
144
  let releaseNotes = null;
131
145
  let releaseNotesFailure;
132
146
  if (ownedSurfaces) {
@@ -235,7 +249,18 @@ export async function composeOwnedUi(options = {}) {
235
249
  },
236
250
  }),
237
251
  ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
238
- ...(tabBridge === null ? {} : { residentTab: { requestDetach: () => tabBridge.requestDetach() } }),
252
+ ...(tabBridge === null || tabRecovery === null ? {} : { residentTab: {
253
+ requestDetach: () => tabBridge.requestDetach(),
254
+ requestRestart: () => tabBridge.requestRestart(),
255
+ reportStatus: status => tabBridge.status(status),
256
+ stallNoticeSeconds: settings?.value("tabsStallNoticeSeconds") ?? 300,
257
+ admit: input => tabRecovery.admit({ ...input, incarnation: options.tab.incarnation }),
258
+ dispatched: () => tabRecovery.dispatched(),
259
+ correlated: (submissionId, entryId) => tabRecovery.correlated(submissionId, entryId),
260
+ checkpoint: text => tabRecovery.checkpoint(text),
261
+ gracefulStop: sessionFile => tabRecovery.gracefulStop(sessionFile),
262
+ ...(recoveredTabInput === undefined ? {} : { recovered: recoveredTabInput }),
263
+ } }),
239
264
  },
240
265
  presentation: {
241
266
  ...(options.terminal === undefined ? {} : { terminal: createPiTerminalBridge(options.terminal) }),
@@ -264,6 +289,14 @@ export async function composeOwnedUi(options = {}) {
264
289
  // the host and paints once attached.
265
290
  terminalHost = createdTerminalHost = new OwnedUiTerminalHost(sessionTerminalHostOptions(presenterOptions));
266
291
  presenter = new OwnedUiSessionPresenter(terminalHost, presenterOptions);
292
+ presentRecoveryDegraded = degraded => {
293
+ if (!degraded)
294
+ return;
295
+ presenter.root.appendWorkflowStatus("draft recovery degraded");
296
+ terminalHost.runtime.requestRender();
297
+ };
298
+ if (recoveryDegraded)
299
+ presentRecoveryDegraded(true);
267
300
  terminalHost.attach(presenter);
268
301
  }
269
302
  catch (error) {
@@ -341,22 +374,35 @@ export async function composeOwnedUi(options = {}) {
341
374
  };
342
375
  return { application, settings };
343
376
  }
344
- export async function bindResidentTabBridge(tabBridge, backend) {
377
+ export async function bindResidentTabBridge(tabBridge, backend, recovery = null) {
345
378
  await tabBridge.start().catch(() => false);
346
379
  tabBridge.status("starting");
380
+ const currentSessionFile = () => currentBackendSessionFile(backend);
347
381
  const announceSession = () => {
348
- const resume = backend.identity.currentSessionResumeMetadata();
349
- tabBridge.session(resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`, backend.session.view().status.footer?.sessionName ?? null);
382
+ tabBridge.session(currentSessionFile(), backend.session.view().status.footer?.sessionName ?? null);
350
383
  };
384
+ let working = false;
351
385
  announceSession();
352
386
  return backend.session.onEvent(event => {
353
387
  if (event.type === "session-lifecycle") {
354
- tabBridge.status(event.lifecycle === "busy" ? "working" : event.lifecycle === "starting" ? "starting" : "idle");
388
+ const wasWorking = working;
389
+ working = event.lifecycle === "busy";
390
+ tabBridge.status(working ? "working" : event.lifecycle === "starting" ? "starting" : "idle");
391
+ if (wasWorking && !working && recovery !== null) {
392
+ void recovery.settled(currentSessionFile()).catch(() => tabBridge.degraded(true));
393
+ }
355
394
  }
356
395
  if (event.type === "session-view")
357
396
  announceSession();
358
397
  });
359
398
  }
399
+ function currentBackendSessionFile(backend) {
400
+ const direct = backend.identity.currentSessionFile?.();
401
+ if (direct !== undefined && direct !== null)
402
+ return direct;
403
+ const resume = backend.identity.currentSessionResumeMetadata();
404
+ return resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`;
405
+ }
360
406
  async function readPackageVersion() {
361
407
  const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
362
408
  const version = manifest?.version;
@@ -36,6 +36,7 @@ export interface OwnedUiSessionIdentityPort {
36
36
  /** Actual session replacements, excluding delivery-only invalidation of callbacks. */
37
37
  readonly sessionBindingGeneration: number;
38
38
  readonly disposed: boolean;
39
+ currentSessionFile(): string | null;
39
40
  currentSessionResumeMetadata(): OwnedUiSessionResumeMetadata | null;
40
41
  }
41
42
  /** The session lifecycle, its view, its event stream, and command admission. */
@@ -94,12 +94,10 @@ export class SettingsApp {
94
94
  // Invariant: keyboard navigation requests visibility once; pointer scrolling then stays free.
95
95
  #selectionNeedsReveal = true;
96
96
  #reveal;
97
- #notice = null;
98
97
  #filter = null;
99
98
  #scrollBeforeFilter = null;
100
99
  #menu = null;
101
100
  #structured = null;
102
- #interruptArmed = false;
103
101
  // Invariant: pending values remain visible until the source reflects them.
104
102
  #pending = new Map();
105
103
  #undoHistory = [];
@@ -138,7 +136,6 @@ export class SettingsApp {
138
136
  }
139
137
  render(rect, host) {
140
138
  const theme = host.theme ?? PLAIN_THEME;
141
- this.#interruptArmed = host.interruptArmed;
142
139
  const rows = this.#rows();
143
140
  const selected = indexOfKey(rows, this.#selectedKey);
144
141
  const footer = this.#footerLines(rect.width, theme);
@@ -259,7 +256,6 @@ export class SettingsApp {
259
256
  case "open-filter":
260
257
  this.#scrollBeforeFilter = this.#scroll;
261
258
  this.#filter = new LineInput("");
262
- this.#notice = null;
263
259
  return { consumed: true };
264
260
  case "activate": {
265
261
  const row = rows[selected];
@@ -385,7 +381,6 @@ export class SettingsApp {
385
381
  const index = rows.findIndex(candidate => rowKey(candidate) === row.key);
386
382
  if (index >= 0) {
387
383
  // Rationale: the pointer acts where it points; the arrow belongs to the keyboard.
388
- this.#notice = null;
389
384
  if (this.#hoverRegion === "minus")
390
385
  this.#cycle(rows, index, -1);
391
386
  else if (this.#hoverRegion === "plus")
@@ -458,10 +453,8 @@ export class SettingsApp {
458
453
  // and pointing at it is not a request for anything else.
459
454
  if (typeof shown === "number")
460
455
  return;
461
- if (!entry.editable || entry.choices === null || entry.choices.length === 0) {
462
- this.#notice = `${labelOf(entry)} cannot be changed here`;
456
+ if (!entry.editable || entry.choices === null || entry.choices.length === 0)
463
457
  return;
464
- }
465
458
  const current = shown === null ? 0 : Math.max(0, entry.choices.indexOf(shown));
466
459
  const anchorKey = `${entry.backend}:${entry.id}`;
467
460
  // Compatibility: pointer opening already brightens the source value. Enter borrows that
@@ -492,10 +485,8 @@ export class SettingsApp {
492
485
  #openStructured(entry) {
493
486
  // Invariant: the flags come from the declaration, not from the stored value: an unset
494
487
  // flag still has a row, showing the default the source would apply.
495
- if (entry.flags.length === 0) {
496
- this.#notice = `${labelOf(entry)} has nothing to configure`;
488
+ if (entry.flags.length === 0)
497
489
  return;
498
- }
499
490
  const stored = typeof entry.rawValue === "object" && entry.rawValue !== null && !Array.isArray(entry.rawValue)
500
491
  ? entry.rawValue
501
492
  : {};
@@ -648,13 +639,11 @@ export class SettingsApp {
648
639
  const failure = changeFailure(outcome);
649
640
  if (failure === null) {
650
641
  this.#recordUndo({ kind: "structured", sequence, entry: open.entry, previous, forward });
651
- this.#notice = null;
652
642
  return;
653
643
  }
654
644
  // Concurrency: only roll back the dialog if no later press has moved the record on.
655
645
  if (this.#structured === open && sameRecord(open.record, forward))
656
646
  replaceRecord(open.record, previous);
657
- this.#notice = `Could not save ${labelOf(open.entry)}: ${failure}`;
658
647
  });
659
648
  }
660
649
  #menuKey(data) {
@@ -744,10 +733,8 @@ export class SettingsApp {
744
733
  if (row === undefined || row.kind !== "element")
745
734
  return;
746
735
  const entry = row.value;
747
- if (!entry.editable) {
748
- this.#notice = `${labelOf(entry)} cannot be changed here`;
736
+ if (!entry.editable)
749
737
  return;
750
- }
751
738
  const shown = this.#shownValue(entry);
752
739
  if (typeof shown === "number") {
753
740
  // Rationale: at the end of the range there is nothing to say: the arrow already reads
@@ -758,10 +745,8 @@ export class SettingsApp {
758
745
  return;
759
746
  }
760
747
  const choices = entry.choices;
761
- if (choices === null || choices.length === 0) {
762
- this.#notice = `${labelOf(entry)} cannot be changed here`;
748
+ if (choices === null || choices.length === 0)
763
749
  return;
764
- }
765
750
  const current = shown === null ? -1 : choices.indexOf(shown);
766
751
  const next = choices[current < 0
767
752
  ? (delta > 0 ? 0 : choices.length - 1)
@@ -781,7 +766,6 @@ export class SettingsApp {
781
766
  if (failure !== null) {
782
767
  if (this.#pending.get(key) === value)
783
768
  this.#pending.delete(key);
784
- this.#notice = `Could not save ${labelOf(entry)}: ${failure}`;
785
769
  return;
786
770
  }
787
771
  if (previous !== null && previous !== value)
@@ -789,7 +773,6 @@ export class SettingsApp {
789
773
  // Concurrency: a later press may have moved on; only the last request clears itself.
790
774
  if (this.#pending.get(key) === value)
791
775
  this.#pending.delete(key);
792
- this.#notice = null;
793
776
  });
794
777
  }
795
778
  #shownValue(entry) {
@@ -841,13 +824,8 @@ export class SettingsApp {
841
824
  }
842
825
  #finishUndo(record, outcome) {
843
826
  const failure = changeFailure(outcome);
844
- if (failure !== null) {
827
+ if (failure !== null)
845
828
  this.#recordUndo(record);
846
- this.#notice = `Could not restore ${labelOf(record.entry)}: ${failure}`;
847
- }
848
- else {
849
- this.#notice = null;
850
- }
851
829
  this.#undoActive = false;
852
830
  this.#drainUndo();
853
831
  }
@@ -878,7 +856,6 @@ export class SettingsApp {
878
856
  return;
879
857
  this.#selectedKey = rowKey(rows[index]);
880
858
  this.#selectionNeedsReveal = true;
881
- this.#notice = null;
882
859
  }
883
860
  #rows() {
884
861
  const needle = this.#filter?.value.trim().toLowerCase() ?? "";
@@ -1037,13 +1014,9 @@ export class SettingsApp {
1037
1014
  const open = this.#structured;
1038
1015
  if (open !== null)
1039
1016
  return this.#dialogLines(open, width, theme);
1040
- const notice = this.#notice;
1041
- const plainStatus = notice !== null && notice.length > 0
1042
- ? notice
1043
- : this.#interruptArmed ? "press ctrl+c again to exit a1" : null;
1044
- const status = plainStatus === null
1045
- ? renderShortcutHintsWithClose(SETTINGS_SHORTCUTS.hintEntries(SCOPE), theme, width, width > 0 ? 1 : 0)
1046
- : truncateToWidth(`${width > 0 ? " " : ""}${theme.fg("dim", plainStatus)}`, width);
1017
+ // Rationale: standing guidance is more useful than transient feedback; failures still
1018
+ // roll back without displacing the Settings interaction contract.
1019
+ const status = renderShortcutHintsWithClose(SETTINGS_SHORTCUTS.hintEntries(SCOPE), theme, width, width > 0 ? 1 : 0);
1047
1020
  const input = this.#filter;
1048
1021
  if (input === null)
1049
1022
  return [status];
@@ -25,7 +25,7 @@ export interface BootstrapOptions {
25
25
  /** Test seam for bounded maintenance scheduled after stale endpoint sweeping. */
26
26
  readonly scheduleMaintenance?: (dataDir: string, paths: ReturnType<typeof resolveProductPaths>) => Promise<void>;
27
27
  /** Test seams for resident launch routing before the guardian starts. */
28
- readonly readResidentTabs?: (configDir: string, profileId: string) => Promise<boolean>;
28
+ readonly readResidentTabs?: (profileDir: string, namespace: string, legacyConfigDir: string, profileId: string) => Promise<boolean>;
29
29
  readonly ensureTerminalHost?: (options: EnsureTerminalHostOptions) => Promise<EnsureTerminalHostResult>;
30
30
  }
31
31
  export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
@@ -18,7 +18,7 @@ import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDoc
18
18
  import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
19
19
  import { markStartupPhase } from "../startup/index.js";
20
20
  import { isCurrentLaunchContract, launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
21
- import { ensureTerminalHost, readResidentTabsSetting, selectResidentHost } from "../resident-tabs/index.js";
21
+ import { ensureTerminalHost, readResidentTabLaunchSettings, selectResidentHost } from "../resident-tabs/index.js";
22
22
  const RELEASE_RESELECTION_MESSAGE = "a1-release-reselection";
23
23
  export async function runBootstrap(options) {
24
24
  return await runBootstrapAttempt(options, 1);
@@ -36,17 +36,27 @@ async function runBootstrapAttempt(options, retriesRemaining) {
36
36
  await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
37
37
  const launchSelected = async (release) => {
38
38
  let residentContext = {};
39
+ // Compatibility: public launch preparation supplies PI_CODING_AGENT_DIR; the fallback keeps
40
+ // direct SDK/bootstrap callers hermetic while retaining their former config-root read.
41
+ const agentProfileDir = environment.PI_CODING_AGENT_DIR ?? paths.configDir;
42
+ const residentLaunchSettings = launchProfileId === "a1"
43
+ ? readResidentTabLaunchSettings(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, launchProfileId)
44
+ : null;
39
45
  const resident = await selectResidentHost({
40
46
  profileId: launchProfileId,
41
47
  hasSessionSelection: sessionArgs.length > 0,
42
48
  currentLaunchContract: isCurrentLaunchContract(release),
43
- readSetting: () => (options.readResidentTabs ?? readResidentTabsSetting)(paths.configDir, launchProfileId),
44
- ensureHost: () => (options.ensureTerminalHost ?? ensureTerminalHost)({
49
+ readSetting: options.readResidentTabs === undefined
50
+ ? async () => (await residentLaunchSettings).residentTabs
51
+ : () => options.readResidentTabs(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, launchProfileId),
52
+ ensureHost: async () => (options.ensureTerminalHost ?? ensureTerminalHost)({
45
53
  releaseRoot: release.releaseRoot,
46
54
  profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
47
55
  dataDir: paths.dataDir,
48
56
  runtimeDir: paths.runtimeDir,
49
57
  environment,
58
+ maxConcurrentStarts: (await residentLaunchSettings).maxConcurrentStarts,
59
+ unresponsiveRestartSeconds: (await residentLaunchSettings).unresponsiveRestartSeconds,
50
60
  }),
51
61
  });
52
62
  if (resident !== null) {
@@ -55,7 +65,8 @@ async function runBootstrapAttempt(options, retriesRemaining) {
55
65
  residentRootArguments: JSON.stringify([
56
66
  "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(CLEAR_EXIT_INTERVAL_MS),
57
67
  "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
58
- "--data-dir", paths.dataDir, "--release-root", release.releaseRoot, "--node-executable", process.execPath,
68
+ "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
69
+ "--release-root", release.releaseRoot, "--node-executable", process.execPath,
59
70
  ]),
60
71
  } : { residentFallback: resident.reason };
61
72
  }