@timurproko/a1 0.2.7-dev.801 → 0.2.7-dev.808

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 (37) hide show
  1. package/dist/app/session-shell/session-shell.js +12 -1
  2. package/dist/app/session-shell/shell-host.js +7 -4
  3. package/dist/composition/owned-ui.d.ts +2 -0
  4. package/dist/composition/owned-ui.js +61 -11
  5. package/dist/composition/settings-route-host.d.ts +6 -1
  6. package/dist/composition/settings-route-host.js +7 -6
  7. package/dist/features/owned-ui/settings-app.d.ts +4 -1
  8. package/dist/features/owned-ui/settings-app.js +9 -3
  9. package/dist/features/resident-tab/shortcut-declarations.js +1 -1
  10. package/dist/features/resident-tab/status-deriver.d.ts +4 -3
  11. package/dist/features/resident-tab/status-deriver.js +2 -5
  12. package/dist/foundation/release/bootstrap.js +2 -2
  13. package/dist/foundation/resident-tabs/bridge-client.d.ts +1 -1
  14. package/dist/foundation/resident-tabs/bridge-client.js +2 -1
  15. package/dist/foundation/terminal-cleanup/index.d.ts +1 -1
  16. package/dist/foundation/terminal-cleanup/index.js +1 -1
  17. package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +5 -0
  18. package/dist/foundation/terminal-cleanup/terminal-reset.js +5 -0
  19. package/dist/integrations/pi/tui-runtime/adapter.d.ts +6 -0
  20. package/dist/integrations/pi/tui-runtime/adapter.js +28 -1
  21. package/dist/native/darwin-arm64/manifest.json +1 -1
  22. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  23. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  24. package/dist/native/darwin-x64/manifest.json +1 -1
  25. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  26. package/dist/native/darwin-x64/terminal-host.node +0 -0
  27. package/dist/native/linux-x64/manifest.json +1 -1
  28. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  29. package/dist/native/linux-x64/terminal-host.node +0 -0
  30. package/dist/native/win32-x64/manifest.json +2 -2
  31. package/dist/native/win32-x64/process-guardian.node +0 -0
  32. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  33. package/dist/native/win32-x64/terminal-host.node +0 -0
  34. package/dist/ui/settings/manager.d.ts +6 -0
  35. package/dist/ui/settings/manager.js +25 -0
  36. package/docs/architecture/resident-tabs.md +7 -6
  37. package/package.json +1 -1
@@ -1762,7 +1762,18 @@ export class OwnedUiSessionPresenter {
1762
1762
  this.#dialogId = dialog.id;
1763
1763
  }
