windows-development-sandbox 1.13.0 → 1.13.2

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 (50) hide show
  1. package/dist/app/features/accounts/accounts.def.d.ts +1 -0
  2. package/dist/app/features/accounts/accounts.def.js +12 -3
  3. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  4. package/dist/app/features/accounts/accounts.run.js +20 -11
  5. package/dist/app/features/defs.d.ts +1 -0
  6. package/dist/app/features/settings/settings-file.d.ts +7 -0
  7. package/dist/app/features/settings/settings-file.js +19 -2
  8. package/dist/app/features/settings/settings-rows.js +9 -1
  9. package/dist/bin/agent-sandbox.js +7 -1
  10. package/dist/bin/chunk-IL7BXJF2.js +7 -0
  11. package/dist/bin/chunk-K4SU3MHJ.js +10432 -0
  12. package/dist/bin/chunk-VENWSGX2.js +78437 -0
  13. package/dist/bin/claude-host.js +10 -4
  14. package/dist/bin/claude-sbx.js +8 -2
  15. package/dist/bin/codex-sbx.js +8 -2
  16. package/dist/bin/gemini-sbx.js +8 -2
  17. package/dist/bin/sandbox.js +7 -2
  18. package/dist/bin/sbx-hide.js +8 -2
  19. package/dist/bin/sbx-show.js +8 -2
  20. package/dist/cli/commands/launch/prepare.js +24 -7
  21. package/dist/cli/commands/launch/running.js +4 -0
  22. package/dist/cli/commands/storage/offer.js +1 -1
  23. package/dist/cli/menu/live.d.ts +29 -0
  24. package/dist/cli/menu/live.js +92 -21
  25. package/dist/cli/menu/menu.d.ts +2 -1
  26. package/dist/cli/menu/menu.js +16 -2
  27. package/dist/cli/menu/pages/list-page.d.ts +5 -0
  28. package/dist/cli/menu/pages/list-page.js +19 -0
  29. package/dist/cli/menu/shell.d.ts +2 -0
  30. package/dist/cli/menu/shell.js +1 -1
  31. package/dist/cli/services/accounts-service.d.ts +7 -0
  32. package/dist/cli/services/accounts-service.js +20 -0
  33. package/dist/cli/tui/app.d.ts +4 -0
  34. package/dist/cli/tui/app.js +5 -0
  35. package/dist/cli/tui/terminal.d.ts +6 -0
  36. package/dist/cli/tui/terminal.js +8 -1
  37. package/dist/cli/validate.js +8 -4
  38. package/dist/core/package-info.js +1 -1
  39. package/dist/host/sandbox/platform.d.ts +2 -0
  40. package/dist/host/sandbox/platform.js +19 -4
  41. package/dist/host/storage/contained/state.d.ts +3 -2
  42. package/dist/host/storage/contained/state.js +5 -2
  43. package/dist/selftest/layout.test.js +10 -1
  44. package/dist/selftest/menu-perf.test.js +198 -12
  45. package/dist/selftest/menu.test.js +4 -0
  46. package/dist/web/index.html +1 -1
  47. package/dist/web/{main-Z2WYAE2Z.js → main-NBQ3LF4E.js} +1 -1
  48. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  49. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  50. package/package.json +1 -1
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- const args = process.argv.slice(2);
4
- const personal = args.includes('--personal') ? ['--personal'] : [];
5
- run(['host-claude', ...personal, '--', ...args.filter((a) => a !== '--personal')]);
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/claude-host.ts
9
+ var args = process.argv.slice(2);
10
+ var personal = args.includes("--personal") ? ["--personal"] : [];
11
+ run(["host-claude", ...personal, "--", ...args.filter((a) => a !== "--personal")]);
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- run(process.argv.slice(2), { harness: 'claude' });
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/claude-sbx.ts
9
+ run(process.argv.slice(2), { harness: "claude" });
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- run(process.argv.slice(2), { harness: 'codex' });
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/codex-sbx.ts
9
+ run(process.argv.slice(2), { harness: "codex" });
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- run(process.argv.slice(2), { harness: 'gemini' });
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/gemini-sbx.ts
9
+ run(process.argv.slice(2), { harness: "gemini" });
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // sandbox / windows-development-sandbox: the menu with no arguments, any command, or `sandbox <agent>`.
3
- import { run } from '../cli/index.js';
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/sandbox.ts
4
9
  run(process.argv.slice(2));
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- run(process.argv.slice(2), { command: 'hide' });
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/sbx-hide.ts
9
+ run(process.argv.slice(2), { command: "hide" });
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../cli/index.js';
3
- run(process.argv.slice(2), { command: 'show' });
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
+ import {
4
+ run
5
+ } from "./chunk-K4SU3MHJ.js";
6
+ import "./chunk-VENWSGX2.js";
7
+
8
+ // src/bin/sbx-show.ts
9
+ run(process.argv.slice(2), { command: "show" });
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
- import { AccountStore } from '../../../host/profile/index.js';
3
+ import { AccountsService } from '../../services/index.js';
4
4
  import { SandboxChannel } from '../../../host/channel/index.js';
