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

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 (115) hide show
  1. package/README.md +30 -0
  2. package/bin/cli.js +4 -0
  3. package/bin/tab-launch-context.d.ts +3 -0
  4. package/bin/tab-launch-context.js +52 -3
  5. package/bin/ui.js +6 -3
  6. package/dist/app/session-shell/session-shell-root.d.ts +0 -1
  7. package/dist/app/session-shell/session-shell-root.js +1 -1
  8. package/dist/app/session-shell/session-shell.js +3 -8
  9. package/dist/app/session-shell/shell-host.d.ts +2 -0
  10. package/dist/app/session-shell/shell-host.js +20 -1
  11. package/dist/cli/dispatch.d.ts +11 -1
  12. package/dist/cli/dispatch.js +79 -5
  13. package/dist/cli/tabs-request.d.ts +7 -0
  14. package/dist/cli/tabs-request.js +1 -0
  15. package/dist/cli/tabs.d.ts +14 -0
  16. package/dist/cli/tabs.js +140 -0
  17. package/dist/composition/owned-ui.d.ts +8 -2
  18. package/dist/composition/owned-ui.js +109 -12
  19. package/dist/features/launch/index.d.ts +1 -1
  20. package/dist/features/launch/intent.d.ts +3 -1
  21. package/dist/features/launch/intent.js +7 -4
  22. package/dist/features/resident-tab/auto-name.d.ts +11 -0
  23. package/dist/features/resident-tab/auto-name.js +42 -0
  24. package/dist/features/resident-tab/index.d.ts +3 -0
  25. package/dist/features/resident-tab/index.js +3 -0
  26. package/dist/features/resident-tab/shortcuts.d.ts +10 -0
  27. package/dist/features/resident-tab/shortcuts.js +35 -0
  28. package/dist/features/resident-tab/status-deriver.d.ts +9 -0
  29. package/dist/features/resident-tab/status-deriver.js +15 -0
  30. package/dist/foundation/launch-context/index.d.ts +4 -0
  31. package/dist/foundation/launch-context/index.js +10 -0
  32. package/dist/foundation/release/bootstrap.d.ts +4 -2
  33. package/dist/foundation/release/bootstrap.js +11 -8
  34. package/dist/foundation/release/cohort-state.d.ts +1 -1
  35. package/dist/foundation/release/cohort-state.js +1 -1
  36. package/dist/foundation/release/release-gc.d.ts +3 -0
  37. package/dist/foundation/release/release-gc.js +44 -8
  38. package/dist/foundation/resident-tabs/bounded-host-command.d.ts +16 -0
  39. package/dist/foundation/resident-tabs/bounded-host-command.js +35 -0
  40. package/dist/foundation/resident-tabs/bridge-client.d.ts +18 -3
  41. package/dist/foundation/resident-tabs/bridge-client.js +87 -7
  42. package/dist/foundation/resident-tabs/doctor.d.ts +29 -0
  43. package/dist/foundation/resident-tabs/doctor.js +166 -0
  44. package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +2 -1
  45. package/dist/foundation/resident-tabs/ensure-terminal-host.js +33 -7
  46. package/dist/foundation/resident-tabs/index.d.ts +7 -1
  47. package/dist/foundation/resident-tabs/index.js +4 -1
  48. package/dist/foundation/resident-tabs/maintenance.d.ts +42 -0
  49. package/dist/foundation/resident-tabs/maintenance.js +78 -0
  50. package/dist/foundation/resident-tabs/release-holds.d.ts +27 -0
  51. package/dist/foundation/resident-tabs/release-holds.js +77 -0
  52. package/dist/foundation/resident-tabs/routing.d.ts +4 -3
  53. package/dist/foundation/resident-tabs/routing.js +6 -4
  54. package/dist/foundation/resident-tabs/settings.d.ts +7 -2
  55. package/dist/foundation/resident-tabs/settings.js +17 -16
  56. package/dist/foundation/resident-tabs/terminal-host-artifact.d.ts +18 -0
  57. package/dist/foundation/resident-tabs/terminal-host-artifact.js +53 -0
  58. package/dist/foundation/resident-tabs/zip-archive.d.ts +9 -0
  59. package/dist/foundation/resident-tabs/zip-archive.js +56 -0
  60. package/dist/foundation/session-writer-lock/index.d.ts +5 -0
  61. package/dist/foundation/session-writer-lock/index.js +13 -9
  62. package/dist/integrations/pi/components/shell-hotkey-sections.js +1 -1
  63. package/dist/integrations/pi/components/shell-presenters-info.d.ts +4 -2
  64. package/dist/integrations/pi/components/shell-presenters-info.js +38 -4
  65. package/dist/integrations/pi/engine/adapter.d.ts +1 -0
  66. package/dist/integrations/pi/engine/adapter.js +3 -0
  67. package/dist/integrations/pi/engine/prompt-suggestions.d.ts +1 -0
  68. package/dist/integrations/pi/engine/prompt-suggestions.js +40 -0
  69. package/dist/native/darwin-arm64/a1_session_lock.node +0 -0
  70. package/dist/native/darwin-arm64/manifest.json +1 -1
  71. package/dist/native/darwin-arm64/terminal-host +0 -0
  72. package/dist/native/darwin-arm64/terminal-host-THIRD-PARTY-NOTICES.txt +10500 -0
  73. package/dist/native/darwin-arm64/terminal-host.manifest.json +44 -0
  74. package/dist/native/darwin-x64/a1_session_lock.node +0 -0
  75. package/dist/native/darwin-x64/manifest.json +20 -0
  76. package/dist/native/darwin-x64/process-guardian +0 -0
  77. package/dist/native/darwin-x64/terminal-host +0 -0
  78. package/dist/native/darwin-x64/terminal-host-THIRD-PARTY-NOTICES.txt +10737 -0
  79. package/dist/native/darwin-x64/terminal-host.manifest.json +44 -0
  80. package/dist/native/linux-x64/a1_session_lock.node +0 -0
  81. package/dist/native/linux-x64/manifest.json +1 -1
  82. package/dist/native/linux-x64/terminal-host +0 -0
  83. package/dist/native/linux-x64/terminal-host-THIRD-PARTY-NOTICES.txt +11226 -0
  84. package/dist/native/linux-x64/terminal-host.manifest.json +44 -0
  85. package/dist/native/win32-x64/a1_session_lock.node +0 -0
  86. package/dist/native/win32-x64/manifest.json +2 -2
  87. package/dist/native/win32-x64/process-guardian.exe +0 -0
  88. package/dist/native/win32-x64/terminal-host-THIRD-PARTY-NOTICES.txt +10757 -0
  89. package/dist/native/win32-x64/terminal-host.exe +0 -0
  90. package/dist/native/win32-x64/terminal-host.manifest.json +44 -0
  91. package/dist/ui/settings/declarations.d.ts +19 -43
  92. package/dist/ui/settings/declarations.js +19 -35
  93. package/dist/ui/settings/index.d.ts +1 -1
  94. package/dist/ui/settings/index.js +1 -1
  95. package/dist/ui/settings/manager.d.ts +1 -0
  96. package/dist/ui/settings/manager.js +9 -1
  97. package/dist/ui/settings/migrations.js +12 -0
  98. package/dist/ui/settings/resolution.d.ts +0 -2
  99. package/dist/ui/settings/resolution.js +1 -13
  100. package/dist/ui/settings/sections.js +0 -4
  101. package/docs/architecture/evidence/resident-tabs/linux-x64.md +37 -0
  102. package/docs/architecture/evidence/resident-tabs/macos-arm64.md +36 -0
  103. package/docs/architecture/evidence/resident-tabs/macos-x64.md +35 -0
  104. package/docs/architecture/evidence/resident-tabs/windows-x64.md +36 -0
  105. package/docs/architecture/internal-naming.md +2 -0
  106. package/docs/architecture/process-guardian-provenance.md +2 -0
  107. package/docs/architecture/resident-tabs-requirements.md +1 -563
  108. package/docs/architecture/resident-tabs.md +166 -94
  109. package/docs/architecture/terminal-host-provenance.md +13 -3
  110. package/docs/architecture/toolchain.md +1 -1
  111. package/docs/ci-release-runbook.md +2 -2
  112. package/docs/features/launch-profiles.md +11 -8
  113. package/docs/manual-resident-tabs-acceptance.md +51 -0
  114. package/docs/validation.md +9 -1
  115. package/package.json +5 -1
