@crouton-kit/crouter 0.3.67 → 0.3.70

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 (56) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +1 -0
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
  8. package/dist/clients/attach/attach-cmd.js +668 -668
  9. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  10. package/dist/clients/attach/auth-pickers.js +11 -0
  11. package/dist/clients/attach/pickers.js +2 -0
  12. package/dist/clients/attach/slash-commands.js +1 -0
  13. package/dist/commands/capture.d.ts +2 -0
  14. package/dist/commands/capture.js +28 -0
  15. package/dist/commands/node.js +3 -3
  16. package/dist/commands/profile/new.js +30 -5
  17. package/dist/commands/sys/setup-core.js +2 -0
  18. package/dist/commands/sys/setup.js +132 -16
  19. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  20. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  21. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  22. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  23. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  24. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  25. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  26. package/dist/core/__tests__/helpers/harness.js +28 -4
  27. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  28. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  29. package/dist/core/__tests__/revive.test.js +70 -1
  30. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  31. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  32. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  33. package/dist/core/canvas/pid.d.ts +14 -10
  34. package/dist/core/canvas/pid.js +14 -10
  35. package/dist/core/command.d.ts +15 -0
  36. package/dist/core/command.js +72 -0
  37. package/dist/core/profiles/select.d.ts +4 -1
  38. package/dist/core/profiles/select.js +356 -81
  39. package/dist/core/runtime/broker.js +2 -0
  40. package/dist/core/runtime/launch.js +1 -1
  41. package/dist/core/runtime/revive.js +187 -140
  42. package/dist/core/runtime/tmux.js +4 -1
  43. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  44. package/dist/core/view/transport-local.js +12 -3
  45. package/dist/daemon/crtrd-cli.d.ts +1 -1
  46. package/dist/daemon/crtrd-cli.js +1 -0
  47. package/dist/suppress-experimental-warnings.d.ts +1 -0
  48. package/dist/suppress-experimental-warnings.js +15 -0
  49. package/dist/types.js +10 -10
  50. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  51. package/dist/web-client/assets/{index-BvzxXXGU.js → index-DiFuLcp6.js} +19 -18
  52. package/dist/web-client/index.html +3 -3
  53. package/dist/web-client/sw.js +1 -1
  54. package/package.json +10 -7
  55. package/scripts/postinstall.mjs +1 -1
  56. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -1,5 +1,17 @@
1
1
  import type { TUI } from '@earendil-works/pi-tui';
2
2
  import type { Picker, PickerControls } from './pickers.js';
3
+ /** A compact auth summary for a provider — used by non-picker surfaces (e.g.
4
+ * the `crtr sys setup` login tab) to show which providers are already
5
+ * configured without mounting the full selector. */
6
+ export interface ProviderAuthSummary {
7
+ id: string;
8
+ name: string;
9
+ configured: boolean;
10
+ }
11
+ /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
12
+ * Mirrors the OAuth selector's provider list; `configured` is true when the
13
+ * provider has an explicit stored credential (OAuth token or API key). */
14
+ export declare function summarizeProviderAuth(agentDir: string): ProviderAuthSummary[];
3
15
  /** `/login` — show the provider selector, then run pi's native login flow in
4
16
  * the viewer. On success, notifies the broker to reload auth via reload_auth.
5
17
  * Returns a Picker (OAuthSelectorComponent as the overlay). */
@@ -47,6 +47,17 @@ function buildProviderList(authStorage, registry) {
47
47
  }
48
48
  return providers;
49
49
  }
50
+ /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
51
+ * Mirrors the OAuth selector's provider list; `configured` is true when the
52
+ * provider has an explicit stored credential (OAuth token or API key). */
53
+ export function summarizeProviderAuth(agentDir) {
54
+ const { authStorage, registry } = buildLocalAuth(agentDir);
55
+ return buildProviderList(authStorage, registry).map((p) => ({
56
+ id: p.id,
57
+ name: p.name,
58
+ configured: authStorage.get(p.id) !== undefined,
59
+ }));
60
+ }
50
61
  /** Open the browser with a URL (best-effort; platform-specific). */
