@timurproko/a1 0.2.7-dev.792 → 0.2.7-dev.806

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 (110) hide show
  1. package/dist/app/session-shell/clipboard-diagnostics.js +1 -1
  2. package/dist/app/session-shell/response-copy-helper.js +21 -6
  3. package/dist/app/session-shell/response-copy-protocol.d.ts +1 -1
  4. package/dist/app/session-shell/response-copy-transport.d.ts +3 -6
  5. package/dist/app/session-shell/response-copy-transport.js +60 -17
  6. package/dist/app/session-shell/session-shell-root.d.ts +2 -0
  7. package/dist/app/session-shell/session-shell-root.js +6 -1
  8. package/dist/app/session-shell/session-shell.d.ts +1 -0
  9. package/dist/app/session-shell/session-shell.js +22 -19
  10. package/dist/cli/tabs.js +1 -1
  11. package/dist/composition/owned-ui.d.ts +1 -0
  12. package/dist/composition/owned-ui.js +16 -3
  13. package/dist/features/resident-tab/shortcut-declarations.d.ts +1 -1
  14. package/dist/features/resident-tab/shortcut-declarations.js +1 -0
  15. package/dist/foundation/launch-guardian/exit-watcher.d.ts +14 -0
  16. package/dist/foundation/launch-guardian/exit-watcher.js +94 -0
  17. package/dist/foundation/launch-guardian/main.d.ts +2 -1
  18. package/dist/foundation/launch-guardian/main.js +58 -6
  19. package/dist/foundation/process-containment/{native-guardian-containment.d.ts → addon-containment.d.ts} +7 -5
  20. package/dist/foundation/process-containment/addon-containment.js +139 -0
  21. package/dist/foundation/process-containment/addon-process-inspector.d.ts +11 -0
  22. package/dist/foundation/process-containment/addon-process-inspector.js +36 -0
  23. package/dist/foundation/process-containment/addon.d.ts +42 -0
  24. package/dist/foundation/process-containment/addon.js +57 -0
  25. package/dist/foundation/process-containment/artifact.d.ts +1 -1
  26. package/dist/foundation/process-containment/artifact.js +6 -5
  27. package/dist/foundation/process-containment/contracts.d.ts +2 -0
  28. package/dist/foundation/process-containment/index.d.ts +4 -4
  29. package/dist/foundation/process-containment/index.js +3 -3
  30. package/dist/foundation/process-containment/native-process-inspector.d.ts +2 -1
  31. package/dist/foundation/process-containment/native-process-inspector.js +10 -10
  32. package/dist/foundation/release/bootstrap.js +53 -16
  33. package/dist/foundation/release/index.d.ts +1 -1
  34. package/dist/foundation/release/index.js +1 -1
  35. package/dist/foundation/release/release-store.d.ts +12 -0
  36. package/dist/foundation/release/release-store.js +124 -2
  37. package/dist/foundation/resident-tabs/bounded-host-command.d.ts +3 -1
  38. package/dist/foundation/resident-tabs/bounded-host-command.js +6 -2
  39. package/dist/foundation/resident-tabs/bridge-client.d.ts +2 -0
  40. package/dist/foundation/resident-tabs/bridge-client.js +13 -0
  41. package/dist/foundation/resident-tabs/doctor.d.ts +0 -1
  42. package/dist/foundation/resident-tabs/doctor.js +4 -4
  43. package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +7 -2
  44. package/dist/foundation/resident-tabs/ensure-terminal-host.js +15 -11
  45. package/dist/foundation/resident-tabs/host-entry.d.ts +1 -0
  46. package/dist/foundation/resident-tabs/host-entry.js +17 -0
  47. package/dist/foundation/resident-tabs/index.d.ts +2 -2
  48. package/dist/foundation/resident-tabs/index.js +1 -1
  49. package/dist/foundation/resident-tabs/maintenance.d.ts +5 -4
  50. package/dist/foundation/resident-tabs/maintenance.js +32 -17
  51. package/dist/foundation/resident-tabs/release-holds.d.ts +3 -2
  52. package/dist/foundation/resident-tabs/release-holds.js +16 -7
  53. package/dist/foundation/resident-tabs/settings.d.ts +0 -1
  54. package/dist/foundation/resident-tabs/settings.js +1 -7
  55. package/dist/foundation/resident-tabs/terminal-host-artifact.d.ts +11 -15
  56. package/dist/foundation/resident-tabs/terminal-host-artifact.js +57 -32
  57. package/dist/foundation/session-writer-lock/index.d.ts +1 -3
  58. package/dist/foundation/session-writer-lock/index.js +8 -6
  59. package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -1
  60. package/dist/integrations/pi/components/shell-shared-facade.d.ts +2 -0
  61. package/dist/native/darwin-arm64/manifest.json +8 -6
  62. package/dist/native/darwin-arm64/process-guardian.node +0 -0
  63. package/dist/native/darwin-arm64/terminal-host-THIRD-PARTY-NOTICES.txt +1 -1
  64. package/dist/native/darwin-arm64/terminal-host.manifest.json +14 -13
  65. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  66. package/dist/native/darwin-x64/manifest.json +9 -7
  67. package/dist/native/darwin-x64/process-guardian.node +0 -0
  68. package/dist/native/darwin-x64/terminal-host-THIRD-PARTY-NOTICES.txt +1 -1
  69. package/dist/native/darwin-x64/terminal-host.manifest.json +15 -14
  70. package/dist/native/darwin-x64/terminal-host.node +0 -0
  71. package/dist/native/linux-x64/manifest.json +8 -6
  72. package/dist/native/linux-x64/process-guardian.node +0 -0
  73. package/dist/native/linux-x64/terminal-host-THIRD-PARTY-NOTICES.txt +1 -1
  74. package/dist/native/linux-x64/terminal-host.manifest.json +14 -13
  75. package/dist/native/linux-x64/{terminal-host → terminal-host.node} +0 -0
  76. package/dist/native/win32-x64/manifest.json +8 -6
  77. package/dist/native/win32-x64/process-guardian.node +0 -0
  78. package/dist/native/win32-x64/terminal-host-THIRD-PARTY-NOTICES.txt +1 -1
  79. package/dist/native/win32-x64/terminal-host.manifest.json +14 -13
  80. package/dist/native/win32-x64/{terminal-host.exe → terminal-host.node} +0 -0
  81. package/dist/product-identity.d.ts +1 -1
  82. package/dist/product-identity.js +1 -1
  83. package/dist/product-identity.json +3 -2
  84. package/dist/runtime-payload-inventory.json +1 -0
  85. package/dist/ui/settings/declarations.d.ts +0 -12
  86. package/dist/ui/settings/declarations.js +0 -10
  87. package/docs/architecture/boundaries.md +10 -9
  88. package/docs/architecture/process-guardian-provenance.md +17 -11
  89. package/docs/architecture/resident-tabs.md +36 -34
  90. package/docs/architecture/terminal-host-provenance.md +5 -3
  91. package/docs/architecture/toolchain.md +8 -6
  92. package/docs/ci-release-runbook.md +1 -1
  93. package/docs/openspec-archive-automation.md +23 -9
  94. package/docs/validation.md +6 -2
  95. package/package.json +5 -3
  96. package/dist/foundation/process-containment/darwin-process-inspector.d.ts +0 -5
  97. package/dist/foundation/process-containment/darwin-process-inspector.js +0 -7
  98. package/dist/foundation/process-containment/native-guardian-containment.js +0 -152
  99. package/dist/foundation/process-containment/windows-process-inspector.d.ts +0 -19
  100. package/dist/foundation/process-containment/windows-process-inspector.js +0 -56
  101. package/dist/native/darwin-arm64/a1_session_lock.node +0 -0
  102. package/dist/native/darwin-arm64/process-guardian +0 -0
  103. package/dist/native/darwin-arm64/terminal-host +0 -0
  104. package/dist/native/darwin-x64/a1_session_lock.node +0 -0
  105. package/dist/native/darwin-x64/process-guardian +0 -0
  106. package/dist/native/darwin-x64/terminal-host +0 -0
  107. package/dist/native/linux-x64/a1_session_lock.node +0 -0
  108. package/dist/native/linux-x64/process-guardian +0 -0
  109. package/dist/native/win32-x64/a1_session_lock.node +0 -0
  110. package/dist/native/win32-x64/process-guardian.exe +0 -0