@@ -8,6 +8,7 @@ export interface BootstrapOptions {
8
8
  readonly launchIntent?: {
9
9
  readonly kind: "interactive";
10
10
  readonly profileId: LaunchProfileId;
11
+ readonly launchMode: "tabs" | "solo";
11
12
  readonly sessionSelection?: SessionSelection;
12
13
  };
13
14
  readonly environment?: NodeJS.ProcessEnv;
@@ -24,9 +25,10 @@ export interface BootstrapOptions {
24
25
  readonly releaseIsLaunchable?: (releaseRoot: string) => boolean;
25
26
  /** Test seam for bounded maintenance scheduled after stale endpoint sweeping. */
26
27
  readonly scheduleMaintenance?: (dataDir: string, paths: ReturnType<typeof resolveProductPaths>) => Promise<void>;
27
- /** Test seams for resident launch routing before the guardian starts. */
28
- readonly readResidentTabs?: (profileDir: string, namespace: string, legacyConfigDir: string, profileId: string) => Promise<boolean>;
28
+ /** Test seam for resident launch routing before the guardian starts. */
29
29
  readonly ensureTerminalHost?: (options: EnsureTerminalHostOptions) => Promise<EnsureTerminalHostResult>;
30
+ /** Test seam for whether this process's standard input and output are terminals; absent reads them. */
31
+ readonly interactiveTerminal?: boolean;
30
32
  }
31
33
  export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
32
34
  export declare function certifyMaterializedRelease(release: MaterializedRelease, dataDir: string, verification?: VerifyMaterializedReleaseOptions): Promise<string>;
@@ -24,13 +24,15 @@ export async function runBootstrap(options) {
24
24
  return await runBootstrapAttempt(options, 1);
25
25
  }
26
26
  async function runBootstrapAttempt(options, retriesRemaining) {
27
- const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: options.launchIntent?.profileId ?? "a1" });
28
- await markStartupPhase(environment, "bootstrap-start");
29
27
  const launchProfileId = options.launchIntent?.profileId ?? "a1";