5
5
  import { describePrune, hostDataFolder, pruneStorage } from '../../../host/storage/index.js';
6
6
  import { RepoGuardStore, readDataSize, saveMarkedProjects } from '../../../host/guard/index.js';
@@ -73,16 +73,33 @@ async function prepareWorkspaceView(ctx, run) {
73
73
  '; ' +
74
74
  plan.options.view);
75
75
  }
76
+ /**
77
+ * Every agent that keeps a login starts the new sandbox on its default (the launched agent on --account when given),
78
+ * so a session of any agent opened later uses the login you chose. Returns the launched agent's login.
79
+ */
80
+ function launchAccounts(ctx, launched, wanted) {
81
+ const accounts = new AccountsService(ctx);
82
+ let account = null;
83
+ for (const agent of accounts.agents().filter((a) => a.kept)) {
84
+ if (agent.name === launched) {
85
+ account = accounts.ensure(launched, wanted);
86
+ continue;
87
+ }
88
+ try {
89
+ accounts.ensure(agent.name, null);
90
+ }
91
+ catch (e) {
92
+ // Another agent's saved login could not be put in place: the launch goes on, and that agent says so.
93
+ console.warn(c.yellow('Could not switch ' + agent.name + ' to its default account: ' + errMsg(e)));
94
+ }
95
+ }
96
+ return account;
97
+ }
76
98
  /** Everything a real launch does before the sandbox starts (skipped by `generate`, which only writes files). */