@@ -39,7 +39,7 @@ export class ClipboardDiagnosticCapture {
39
39
  // Security: project only declared scalar fields. Never serialize the supplied event object or raw errors.
40
40
  this.#records.push({ source, phase: event.phase, atMs: scalar(event.atMs), request: event.request !== undefined && event.request < 0 ? -scalar(-event.request) : scalar(event.request), pending: scalar(event.pending), bytes: scalar(event.bytes), elapsedMs: scalar(event.elapsedMs),
41
41
  sourceUnits: scalar(event.sourceUnits), pendingBytes,
42
- transport: ["native", "terminal", "provided", "injected"].includes(event.transport ?? "") ? event.transport : "none",
42
+ transport: ["native", "terminal", "platform", "provided", "injected"].includes(event.transport ?? "") ? event.transport : "none",
43
43
  outcome: ["ready", "failed", "canceled", "delivered", "submitted-unverified", "timed-out", "superseded"].includes(event.outcome ?? "") ? event.outcome : "none" });
44
44
  if (this.#records.length > 128)
45
45
  this.#records.shift();
@@ -1,5 +1,6 @@
1
1
  import { MAX_COPY_ROWS, MAX_COPY_SOURCE_UNITS, selectionCopyRowText } from "../../ui/components/index.js";
2
2
  import { COPY_CHUNK_UNITS, MAX_COPY_BYTES, MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
3
+ const NATIVE_RETRY_DELAYS_MS = [20, 60];
3
4
  // Security: this isolated executor never inherits the UI terminal and never logs clipboard contents.
4
5
  let header;
5
6
  let fragments = [];
@@ -24,6 +25,14 @@ function nextOutput() {
24
25
  send({ kind: "data", text });
25
26
  }
26
27
  function send(message) { process.send?.(message); }
28
+ function transientClipboardError(error) {
29
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ return /^(?:EAGAIN|EBUSY)$/iu.test(code) || /(?:busy|locked|contention|temporar|try again)/iu.test(message);
32
+ }
33
+ function delay(milliseconds) {
34
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
35
+ }
27
36
  function finish(result, control) {
28
37
  if (finished)
29
38
  return;
@@ -97,13 +106,19 @@ async function receive(message) {
97
106
  return;
98
107
  }
99
108
  send({ kind: "phase", phase: "submitting", bytes });
100
- try {
101
- await native.setText(text);
102
- finish({ outcome: "delivered" });
103
- }
104
- catch {
105
- finish({ outcome: "failed", failure: "denied" });
109
+ for (let attempt = 0; attempt <= NATIVE_RETRY_DELAYS_MS.length; attempt++) {
110
+ try {
111
+ await native.setText(text);
112
+ finish({ outcome: "delivered" });
113
+ return;
114
+ }
115
+ catch (error) {
116
+ if (!transientClipboardError(error) || attempt === NATIVE_RETRY_DELAYS_MS.length)
117
+ break;
118
+ await delay(NATIVE_RETRY_DELAYS_MS[attempt]);
119
+ }
106
120
  }
121
+ finish({ outcome: "failed", failure: "denied" });
107
122
  return;
108
123
  }
109
124
  else
@@ -19,7 +19,7 @@ export interface ResponseCopyEvent {
19
19
  readonly pending: number;
20
20
  readonly sourceUnits: number;
21
21
  readonly bytes?: number;
22
- readonly transport?: "native" | "terminal" | "injected";
22
+ readonly transport?: "native" | "terminal" | "platform" | "injected";
23
23
  readonly outcome?: CopyOutcome;
24
24
  }
25
25
  /** One request per helper lifetime. Each source fragment is acknowledged before the next is sent. */
@@ -7,7 +7,7 @@ export interface ResponseCopyJob {
7
7
  readonly stopped: Promise<void>;
8
8
  cancel(): void;
9
9
  }
10
- export type CopyPhaseObserver = (phase: "extracted" | "encoded" | "submitting", bytes: number, transport: "native" | "terminal" | "injected") => void;
10
+ export type CopyPhaseObserver = (phase: "extracted" | "encoded" | "submitting", bytes: number, transport: "native" | "terminal" | "platform" | "injected") => void;
11
11
  export type ResponseCopyExecutor = (snapshot: SelectionCopySnapshot, phase: CopyPhaseObserver) => ResponseCopyJob;
12
12
  /** The production executor also holds a spare helper; the owner warms it at start and disposes it at shutdown unless the pool is shared. */
13
13
  export interface OwnedResponseCopyExecutor extends ResponseCopyExecutor {
@@ -22,17 +22,14 @@ export interface ResponseCopyTerminal {
22
22
  }
23
23
  /** Never substitute a remote host's native clipboard for the terminal user's clipboard. */
24
24
  export declare function responseCopyDestination(env: NodeJS.ProcessEnv): "native" | "terminal";
25
- /** Node documents synchronous POSIX TTY and Windows pipe writes; do not put clipboard traffic there. */
26
- export declare function hasAsyncClipboardOutput(platform?: NodeJS.Platform, stdout?: {
27
- readonly isTTY?: boolean | undefined;
28
- readonly fd?: number | undefined;
29
- }): boolean;
30
25
  /** Isolates preparation and native clipboard access; only the owner may submit terminal controls. */
31
26
  export declare function createResponseCopyExecutor(options?: {
32
27
  readonly terminal?: ResponseCopyTerminal;
33
28
  readonly destination?: "native" | "terminal";
34
29
  /** Explicit host seam: must be non-blocking and honor cancellation, or its unresolved lifetime is quarantined. */
35
30
  readonly writeText?: (text: string, signal: AbortSignal) => Promise<void>;
31
+ /** Platform/fault-test seam; production writes through the supported operating-system command. */
32
+ readonly platformWriteText?: (text: string, signal: AbortSignal) => Promise<boolean>;
36
33
  /** Package/fault-test seam: the production helper is always an emitted sibling. */
37
34
  readonly helper?: URL;
38
35
  /** Idle bound for the spare helper; production keeps the default. */
@@ -1,25 +1,58 @@
1
- import { fork } from "node:child_process";
2
- import { fstatSync } from "node:fs";
1
+ import { execFile, fork } from "node:child_process";
3
2
  import { HelperPool } from "./helper-pool.js";
3
+ import { PRODUCT_IDENTITY } from "../../product-identity.js";
4
4
  import { COPY_CHUNK_UNITS, COPY_CLEANUP_MS, MAX_COPY_BYTES, MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
5
+ const PLATFORM_WRITE_TIMEOUT_MS = 1_500;
5
6
  /** Never substitute a remote host's native clipboard for the terminal user's clipboard. */
6
7
  export function responseCopyDestination(env) {
7
- return env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY || env.TMUX || env.STY || env.WSL_DISTRO_NAME
8
+ return env[PRODUCT_IDENTITY.environment.tabId] || env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY || env.TMUX || env.STY || env.WSL_DISTRO_NAME
8
9
  ? "terminal" : "native";
9
10
  }
10
- /** Node documents synchronous POSIX TTY and Windows pipe writes; do not put clipboard traffic there. */
11
- export function hasAsyncClipboardOutput(platform = process.platform, stdout = process.stdout) {
12
- if (stdout.isTTY)
13
- return platform === "win32";
14
- if (platform === "win32")
15
- return false;
16
- try {
17
- const stat = fstatSync(stdout.fd ?? -1);
18
- return stat.isFIFO() || stat.isSocket();
19
- }
20
- catch {
21
- return false;
11
+ function platformClipboardCommands() {
12
+ if (process.platform === "win32")
13
+ return [{
14
+ command: "powershell.exe",
15
+ args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$value=[Console]::In.ReadToEnd(); Set-Clipboard -Value $value"],
16
+ }];
17
+ if (process.platform === "darwin")
18
+ return [{ command: "pbcopy", args: [] }];
19
+ return [
20
+ { command: "wl-copy", args: ["--type", "text/plain;charset=utf-8"] },
21
+ { command: "xclip", args: ["-selection", "clipboard", "-in"] },
22
+ ];
23
+ }
24
+ async function writePlatformClipboardText(text, signal) {
25
+ for (const route of platformClipboardCommands()) {
26
+ if (signal.aborted)
27
+ return false;
28
+ const delivered = await new Promise(resolve => {
29
+ let settled = false;
30
+ const done = (value) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ resolve(value);
35
+ };
36
+ const child = execFile(route.command, [...route.args], {
37
+ signal,
38
+ windowsHide: true,
39
+ timeout: PLATFORM_WRITE_TIMEOUT_MS,
40
+ killSignal: "SIGKILL",
41
+ maxBuffer: 1_024,
42
+ }, error => done(error === null));
43
+ child.once("error", () => done(false));
44
+ if (child.stdin === null) {
45
+ child.kill("SIGKILL");
46
+ done(false);
47
+ return;
48
+ }
49
+ child.stdin.once("error", () => { child.kill("SIGKILL"); done(false); });
50
+ child.stdin.end(text);
51
+ });
52
+ if (delivered)
53
+ return true;
22
54
  }
55
+ return false;
23
56
  }
24
57
  /** Isolates preparation and native clipboard access; only the owner may submit terminal controls. */
25
58
  export function createResponseCopyExecutor(options = {}) {
@@ -43,8 +76,18 @@ export function createResponseCopyExecutor(options = {}) {
43
76
  };
44
77
  let outcome = await execute(destination);
45
78
  // Concurrency: helper results settle only after exit; fallback cannot race an older native write.
46
- if (!controller.signal.aborted && destination === "native" && outcome.result.failure === "unavailable" && options.terminal !== undefined) {
47
- outcome = await execute("terminal");
79
+ if (!controller.signal.aborted && destination === "native" && outcome.result.outcome === "failed") {
80
+ const prepared = await execute("prepare");
81
+ if (prepared.result.outcome === "delivered" && prepared.text !== undefined) {
82
+ phase("submitting", prepared.bytes ?? 0, "platform");
83
+ const delivered = await (options.platformWriteText ?? writePlatformClipboardText)(prepared.text, controller.signal);
84
+ outcome = { kind: "result", result: delivered ? { outcome: "delivered" } : { outcome: "failed", failure: "unavailable" } };
85
+ }
86
+ else
87
+ outcome = { kind: "result", result: { outcome: "failed", failure: "transport" } };
88
+ if (!controller.signal.aborted && outcome.result.outcome === "failed" && options.terminal !== undefined) {
89
+ outcome = await execute("terminal");
90
+ }
48
91
  }
49
92
  if (controller.signal.aborted)
50
93
  return { outcome: "canceled" };
@@ -46,6 +46,7 @@ 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
+ closeOwnedRoute?(route: string): void;
49
50
  reportStatus?(status: "working" | "idle" | "stalled"): void;
50
51
  readonly stallNoticeSeconds?: number;
51
52
  admit?(input: {
@@ -191,6 +192,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
191
192
  readonly requestHyperlinkCleanup?: (rows?: readonly number[]) => void;
192
193
  readonly enableDockInputReuse?: boolean;
193
194
  readonly persistentHistory?: boolean;
195
+ readonly hideSettingsCommand?: boolean;
194
196
  readonly historyEditor?: import("../../integrations/pi/components/index.js").HistoryEditorConstructor;
195
197
  readonly onSubmit: (text: string) => void;
196
198
  readonly onPasteRejected?: (error: unknown) => void;
@@ -438,6 +438,7 @@ export class OwnedUiSessionShellRoot {
438
438
  ? Math.max(0, dock.inputRows - 2)
439
439
  : 0;
440
440
  const copyableDockRows = [
441
+ ...dock.copyableMessageRows,
441
442
  ...Array.from({ length: promptCopyRows }, (_value, index) => ({
442
443
  row: editorOffset + index + 1,
443
444
  prompt: index === 0 ? "first" : "continuation",
@@ -620,7 +621,10 @@ export class OwnedUiSessionShellRoot {
620
621
  #renderDockLayout(width) {
621
622
  const queued = this.#customViewport ? [] : this.#renderQueued(width);
622
623
  const statusRows = this.#customViewport ? this.#status.renderDock(width) : this.#renderStatus(width);
623
- const transientRows = [...queued, ...statusRows, ...this.#renderDockNotice(width)];
624
+ const noticeRows = this.#renderDockNotice(width);
625
+ const transientRows = [...queued, ...statusRows, ...noticeRows];
626
+ const messageStart = queued.length;
627
+ const copyableMessageRows = Array.from({ length: statusRows.length + noticeRows.length }, (_value, index) => ({ row: messageStart + index }));
624
628
  const aboveWidgets = this.#renderWidgets("aboveEditor", width);
625
629
  const input = this.#inputSurface.render(width);
626
630
  const body = this.usesDefaultInputSurface() ? this.editor.bodyGeometry?.() : undefined;
@@ -634,6 +638,7 @@ export class OwnedUiSessionShellRoot {
634
638
  editorOffset: inputOffset + (body?.rowOffset ?? 0),
635
639
  inputRows: body?.rowCount ?? input.length,
636
640
  footerRows: footer.length,
641
+ copyableMessageRows,
637
642
  };
638
643
  }
639
644
  #renderCopyAcknowledgement(width) {
@@ -72,6 +72,7 @@ export declare class OwnedUiSessionPresenter implements OwnedUiHostedPresenter {
72
72
  readonly mode: "regular" | "fullscreen";
73
73
  readonly columns: number;
74
74
  }): OwnedUiPresenterRelease;
75
+ openOwnedRoute(route: string): void;
75
76
  hotkeysPresentation(): PiShellHotkeysPresentation;
76
77
  }
77
78
  /**
@@ -1,5 +1,5 @@
1
1
  import { ResponseCopyCoordinator } from "./response-copy-coordinator.js";
2
- import { createResponseCopyExecutor, hasAsyncClipboardOutput, responseCopyDestination } from "./response-copy-transport.js";
2
+ import { createResponseCopyExecutor, responseCopyDestination } from "./response-copy-transport.js";
3
3
  import { MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
4
4
  import { PromptHistoryController } from "./prompt-history-controller.js";
5
5
  import { PRODUCT_TEXT } from "../../product-identity.js";
@@ -129,31 +129,24 @@ export class OwnedUiSessionPresenter {
129
129
  // throw releases them in reverse, so a shell that never finished constructing holds nothing.
130
130
  const constructed = [() => host.disconnect(this)];
131
131
  try {
132
- const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
133
132
  this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
134
133
  execute: responseCopy?.execute ?? (this.#copyExecutor = createResponseCopyExecutor({
135
134
  ...(sharedClipboard === undefined ? {} : { pool: sharedClipboard.copy }),
136
135
  ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
137
136
  ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
138
- ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
139
- if (signal.aborted || this.#disposed || !this.#terminal.running)
140
- throw new Error("Copy canceled");
141
- // Performance: never grow the real terminal's pending buffer with another clipboard payload.
142
- if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
143
- throw new Error("Clipboard terminal is busy");
144
- }
145
- this.#terminal.writeControl(control);
146
- } } } : {}),
137
+ // Invariant: the owned host is the route contract. Resident and pseudoterminal children must not infer
138
+ // clipboard reachability from Node's view of stdout, and inactive presenters are fenced here.
139
+ terminal: { submit: async (control, signal) => {
140
+ if (signal.aborted || this.#disposed || !this.#terminal.running || !this.#terminal.attached)
141
+ throw new Error("Copy canceled");
142
+ // Performance: never grow the real terminal's pending buffer with another clipboard payload.
143
+ if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
144
+ throw new Error("Clipboard terminal is busy");
145
+ }
146
+ this.#terminal.writeControl(control);
147
+ } },
147
148
  })),
148
149
  ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
149
- onFailure: result => {
150
- if (this.#disposed || !this.#terminal.running)
151
- return;
152
- this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
153
- : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
154
- : "Could not copy the selection; the clipboard is unavailable.");
155
- this.#terminal.requestRender();
156
- },
157
150
  }) : null;
158
151
  constructed.push(() => { this.#responseCopy?.dispose(); this.#copyExecutor?.dispose(); });
159
152
  const readClipboard = async (signal) => {
@@ -189,6 +182,7 @@ export class OwnedUiSessionPresenter {
189
182
  }),
190
183
  enableDockInputReuse: inputPresentation?.viewportReuse !== false,
191
184
  footerSessionName: this.#residentTabOptions === undefined,
185
+ hideSettingsCommand: this.#residentTabOptions !== undefined,
192
186
  persistentHistory: this.#customViewport && promptHistory !== undefined,
193
187
  ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
194
188
  onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
@@ -1343,6 +1337,8 @@ export class OwnedUiSessionPresenter {
1343
1337
  return { outcome: "completed", diagnostic: null };
1344
1338
  }
1345
1339
  if (request.command === "settings" && request.selection === undefined && request.confirmed === undefined) {
1340
+ if (this.#residentTabOptions !== undefined)
1341
+ return rejected("Settings is available from the global Settings tab.");
1346
1342
  this.showSettingsSelector();
1347
1343
  return { outcome: "completed", diagnostic: null };
1348
1344
  }
@@ -1717,6 +1713,7 @@ export class OwnedUiSessionPresenter {
1717
1713
  void onClosed?.()?.catch(() => undefined);
1718
1714
  }
1719
1715
  catch { /* Rationale: a failed acknowledgement stays pending. */ }
1716
+ this.#residentTabOptions?.closeOwnedRoute?.(route);
1720
1717
  }
1721
1718
  },
1722
1719
  });
@@ -1764,6 +1761,9 @@ export class OwnedUiSessionPresenter {
1764
1761
  });
1765
1762
  this.#dialogId = dialog.id;
1766
1763
  }
1764
+ openOwnedRoute(route) {
1765
+ this.#openOwnedRoute(route);
1766
+ }
1767
1767
  hotkeysPresentation() {
1768
1768
  const presentation = this.root.hotkeysPresentation();
1769
1769
  return this.#residentTabOptions === undefined ? presentation : { ...presentation, residentTabs: true };
@@ -1773,6 +1773,9 @@ export class OwnedUiSessionPresenter {
1773
1773
  const separator = body.search(/\s/);
1774
1774
  const name = separator < 0 ? body : body.slice(0, separator);
1775
1775
  const argument = separator < 0 ? "" : body.slice(separator + 1).trimStart();
1776
+ if (name === "settings" && this.#residentTabOptions !== undefined) {
1777
+ return rejected("Settings is available from the global Settings tab.");
1778
+ }
1776
1779
  if (this.#routeHost?.claims(name))
1777
1780
  return this.#openOwnedRoute(name);
1778
1781
  if (this.#isWorkflowRoute(name))
package/dist/cli/tabs.js CHANGED
@@ -30,7 +30,7 @@ export async function runTabsCommand(request, options = {}) {
30
30
  stateDir: report.stateDir,
31
31
  hostStatus: report,
32
32
  releaseRoots,
33
- settings: { tabsMax: settings.tabsMax, tabsAutoName: settings.tabsAutoName },
33
+ settings: { tabsAutoName: settings.tabsAutoName },
34
34
  });
35
35
  if (request.json)
36
36
  stdout(`${JSON.stringify({ schema: "a1-tabs-doctor-v1", path: bundle.path, bytes: bundle.bytes, dropped: bundle.dropped })}\n`);
@@ -87,5 +87,6 @@ export declare function composeOwnedUiApplication(options?: OwnedUiCompositionOp
87
87
  export declare function composeOwnedUi(options?: OwnedUiCompositionOptions): Promise<OwnedUiComposition>;
88
88
  export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backend: Pick<ComposedSessionBackend, "identity" | "session" | "workflows" | "generateResidentTabName">, recovery?: Pick<ResidentTabRecovery, "settled"> | null, options?: {
89
89
  readonly autoName?: boolean;
90
+ readonly openRoute?: (route: "settings") => void;
90
91
  }): Promise<() => void>;
91
92
  export declare function correlateResidentTabSubmission(recovery: Pick<ResidentTabRecovery, "correlated" | "settled">, backend: Pick<ComposedSessionBackend, "identity" | "session">, submissionId: string, entryId: string): Promise<void>;
@@ -102,7 +102,7 @@ export async function composeOwnedUi(options = {}) {
102
102
  namespace: PRODUCT_IDENTITY.commandName,
103
103
  legacyConfigDir: productPaths.configDir,
104
104
  agentProvider: () => backend.settingsPort(),
105
- ...(options.hideTabSettings ? { hiddenOwnedSettingIds: ["tabsMax", "tabsAutoName"] } : {}),
105
+ ...(options.hideTabSettings ? { hiddenOwnedSettingIds: ["tabsAutoName"] } : {}),
106
106
  hiddenAgentSettingIds: ["fullscreenWheelScrollLines"],
107
107
  agentSettingLabelOverrides: { fullscreenCopyOnSelect: "Copy on select" },
108
108
  });
@@ -167,7 +167,7 @@ export async function composeOwnedUi(options = {}) {
167
167
  if (tabBridge !== null && settings !== null && ownedSurfaces)
168
168
  tabBridge.accent(settings.value("accentColor"));
169
169
  let removeControllerBoundaryInput = () => { };
170
- const unsubscribeTabBridge = tabBridge === null ? () => { } : await bindResidentTabBridge(tabBridge, backend, tabRecovery, { autoName: settings?.value("tabsAutoName") ?? true });
170
+ let unsubscribeTabBridge = () => { };
171
171
  let releaseNotes = null;
172
172
  let releaseNotesFailure;
173
173
  if (ownedSurfaces) {
@@ -293,6 +293,10 @@ export async function composeOwnedUi(options = {}) {
293
293
  ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
294
294
  ...(tabBridge === null || tabRecovery === null ? {} : { residentTab: {
295
295
  requestDetach: () => tabBridge.requestDetach(),
296
+ closeOwnedRoute: route => {
297
+ if (tabBridge.tabId.startsWith("settings-") && route === "settings")
298
+ void tabBridge.requestClose();
299
+ },
296
300
  reportStatus: status => tabBridge.status(status),
297
301
  stallNoticeSeconds: 300,
298
302
  admit: input => tabRecovery.admit({ ...input, incarnation: options.tab.incarnation }),
@@ -344,6 +348,12 @@ export async function composeOwnedUi(options = {}) {
344
348
  if (recoveryDegraded)
345
349
  presentRecoveryDegraded(true);
346
350
  terminalHost.attach(presenter);
351
+ if (tabBridge !== null) {
352
+ unsubscribeTabBridge = await bindResidentTabBridge(tabBridge, backend, tabRecovery, {
353
+ autoName: settings?.value("tabsAutoName") ?? true,
354
+ openRoute: route => presenter.openOwnedRoute(route),
355
+ });
356
+ }
347
357
  }
348
358
  catch (error) {
349
359
  await createdTerminalHost?.dispose().catch(() => undefined);
@@ -477,7 +487,10 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
477
487
  void backend.workflows.executeWorkflow({ command: "name", argument: command.name }).catch(() => { });
478
488
  }
479
489
  else if (command.type === "route") {
480
- void backend.workflows.executeWorkflow({ command: command.route, argument: "" }).catch(() => { });
490
+ if (options.openRoute !== undefined)
491
+ options.openRoute(command.route);
492
+ else
493
+ void backend.workflows.executeWorkflow({ command: command.route, argument: "" }).catch(() => { });
481
494
  }
482
495
  else if (command.type === "stop") {
483
496
  void backend.session.dispose().catch(() => { });
@@ -1,4 +1,4 @@
1
- export type ResidentTabAction = "tabs.new" | "tabs.close" | "tabs.rename" | "tabs.retry" | "tabs.fresh" | "tabs.next" | "tabs.previous" | "tabs.moveRight" | "tabs.moveLeft" | `tabs.jump${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10}`;
1
+ export type ResidentTabAction = "tabs.new" | "tabs.close" | "tabs.rename" | "tabs.settings" | "tabs.retry" | "tabs.fresh" | "tabs.next" | "tabs.previous" | "tabs.moveRight" | "tabs.moveLeft" | `tabs.jump${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10}`;
2
2
  export interface ResidentTabShortcut {
3
3
  readonly id: ResidentTabAction;
4
4
  readonly defaultKey: string | null;
@@ -13,6 +13,7 @@ export const RESIDENT_TAB_SHORTCUTS = Object.freeze([
13
13
  { id: "tabs.new", defaultKey: "alt+a", scope: "global", description: "New A1 tab" },
14
14
  { id: "tabs.close", defaultKey: "alt+w", scope: "global", description: "Close A1 tab" },
15
15
  { id: "tabs.rename", defaultKey: "f2", scope: "global", description: "Rename A1 tab" },
16
+ { id: "tabs.settings", defaultKey: "alt+.", scope: "global", description: "Open Settings tab" },
16
17
  ...jumps,
17
18
  { id: "tabs.next", defaultKey: null, scope: "global", description: "View next A1 tab" },
18
19
  { id: "tabs.previous", defaultKey: null, scope: "global", description: "View previous A1 tab" },
@@ -0,0 +1,14 @@
1
+ import { type ProcessGuardianAddon } from "../process-containment/index.js";
2
+ interface ExitWatcherOptions {
3
+ readonly guardianPid: number;
4
+ readonly noticePath: string;
5
+ readonly encodedModes: string;
6
+ readonly addonPath: string;
7
+ readonly runtimePid: number;
8
+ readonly processGroup: number | null;
9
+ readonly foregroundGroup: number | null;
10
+ }
11
+ /** Waits outside launch containment, then performs the cleanup its dead guardian could not. */
12
+ export declare function runExitWatcher(arguments_: readonly string[], platform?: NodeJS.Platform, loadAddon?: (path: string) => ProcessGuardianAddon): Promise<number>;
13
+ export declare function exitWatcherArguments(options: ExitWatcherOptions): string[];
14
+ export {};
@@ -0,0 +1,94 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { resolve } from "node:path";
3
+ import { loadProcessGuardianAddon } from "../process-containment/index.js";
4
+ /** Waits outside launch containment, then performs the cleanup its dead guardian could not. */
5
+ export async function runExitWatcher(arguments_, platform = process.platform, loadAddon = loadProcessGuardianAddon) {
6
+ const options = parseArguments(arguments_);
7
+ const addon = loadAddon(options.addonPath);
8
+ if (platform === "win32")
9
+ addon.ignoreConsoleControl();
10
+ else {
11
+ process.on("SIGINT", ignoreSignal);
12
+ process.on("SIGTTOU", ignoreSignal);
13
+ }
14
+ try {
15
+ await addon.waitForProcessExit(options.guardianPid);
16
+ if (platform === "win32") {
17
+ // Rationale: job termination is asynchronous; keep the runtime's final frame ahead of the notice.
18
+ await Promise.race([
19
+ addon.waitForProcessExit(options.runtimePid),
20
+ delay(2_000),
21
+ ]);
22
+ }
23
+ else if (options.processGroup !== null) {
24
+ addon.stopProcessGroup(options.processGroup);
25
+ if (options.foregroundGroup !== null)
26
+ addon.restoreForegroundGroup(options.foregroundGroup);
27
+ }
28
+ addon.deliverExitNotice(options.noticePath, options.encodedModes);
29
+ return 0;
30
+ }
31
+ finally {
32
+ if (platform !== "win32") {
33
+ process.off("SIGINT", ignoreSignal);
34
+ process.off("SIGTTOU", ignoreSignal);
35
+ }
36
+ }
37
+ }
38
+ export function exitWatcherArguments(options) {
39
+ return [
40
+ "--guardian-pid", String(options.guardianPid),
41
+ "--notice", options.noticePath,
42
+ "--modes", options.encodedModes,
43
+ "--addon", options.addonPath,
44
+ "--runtime-pid", String(options.runtimePid),
45
+ ...(options.processGroup === null ? [] : ["--process-group", String(options.processGroup)]),
46
+ ...(options.foregroundGroup === null ? [] : ["--foreground-group", String(options.foregroundGroup)]),
47
+ ];
48
+ }
49
+ function parseArguments(arguments_) {
50
+ const values = new Map();
51
+ for (let index = 0; index < arguments_.length; index += 2) {
52
+ const name = arguments_[index];
53
+ const value = arguments_[index + 1];
54
+ if (!name?.startsWith("--") || value === undefined || values.has(name))
55
+ throw new TypeError("exit watcher arguments are malformed");
56
+ values.set(name, value);
57
+ }
58
+ const guardianPid = positiveInteger(values.get("--guardian-pid"), "guardian PID");
59
+ const runtimePid = positiveInteger(values.get("--runtime-pid"), "runtime PID");
60
+ const noticePath = boundedValue(values.get("--notice"), "notice path");
61
+ const encodedModes = boundedValue(values.get("--modes"), "terminal modes", 32_768);
62
+ const addonPath = boundedValue(values.get("--addon"), "addon path");
63
+ const processGroup = optionalPositiveInteger(values.get("--process-group"), "process group");
64
+ const foregroundGroup = optionalPositiveInteger(values.get("--foreground-group"), "foreground group");
65
+ const expected = 5 + (processGroup === null ? 0 : 1) + (foregroundGroup === null ? 0 : 1);
66
+ if (values.size !== expected)
67
+ throw new TypeError("exit watcher received an unknown argument");
68
+ return { guardianPid, noticePath, encodedModes, addonPath, runtimePid, processGroup, foregroundGroup };
69
+ }
70
+ function positiveInteger(value, name) {
71
+ const parsed = Number(value);
72
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
73
+ throw new TypeError(`exit watcher ${name} must be a positive safe integer`);
74
+ return parsed;
75
+ }
76
+ function optionalPositiveInteger(value, name) {
77
+ return value === undefined ? null : positiveInteger(value, name);
78
+ }
79
+ function boundedValue(value, name, limit = 4_096) {
80
+ if (!value || value.length > limit || value.includes("\0"))
81
+ throw new TypeError(`exit watcher ${name} is invalid`);
82
+ return value;
83
+ }
84
+ function delay(milliseconds) {
85
+ return new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds));
86
+ }
87
+ function ignoreSignal() { }
88
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
89
+ if (invokedPath === fileURLToPath(import.meta.url)) {
90
+ void runExitWatcher(process.argv.slice(2)).then(code => process.exit(code), error => {
91
+ process.stderr.write(`a1 exit watcher: ${error instanceof Error ? error.message : String(error)}\n`);
92
+ process.exit(2);
93
+ });
94
+ }
@@ -1,6 +1,6 @@
1
1
  import type { LaunchInstanceStopIntent, LaunchProfileId, SupervisorCommand } from "../lifecycle/index.js";
2
2
  import { type SessionSelection } from "../lifecycle/index.js";
3
- import { type NativeProcessInspector, type ProcessContainment } from "../process-containment/index.js";
3
+ import { type NativeProcessInspector, type ProcessContainment, type ProcessGuardianAddon } from "../process-containment/index.js";
4
4
  interface GuardianControl {
5
5
  connect(endpoint: string, timeoutMs?: number): Promise<unknown>;
6
6
  command(command: SupervisorCommand): Promise<{
@@ -32,6 +32,7 @@ export interface LaunchGuardianOptions {
32
32
  readonly inspector?: NativeProcessInspector;
33
33
  readonly control?: GuardianControl;
34
34
  readonly containment?: ProcessContainment;
35
+ readonly addon?: ProcessGuardianAddon;
35
36
  readonly ensureHelper?: (path: string) => Promise<void>;
36
37
  }
37
38
  export declare function runLaunchGuardian(options: LaunchGuardianOptions): Promise<number>;