28
+ const launchMode = options.launchIntent?.launchMode ?? (launchProfileId === "a1" ? "tabs" : "solo");
29
+ const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: launchProfileId, launchMode });
30
+ await markStartupPhase(environment, "bootstrap-start");
30
31
  assertLaunchProfileId(launchProfileId);
31
32
  const sessionArgs = sessionSelectionArguments(options.launchIntent?.sessionSelection);
32
- if (sessionArgs.length > 0 && launchProfileId !== "a1")
33
- throw new Error("session selection requires the normal A1 profile");
33
+ if (sessionArgs.length > 0 && (launchProfileId !== "a1" || launchMode === "solo")) {
34
+ throw new Error("session selection requires the normal tabbed A1 launch");
35
+ }
34
36
  const output = options.output ?? process.stderr;
35
37
  const paths = resolveProductPaths(environment);
36
38
  await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
@@ -44,11 +46,9 @@ async function runBootstrapAttempt(options, retriesRemaining) {
44
46
  : null;
45
47
  const resident = await selectResidentHost({
46
48
  profileId: launchProfileId,
47
- hasSessionSelection: sessionArgs.length > 0,
48
49
  currentLaunchContract: isCurrentLaunchContract(release),
49
- readSetting: options.readResidentTabs === undefined
50
- ? async () => (await residentLaunchSettings).residentTabs
51
- : () => options.readResidentTabs(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, launchProfileId),
50
+ solo: launchMode === "solo",
51
+ interactive: options.interactiveTerminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
52
52
  ensureHost: async () => (options.ensureTerminalHost ?? ensureTerminalHost)({
53
53
  releaseRoot: release.releaseRoot,
54
54
  profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
@@ -64,6 +64,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
64
64
  residentRootExecutable: resident.executable,
65
65
  residentRootArguments: JSON.stringify([
66
66
  "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(CLEAR_EXIT_INTERVAL_MS),
67
+ "--tabs-max", String((await residentLaunchSettings).tabsMax),
67
68
  "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
68
69
  "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
69
70
  "--release-root", release.releaseRoot, "--node-executable", process.execPath,
@@ -395,12 +396,14 @@ async function launchUi(release, environment, sessionArgs, residentContext = {})
395
396
  * version instead of failing while an installation is being replaced.
396
397
  */
397
398
  export function releaseEnvironment(environment, release, profile) {
399
+ const launchMode = readLaunchContext(environment).launchMode;
398
400
  return withLaunchContext(environment, {
399
401
  releaseId: release.releaseId,
400
402
  releaseLayers: (release.dependencyLayers ?? []).map(layer => layer.layerId).join(","),
401
403
  releaseRoot: release.releaseRoot,
402
404
  releaseDigest: release.contentDigest,
403
405
  ...(profile === undefined ? {} : { launchProfile: profile }),
406
+ ...(launchMode === undefined ? {} : { launchMode }),
404
407
  }, process.platform, launchContractTarget(release));
405
408
  }
406
409
  export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
@@ -19,7 +19,7 @@ export interface ReleaseReferences {
19
19
  readonly rollback: string | null;
20
20
  readonly retention: readonly string[];
21
21
  }
22
- export type ExternalReleaseHoldAuthority = "agent" | "migration";
22
+ export type ExternalReleaseHoldAuthority = "agent" | "migration" | "resident";
23
23
  /** A current hold supplied by an authority that must participate in every reconciliation. */
24
24
  export interface ExternalReleaseHold {
25
25
  readonly authority: ExternalReleaseHoldAuthority;
@@ -12,7 +12,7 @@ const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
12
12
  export function planProtectedReleases(state, inputs = {}) {
13
13
  const holds = inputs.externalHolds ?? [];
14
14
  for (const hold of holds) {
15
- if (hold.authority !== "agent" && hold.authority !== "migration")
15
+ if (hold.authority !== "agent" && hold.authority !== "migration" && hold.authority !== "resident")
16
16
  throw new Error(`unknown release hold authority: ${String(hold.authority)}`);
17
17
  if (!state.releases[hold.releaseId] && !state.cleanup.pending[hold.releaseId]) {
18
18
  throw new Error(`external ${hold.authority} hold names unknown release ${hold.releaseId}`);
@@ -2,6 +2,7 @@ import { rename, rm } from "node:fs/promises";
2
2
  import { CohortStateStore, type ExternalReleaseHold, type OrphanReleaseDisposition } from "./cohort-state.js";
3
3
  import type { ProductPaths } from "../lifecycle/index.js";
4
4
  import { UpdateTransactionStore } from "./update-transaction.js";
5
+ import { type ResidentReleaseHoldReport } from "../resident-tabs/index.js";
5
6
  export interface ReleaseCleanupLimits {
6
7
  readonly maxItems: number;
7
8
  readonly maxDurationMs: number;
@@ -21,6 +22,8 @@ export interface ReleaseCleanupOptions {
21
22
  readonly operations?: ReleaseCleanupOperations;
22
23
  /** Process-start seam for scheduler and continuation tests. */
23
24
  readonly workerSpawner?: (entry: string, environment: NodeJS.ProcessEnv) => Promise<void>;
25
+ /** Resident-tab inspection seam; the default asks a verified installed terminal host. */
26
+ readonly residentHolds?: (dataDir: string, releaseRoots: readonly string[]) => Promise<ResidentReleaseHoldReport>;
24
27
  }
25
28
  export interface ReleaseCleanupWorkerOptions {
26
29
  readonly cleanup?: ReleaseCleanupOptions;
@@ -14,6 +14,7 @@ import { UpdateTransactionStore } from "./update-transaction.js";
14
14
  import { cleanupUpdateRecoveryCapsules } from "./update-recovery.js";
15
15
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
16
16
  import { collectCompileCaches, startupCompileCachePath } from "../startup/index.js";
17
+ import { readResidentReleaseHolds } from "../resident-tabs/index.js";
17
18
  const DEFAULT_CANDIDATE_AGE_MS = 60 * 60 * 1_000;
18
19
  const DEFAULT_LIMITS = { maxItems: 8, maxDurationMs: 2_000, concurrency: 1 };
19
20
  const DEFAULT_WORKER_DURATION_MS = 10 * 60 * 1_000;
@@ -30,7 +31,7 @@ export async function prepareReleaseCleanup(dataDir, paths, options = {}) {
30
31
  const store = new CohortStateStore(dataDir);
31
32
  const discovered = await discoverCleanupInputs(dataDir, store, options);
32
33
  const transactionStore = options.transactionStore ?? new UpdateTransactionStore(dataDir);
33
- await store.reconcileRetention(async () => await protectionInputs(paths, transactionStore, options.externalHolds ?? []), discovered.orphans);
34
+ await store.reconcileRetention(async () => await protectionInputs(dataDir, paths, transactionStore, options.externalHolds ?? [], options.residentHolds), discovered.orphans);
34
35
  for (const diagnostic of discovered.diagnostics.slice(0, 16)) {
35
36
  await store.recordCleanupFailure(diagnostic.releaseId, diagnostic.stage, diagnostic.error);
36
37
  }
@@ -77,7 +78,7 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
77
78
  maximum -= 1;
78
79
  const certificationId = certificationReleaseId(artifact);
79
80
  if (certificationId !== null) {
80
- const inputs = await protectionInputs(paths, transactionStore, options.externalHolds ?? []);
81
+ const inputs = await protectionInputs(dataDir, paths, transactionStore, options.externalHolds ?? [], options.residentHolds);
81
82
  if (isProtectedDetachedRelease(await store.read(), inputs, certificationId))
82
83
  continue;
83
84
  }
@@ -232,7 +233,7 @@ export async function collectRelease(store, dataDir, releaseId, externalReferenc
232
233
  const state = await store.read();
233
234
  if (!state.releases[releaseId])
234
235
  throw new Error(`unknown release ${releaseId}`);
235
- await store.reconcileRetention(async () => ({ ...(await protectionInputs(paths, new UpdateTransactionStore(dataDir), holds)) }));
236
+ await store.reconcileRetention(async () => ({ ...(await protectionInputs(dataDir, paths, new UpdateTransactionStore(dataDir), holds)) }));
236
237
  const disposition = (await store.read()).cleanup.pending[releaseId];
237
238
  if (!disposition)
238
239
  throw new Error(`release ${releaseId} is still referenced and cannot be collected`);
@@ -243,7 +244,7 @@ export async function collectRelease(store, dataDir, releaseId, externalReferenc
243
244
  async function collectDisposition(store, dataDir, paths, disposition, options) {
244
245
  const releaseId = disposition.release.releaseId;
245
246
  try {
246
- const inputs = await protectionInputs(paths, options.transactionStore ?? new UpdateTransactionStore(dataDir), options.externalHolds ?? []);
247
+ const inputs = await protectionInputs(dataDir, paths, options.transactionStore ?? new UpdateTransactionStore(dataDir), options.externalHolds ?? [], options.residentHolds);
247
248
  const current = await store.read();
248
249
  if (isProtectedDetachedRelease(current, inputs, releaseId))
249
250
  return false;
@@ -289,12 +290,47 @@ function isProtectedDetachedRelease(state, inputs, releaseId) {
289
290
  || (inputs.externalHolds ?? []).some(hold => hold.releaseId === releaseId)
290
291
  || (inputs.transaction?.status === "active" && inputs.transaction.priorActiveReleaseId === releaseId);
291
292
  }
292
- async function protectionInputs(paths, transactionStore, externalHolds) {
293
- const [liveIds, transaction] = await Promise.all([
293
+ async function protectionInputs(dataDir, paths, transactionStore, externalHolds, residentHolds) {
294
+ const [liveIds, transaction, resident] = await Promise.all([
294
295
  paths ? liveReleaseIds(paths) : Promise.resolve([]),
295
296
  transactionStore.read(),
297
+ residentReleaseHolds(dataDir, residentHolds),
296
298
  ]);
297
- return { liveReleaseIds: liveIds, externalHolds, transaction: transactionReference(transaction) };
299
+ return { liveReleaseIds: liveIds, externalHolds: [...externalHolds, ...resident], transaction: transactionReference(transaction) };
300
+ }
301
+ /**
302
+ * Holds every release a live resident server, holder, or tab child runs from. Retention fails
303
+ * closed: an incomplete report, or a recorded root inside the release store that names no known
304
+ * release, holds every known release.
305
+ */
306
+ async function residentReleaseHolds(dataDir, reader) {
307
+ const state = await new CohortStateStore(dataDir).read();
308
+ const records = [
309
+ ...Object.values(state.releases),
310
+ ...Object.values(state.cleanup.pending).map(disposition => disposition.release),
311
+ ];
312
+ const preferred = [state.references.active, state.references.rollback]
313
+ .flatMap(id => id && state.releases[id] ? [state.releases[id].releaseRoot] : []);
314
+ const roots = [...new Set([...preferred, ...Object.values(state.releases).map(record => record.releaseRoot)])];
315
+ const report = await (reader ?? ((directory, releaseRoots) => readResidentReleaseHolds({ dataDir: directory, releaseRoots })))(dataDir, roots);
316
+ const everything = () => [...new Set(records.map(record => record.releaseId))].map(releaseId => ({ authority: "resident", releaseId }));
317
+ if (!report.complete)
318
+ return everything();
319
+ const byRoot = new Map(records.map(record => [comparablePath(record.releaseRoot), record.releaseId]));
320
+ const store = comparablePath(resolve(dataDir, "releases"));
321
+ const holds = new Set();
322
+ for (const root of report.releaseRoots) {
323
+ const releaseId = byRoot.get(comparablePath(root));
324
+ if (releaseId)
325
+ holds.add(releaseId);
326
+ else if (comparablePath(root).startsWith(`${store}${sep}`))
327
+ return everything();
328
+ }
329
+ return [...holds].sort().map(releaseId => ({ authority: "resident", releaseId }));
330
+ }
331
+ function comparablePath(path) {
332
+ const absolute = resolve(path);
333
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
298
334
  }
299
335
  function transactionReference(transaction) {
300
336
  return transaction === null ? null : { status: transaction.status, priorActiveReleaseId: transaction.priorActiveReleaseId };
@@ -745,7 +781,7 @@ function parseWorkerHolds(value) {
745
781
  if (!Array.isArray(parsed))
746
782
  return [];
747
783
  return parsed.filter((hold) => Boolean(hold) && typeof hold === "object"
748
- && (hold.authority === "agent" || hold.authority === "migration")
784
+ && ["agent", "migration", "resident"].includes(hold.authority)
749
785
  && typeof hold.releaseId === "string");
750
786
  }
751
787
  catch {
@@ -0,0 +1,16 @@
1
+ export interface BoundedHostCommandResult {
2
+ readonly code: number;
3
+ readonly stdout: string;
4
+ }
5
+ export interface BoundedHostCommandLimits {
6
+ /** Output beyond this many bytes fails the exchange instead of being parsed. */
7
+ readonly maxStdoutBytes: number;
8
+ readonly timeoutMs: number;
9
+ /** Names the exchange in the timeout error. */
10
+ readonly label: string;
11
+ }
12
+ /**
13
+ * Runs one terminal-host command whose report is small. An oversized report kills the command and
14
+ * reports a failed exchange; a command that outlives its deadline is killed and rejected.
15
+ */
16
+ export declare function runBoundedHostCommand(executable: string, arguments_: readonly string[], limits: BoundedHostCommandLimits): Promise<BoundedHostCommandResult>;
@@ -0,0 +1,35 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Runs one terminal-host command whose report is small. An oversized report kills the command and
4
+ * reports a failed exchange; a command that outlives its deadline is killed and rejected.
5
+ */
6
+ export async function runBoundedHostCommand(executable, arguments_, limits) {
7
+ return await new Promise((resolvePromise, rejectPromise) => {
8
+ const child = spawn(executable, [...arguments_], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
9
+ const stdout = [];
10
+ let size = 0;
11
+ let overflowed = false;
12
+ child.stdout.on("data", chunk => {
13
+ size += chunk.length;
14
+ // Security: an oversized answer is a failed exchange, never a truncated report to parse.
15
+ if (size > limits.maxStdoutBytes) {
16
+ overflowed = true;
17
+ child.kill("SIGKILL");
18
+ }
19
+ else
20
+ stdout.push(Buffer.from(chunk));
21
+ });
22
+ child.once("error", rejectPromise);
23
+ child.once("close", code => {
24
+ clearTimeout(timer);
25
+ resolvePromise(overflowed
26
+ ? { code: code === 0 || code === null ? 1 : code, stdout: "" }
27
+ : { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8") });
28
+ });
29
+ const timer = setTimeout(() => {
30
+ child.kill("SIGKILL");
31
+ rejectPromise(new Error(`${limits.label} timed out`));
32
+ }, limits.timeoutMs);
33
+ timer.unref();
34
+ });
35
+ }
@@ -1,4 +1,4 @@
1
- export type ResidentTabStatus = "starting" | "working" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
1
+ export type ResidentTabStatus = "starting" | "working" | "needs-input" | "done-unseen" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
2
2
  export interface TabBridgeCredentials {
3
3
  readonly tabId: string;
4
4
  readonly token: string;
@@ -8,6 +8,17 @@ export interface TabAttachment {
8
8
  readonly clientId: string;
9
9
  readonly controllerRevision: number;
10
10
  }
11
+ export type TabBridgeCommand = {
12
+ readonly type: "visibility";
13
+ readonly visible: boolean;
14
+ } | {
15
+ readonly type: "rename";
16
+ readonly name: string;
17
+ readonly nameSource: "default" | "user" | "auto";
18
+ } | {
19
+ readonly type: "stop";
20
+ readonly deadlineMs: number;
21
+ };
11
22
  interface BridgeTransport {
12
23
  write(bytes: Uint8Array): boolean;
13
24
  destroy(): void;
@@ -30,12 +41,16 @@ export declare class TabBridgeClient {
30
41
  constructor(options: TabBridgeOptions);
31
42
  get available(): boolean;
32
43
  captureAttachment(): TabAttachment | null;
44
+ onCommand(listener: (command: TabBridgeCommand) => void): () => void;
33
45
  start(timeoutMs?: number): Promise<boolean>;
34
- status(state: ResidentTabStatus, degraded?: boolean): void;
46
+ status(state: ResidentTabStatus, degraded?: boolean, pendingRequests?: number, queuedInput?: number): void;
35
47
  degraded(degraded: boolean): void;
36
- session(file: string | null, name: string | null): void;
48
+ accent(accent: string): void;
49
+ session(file: string | null, name: string | null, nameSource?: "default" | "user" | "auto"): void;
50
+ prompt(interrupted: boolean): void;
37
51
  requestDetach(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
38
52
  requestRestart(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
53
+ consumeControllerBoundaryInput(data: string): string;
39
54
  close(): void;
40
55
  }
41
56
  export {};
@@ -13,13 +13,20 @@ export class TabBridgeClient {
13
13
  #closed = false;
14
14
  #retry = null;
15
15
  #latestStatus = null;
16
+ #accent = null;
16
17
  #latestSession = null;
17
18
  #heartbeat = null;
18
19
  #pending = new Map();
19
20
  #startWaiter = null;
21
+ #commandListeners = new Set();
22
+ #boundaryInputRemainder = "";
20
23
  constructor(options) { this.#options = options; }
21
24
  get available() { return this.#available && this.#connected; }
22
25
  captureAttachment() { return this.#attachment === null ? null : { ...this.#attachment }; }
26
+ onCommand(listener) {
27
+ this.#commandListeners.add(listener);
28
+ return () => this.#commandListeners.delete(listener);
29
+ }
23
30
  async start(timeoutMs = 4_000) {
24
31
  if (this.#closed || this.#transport !== null)
25
32
  return this.available;
@@ -56,7 +63,7 @@ export class TabBridgeClient {
56
63
  transport.once("connect", () => {
57
64
  this.#send("hello", {
58
65
  role: "bridge", generation: 1, build: this.#options.build ?? "a1",
59
- features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "recovery-journal"],
66
+ features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "bridge-visibility", "bridge-rename", "bridge-stop", "controller-boundary", "recovery-journal"],
60
67
  token: [...Buffer.from(this.#options.credentials.token, "base64")], tabId: this.#options.credentials.tabId,
61
68
  pid: this.#options.pid ?? process.pid, startIdentity: this.#options.startIdentity ?? `${process.pid}:node`,
62
69
  }, ++this.#request);
@@ -66,23 +73,34 @@ export class TabBridgeClient {
66
73
  timer.unref();
67
74
  });
68
75
  }
69
- status(state, degraded = false) {
70
- this.#latestStatus = { state, degraded };
76
+ status(state, degraded = false, pendingRequests = 0, queuedInput = 0) {
77
+ this.#latestStatus = { state, degraded, accent: this.#accent, pendingRequests, queuedInput };
71
78
  const recoveryState = state === "unresponsive" || state === "stalled" || state === "crashed" || state === "restarting" || state === "failed";
72
79
  if (recoveryState && !this.#supports("recovery-status"))
73
80
  return;
74
81
  this.#send("bridge.status", {
75
82
  seq: ++this.#sequence,
76
83
  state,
84
+ pendingRequests: Math.max(0, Math.trunc(pendingRequests)),
85
+ queuedInput: Math.max(0, Math.trunc(queuedInput)),
86
+ ...(this.#accent === null ? {} : { accent: this.#accent }),
77
87
  ...(degraded && this.#supports("recovery-status") ? { degraded: true } : {}),
78
88
  });
79
89
  }
80
90
  degraded(degraded) {
81
91
  this.status(this.#latestStatus?.state ?? "idle", degraded);
82
92
  }
83
- session(file, name) {
84
- this.#latestSession = { file, name };
85
- this.#send("bridge.session", { file, name });
93
+ accent(accent) {
94
+ this.#accent = accent;
95
+ if (this.#latestStatus !== null)
96
+ this.status(this.#latestStatus.state, this.#latestStatus.degraded, this.#latestStatus.pendingRequests, this.#latestStatus.queuedInput);
97
+ }
98
+ session(file, name, nameSource = "default") {
99
+ this.#latestSession = { file, name, nameSource };
100
+ this.#send("bridge.session", { file, name, nameSource });
101
+ }
102
+ prompt(interrupted) {
103
+ this.#send("bridge.prompt", { interrupted });
86
104
  }
87
105
  requestDetach(origin = this.captureAttachment(), timeoutMs = 1_000) {
88
106
  return this.#requestAction("detach", origin, timeoutMs);
@@ -90,6 +108,51 @@ export class TabBridgeClient {
90
108
  requestRestart(origin = this.captureAttachment(), timeoutMs = 1_000) {
91
109
  return this.#requestAction("restart", origin, timeoutMs);
92
110
  }
111
+ consumeControllerBoundaryInput(data) {
112
+ const prefix = "\x1bP+a1;controller=";
113
+ const suffix = "\x1b\\";
114
+ let pending = this.#boundaryInputRemainder + data;
115
+ this.#boundaryInputRemainder = "";
116
+ let output = "";
117
+ while (pending.length > 0) {
118
+ const start = pending.indexOf(prefix);
119
+ if (start === -1) {
120
+ let retained = 0;
121
+ const maximum = Math.min(prefix.length - 1, pending.length);
122
+ for (let length = maximum; length > 0; length--) {
123
+ if (pending.endsWith(prefix.slice(0, length))) {
124
+ retained = length;
125
+ break;
126
+ }
127
+ }
128
+ const split = pending.length - retained;
129
+ output += pending.slice(0, split);
130
+ this.#boundaryInputRemainder = pending.slice(split);
131
+ break;
132
+ }
133
+ output += pending.slice(0, start);
134
+ const end = pending.indexOf(suffix, start + prefix.length);
135
+ if (end === -1) {
136
+ this.#boundaryInputRemainder = pending.slice(start);
137
+ break;
138
+ }
139
+ const body = pending.slice(start + prefix.length, end);
140
+ const separator = body.indexOf(";");
141
+ const generation = Number(body.slice(0, separator));
142
+ const nonceHex = body.slice(separator + 1);
143
+ if (separator > 0 && Number.isSafeInteger(generation) && generation > 0 && /^[0-9a-f]{32}$/u.test(nonceHex)) {
144
+ this.#send("bridge.boundary", {
145
+ generation,
146
+ nonce: [...Buffer.from(nonceHex, "hex")],
147
+ });
148
+ }
149
+ else {
150
+ output += pending.slice(start, end + suffix.length);
151
+ }
152
+ pending = pending.slice(end + suffix.length);
153
+ }
154
+ return output;
155
+ }
93
156
  close() {
94
157
  this.#closed = true;
95
158
  if (this.#retry !== null)
@@ -101,6 +164,10 @@ export class TabBridgeClient {
101
164
  transport?.destroy();
102
165
  this.#fail(false);
103
166
  }
167
+ #emitCommand(command) {
168
+ for (const listener of this.#commandListeners)
169
+ listener(command);
170
+ }
104
171
  #send(type, body, requestId) {
105
172
  if (this.#transport === null || (!this.#connected && type !== "hello"))
106
173
  return;
@@ -121,7 +188,7 @@ export class TabBridgeClient {
121
188
  if (this.#supports("bridge-heartbeat"))
122
189
  this.#startHeartbeat();
123
190
  if (this.#latestStatus !== null)
124
- this.status(this.#latestStatus.state, this.#latestStatus.degraded);
191
+ this.status(this.#latestStatus.state, this.#latestStatus.degraded, this.#latestStatus.pendingRequests, this.#latestStatus.queuedInput);
125
192
  if (this.#latestSession !== null)
126
193
  this.#send("bridge.session", this.#latestSession);
127
194
  return;
@@ -130,6 +197,19 @@ export class TabBridgeClient {
130
197
  this.#fail();
131
198
  return;
132
199
  }
200
+ if (message.type === "bridge.visibility" && typeof message.body["visible"] === "boolean") {
201
+ this.#emitCommand({ type: "visibility", visible: message.body["visible"] });
202
+ return;
203
+ }
204
+ if (message.type === "bridge.rename" && typeof message.body["name"] === "string") {
205
+ const source = message.body["nameSource"];
206
+ this.#emitCommand({ type: "rename", name: message.body["name"], nameSource: source === "auto" || source === "default" ? source : "user" });
207
+ return;
208
+ }
209
+ if (message.type === "bridge.stop" && typeof message.body["deadlineMs"] === "number") {
210
+ this.#emitCommand({ type: "stop", deadlineMs: message.body["deadlineMs"] });
211
+ return;
212
+ }
133
213
  if (message.type === "attachment") {
134
214
  const clientId = message.body["clientId"];
135
215
  const revision = message.body["controllerRevision"];
@@ -0,0 +1,29 @@
1
+ import type { MaintenanceReport } from "./maintenance.js";
2
+ export declare const DOCTOR_MAX_BYTES: number;
3
+ export interface DoctorOptions {
4
+ /** The profile's resident state directory, as reported by the terminal host. */
5
+ readonly stateDir: string;
6
+ readonly hostStatus: MaintenanceReport;
7
+ readonly releaseRoots: readonly string[];
8
+ readonly settings: {
9
+ readonly tabsMax: number;
10
+ readonly tabsAutoName: boolean;
11
+ };
12
+ readonly home?: string;
13
+ readonly platform?: NodeJS.Platform;
14
+ readonly architecture?: string;
15
+ readonly now?: Date;
16
+ readonly maxBytes?: number;
17
+ }
18
+ export interface DoctorBundle {
19
+ readonly path: string;
20
+ readonly bytes: number;
21
+ readonly dropped: readonly string[];
22
+ }
23
+ /**
24
+ * Writes an owner-only diagnostic bundle by allowlist: structured logs, crash records, host
25
+ * status, a redacted registry, the owner marker without secrets, artifact verification, platform
26
+ * facts, and the Tabs settings. Secrets, tokens, environment values, prompt journals, drafts,
27
+ * last screens, the private child-stderr sink, and terminal content are never read.
28
+ */
29
+ export declare function writeDoctorBundle(options: DoctorOptions): Promise<DoctorBundle>;