51
62
  function openBrowser(url) {
52
63
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -141,6 +141,8 @@ export function buildSettingsPicker(data, send, close, notify) {
141
141
  onClearOnShrinkChange: out,
142
142
  onShowTerminalProgressChange: out,
143
143
  onWarningsChange: out,
144
+ onShowCacheMissNoticesChange: out,
145
+ onOutputPadChange: out,
144
146
  };
145
147
  const component = new SettingsSelectorComponent(data.settings, callbacks);
146
148
  return { component, focus: component.getSettingsList() };
@@ -62,6 +62,7 @@ const THINKING_LEVELS = new Set(Object.keys({
62
62
  medium: true,
63
63
  high: true,
64
64
  xhigh: true,
65
+ max: true,
65
66
  }));
66
67
  const BUILTIN_NAMES = new Set(BUILTIN_SLASH_COMMANDS.map((c) => c.name));
67
68
  /** Builtin slash commands safe to run against a REMOTE attach (`--canvas`) —
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../core/command.js';
2
+ export declare function registerCapture(): BranchDef;
@@ -0,0 +1,28 @@
1
+ // `crtr capture` — transparent passthrough to the external Capture CLI
2
+ // (`@crouton-kit/capture`, bin `capture`). Capture stays a separately
3
+ // published, open-source package: crouter takes no build-time dependency on
4
+ // it and does not mirror its command tree. Every argument after `capture` is
5
+ // forwarded verbatim (see the `passthrough` field on BranchDef in
6
+ // core/command.ts) — including `crtr capture -h`, which shows CAPTURE's own
7
+ // help, not crtr's. crtr's only framing of this command lives in the
8
+ // rootEntry below (surfaced at `crtr -h`); there is no separate crtr-level
9
+ // help body to keep in sync with capture's schema.
10
+ import { defineBranch } from '../core/command.js';
11
+ const INSTALL_HINT = 'Install the Capture CLI: npm install -g @crouton-kit/capture';
12
+ export function registerCapture() {
13
+ return defineBranch({
14
+ name: 'capture',
15
+ passthrough: { bin: 'capture', installHint: INSTALL_HINT },
16
+ rootEntry: {
17
+ concept: 'browser automation for agents via the external Capture CLI (CDP screenshots, HAR, a11y, JS-exec, and site libs)',
18
+ desc: 'forwards verbatim to the external `capture` binary',
19
+ useWhen: 'you need headless browser automation — screenshot or inspect a page, record a HAR, or run JS in a real browser to reach a site\'s backend (e.g. Reddit/X via the bundled libs). `crtr capture <args>` forwards every argument verbatim to the external `capture` CLI, including `-h` — run `crtr capture -h` for capture\'s own command schema. Requires `capture` on PATH (npm install -g @crouton-kit/capture).',
20
+ },
21
+ help: {
22
+ name: 'capture',
23
+ summary: 'transparent alias for the external `capture` CLI — every argument forwards verbatim',
24
+ model: 'This branch owns no schema of its own: it execs `capture` with whatever args follow, inherits stdio, and propagates its exit code. Run `crtr capture -h` for capture\'s real command tree.',
25
+ },
26
+ children: [],
27
+ });
28
+ }
@@ -137,7 +137,7 @@ const nodeNew = defineLeaf({
137
137
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
138
138
  { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on local `main`; the child\'s cwd is pinned there, and it lands the work serially onto local main later with `crtr node worktree close`. Mutually exclusive with --root.' },
139
139
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
140
- { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
140
+ { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), a family alias (opus|sonnet|haiku), or an exact `provider/id` for a concrete model. Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
141
141
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (which may be none, e.g. a historical null-profile parent). A shell/no-spawner root with no profile coverage instead runs the startup selector and can persist the stable root profile. --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
142
142
  { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation (e.g. which applet/page spawned this), NOT the visible prompt. Appended as a `<situational-context>` sibling block in the injected bearings, never as visible chat. Preserved unchanged into a deferred --at/--every/--when node_birth recipe.' },
143
143
  { kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
@@ -188,7 +188,7 @@ const nodeNew = defineLeaf({
188
188
  throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a `provider/id`, a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku).' });
189
189
  }
190
190
  if (!normalizeModel(modelSpec).includes('/')) {
191
- throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku) — a live-broker substring search (like `node config --model`) is not available at spawn.' });
191
+ throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), a family alias (opus|sonnet|haiku), or an exact `provider/id` — a live-broker substring search (like `node config --model`) is not available at spawn.' });
192
192
  }
193
193
  }
194
194
  const model = modelSpec;
@@ -869,7 +869,7 @@ const nodeConfig = defineLeaf({
869
869
  else {
870
870
  const normalized = normalizeModel(modelSpec);
871
871
  if (!normalized.includes('/')) {
872
- throw new InputError({ error: 'unresolvable_dormant', message: `'${modelSpec}' does not resolve to a concrete model for a dormant node`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5) or a tier/alias — the live registry search is only available while the node\'s broker is up.' });
872
+ throw new InputError({ error: 'unresolvable_dormant', message: `'${modelSpec}' does not resolve to a concrete model for a dormant node`, field: 'model', next: 'Pass a `provider/tier` (e.g. openai/ultra) or a tier/alias — the live registry search is only available while the node\'s broker is up.' });
873
873
  }
874
874
  persistDormantModel(nodeId, normalized);
875
875
  resolved = normalized;
@@ -1,9 +1,10 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { createProfile, profileRoot } from '../../core/profiles/manifest.js';
2
+ import { usage } from '../../core/errors.js';
3
+ import { createProfile, ensureRootProfile, profileRoot } from '../../core/profiles/manifest.js';
3
4
  export const newLeaf = defineLeaf({
4
5
  name: 'new',
5
6
  description: 'create a new profile',
6
- whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile project add` afterward for every additional one.',
7
+ whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile project add` afterward for every additional one. Use `--root` (no name) to provision the canonical stable root profile — the default identity a home falls back to — idempotently, without spawning a node.',
7
8
  help: {
8
9
  name: 'profile new',
9
10
  summary: 'create a profile: `<profiles-root>/<slug>-<id>/` with a manifest + its own `memory/` store',
@@ -12,8 +13,15 @@ export const newLeaf = defineLeaf({
12
13
  kind: 'flag',
13
14
  name: 'name',
14
15
  type: 'string',
15
- required: true,
16
- constraint: 'Display name for the profile. Also slugified into the stable profile id — the id never changes on later rename.',
16
+ required: false,
17
+ constraint: 'Display name for the profile. Also slugified into the stable profile id — the id never changes on later rename. Required unless `--root` is given; mutually exclusive with `--root`.',
18
+ },
19
+ {
20
+ kind: 'flag',
21
+ name: 'root',
22
+ type: 'bool',
23
+ required: false,
24
+ constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`.',
17
25
  },
18
26
  {
19
27
  kind: 'flag',
@@ -32,11 +40,28 @@ export const newLeaf = defineLeaf({
32
40
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
33
41
  ],
34
42
  outputKind: 'object',
35
- effects: ['Creates `<profile-dir>/profile.json` and `<profile-dir>/memory/`.'],
43
+ effects: ['Creates `<profile-dir>/profile.json` and `<profile-dir>/memory/` (the fixed root dir when `--root`).'],
36
44
  },
37
45
  run: async (input) => {
38
46
  const name = input['name'];
39
47
  const project = input['project'];
48
+ const root = input['root'] === true;
49
+ if (root) {
50
+ if (name !== undefined || project !== undefined) {
51
+ throw usage('`--root` provisions the canonical stable root profile and takes no `--name`/`--project`.');
52
+ }
53
+ const { profileId, manifest } = ensureRootProfile();
54
+ return {
55
+ profile_id: profileId,
56
+ name: manifest.name,
57
+ path: profileRoot(profileId),
58
+ projects: manifest.projects,
59
+ created_at: manifest.created_at,
60
+ follow_up: `Root profile ensured. Spawn nodes under it with \`--profile ${profileId}\`, or widen its purview with \`crtr profile project add ${profileId} --dir <path>\`.`,
61
+ };
62
+ }
63
+ if (name === undefined)
64
+ throw usage('provide `--name <name>` to create a profile, or `--root` to provision the stable root profile.');
40
65
  const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : []);
41
66
  return {
42
67
  profile_id: profileId,
@@ -270,6 +270,8 @@ export function renderSetupStaticInstructions(state) {
270
270
  return [
271
271
  instructions.headline,
272
272
  '',
273
+ 'Provider login: open the wizard in a TTY and use the Login tab (same flow as /login) to authenticate a model provider.',
274
+ '',
273
275
  'Packages:',
274
276
  ...instructions.packageLines,
275
277
  '',
@@ -1,7 +1,8 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
3
3
  import { TUI, ProcessTerminal, Input, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
4
- import { applyTheme } from '../../clients/attach/config-load.js';
4
+ import { applyTheme, defaultAgentDir } from '../../clients/attach/config-load.js';
5
+ import { buildLoginPicker, summarizeProviderAuth } from '../../clients/attach/auth-pickers.js';
5
6
  const ANSI_GREEN = (s) => `\x1b[32m${s}\x1b[39m`;
6
7
  const ANSI_YELLOW = (s) => `\x1b[33m${s}\x1b[39m`;
7
8
  const theme = {
@@ -103,6 +104,10 @@ class SetupWizard {
103
104
  exaInput = new Input();
104
105
  selectedPackageIds = new Set();
105
106
  selectedSystemTools = new Set();
107
+ authSummary;
108
+ reloadAuthSummary;
109
+ notice;
110
+ onOpenLogin;
106
111
  activeTab = 0;
107
112
  pluginIndex = 0;
108
113
  systemIndex = 0;
@@ -113,10 +118,12 @@ class SetupWizard {
113
118
  _focused = false;
114
119
  onSubmit;
115
120
  onCancel;
116
- constructor(manifest, installedPackageIds, systemTools) {
121
+ constructor(manifest, installedPackageIds, systemTools, authSummary, reloadAuthSummary) {
117
122
  this.manifest = manifest;
118
123
  this.installedPackageIds = installedPackageIds;
119
124
  this.systemTools = systemTools;
125
+ this.authSummary = authSummary;
126
+ this.reloadAuthSummary = reloadAuthSummary;
120
127
  for (const entry of this.manifest)
121
128
  this.selectedPackageIds.add(entry.id);
122
129
  for (const tool of this.systemTools) {
@@ -130,7 +137,7 @@ class SetupWizard {
130
137
  }
131
138
  set focused(value) {
132
139
  this._focused = value;
133
- this.exaInput.focused = value && this.activeTab === 2 && !this.confirming;
140
+ this.exaInput.focused = value && this.activeTab === 3 && !this.confirming;
134
141
  }
135
142
  get focused() {
136
143
  return this._focused;
@@ -139,13 +146,23 @@ class SetupWizard {
139
146
  this.cachedWidth = undefined;
140
147
  this.cachedLines = undefined;
141
148
  }
149
+ /** Show a one-line notice on the login tab (login success/failure feedback). */
150
+ setNotice(message) {
151
+ this.notice = message;
152
+ this.invalidate();
153
+ }
154
+ /** Recompute provider auth status after a login flow closes. */
155
+ refreshAuth() {
156
+ this.authSummary = this.reloadAuthSummary();
157
+ this.invalidate();
158
+ }
142
159
  setTab(next) {
143
160
  this.activeTab = next;
144
- this.exaInput.focused = this.focused && next === 2 && !this.confirming;
161
+ this.exaInput.focused = this.focused && next === 3 && !this.confirming;
145
162
  this.invalidate();
146
163
  }
147
164
  moveTab(delta) {
148
- const next = ((this.activeTab + delta + 3) % 3);
165
+ const next = ((this.activeTab + delta + 4) % 4);
149
166
  this.setTab(next);
150
167
  }
151
168
  cancel() {
@@ -263,6 +280,13 @@ class SetupWizard {
263
280
  if (this.handleGlobalTabSwitch(data))
264
281
  return;
265
282
  if (this.activeTab === 0) {
283
+ if (matchesKey(data, 'enter')) {
284
+ this.onOpenLogin?.();
285
+ return;
286
+ }
287
+ return;
288
+ }
289
+ if (this.activeTab === 1) {
266
290
  if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
267
291
  this.movePlugin(1);
268
292
  return;
@@ -285,7 +309,7 @@ class SetupWizard {
285
309
  }
286
310
  return;
287
311
  }
288
- if (this.activeTab === 1) {
312
+ if (this.activeTab === 2) {
289
313
  if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
290
314
  this.moveSystem(1);
291
315
  return;
@@ -307,7 +331,7 @@ class SetupWizard {
307
331
  this.exaInput.handleInput(data);
308
332
  }
309
333
  renderHeader(width) {
310
- const tabs = ['Plugins', 'System deps', 'Exa key'];
334
+ const tabs = ['Login', 'Plugins', 'System deps', 'Exa key'];
311
335
  const tabLine = tabs
312
336
  .map((label, index) => tabPill(`${index + 1}. ${label}`, index === this.activeTab))
313
337
  .join(theme.fg('dim', ' · '));
@@ -321,12 +345,39 @@ class SetupWizard {
321
345
  const line = this.confirming
322
346
  ? 'y/enter confirm · n/esc go back · q cancel'
323
347
  : this.activeTab === 0
324
- ? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
348
+ ? 'enter log in · Tab/→ next · q cancel'
325
349
  : this.activeTab === 1
326
- ? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
327
- : 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel';
350
+ ? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
351
+ : this.activeTab === 2
352
+ ? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
353
+ : 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel';
328
354
  return [truncateToWidth(theme.fg('dim', line), width)];
329
355
  }
356
+ renderLogin(width) {
357
+ const out = [];
358
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Login'))}${theme.fg('dim', ' optional · same providers as /login')}`, width));
359
+ for (const line of wrapText('Log in to a model provider so crtr can run turns. With no provider configured, nodes fail to start when you first use the app.', Math.max(1, width))) {
360
+ out.push(truncateToWidth(theme.fg('muted', line), width));
361
+ }
362
+ out.push('');
363
+ const configured = this.authSummary.filter((p) => p.configured);
364
+ if (configured.length > 0) {
365
+ out.push(truncateToWidth(theme.fg('success', `✓ Configured: ${configured.map((p) => p.name).join(', ')}`), width));
366
+ }
367
+ else {
368
+ out.push(truncateToWidth(theme.fg('warning', 'No provider configured yet.'), width));
369
+ }
370
+ out.push('');
371
+ out.push(...framed([
372
+ theme.fg('accent', theme.bold('Press Enter to open the provider picker')),
373
+ theme.fg('dim', 'Pick a provider, then complete its OAuth or API-key login.'),
374
+ ], width));
375
+ if (this.notice !== undefined) {
376
+ out.push('');
377
+ out.push(truncateToWidth(theme.fg('success', this.notice), width));
378
+ }
379
+ return out;
380
+ }
330
381
  renderPlugins(width) {
331
382
  const out = [];
332
383
  out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Plugins'))}${theme.fg('dim', ' default ON')}`, width));
@@ -413,7 +464,15 @@ class SetupWizard {
413
464
  const lines = [
414
465
  ...this.renderHeader(width),
415
466
  '',
416
- ...(this.confirming ? this.renderConfirm(width) : this.activeTab === 0 ? this.renderPlugins(width) : this.activeTab === 1 ? this.renderSystems(width) : this.renderExa(width)),
467
+ ...(this.confirming
468
+ ? this.renderConfirm(width)
469
+ : this.activeTab === 0
470
+ ? this.renderLogin(width)
471
+ : this.activeTab === 1
472
+ ? this.renderPlugins(width)
473
+ : this.activeTab === 2
474
+ ? this.renderSystems(width)
475
+ : this.renderExa(width)),
417
476
  '',
418
477
  ...this.renderFooter(width),
419
478
  ];
@@ -422,6 +481,52 @@ class SetupWizard {
422
481
  return this.cachedLines;
423
482
  }
424
483
  }
484
+ const LOGIN_OVERLAY_OPTIONS = { anchor: 'center', width: '80%', maxHeight: '85%' };
485
+ /** Mount pi's real `/login` provider picker + login dialog as a centered overlay
486
+ * over the setup wizard, driving the SAME OAuth/API-key flow the in-app /login
487
+ * uses (`buildLoginPicker`). Credentials write to `agentDir`'s auth.json; on
488
+ * close, focus returns to the wizard and its auth summary refreshes. */
489
+ function openLoginOverlay(tui, wizard, agentDir) {
490
+ let handle;
491
+ let current;
492
+ let done = false;
493
+ const disposeCurrent = () => {
494
+ const disposable = current?.component;
495
+ try {
496
+ disposable?.dispose?.();
497
+ }
498
+ catch {
499
+ /* ignore dispose errors during teardown */
500
+ }
501
+ };
502
+ const mount = (p) => {
503
+ handle = tui.showOverlay(p.component, LOGIN_OVERLAY_OPTIONS);
504
+ tui.setFocus(p.focus);
505
+ };
506
+ const unmount = () => handle?.hide();
507
+ const replace = (component, focus) => {
508
+ if (done)
509
+ return;
510
+ disposeCurrent();
511
+ unmount();
512
+ current = { component, focus };
513
+ mount(current);
514
+ tui.requestRender();
515
+ };
516
+ const close = () => {
517
+ if (done)
518
+ return;
519
+ done = true;
520
+ disposeCurrent();
521
+ unmount();
522
+ wizard.refreshAuth();
523
+ tui.setFocus(wizard);
524
+ tui.requestRender();
525
+ };
526
+ current = buildLoginPicker(tui, agentDir, () => wizard.refreshAuth(), (msg) => wizard.setNotice(msg), { close, replace });
527
+ mount(current);
528
+ tui.requestRender();
529
+ }
425
530
  async function runSetup() {
426
531
  const state = buildSetupInitialState();
427
532
  if (!process.stdout.isTTY) {
@@ -429,9 +534,19 @@ async function runSetup() {
429
534
  return;
430
535
  }
431
536
  applyTheme({ cwd: process.cwd() });
537
+ const agentDir = defaultAgentDir();
538
+ const loadAuthSummary = () => {
539
+ try {
540
+ return summarizeProviderAuth(agentDir);
541
+ }
542
+ catch {
543
+ return [];
544
+ }
545
+ };
432
546
  const terminal = new ProcessTerminal();
433
547
  const tui = new TUI(terminal, true);
434
- const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools);
548
+ const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, loadAuthSummary(), loadAuthSummary);
549
+ wizard.onOpenLogin = () => openLoginOverlay(tui, wizard, agentDir);
435
550
  tui.addChild(wizard);
436
551
  tui.setFocus(wizard);
437
552
  await new Promise((resolve, reject) => {
@@ -496,16 +611,17 @@ async function runSetup() {
496
611
  }
497
612
  export const sysSetupLeaf = defineLeaf({
498
613
  name: 'setup',
499
- description: 'install the bundled pi plugins, system deps, and Exa key setup',
500
- whenToUse: 'you want to bootstrap this machine for crtr in one pass: install the vendored pi plugins, confirm tmux and nvim, and save your Exa API key.',
614
+ description: 'log in to a model provider, install the bundled pi plugins, system deps, and Exa key setup',
615
+ whenToUse: 'you want to bootstrap this machine for crtr in one pass: log in to a model provider (same flow as /login), install the vendored pi plugins, confirm tmux and nvim, and save your Exa API key.',
501
616
  help: {
502
617
  name: 'sys setup',
503
- summary: 'open the multi-tab setup wizard for bundled pi plugins, system deps, and Exa key',
618
+ summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa key',
504
619
  inputNote: 'No input parameters.',
505
620
  output: [],
506
621
  outputKind: 'object',
507
622
  effects: [
508
- 'Opens a pi-tui wizard that collects companion package installs, system dependency installs, and an Exa API key.',
623
+ 'Opens a pi-tui wizard whose first tab logs in to a model provider (pi\'s real OAuth/API-key flow, same as in-app /login) so a fresh install has credentials before running any node.',
624
+ 'Collects companion package installs, system dependency installs, and an Exa API key across the remaining tabs.',
509
625
  'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
510
626
  ],
511
627
  },
@@ -33,7 +33,7 @@
33
33
  import { test, before, after } from 'node:test';
34
34
  import assert from 'node:assert/strict';
35
35
  import { spawnSync } from 'node:child_process';
36
- import { createHarness, hasTmux } from '../helpers/harness.js';
36
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
37
37
  import { closeDb } from '../../canvas/db.js';
38
38
  import { getFocusByNode, listFocuses } from '../../canvas/focuses.js';
39
39
  import { nodeInPane } from '../../../commands/node.js';
@@ -50,13 +50,16 @@ after(async () => {
50
50
  });
51
51
  /** Split a throwaway pane in the harness session; return its %id. */
52
52
  function makePane() {
53
- const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
53
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
54
54
  const pane = (r.stdout ?? '').trim();
55
55
  assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
56
56
  return pane;
57
57
  }
58
58
  function tagPane(pane, value) {
59
- spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
59
+ spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], {
60
+ stdio: 'ignore',
61
+ timeout: TMUX_TIMEOUT_MS,
62
+ });
60
63
  }
61
64
  /** Parse `crtr`'s human-list stdout (`- key: value` lines) into a map. */
62
65
  function parseList(stdout) {
@@ -70,13 +73,13 @@ function parseList(stdout) {
70
73
  }
71
74
  /** Read a pane's `@crtr_node` option ('' when unset / pane gone). */
72
75
  function paneTag(pane) {
73
- const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8' });
76
+ const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
74
77
  return (r.stdout ?? '').trim();
75
78
  }
76
79
  /** Every pane in the harness session whose `@crtr_node` tag names `id` — the live
77
80
  * on-screen viewer panes for that node. A correct dedup keeps this at exactly 1. */
78
81
  function taggedViewerPanes(session, id) {
79
- const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8' });
82
+ const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
80
83
  return (r.stdout ?? '')
81
84
  .split('\n')
82
85
  .map((l) => l.trim())
@@ -112,7 +115,7 @@ test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (u
112
115
  closeDb();
113
116
  assert.equal(nodeInPane(viewer), broker, 'tagged viewer pane → resolves the broker (the fix)');
114
117
  // A stale tag pointing at a DONE node is ignored (must be live: active/idle).
115
- spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore' });
118
+ spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS });
116
119
  });
117
120
  test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a fresh BROKER root', async (t) => {
118
121
  if (!hasTmux())
@@ -54,10 +54,13 @@ import assert from 'node:assert/strict';
54
54
  import { spawnSync } from 'node:child_process';
55
55
  import { mkdirSync, writeFileSync } from 'node:fs';
56
56
  import { join } from 'node:path';
57
- import { createHarness, hasTmux } from '../helpers/harness.js';
57
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
58
58
  import { isPidAlive } from '../../canvas/pid.js';
59
59
  function sessionExists(session) {
60
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
60
+ return (spawnSync('tmux', ['has-session', '-t', session], {
61
+ stdio: 'ignore',
62
+ timeout: TMUX_TIMEOUT_MS,
63
+ }).status === 0);
61
64
  }
62
65
  function seedAssistantSession(sessionFile) {
63
66
  mkdirSync(join(sessionFile, '..'), { recursive: true });
@@ -27,12 +27,12 @@
27
27
  import { test } from 'node:test';
28
28
  import assert from 'node:assert/strict';
29
29
  import { spawnSync } from 'node:child_process';
30
- import { hasTmux } from '../helpers/harness.js';
30
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
31
31
  import { spawnAndDetach } from '../../spawn.js';
32
32
  const SKIP = !hasTmux();
33
33
  // --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
34
34
  function tmux(args) {
35
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
35
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
36
36
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
37
37
  }
38
38
  /** Every pane in the session (across windows) with its dead flag. */
@@ -146,6 +146,9 @@ test('dead-pane regression: spawnAndDetach into a remain-on-exit-ON window is DE
146
146
  else
147
147
  process.env['TMUX'] = origTmux;
148
148
  }
149
- assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
149
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
150
+ stdio: 'ignore',
151
+ timeout: TMUX_TIMEOUT_MS,
152
+ }).status === 0, false, 'isolated session killed — no stray');
150
153
  }
151
154
  });
@@ -26,18 +26,23 @@ import assert from 'node:assert/strict';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { mkdirSync, writeFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
- import { createHarness, hasTmux } from '../helpers/harness.js';
29
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
30
30
  import { getNode } from '../../canvas/canvas.js';
31
31
  import { getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
32
32
  import { closeDb } from '../../canvas/db.js';
33
33
  import { isPidAlive } from '../../canvas/pid.js';
34
34
  const SKIP = !hasTmux() ? 'tmux unavailable' : false;
35
35
  function sessionExists(session) {
36
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
36
+ return (spawnSync('tmux', ['has-session', '-t', session], {
37
+ stdio: 'ignore',
38
+ timeout: TMUX_TIMEOUT_MS,
39
+ }).status === 0);
37
40
  }
38
41
  function paneExistsReal(pane) {
39
- return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], { encoding: 'utf8' }).stdout ?? '').trim() ===
40
- pane);
42
+ return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
43
+ encoding: 'utf8',
44
+ timeout: TMUX_TIMEOUT_MS,
45
+ }).stdout ?? '').trim() === pane);
41
46
  }