1764
1764
  openOwnedRoute(route) {
1765
- this.#openOwnedRoute(route);
1765
+ if (this.#disposed)
1766
+ return;
1767
+ // Invariant: a route requested before the runtime runs (the resident Settings bridge route arrives
1768
+ // before start()) is retained, latest wins, and presented through the startup-route path once started.
1769
+ if (!this.#terminal.running) {
1770
+ this.#startupRoute = { route };
1771
+ return;
1772
+ }
1773
+ const result = this.#openOwnedRoute(route);
1774
+ if (result.outcome !== "completed" && result.diagnostic !== null) {
1775
+ this.root.appendWorkflowMessage({ kind: "warning", message: result.diagnostic });
1776
+ }
1766
1777
  }
1767
1778
  hotkeysPresentation() {
1768
1779
  const presentation = this.root.hotkeysPresentation();
@@ -4,7 +4,7 @@ import { piCanvasBackgroundAnsi } from "../../integrations/pi/components/theme.j
4
4
  import { residentFixtureControl } from "../../foundation/resident-tabs/fixture-control.js";
5
5
  import { residentProgressObserver } from "../../foundation/resident-tabs/progress-observation.js";
6
6
  import { DamageAwareTerminalAdapter, } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
7
- import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
7
+ import { PiTuiRuntimeAdapter, legacyKeyInput } from "../../integrations/pi/tui-runtime/adapter.js";
8
8
  import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
9
9
  import { pinnedLayoutRoot, } from "./session-shell-root.js";
10
10
  /** The terminal a session's shell options call for: its layout, TUI mode, cursor, and presentation seams. */
@@ -348,7 +348,8 @@ export class OwnedUiTerminalHost {
348
348
  callbacks.closed(rendered);
349
349
  };
350
350
  removeInterruptWatch = runtime.addInputListener(data => {
351
- if (!attached() || !data.includes(INTERRUPT))
351
+ // Compatibility: a kitty-keyboard child reports Ctrl+C as a CSI-u sequence rather than the byte.
352
+ if (!attached() || !data.includes(INTERRUPT) && legacyKeyInput(data) !== INTERRUPT)
352
353
  return undefined;
353
354
  // Protocol: route hosts decide whether one interrupt closes their app. Raw-input forwarding
354
355
  // is required because Pi handles Ctrl+C before the fullscreen overlay receives normal input.
@@ -406,8 +407,10 @@ export class OwnedUiTerminalHost {
406
407
  const { events, rest } = parseMouseInput(data);
407
408
  for (const event of events)
408
409
  surface.handleMouse(event);
409
- if (rest.length > 0)
410
- surface.handleInput(rest);
410
+ // Invariant: owned screens see legacy key bytes whether the terminal reports legacy or kitty keys.
411
+ const key = rest.length > 0 ? legacyKeyInput(rest) : "";
412
+ if (key.length > 0)
413
+ surface.handleInput(key);
411
414
  if (surface.isClosed()) {
412
415
  closeSurface(true);
413
416
  return;
@@ -90,3 +90,5 @@ export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backen
90
90
  readonly openRoute?: (route: "settings") => void;
91
91
  }): Promise<() => void>;
92
92
  export declare function correlateResidentTabSubmission(recovery: Pick<ResidentTabRecovery, "correlated" | "settled">, backend: Pick<ComposedSessionBackend, "identity" | "session">, submissionId: string, entryId: string): Promise<void>;
93
+ /** Rereads the shared settings document whenever its stat changes; returns the stop. */
94
+ export declare function watchSharedSettings(settings: Pick<OwnedSettingsManager, "file" | "refresh">): () => void;
@@ -1,3 +1,4 @@
1
+ import { unwatchFile, watchFile } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { SuggestionDiagnosticCapture } from "../features/prompt-suggestions/diagnostics.js";
3
4
  import { PRODUCT_IDENTITY } from "../product-identity.js";
@@ -188,13 +189,19 @@ export async function composeOwnedUi(options = {}) {
188
189
  : await import("../features/owned-ui/release-note-state.js").then(module => module.claimReleaseNote({
189
190
  configDir: productPaths.configDir, profileId: options.profileId, version: currentReleaseNote.version,
190
191
  }));
191
- const unsubscribeAccent = settings === null || !ownedSurfaces
192
+ const unsubscribeAccentChange = settings === null || !ownedSurfaces
192
193
  ? () => { }
193
194
  : settings.onChange(current => {
194
195
  const accent = current.value("accentColor");
195
196
  host.setAccentColor(accent);
196
197
  tabBridge?.accent(accent);
197
198
  });
199
+ // Rationale: every resident tab is its own process over one shared settings document, so a
200
+ // change saved in any tab reaches the others only by rereading that document.
201
+ const unwatchSharedSettings = settings === null || !ownedSurfaces || tabBridge === null
202
+ ? () => { }
203
+ : watchSharedSettings(settings);
204
+ const unsubscribeAccent = () => { unwatchSharedSettings(); unsubscribeAccentChange(); };
198
205
  // Rationale: Routes open only after shell construction.
199
206
  const references = {
200
207
  changelog: async (input) => {
@@ -210,7 +217,9 @@ export async function composeOwnedUi(options = {}) {
210
217
  },
211
218
  session: () => import("./session-info-reference.js").then(m => m.loadSessionInfoReference(backend)),
212
219
  };
213
- const routeHost = settings === null || !ownedSurfaces ? null : createOwnedRouteHost(settings, references);
220
+ const routeHost = settings === null || !ownedSurfaces ? null : createOwnedRouteHost(settings, references, {
221
+ settingsPinned: tabBridge?.tabId.startsWith("settings-") === true,
222
+ });
214
223
  const viewportSettings = settings === null || !ownedSurfaces ? null : {
215
224
  snapshot: () => viewportSettingsSnapshot(settings),
216
225
  onChange: listener => settings.onChange(() => listener(viewportSettingsSnapshot(settings))),
@@ -436,15 +445,27 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
436
445
  tabBridge.status("starting");
437
446
  const currentSessionFile = () => currentBackendSessionFile(backend);
438
447
  let pendingAutoName = null;
448
+ // Invariant: while a generated name is being applied, the session name it produces is automatic
449
+ // even if the session normalizes it; reporting it as `user` would block the strip's title
450
+ // animation and every later automatic rename.
451
+ let applyingAutoName = false;
439
452
  const isDefaultName = (name) => name == null || /^agent(?: \d+)?$/i.test(name);
440
453
  const currentSessionName = () => backend.session.view().status.footer?.sessionName ?? null;
441
454
  const announceSession = () => {
442
455
  const name = currentSessionName();
443
- const nameSource = name === pendingAutoName ? "auto" : isDefaultName(name) ? "default" : "user";
456
+ // Invariant: the session may announce the applied name after the workflow resolves; the
457
+ // first non-default name announced for a pending auto-name is that automatic name.
458
+ if (applyingAutoName && !isDefaultName(name)) {
459
+ pendingAutoName = name;
460
+ applyingAutoName = false;
461
+ }
462
+ const automatic = name === pendingAutoName;
463
+ const nameSource = automatic ? "auto" : isDefaultName(name) ? "default" : "user";
444
464
  tabBridge.session(currentSessionFile(), name, nameSource);
445
465
  };
446
466
  let working = false;
447
- let lastTurnFailed = false;
467
+ // Invariant: the last turn's settlement stays reported until the next turn starts.
468
+ let lastTurn;
448
469
  let autoNameAttempted = false;
449
470
  let autoNameArmed = false;
450
471
  // Invariant: naming launches once per tab during an agent run, as soon as its prompt is projected,
@@ -461,12 +482,26 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
461
482
  if (!isDefaultName(currentSessionName()))
462
483
  return;
463
484
  pendingAutoName = name;
485
+ applyingAutoName = true;
464
486
  const result = await backend.workflows.executeWorkflow({ command: "name", argument: name });
465
- if (result.outcome === "completed")
487
+ if (result.outcome !== "completed") {
488
+ applyingAutoName = false;
489
+ if (pendingAutoName === name)
490
+ pendingAutoName = null;
491
+ return;
492
+ }
493
+ const current = currentSessionName();
494
+ if (current !== null && !isDefaultName(current)) {
495
+ // Invariant: The session already holds the applied name; report it as automatic now.
496
+ pendingAutoName = current;
497
+ applyingAutoName = false;
498
+ tabBridge.session(currentSessionFile(), current, "auto");
499
+ }
500
+ else {
501
+ // Invariant: The session announces later; show the generated name meanwhile, still automatic.
466
502
  tabBridge.session(currentSessionFile(), name, "auto");
467
- else if (pendingAutoName === name)
468
- pendingAutoName = null;
469
- }).catch(() => { pendingAutoName = null; });
503
+ }
504
+ }).catch(() => { pendingAutoName = null; applyingAutoName = false; });
470
505
  };
471
506
  const reportStatus = (lifecycle) => {
472
507
  const view = backend.session.view();
@@ -476,7 +511,7 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
476
511
  lifecycle: lifecycle ?? view.lifecycle,
477
512
  pendingRequests,
478
513
  queuedInput,
479
- ...(lastTurnFailed ? { stopReason: "error" } : {}),
514
+ ...(lastTurn === undefined ? {} : { lastTurn }),
480
515
  }), false, pendingRequests, queuedInput);