77
99
  export async function prepare(ctx, run) {
78
100
  const { options, dataPath, storageRoot } = ctx;
79
101
  const { config } = run;
80
- const accounts = new AccountStore(storageRoot, config.claudeProfile.sharePluginsAcrossAccounts);
81
- const account = options.account ?? accounts.defaultName();
82
- if (account && run.harnessName === 'claude' && account !== accounts.activeFor(dataPath))
83
- accounts.activate(dataPath, account);
84
- else if (run.harnessName === 'claude')
85
- accounts.syncPlugins(dataPath);
102
+ const account = launchAccounts(ctx, run.harnessName, options.account ?? null);
86
103
  const terminal = config.provisioning.terminal.enabled
87
104
  ? await ensureTerminal(dataPath, config.provisioning.refresh, { pwsh: config.toolchains.pwsh.enabled !== false })
88
105
  : { exe: null, message: 'disabled' };
@@ -6,6 +6,7 @@ import { NeedsRestart } from '../../../host/projects/index.js';
6
6
  import { RunDir } from '../../../shared/channel/index.js';
7
7
  import { errMsg, fail, sleep } from '../../../core/util/index.js';
8
8
  import { c, follow, working, table } from '../../output/index.js';
9
+ import { AccountsService } from '../../services/index.js';
9
10
  import { bootLine, readyIn, stillWorking } from './boot-progress.js';
10
11
  import { hereDir, joinHere, readyHere, offerRestart } from '../projects/index.js';
11
12
  /** How long a session may take to start (its project's setup, the agent's install). */
@@ -65,6 +66,9 @@ export function waitForStart(ctx, requestId, what) {
65
66
  /** Asks the sandbox for a session; the agent's definition goes along (the sandbox may have booted with another). */
66
67
  function requestSession(ctx, channel, harness, args, joined) {
67
68
  const harnessSpec = ctx.loaded.config.harnesses[harness];
69
+ // --account into a running sandbox switches its login first (sessions already running switch on their restart).
70
+ if (ctx.options.account && !ctx.borrowed)
71
+ new AccountsService(ctx).ensure(harness, ctx.options.account);
68
72
  return channel.requestSession(harness, args, joined.guestDir, {
69
73
  ...(joined.spec ? { project: joined.spec } : {}),
70
74
  ...(harnessSpec ? { harnessSpec } : {}),
@@ -88,7 +88,7 @@ export async function storageBeforeCommand(ctx, command) {
88
88
  ensureStorageRoot(ctx.storageRoot, protectPrivateFolder);
89
89
  // Contained storage (beta): a mount an earlier sandbox left behind is only mentioned here; repair fixes it.
90
90
  if (command !== 'launch' && !ctx.options.json) {
91
- const stale = staleMountNote(ctx.storageRoot, ctx.loaded.config.storage.dataName, isSandboxRunning());
91
+ const stale = staleMountNote(ctx.storageRoot, ctx.loaded.config.storage.dataName, isSandboxRunning);
92
92
  if (stale)
93
93
  console.error(c.yellow(stale));
94
94
  }
@@ -5,20 +5,49 @@ export interface LiveOptions {
5
5
  readonly derive: () => string;
6
6
  /** The line changed: draw again. */
7
7
  readonly onChange: () => void;
8
+ /** How often the line is derived again (and the shortest gap between two process lists). */
8
9
  readonly intervalMs?: number;
10
+ /** The longest gap between two process lists while no sandbox runs and nobody presses a key. */
11
+ readonly idleMaxMs?: number;
12
+ /** Whether a process still runs (default: a signal-0 check, which starts no program). */
13
+ readonly alive?: (pid: number) => boolean;
14
+ /** Whether the process list is needed (default: not inside a sandbox, where "is it running?" is always yes). */
15
+ readonly needsList?: () => boolean;
16
+ /**
17
+ * A cheap fingerprint of what a sandbox start changes (the data folder's run folder): while none runs, a change
18
+ * fetches the list at the next tick instead of after the idle gap.
19
+ */
20
+ readonly signal?: () => string;
9
21
  }
22
+ /** The gap before the next process list after `unchanged` lists in a row found the same: doubling, up to `max`. */
23
+ export declare function listGap(unchanged: number, base: number, max: number): number;
10
24
  export declare class LiveStatus {
11
25
  private readonly o;
12
26
  private tasklist;
27
+ /** The sandbox's processes in the last list (none: no sandbox was running). */
28
+ private pids;
29
+ private fetchedAt;
30
+ /** Lists in a row that found the same answer (the gap to the next one grows with it). */
31
+ private unchanged;
32
+ /** The signal when the list was last fetched. */
33
+ private signalled;
13
34
  private text;
14
35
  private timer;
15
36
  private pending;
16
37
  private running;
17
38
  constructor(o: LiveOptions);
39
+ private get interval();
18
40
  /** The status line as of the last refresh. */
19
41
  line(): string;
20
42
  /** Reads the process list now and derives the line (one fetch at a time; a caller during one waits for it). */
21
43
  refresh(): Promise<void>;
44
+ private fetch;
45
+ private load;
46
+ /** Whether the process list may have changed since it was fetched (checked on every tick, cheaply). */
47
+ private listDue;
48
+ private tick;
49
+ /** A key was pressed: someone is looking, so a list older than one interval is fetched again (after the draw). */
50
+ touch(): void;
22
51
  private derive;
23
52
  /** Starts keeping the line fresh and answers the host's process questions from it (until stop). */
24
53
  start(): Promise<void>;
@@ -1,14 +1,42 @@
1
1
  // What the menu shows about the running sandbox (the status line), kept fresh in the background. Drawing and keys only
2
- // read the last copy: the process list is fetched without blocking (every few seconds, and after an action), and the
3
- // screen is told when what it shows has changed. While it runs, the host's own "is the sandbox running?" questions are
4
- // answered from the same copy, so no page waits for a child process.
2
+ // read the last copy, and the screen is told when what it shows has changed. While it runs, the host's own "is the
3
+ // sandbox running?" questions are answered from the same copy, so no page waits for a child process.
4
+ //
5
+ // The process list (tasklist) is the expensive part: on a busy PC one run costs a large share of a second of CPU. So it
6
+ // is not fetched on a timer: while a sandbox runs, its processes are checked by id (no program started) and the list is
7
+ // fetched again only when one of them has ended; while none runs, the list is fetched less and less often while nothing
8
+ // changes (and again soon after a key press, someone is looking, or when the data folder's run folder changes, a start
9
+ // writes there), and after every action.
5
10
  import { sandboxStatus } from '../../app/features/sandbox/index.js';
6
- import { readTasklistAsync, useTasklistCache } from '../../host/sandbox/index.js';
11
+ import { insideSandbox, readTasklistAsync, sandboxPids, useTasklistCache } from '../../host/sandbox/index.js';
7
12
  /** The same rule as the web's live status: the sandbox status query's interval. */
8
13
  const DEFAULT_INTERVAL_MS = sandboxStatus.live?.everyMs ?? 2500;
14
+ /** While no sandbox runs, nobody presses a key and the data folder is quiet, the list is fetched this seldom. */
15
+ const DEFAULT_IDLE_MAX_MS = 30000;
16
+ /** The gap before the next process list after `unchanged` lists in a row found the same: doubling, up to `max`. */
17
+ export function listGap(unchanged, base, max) {
18
+ return Math.min(max, base * 2 ** Math.max(0, Math.min(unchanged, 16)));
19
+ }
20
+ /** A process with this id exists (EPERM: it does, but belongs to someone else, such as the sandbox's own service). */
21
+ function processAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (e) {
27
+ return e.code === 'EPERM';
28
+ }
29
+ }
9
30
  export class LiveStatus {
10
31
  o;
11
32
  tasklist;
33
+ /** The sandbox's processes in the last list (none: no sandbox was running). */
34
+ pids = [];
35
+ fetchedAt = 0;
36
+ /** Lists in a row that found the same answer (the gap to the next one grows with it). */
37
+ unchanged = 0;
38
+ /** The signal when the list was last fetched. */
39
+ signalled = '';
12
40
  text = '';
13
41
  timer;
14
42
  pending;
@@ -16,30 +44,72 @@ export class LiveStatus {
16
44
  constructor(o) {
17
45
  this.o = o;
18
46
  }
47
+ get interval() {
48
+ return this.o.intervalMs ?? DEFAULT_INTERVAL_MS;
49
+ }
19
50
  /** The status line as of the last refresh. */
20
51
  line() {
21
52
  return this.text;
22
53
  }
23
54
  /** Reads the process list now and derives the line (one fetch at a time; a caller during one waits for it). */
24
55
  refresh() {
25
- this.pending ??= (async () => {
26
- try {
27
- const list = await (this.o.fetch ?? readTasklistAsync)();
28
- // Stopped meanwhile: a command may be running, and nothing here may touch the host's facts any more.
29
- if (!this.running)
30
- return;
31
- this.tasklist = list;
32
- this.derive();
33
- }
34
- catch {
35
- // The line keeps what it showed; the next refresh tries again.
36
- }
37
- finally {
38
- this.pending = undefined;
39
- }
40
- })();
56
+ // The next gap starts again from the shortest one.
57
+ this.unchanged = -1;
58
+ return this.fetch();
59
+ }
60
+ fetch() {
61
+ // Cleared in a callback, so also after a fetch that finished without waiting for anything.
62
+ this.pending ??= this.load().finally(() => (this.pending = undefined));
41
63
  return this.pending;
42
64
  }
65
+ async load() {
66
+ try {
67
+ const needed = (this.o.needsList ?? (() => !insideSandbox()))();
68
+ this.signalled = this.o.signal?.() ?? '';
69
+ // Not needed: an empty copy, so nothing runs tasklist itself either.
70
+ const list = needed ? await (this.o.fetch ?? readTasklistAsync)() : '';
71
+ // Stopped meanwhile: a command may be running, and nothing here may touch the host's facts any more.
72
+ if (!this.running)
73
+ return;
74
+ const pids = sandboxPids(list);
75
+ const same = this.tasklist !== undefined && pids.length > 0 === this.pids.length > 0;
76
+ this.unchanged = same ? this.unchanged + 1 : 0;
77
+ this.tasklist = list;
78
+ this.pids = pids;
79
+ this.fetchedAt = Date.now();
80
+ this.derive();
81
+ }
82
+ catch {
83
+ // The line keeps what it showed; the next refresh tries again.
84
+ }
85
+ }
86
+ /** Whether the process list may have changed since it was fetched (checked on every tick, cheaply). */
87
+ listDue(now) {
88
+ if (this.tasklist === undefined)
89
+ return true;
90
+ if (this.pids.length)
91
+ return !this.pids.every((pid) => (this.o.alive ?? processAlive)(pid));
92
+ if (this.o.signal && this.o.signal() !== this.signalled)
93
+ return true;
94
+ return now - this.fetchedAt >= listGap(this.unchanged, this.interval, this.o.idleMaxMs ?? DEFAULT_IDLE_MAX_MS);
95
+ }
96
+ tick() {
97
+ if (this.pending)
98
+ return;
99
+ if (this.listDue(Date.now()))
100
+ void this.fetch();
101
+ else
102
+ this.derive();
103
+ }
104
+ /** A key was pressed: someone is looking, so a list older than one interval is fetched again (after the draw). */
105
+ touch() {
106
+ if (!this.running || this.pending || this.pids.length || Date.now() - this.fetchedAt < this.interval)
107
+ return;
108
+ setImmediate(() => {
109
+ if (this.running)
110
+ void this.refresh();
111
+ });
112
+ }
43
113
  derive() {
44
114
  const next = this.o.derive();
45
115
  if (next === this.text)
@@ -54,7 +124,7 @@ export class LiveStatus {
54
124
  return;
55
125
  this.running = true;
56
126
  useTasklistCache(() => this.tasklist);
57
- this.timer = setInterval(() => void this.refresh(), this.o.intervalMs ?? DEFAULT_INTERVAL_MS);
127
+ this.timer = setInterval(() => this.tick(), this.interval);
58
128
  this.timer.unref();
59
129
  await this.refresh();
60
130
  }
@@ -65,5 +135,6 @@ export class LiveStatus {
65
135
  this.timer = undefined;
66
136
  useTasklistCache(null);
67
137
  this.tasklist = undefined;
138
+ this.pids = [];
68
139
  }
69
140
  }
@@ -1,10 +1,11 @@
1
1
  import type { Context } from '../commands/context.js';
2
2
  import { Terminal } from '../tui/index.js';
3
- /** What a test may replace: the terminal, the process list fetch and how often it is fetched. */
3
+ /** What a test may replace: the terminal, the process list fetch, how often it is fetched and the process check. */
4
4
  export interface MenuOptions {
5
5
  readonly terminal?: Terminal;
6
6
  readonly fetchTasklist?: () => Promise<string>;
7
7
  readonly refreshMs?: number;
8
+ readonly processAlive?: (pid: number) => boolean;
8
9
  }
9
10
  export declare class Menu {
10
11
  private readonly ctx;
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { loadConfig } from '../../core/config/index.js';
7
7
  import { errMsg } from '../../core/util/index.js';
8
8
  import { SandboxChannel } from '../../host/channel/index.js';
9
+ import { RunDir } from '../../shared/channel/index.js';
9
10
  import { isSandboxRunning } from '../../host/sandbox/index.js';
10
11
  import { lookAtSandbox } from '../commands/shared-use.js';
11
12
  import { commandLine } from '../history.js';
@@ -26,6 +27,15 @@ import { readMenuView, saveMenuView } from './memory.js';
26
27
  import { SECTION_LABELS, Shell } from './shell.js';
27
28
  /** Where the menu was last left: it opens there again (also in a later run, from menu-state.json). */
28
29
  let lastView;
30
+ /** When the data folder's run folder last changed (a start writes there): a stat, never a read of what is in it. */
31
+ function runFolderStamp(dataPath) {
32
+ try {
33
+ return String(fs.lstatSync(new RunDir(dataPath).root).mtimeMs);
34
+ }
35
+ catch {
36
+ return '';
37
+ }
38
+ }
29
39
  export class Menu {
30
40
  ctx;
31
41
  runArgv;
@@ -40,9 +50,12 @@ export class Menu {
40
50
  derive: () => this.status(),
41
51
  onChange: () => this.app.requestRedraw(),
42
52
  intervalMs: options.refreshMs,
53
+ alive: options.processAlive,
54
+ signal: () => runFolderStamp(ctx.dataPath),
43
55
  });
44
56
  this.app.status = () => this.live.line();
45
57
  this.app.afterAction = () => this.live.refresh();
58
+ this.app.onKey = () => this.live.touch();
46
59
  try {
47
60
  Frame.version =
48
61
  JSON.parse(fs.readFileSync(path.join(ctx.packageRoot, 'package.json'), 'utf8'))
@@ -95,7 +108,8 @@ export class Menu {
95
108
  }
96
109
  try {
97
110
  const shell = this.shell();
98
- await this.live.start();
111
+ // The first screen does not wait for the process list: the pages show "Loading…" until it is there.
112
+ this.app.ready = this.live.start();
99
113
  await shell.restore(lastView ?? readMenuView(SECTION_LABELS) ?? { section: 0, screens: {} });
100
114
  for (;;) {
101
115
  const exit = await this.app.show(shell);
@@ -112,7 +126,7 @@ export class Menu {
112
126
  if (code !== undefined)
113
127
  return code;
114
128
  }
115
- await this.live.start();
129
+ this.app.ready = this.live.start();
116
130
  await shell.reload();
117
131
  }
118
132
  }
@@ -57,6 +57,11 @@ export declare abstract class ListPage<T> implements Page {
57
57
  protected ticked(): T[];
58
58
  /** Loads the data again; a slow load does not hold up the keys (the tab keeps what it showed, then updates). */
59
59
  open(): Promise<void>;
60
+ /**
61
+ * The section came on screen again: it shows what it had at once and loads the data again after that screen is
62
+ * drawn (the list is rebuilt on the entry and search the user is on). The first time, it is open().
63
+ */
64
+ show(): Promise<void>;
60
65
  /**
61
66
  * Loads the data and waits for it at most QUICK_LOAD_MS. In time, the list is built here (`build`) or by the caller;
62
67
  * later, the list is built when the data arrives, on the entry and search the user has moved to meanwhile.
@@ -97,6 +97,23 @@ export class ListPage {
97
97
  async open() {
98
98
  await this.reload(true);
99
99
  }
100
+ /**
101
+ * The section came on screen again: it shows what it had at once and loads the data again after that screen is
102
+ * drawn (the list is rebuilt on the entry and search the user is on). The first time, it is open().
103
+ */
104
+ async show() {
105
+ if (!this.loaded)
106
+ return this.open();
107
+ const mine = ++this.generation;
108
+ setImmediate(() => {
109
+ if (mine !== this.generation)
110
+ return;
111
+ void this.loadData().then(() => {
112
+ if (mine === this.generation)
113
+ this.rebuildLate();
114
+ });
115
+ });
116
+ }
100
117
  /**
101
118
  * Loads the data and waits for it at most QUICK_LOAD_MS. In time, the list is built here (`build`) or by the caller;
102
119
  * later, the list is built when the data arrives, on the entry and search the user has moved to meanwhile.
@@ -125,6 +142,8 @@ export class ListPage {
125
142
  }
126
143
  async loadData() {
127
144
  try {
145
+ // The first screen is drawn before the menu's facts (the process list) are known; loads wait for them.
146
+ await this.app.ready;
128
147
  await this.load();
129
148
  }
130
149
  catch (e) {
@@ -47,6 +47,8 @@ export interface Page {
47
47
  setFocus(list: boolean): void;
48
48
  /** The section was opened (or the shell came back from a command): load what it shows again. */
49
49
  open(): Promise<void>;
50
+ /** The section came on screen again: show what it had at once and load it again in the background (default open). */
51
+ show?(): Promise<void>;
50
52
  render(size: Size): FrameParts;
51
53
  /** A `quit` from the page means "Esc with nothing left to close": the shell moves to the navigation. */
52
54
  handle(key: Key): Promise<Step<ShellExit>> | Step<ShellExit>;
@@ -76,7 +76,7 @@ export class Shell {
76
76
  this.remember();
77
77
  this.active = Math.max(0, Math.min(this.pages.length - 1, index));
78
78
  this.page.setFocus(this.focus === 'list');
79
- await this.page.open();
79
+ await (this.page.show ?? this.page.open).call(this.page);
80
80
  if (this.restored.has(this.active))
81
81
  return;
82
82
  this.restored.add(this.active);
@@ -31,6 +31,13 @@ export declare class AccountsService {
31
31
  save(name: string, fromHost: boolean, agent?: string): AccountRef;
32
32
  /** Makes the sandbox use a saved login. Running sessions keep the old one until restart(). */
33
33
  activate(name: string, agent?: string): AccountRef;
34
+ /** The agent's default login, if it keeps logins and one is set. */
35
+ defaultName(agent?: string): string | null;
36
+ /**
37
+ * Makes the sandbox use `wanted`, else the agent's default, when it uses another login now (a launch, or a session
38
+ * started with --account). Returns the login it then uses; null when the agent keeps no login or none is saved.
39
+ */
40
+ ensure(agent: string, wanted: string | null): string | null;
34
41
  /** Whether a session of this agent is running that would pick up a new login on restart. */
35
42
  hasRunningSessions(agent?: string): boolean;
36
43
  /** Restarts the agent's running sessions (conversations continue); returns the sandbox's own message. */
@@ -74,6 +74,26 @@ export class AccountsService {
74
74
  store.activate(this.ctx.dataPath, name, stateDir);
75
75
  return { name, email: null };
76
76
  }
77
+ /** The agent's default login, if it keeps logins and one is set. */
78
+ defaultName(agent = CLAUDE) {
79
+ return agent === CLAUDE ? this.claude.defaultName() : this.other(agent).store.defaultName();
80
+ }
81
+ /**
82
+ * Makes the sandbox use `wanted`, else the agent's default, when it uses another login now (a launch, or a session
83
+ * started with --account). Returns the login it then uses; null when the agent keeps no login or none is saved.
84
+ */
85
+ ensure(agent, wanted) {
86
+ if (!this.agents().some((a) => a.name === agent && a.kept))
87
+ return null;
88
+ const name = wanted ?? this.defaultName(agent);
89
+ if (!name)
90
+ return this.activeName(agent);
91
+ if (name !== this.activeName(agent))
92
+ this.activate(name, agent);
93
+ else if (agent === CLAUDE)
94
+ this.claude.syncPlugins(this.ctx.dataPath);
95
+ return name;
96
+ }
77
97
  /** Whether a session of this agent is running that would pick up a new login on restart. */
78
98
  hasRunningSessions(agent = CLAUDE) {
79
99
  return (isSandboxRunning() && new SandboxChannel(this.ctx.dataPath).runningSessions().some((s) => s.harness === agent));
@@ -13,6 +13,10 @@ export declare class App {
13
13
  status: () => string;
14
14
  /** Awaited after an action changed something, before the screen loads what it shows again. */
15
15
  afterAction: () => Promise<void>;
16
+ /** Settles when the facts pages load from are known (the first screen is drawn without waiting for it). */
17
+ ready: Promise<void>;
18
+ /** Told about every key (after it was handled and drawn): someone is looking at the screen. */
19
+ onKey: () => void;
16
20
  /** The navigation on the left of every screen (none: no navigation column). */
17
21
  nav: () => NavState | undefined;
18
22
  /** The screens being shown, innermost last: a dialog is drawn over the one below it. */
@@ -19,6 +19,10 @@ export class App {
19
19
  status = () => '';
20
20
  /** Awaited after an action changed something, before the screen loads what it shows again. */
21
21
  afterAction = () => Promise.resolve();
22
+ /** Settles when the facts pages load from are known (the first screen is drawn without waiting for it). */
23
+ ready = Promise.resolve();
24
+ /** Told about every key (after it was handled and drawn): someone is looking at the screen. */
25
+ onKey = () => undefined;
22
26
  /** The navigation on the left of every screen (none: no navigation column). */
23
27
  nav = () => undefined;
24
28
  /** The screens being shown, innermost last: a dialog is drawn over the one below it. */
@@ -67,6 +71,7 @@ export class App {
67
71
  drawnAt = Date.now();
68
72
  }
69
73
  const step = await screen.handle(await this.terminal.key());
74
+ this.onKey();
70
75
  if (step)
71
76
  return step.done;
72
77
  }
@@ -10,6 +10,12 @@ export interface Key {
10
10
  export declare class ExitRequested extends Error {
11
11
  constructor();
12
12
  }
13
+ /**
14
+ * How long a lone ESC waits for the rest of an escape sequence before it counts as the Esc key. Node's default is
15
+ * 500 ms, which every Esc (back, close) waited for. Terminals send a key's whole sequence in one write, so a short wait
16
+ * is enough.
17
+ */
18
+ export declare const ESCAPE_TIMEOUT_MS = 25;
13
19
  /** Whether this terminal is known to take synchronized updates (AGENT_SANDBOX_SYNC_UPDATE=1 or 0 overrides). */
14
20
  export declare function supportsSyncUpdate(env?: NodeJS.ProcessEnv): boolean;
15
21
  export declare class Terminal {
@@ -14,6 +14,12 @@ export class ExitRequested extends Error {
14
14
  /** DEC 2026: the terminal shows a frame only when it is complete. Others ignore it, so only these are asked. */
15
15
  const SYNC_BEGIN = '\x1b[?2026h';
16
16
  const SYNC_END = '\x1b[?2026l';
17
+ /**
18
+ * How long a lone ESC waits for the rest of an escape sequence before it counts as the Esc key. Node's default is
19
+ * 500 ms, which every Esc (back, close) waited for. Terminals send a key's whole sequence in one write, so a short wait
20
+ * is enough.
21
+ */
22
+ export const ESCAPE_TIMEOUT_MS = 25;
17
23
  /** Whether this terminal is known to take synchronized updates (AGENT_SANDBOX_SYNC_UPDATE=1 or 0 overrides). */
18
24
  export function supportsSyncUpdate(env = process.env) {
19
25
  const forced = env.AGENT_SANDBOX_SYNC_UPDATE;
@@ -59,7 +65,8 @@ export class Terminal {
59
65
  return;
60
66
  this.active = true;
61
67
  this.frame = [];
62
- readline.emitKeypressEvents(this.stdin);
68
+ // The first call on a stream sets its decoder; later calls (and readline interfaces) keep it.
69
+ readline.emitKeypressEvents(this.stdin, { escapeCodeTimeout: ESCAPE_TIMEOUT_MS });
63
70
  if (this.stdin.isTTY)
64
71
  this.stdin.setRawMode(true);
65
72
  this.stdin.on('keypress', this.onKeypress);