42
47
  // Split a throwaway pane in the harness session; return its %id. The caller pane
43
48
  // `node focus` opens the viewer beside (a09b71f: spawn no longer auto-opens one).
@@ -49,7 +54,7 @@ function seedAssistantSession(sessionFile) {
49
54
  writeFileSync(sessionFile, JSON.stringify({ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] } }) + '\n');
50
55
  }
51
56
  function makePane(session) {
52
- const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
57
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
53
58
  const pane = (r.stdout ?? '').trim();
54
59
  assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
55
60
  return pane;
@@ -21,11 +21,11 @@
21
21
  import { test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { spawnSync } from 'node:child_process';
24
- import { hasTmux } from '../helpers/harness.js';
24
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
25
25
  import { spawnAndDetach } from '../../spawn.js';
26
26
  const SKIP = !hasTmux();
27
27
  function tmux(args) {
28
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
28
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
29
29
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
30
30
  }
31
31
  /** window id of a pane (across the server). '' on error. */
@@ -96,6 +96,9 @@ test('human-new-window regression: spawnAndDetach new-window into a watched-pane
96
96
  else
97
97
  process.env['TMUX'] = origTmux;
98
98
  }
99
- assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
99
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
100
+ stdio: 'ignore',
101
+ timeout: TMUX_TIMEOUT_MS,
102
+ }).status === 0, false, 'isolated session killed — no stray');
100
103
  }
101
104
  });
@@ -32,12 +32,12 @@ import { tmpdir } from 'node:os';
32
32
  import { join, dirname } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { createRequire } from 'node:module';
35
- import { hasTmux } from '../helpers/harness.js';
35
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
36
36
  const CROUTER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
37
37
  const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
38
38
  const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
39
39
  function tmux(args) {
40
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
40
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
41
41
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
42
42
  }
43
43
  function sessionPanes(session) {
@@ -1,5 +1,6 @@
1
1
  import type { NodeMeta, NodeStatus, Mode, Lifecycle, ExitIntent } from '../../canvas/types.js';
2
2
  import type { InboxEntry } from '../../feed/inbox.js';
3
+ export declare const TMUX_TIMEOUT_MS = 10000;
3
4
  /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
4
5
  export declare function hasTmux(): boolean;
5
6
  export interface WaitOpts {