481
516
  };
482
517
  announceSession();
@@ -501,7 +536,7 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
501
536
  const wasWorking = working;
502
537
  working = event.lifecycle === "busy";
503
538
  if (working)
504
- lastTurnFailed = false;
539
+ lastTurn = undefined;
505
540
  reportStatus(event.lifecycle);
506
541
  if (wasWorking && !working && recovery !== null) {
507
542
  void recovery.settled(currentSessionFile()).catch(() => tabBridge.degraded(true));
@@ -517,7 +552,7 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
517
552
  if (event.type === "agent-run-settled") {
518
553
  launchAutoName();
519
554
  autoNameArmed = false;
520
- lastTurnFailed = !event.successful && !event.aborted;
555
+ lastTurn = event.aborted ? "aborted" : event.successful ? "done" : "error";
521
556
  tabBridge.prompt(event.aborted);
522
557
  reportStatus();
523
558
  }
@@ -546,6 +581,21 @@ function currentBackendSessionFile(backend) {
546
581
  const resume = backend.identity.currentSessionResumeMetadata();
547
582
  return resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`;
548
583
  }
584
+ const SHARED_SETTINGS_POLL_MS = 250;
585
+ /** Rereads the shared settings document whenever its stat changes; returns the stop. */
586
+ export function watchSharedSettings(settings) {
587
+ const file = settings.file;
588
+ const listener = () => {
589
+ try {
590
+ settings.refresh();
591
+ }
592
+ catch {
593
+ // Compatibility: an unreadable peer write resolves on the next stat change.
594
+ }
595
+ };
596
+ watchFile(file, { interval: SHARED_SETTINGS_POLL_MS, persistent: false }, listener);
597
+ return () => unwatchFile(file, listener);
598
+ }
549
599
  async function readPackageVersion() {
550
600
  const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
551
601
  const version = manifest?.version;
@@ -9,5 +9,10 @@ export interface OwnedReferenceProviders {
9
9
  hotkeys(): Promise<ReferenceDocumentProvider>;
10
10
  session(): Promise<ReferenceDocumentProvider>;
11
11
  }
12
+ /** How the settings route is presented. */
13
+ export interface OwnedRouteHostOptions {
14
+ /** The resident Settings tab: the screen is the tab, so it offers no screen-level close. */
15
+ readonly settingsPinned?: boolean;
16
+ }
12
17
  /** Declares settings and lazy document routes. */
13
- export declare function createOwnedRouteHost(settings: OwnedSettingsManager, references?: OwnedReferenceProviders): UiRouteHost;
18
+ export declare function createOwnedRouteHost(settings: OwnedSettingsManager, references?: OwnedReferenceProviders, options?: OwnedRouteHostOptions): UiRouteHost;
@@ -3,7 +3,7 @@ import { SETTINGS_APP_ID, SETTINGS_ROUTE } from "../features/owned-ui/settings-r
3
3
  import { piTheme, renderPiAccentPreview } from "../integrations/pi/components/upstream/theme/theme.js";
4
4
  import { faint } from "../ui/components/text.js";
5
5
  /** Declares settings and lazy document routes. */
6
- export function createOwnedRouteHost(settings, references) {
6
+ export function createOwnedRouteHost(settings, references, options = {}) {
7
7
  const routes = new Set([SETTINGS_ROUTE, ...(references === undefined ? [] : [CHANGELOG_ROUTE, HOTKEYS_ROUTE, "session"])]);
8
8
  return {
9
9
  claims: route => routes.has(route),
@@ -11,7 +11,7 @@ export function createOwnedRouteHost(settings, references) {
11
11
  if (!routes.has(route))
12
12
  return null;
13
13
  if (route === SETTINGS_ROUTE) {
14
- return deferredSurface(SETTINGS_APP_ID, "settings", () => loadSettingsSurface(settings));
14
+ return deferredSurface(SETTINGS_APP_ID, "settings", () => loadSettingsSurface(settings, options.settingsPinned === true));
15
15
  }
16
16
  const changelog = route === CHANGELOG_ROUTE;
17
17
  const session = route === "session";
@@ -61,9 +61,10 @@ function deferredSurface(id, label, load) {
61
61
  onExitRequested: listener => { onExit = listener; },
62
62
  };
63
63
  }
64
- async function loadSettingsSurface(settings) {
64
+ async function loadSettingsSurface(settings, pinned) {
65
65
  const { SettingsApp } = await import("../features/owned-ui/settings-app.js");
66
- return hostApp(SETTINGS_APP_ID, SETTINGS_ROUTE, () => new SettingsApp(settings));
66
+ // Invariant: a pinned Settings tab closes as a tab; neither Esc nor one Ctrl+C dismisses its screen.
67
+ return hostApp(SETTINGS_APP_ID, SETTINGS_ROUTE, () => new SettingsApp(settings, { pinned }), !pinned);
67
68
  }
68
69
  async function loadReferenceSurface(settings, id, route, title, document) {
69
70
  // Rationale: the app module and the document load together, so a document that fails
@@ -80,7 +81,7 @@ async function loadReferenceSurface(settings, id, route, title, document) {
80
81
  }),
81
82
  }));
82
83
  }
83
- async function hostApp(id, route, create) {
84
+ async function hostApp(id, route, create, closeOnInterrupt = true) {
84
85
  const [{ UiAppHost }, { UiAppRegistry }] = await Promise.all([
85
86
  import("../ui/apps/host.js"),
86
87
  import("../ui/apps/registry.js"),
@@ -94,7 +95,7 @@ async function hostApp(id, route, create) {
94
95
  let onExit = () => undefined;
95
96
  const host = new UiAppHost({
96
97
  registry,
97
- closeOnInterrupt: true,
98
+ closeOnInterrupt,
98
99
  theme: pinnedTheme(),
99
100
  surface: {
100
101
  size: () => size,
@@ -12,7 +12,10 @@ export declare const SETTINGS_SHORTCUTS: ShortcutRegistry<Action>;
12
12
  export declare class SettingsApp implements UiApp {
13
13
  #private;
14
14
  readonly id = "settings";
15
- constructor(session: OwnedSettingsManager);
15
+ /** `pinned` presents the screen as a resident Settings tab, which offers no screen-level close. */
16
+ constructor(session: OwnedSettingsManager, options?: {
17
+ readonly pinned?: boolean;
18
+ });
16
19
  onActivate(host: AppHostServices): void;
17
20
  onClose(_host: AppHostServices): void;
18
21
  render(rect: PaneRect, host: AppHostServices): readonly string[];
@@ -120,8 +120,11 @@ export class SettingsApp {
120
120
  #activeUntil = 0;
121
121
  #activityTimer;
122
122
  #renderedScroll;
123
- constructor(session) {
123
+ #pinned;
124
+ /** `pinned` presents the screen as a resident Settings tab, which offers no screen-level close. */
125
+ constructor(session, options = {}) {
124
126
  this.#session = session;
127
+ this.#pinned = options.pinned === true;
125
128
  }
126
129
  onActivate(host) {
127
130
  this.#closed = false;
@@ -251,7 +254,8 @@ export class SettingsApp {
251
254
  const selected = indexOfKey(rows, this.#selectedKey);
252
255
  switch (action) {
253
256
  case "close":
254
- host.close();
257
+ if (!this.#pinned)
258
+ host.close();
255
259
  return { consumed: true };
256
260
  case "open-filter":
257
261
  this.#scrollBeforeFilter = this.#scroll;
@@ -1016,7 +1020,9 @@ export class SettingsApp {
1016
1020
  return this.#dialogLines(open, width, theme);
1017
1021
  // Rationale: standing guidance is more useful than transient feedback; failures still
1018
1022
  // roll back without displacing the Settings interaction contract.
1019
- const status = renderShortcutHintsWithClose(SETTINGS_SHORTCUTS.hintEntries(SCOPE), theme, width, width > 0 ? 1 : 0);
1023
+ const entries = SETTINGS_SHORTCUTS.hintEntries(SCOPE).filter(entry => !this.#pinned
1024
+ || entry.key !== DIALOG_CLOSE_SHORTCUT_HINT.key || entry.action !== DIALOG_CLOSE_SHORTCUT_HINT.action);
1025
+ const status = renderShortcutHintsWithClose(entries, theme, width, width > 0 ? 1 : 0);
1020
1026
  const input = this.#filter;
1021
1027
  if (input === null)
1022
1028
  return [status];
@@ -13,7 +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
+ { id: "tabs.settings", defaultKey: "alt+,", scope: "global", description: "Open Settings tab" },
17
17
  ...jumps,
18
18
  { id: "tabs.next", defaultKey: null, scope: "global", description: "View next A1 tab" },
19
19
  { id: "tabs.previous", defaultKey: null, scope: "global", description: "View previous A1 tab" },
@@ -1,9 +1,10 @@
1
- export type ResidentTabDerivedStatus = "starting" | "working" | "needs-input" | "done-unseen" | "idle" | "failed";
1
+ /** How the tab's last agent turn settled; it persists until the next turn starts. */
2
+ export type ResidentTabTurnSettlement = "done" | "aborted" | "error";
3
+ export type ResidentTabDerivedStatus = "starting" | "working" | "needs-input" | "idle" | "failed" | ResidentTabTurnSettlement;
2
4
  export interface ResidentTabFacts {
3
5
  readonly lifecycle: string;
4
6
  readonly pendingRequests: number;
5
7
  readonly queuedInput: number;
6
- readonly stopReason?: string;
7
- readonly settledUnseen?: boolean;
8
+ readonly lastTurn?: ResidentTabTurnSettlement;
8
9
  }
9
10
  export declare function deriveResidentTabStatus(facts: ResidentTabFacts): ResidentTabDerivedStatus;
@@ -1,3 +1,4 @@
1
+ // Invariant: `failed` is reserved for a failed tab lifecycle; a turn that errored reports `error`.
1
2
  export function deriveResidentTabStatus(facts) {
2
3
  if (facts.lifecycle === "failed")
3
4
  return "failed";
@@ -7,9 +8,5 @@ export function deriveResidentTabStatus(facts) {
7
8
  return "working";
8
9
  if (facts.lifecycle === "starting")
9
10
  return "starting";
10
- if (facts.stopReason === "error")
11
- return "failed";
12
- if (facts.settledUnseen === true)
13
- return "done-unseen";
14
- return "idle";
11
+ return facts.lastTurn ?? "idle";
15
12
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { CLEAR_EXIT_INTERVAL_MS, restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
2
+ import { RESIDENT_DETACH_INTERVAL_MS, restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
3
3
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { connect } from "node:net";
5
5
  import { resolve } from "node:path";
@@ -64,7 +64,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
64
64
  residentRootExecutable: resident.executable.executable,
65
65
  residentRootArguments: JSON.stringify([
66
66
  resident.executable.entry,
67
- "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(CLEAR_EXIT_INTERVAL_MS),
67
+ "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(RESIDENT_DETACH_INTERVAL_MS),
68
68
  "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
69
69
  "--settings-file", resolve(agentProfileDir, "settings.json"),
70
70
  "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
@@ -1,4 +1,4 @@
1
- export type ResidentTabStatus = "starting" | "working" | "needs-input" | "done-unseen" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
1
+ export type ResidentTabStatus = "starting" | "working" | "needs-input" | "done-unseen" | "done" | "aborted" | "error" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
2
2
  export interface TabBridgeCredentials {
3
3
  readonly tabId: string;
4
4
  readonly token: string;
@@ -137,7 +137,8 @@ export class TabBridgeClient {
137
137
  if (start === -1) {
138
138
  let retained = 0;
139
139
  const maximum = Math.min(prefix.length - 1, pending.length);
140
- for (let length = maximum; length > 0; length--) {
140
+ // Invariant: a lone trailing Esc is a user key and passes at once; only `\x1bP` or more is withheld.
141
+ for (let length = maximum; length > 1; length--) {
141
142
  if (pending.endsWith(prefix.slice(0, length))) {
142
143
  retained = length;
143
144
  break;
@@ -1,3 +1,3 @@
1
- export { CLEAR_EXIT_INTERVAL_MS, EMERGENCY_TERMINAL_RESET, boundedCleanup, restoreAfterOwnedExit, restoreProcessTerminal } from "./terminal-reset.js";
1
+ export { CLEAR_EXIT_INTERVAL_MS, EMERGENCY_TERMINAL_RESET, RESIDENT_DETACH_INTERVAL_MS, boundedCleanup, restoreAfterOwnedExit, restoreProcessTerminal } from "./terminal-reset.js";
2
2
  export { installFatalExit, writeFatalDiagnostic } from "./fatal-exit.js";
3
3
  export type { FatalExitOptions } from "./fatal-exit.js";
@@ -1,2 +1,2 @@
1
- export { CLEAR_EXIT_INTERVAL_MS, EMERGENCY_TERMINAL_RESET, boundedCleanup, restoreAfterOwnedExit, restoreProcessTerminal } from "./terminal-reset.js";
1
+ export { CLEAR_EXIT_INTERVAL_MS, EMERGENCY_TERMINAL_RESET, RESIDENT_DETACH_INTERVAL_MS, boundedCleanup, restoreAfterOwnedExit, restoreProcessTerminal } from "./terminal-reset.js";
2
2
  export { installFatalExit, writeFatalDiagnostic } from "./fatal-exit.js";
@@ -1,4 +1,9 @@
1
1
  export declare const CLEAR_EXIT_INTERVAL_MS = 500;
2
+ /**
3
+ * How long a first `Ctrl+C` keeps the resident attach client's detach chord armed. It matches the
4
+ * owned dialogs' interrupt chord so an unhurried double press leaves resident tabs from any tab.
5
+ */
6
+ export declare const RESIDENT_DETACH_INTERVAL_MS = 1500;
2
7
  export declare const EMERGENCY_TERMINAL_RESET: string;
3
8
  export declare function restoreProcessTerminal(): void;
4
9
  export declare function restoreAfterOwnedExit(ownsTerminal: boolean, code: number | null, signal: string | null, restore?: () => void): number;
@@ -1,5 +1,10 @@
1
1
  import { writeSync } from "node:fs";
2
2
  export const CLEAR_EXIT_INTERVAL_MS = 500;
3
+ /**
4
+ * How long a first `Ctrl+C` keeps the resident attach client's detach chord armed. It matches the
5
+ * owned dialogs' interrupt chord so an unhurried double press leaves resident tabs from any tab.
6
+ */
7
+ export const RESIDENT_DETACH_INTERVAL_MS = 1_500;
3
8
  // Protocol: reset modes on both screens; setting keyboard flags to zero is idempotent,
4
9
  // unlike repeatedly popping a keyboard-protocol stack owned by a parent application.
5
10
  // Resetting parent-screen margins homes the cursor, so preserve its restored position.
@@ -1,5 +1,11 @@
1
1
  import { type ProgramStatus, type WheelScrollLines } from "@earendil-works/pi-tui";
2
2
  import type { PiTuiComponentPort, PiTuiInputListener, PiTuiOverlayHandle, PiTuiOverlayOptions, PiTuiPreInputListener, PiTuiRuntimeAdapterOptions, PiTuiRuntimeState, PiTuiScrollState, PiTuiStopOptions, PiTuiViewport } from "./contracts.js";
3
+ /**
4
+ * Re-encodes one key report decoded by pi-tui in the legacy form, so a kitty-keyboard child
5
+ * (resident tabs) and a legacy terminal produce the same owned-screen action. Releases decode
6
+ * to nothing; mouse reports, paste, and unknown sequences pass through unchanged.
7
+ */
8
+ export declare function legacyKeyInput(data: string): string;
3
9
  export type PiTuiRuntimeErrorStage = "construction" | "start" | "input-drain" | "restoration";
4
10
  /** Identifies the Pi TUI lifecycle stage that failed while preserving the original cause. */
5
11
  export declare class PiTuiRuntimeError extends Error {
@@ -1,8 +1,35 @@
1
- import { HStack, ProcessTerminal, ScrollView, TuiAltScreen, TuiMainScreen, VStack, visibleWidth, } from "@earendil-works/pi-tui";
1
+ import { HStack, ProcessTerminal, ScrollView, TuiAltScreen, TuiMainScreen, VStack, decodeKittyPrintable, isKeyRelease, parseKey, visibleWidth, } from "@earendil-works/pi-tui";
2
2
  import { MouseReportInput, stripSgrMouseReports } from "./mouse-report-input.js";
3
3
  import { GeometryObservedAltScreen, OverlayGeometryTracker } from "./overlay-geometry.js";
4
4
  import { boundedCleanup, EMERGENCY_TERMINAL_RESET } from "../../../foundation/terminal-cleanup/terminal-reset.js";
5
5
  import { InputPresentationCoordinator, } from "./input-presentation-coordinator.js";
6
+ // Protocol: the legacy bytes owned screens and their key tables compare against.
7
+ const LEGACY_KEY_BYTES = {
8
+ escape: "\u001b", up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D",
9
+ "shift+up": "\u001b[1;2A", "shift+down": "\u001b[1;2B", pageUp: "\u001b[5~", pageDown: "\u001b[6~",
10
+ home: "\u001b[H", end: "\u001b[F", "ctrl+home": "\u001b[1;5H", "ctrl+end": "\u001b[1;5F",
11
+ enter: "\r", tab: "\t", "shift+tab": "\u001b[Z", backspace: "\u007f", delete: "\u001b[3~",
12
+ space: " ", "ctrl+c": "\u0003", "ctrl+z": "\u001a",
13
+ };
14
+ /**
15
+ * Re-encodes one key report decoded by pi-tui in the legacy form, so a kitty-keyboard child
16
+ * (resident tabs) and a legacy terminal produce the same owned-screen action. Releases decode
17
+ * to nothing; mouse reports, paste, and unknown sequences pass through unchanged.
18
+ */
19
+ export function legacyKeyInput(data) {
20
+ if (data.length <= 1 || !data.startsWith("\u001b"))
21
+ return data;
22
+ const key = parseKey(data);
23
+ if (key === undefined)
24
+ return data;
25
+ if (isKeyRelease(data))
26
+ return "";
27
+ const legacy = LEGACY_KEY_BYTES[key];
28
+ if (legacy !== undefined)
29
+ return legacy;
30
+ const printable = decodeKittyPrintable(data);
31
+ return printable !== undefined && printable >= " " && printable !== "\u007f" ? printable : data;
32
+ }
6
33
  /** Identifies the Pi TUI lifecycle stage that failed while preserving the original cause. */
7
34
  export class PiTuiRuntimeError extends Error {
8
35
  stage;
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "arm64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-19T06:24:29.067Z",
10
+ "builtAt": "2026-10-19T14:46:40.019Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "a87eaaffcd986b624c8b044b756446cc1bdaf4334a1775b97820542fe34f9fb7",
@@ -9,11 +9,11 @@
9
9
  "architecture": "arm64",
10
10
  "target": "aarch64-apple-darwin",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-19T06:27:25.061Z",
12
+ "builtAt": "2026-10-19T14:50:21.966Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "54b4a6c6f4b8abf76afd0a65053dd9916653dcf7990639c716bdd0d0ee735ad2",
16
- "size": 5710496
15
+ "sha256": "12b73c5b9c2023badcdcbea9f2c3cca018cbb1704c0cb452303f59a551b0c407",
16
+ "size": 5813520
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-19T06:24:37.379Z",
10
+ "builtAt": "2026-10-19T14:46:46.372Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "783a65f0de287a1c02cd5429dbe97bc8633f4cc8a7b92e3e961180b9bdcff814",
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-apple-darwin",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-19T06:30:00.884Z",
12
+ "builtAt": "2026-10-19T14:53:04.323Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "c4c070744e7dd382532d11759533d5cc93acd53befd999e993b85ad17e848cd4",
16
- "size": 6226880
15
+ "sha256": "aea613cb66de9d7f87018ee32aba7d30d695a86752301e1dde771bdeb7dad724",
16
+ "size": 6349264
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,7 +7,7 @@
7
7
  "platform": "linux",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-19T06:24:11.933Z",
10
+ "builtAt": "2026-10-19T14:46:32.168Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "118e5954e3864672bd8482ab7edf419cb27dcdf2533720316979b7f5cc5a53a5",
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-unknown-linux-gnu",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-19T06:27:20.231Z",
12
+ "builtAt": "2026-10-19T14:49:27.044Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "1e2d6d37ee740ee716aa67f5d37aaac6c193739eeae14ce19320fc8b34939d16",
16
- "size": 6981672
15
+ "sha256": "890d87b2fccecb4956243c40da97c461e180a40ace6b340d5c9f808ab9f4b586",
16
+ "size": 7099552
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,10 +7,10 @@
7
7
  "platform": "win32",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-19T06:24:34.859Z",
10
+ "builtAt": "2026-10-19T14:46:55.773Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
- "sha256": "24685c41544bb8e90fb5162f9ab9545cb698204530cf131644aaf6ee97159c6e",
13
+ "sha256": "e8b39eb8c589c028024164c021a08183021574d06b48fbefc34fb3fb55636c62",
14
14
  "size": 402432
15
15
  },
16
16
  "provenance": {
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-pc-windows-msvc",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-19T06:28:49.237Z",
12
+ "builtAt": "2026-10-19T14:49:14.794Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "a4d34ff27229603c8f354607c2bff377506d34ac72f90886ffe6abea37fd2f88",
16
- "size": 6212608
15
+ "sha256": "7661bc29672ef40853a94bcfe367fd6ed5c03b9e6e3e3ac4ac4bbea4c2aad891",
16
+ "size": 6323712
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -47,6 +47,12 @@ export declare class OwnedSettingsManager {
47
47
  /** Reads and resolves the product namespace. Never throws: every failure becomes a notice. */
48
48
  read(): OwnedUiSettingsResolution;
49
49
  load(): Promise<void>;
50
+ /**
51
+ * Adopts values another process stored in the shared document. Notifies only when a
52
+ * resolved value differs, so a peer's own write echo stays silent. Restart-bound values
53
+ * keep their session-start effect and surface as pending instead.
54
+ */
55
+ refresh(): boolean;
50
56
  sections(): readonly OwnedUiSettingsSection[];
51
57
  /**
52
58
  * The value in effect for a declared setting, typed by its declaration. A restart-bound
@@ -129,6 +129,28 @@ export class OwnedSettingsManager {
129
129
  this.#agentSnapshot = agent === null ? null : await snapshotOf(agent, this.#hiddenAgentSettingIds);
130
130
  this.#notify();
131
131
  }
132
+ /**
133
+ * Adopts values another process stored in the shared document. Notifies only when a
134
+ * resolved value differs, so a peer's own write echo stays silent. Restart-bound values
135
+ * keep their session-start effect and surface as pending instead.
136
+ */
137
+ refresh() {
138
+ const next = this.read();
139
+ if (resolvedValuesKey(next) === resolvedValuesKey(this.#resolution))
140
+ return false;
141
+ this.#resolution = next;
142
+ for (const setting of next.settings) {
143
+ const id = setting.declaration.id;
144
+ if (!this.#restartEffective.has(id))
145
+ continue;
146
+ if (setting.value === this.#restartEffective.get(id))
147
+ this.#pending.delete(id);
148
+ else
149
+ this.#pending.set(id, setting.value);
150
+ }
151
+ this.#notify();
152
+ return true;
153
+ }
132
154
  sections() {
133
155
  const resolution = this.#hiddenOwnedSettingIds.size === 0
134
156
  ? this.#resolution
@@ -270,6 +292,9 @@ async function snapshotOf(agent, hidden) {
270
292
  return { descriptors: [], writeAdvertised: false, failure: describe(error) };
271
293
  }
272
294
  }
295
+ function resolvedValuesKey(resolution) {
296
+ return JSON.stringify(resolution.settings.map(setting => [setting.declaration.id, setting.value]));
297
+ }
273
298
  function failed(failure) {
274
299
  return {
275
300
  status: "failed",
@@ -262,11 +262,12 @@ Tab status is an A1-owned state machine fed by `bridge.status`:
262
262
  | `idle` | settled idle | none |
263
263
  | `working` | agent or turn start, tool execution, compaction | shared progress frames |
264
264
  | `needs-input` | an extension UI, trust, or permission request is pending | yellow `?` (warning) |
265
- | `done-unseen` | the turn settled while no client viewed the tab | `✓` success |
266
- | `error` | the last turn's `stopReason` was error | `✗` error |
267
- | `crashed` / `restarting` / `failed` | holder or child exit | `✗` error plus a banner |
265
+ | `done` | the last turn finished successfully | green `✓` success |
266
+ | `aborted` | the last turn was aborted | red `✗` error |
267
+ | `error` | the last turn ended with an error | red `✗` error |
268
+ | `crashed` / `restarting` / `failed` | holder or child exit, or a failed tab lifecycle (never a failed turn) | `✗` error plus a banner |
268
269
 
269
- - `seen` is tracked server-side, so viewing a tab in any client clears `✓` everywhere.
270
+ - A `done`, `aborted`, or `error` marker stays on the chip, including after any client views the tab, until that tab's next turn starts.
270
271
  - A tab with a broken bridge shows only process-level states.
271
272
  - Future CLI tabs will derive status from process state, and may use BEL or OSC 9/777 from the program as an attention input.
272
273
  - The glyph set is the same in every client. A working spinner uses the resolved A1 accent reported by that specific tab.
@@ -335,7 +336,7 @@ Tab status is an A1-owned state machine fed by `bridge.status`:
335
336
  | `Alt+A` | new tab |
336
337
  | `Alt+W` | close |
337
338
  | `F2` | rename |
338
- | `Alt+.` | create or activate the global Settings tab |
339
+ | `Alt+,` | open the global Settings tab, or close it while it is viewed |
339
340
  | `Alt+1`…`Alt+9`, `Alt+0` | jump to a tab |
340
341
  | unbound (`tabs.next` / `tabs.previous`) | next / previous, wrapping |
341
342
  | unbound (`tabs.moveRight` / `tabs.moveLeft`) | move the tab right / left |
@@ -349,7 +350,7 @@ Tab status is an A1-owned state machine fed by `bridge.status`:
349
350
  - right-click opens `Rename`/`Close` (`Close` only for Settings);
350
351
  - dragging reorders, with a `│` drop marker, after 250 ms or once the pointer moves;
351
352
  - `+` opens a persistent dark v2 menu with `+ Agent`, a separator, and `Settings`; hover and click remain active through the opening release.
352
- - **Settings.** The strip owns one profile-global `Settings` tab. The grouped launcher and `Alt+.` create or activate it; closing the screen or tab removes it. Resident Agent autocomplete omits `/settings`, and manually entering it does not open a per-Agent screen. `a1 solo` and `a1 pi` retain their existing Settings routes.
353
+ - **Settings.** The strip owns one profile-global `Settings` tab. The grouped launcher creates or activates it; `Alt+,` does the same from another tab and closes it while it is viewed. Esc does not close the Settings tab; closing the tab removes it. Resident Agent autocomplete omits `/settings`, and manually entering it does not open a per-Agent screen. `a1 solo` and `a1 pi` retain their existing Settings routes.
353
354
  - **Commands inside A1 tabs:** existing `/name <name>` also renames the tab. Milestone 5 adds no tab-management slash commands: `/new-tab`, `/close`, `/tabs`, `/quit-all`, and `/restart-tab` are not exposed.
354
355
  - **Naming:**
355
356
  - Default names are `agent` or `agent N`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.2.7-dev.801",
3
+ "version": "0.2.7-dev.808",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",