talos-code 0.1.0 → 0.2.1

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 (40) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +9 -4
  3. package/dist/args.js +4 -1
  4. package/dist/commands/provider-cli.js +146 -87
  5. package/dist/config/types.js +1 -1
  6. package/dist/i18n/en/approval.js +3 -0
  7. package/dist/i18n/en/credentials.js +10 -0
  8. package/dist/i18n/en/errors.js +21 -0
  9. package/dist/i18n/en/firstrun.js +12 -0
  10. package/dist/i18n/en/screen.js +38 -0
  11. package/dist/i18n/error-view.js +1 -1
  12. package/dist/main.js +2 -1
  13. package/dist/provider/openrouter-login.js +172 -0
  14. package/dist/runtime/context-archive.js +69 -0
  15. package/dist/runtime/repo.js +11 -0
  16. package/dist/runtime/supervisor.js +5 -2
  17. package/dist/runtime/talos-composition.js +9 -3
  18. package/dist/tui/agent-view.js +51 -0
  19. package/dist/tui/app.js +364 -27
  20. package/dist/tui/catalog-service.js +2 -1
  21. package/dist/tui/components/status-indicator.js +10 -4
  22. package/dist/tui/components/terminal-shell.js +10 -1
  23. package/dist/tui/descendant-approvals.js +104 -0
  24. package/dist/tui/launch-state.js +9 -0
  25. package/dist/tui/live-activity.js +13 -2
  26. package/dist/tui/overlays/agent-tree.js +7 -3
  27. package/dist/tui/overlays/approval-dialog.js +3 -1
  28. package/dist/tui/overlays/model-picker.js +5 -0
  29. package/dist/tui/overlays/provider-picker.js +26 -0
  30. package/dist/tui/project-trust-prompt.js +5 -3
  31. package/dist/tui/session-controller.js +25 -12
  32. package/dist/tui/setup-wizard.js +46 -0
  33. package/dist/tui/slash-commands.js +3 -1
  34. package/dist/tui/theme-catalog.js +3 -1
  35. package/dist/version.js +1 -1
  36. package/dist/workspace/checkpoint-store.js +32 -1
  37. package/dist/workspace/checkpoint.js +40 -12
  38. package/package.json +3 -1
  39. package/vendor/harness-ui/src/kernel/talosHarness.mjs +149 -2
  40. package/vendor/manifest.json +2 -2
@@ -466,7 +466,7 @@ export function createTuiCatalogService(deps) {
466
466
  const result = await localModels(provider, local, signal);
467
467
  if (result.kind !== 'ok') {
468
468
  const said = t(result.kind === 'down' ? 'firstrun.models.down' : 'firstrun.models.unreadable', { label, address: result.address });
469
- return { provider, label, rows: known, verified: false, notice: known.length ? `${said} ${t('firstrun.models.knownOnly')}` : said };
469
+ return { provider, label, rows: known, verified: false, notice: known.length ? `${said} ${t('firstrun.models.knownOnly')}` : said, engine: result.kind };
470
470
  }
471
471
  const rows = mergeModels(result.rows, known);
472
472
  const installed = result.rows.length;
@@ -498,6 +498,7 @@ export function createTuiCatalogService(deps) {
498
498
  throw Object.assign(new Error('CONFIG_SCOPE_INVALID'), { code: 'CONFIG_SCOPE_INVALID' }); await deps.persistModel(id); },
499
499
  testProviderKey,
500
500
  keyTestNotice,
501
+ ...(deps.openRouterLogin ? { startOpenRouterLogin: (input) => deps.openRouterLogin(input) } : {}),
501
502
  async setProviderKey(id, secret, signal) {
502
503
  const test = await testProviderKey(id, secret, signal);
503
504
  if (!test.passed)
@@ -1,4 +1,4 @@
1
- import { t } from "../../i18n/index.js";
1
+ import { t, tn } from "../../i18n/index.js";
2
2
  import { frameIndex, stableElapsedLabel } from "../render-scheduler.js";
3
3
  import { retryLine } from "../live-activity.js";
4
4
  /* A static glyph for the states that do not spin: nothing moves while TALOS waits for the person or after the turn. */
@@ -7,16 +7,22 @@ const STATIC_GLYPH = {
7
7
  done: { unicode: '✓', ascii: '+' }, interrupted: { unicode: '■', ascii: '-' }, failed: { unicode: '✗', ascii: 'x' },
8
8
  };
9
9
  /** The one word for an activity, shared by the status row and the main roster row (gate E7). */
10
- export function activityWord(kind, tool) { return kind === 'tool' && tool ? t('screen.busy.toolNamed', { tool }) : t(`screen.busy.${kind}`); }
10
+ export function activityWord(kind, tool, agents) {
11
+ if (kind === 'agents' && agents && agents > 0)
12
+ return tn('screen.busy.agentsCount', agents);
13
+ return kind === 'tool' && tool ? t('screen.busy.toolNamed', { tool }) : t(`screen.busy.${kind}`);
14
+ }
11
15
  const NO_ELAPSED = new Set(['idle', 'error']);
12
- export function busyIndicatorText({ kind, elapsedMs, frame, unicode, frames, tool, retry, nowMs }) {
16
+ export function busyIndicatorText({ kind, elapsedMs, frame, unicode, frames, tool, agents, retry, nowMs }) {
13
17
  const spinnerFrames = frames?.length ? frames : (unicode ? ['⠋', '⠙', '⠹', '⠸'] : ['-', '\\', '|', '/']);
14
18
  const fixed = STATIC_GLYPH[kind];
15
19
  const glyph = fixed ? (unicode ? fixed.unicode : fixed.ascii) : spinnerFrames[Math.abs(frame) % spinnerFrames.length];
16
20
  /* R5a S4: the retry line replaces word and clock (the attempt, the status and the countdown say more than elapsed). */
17
21
  if (kind === 'retrying' && retry)
18
22
  return `${glyph} ${retryLine(retry, nowMs ?? Date.now())}`;
19
- const verb = activityWord(kind, tool).padEnd(10, ' ');
23
+ /* P13 (owner decision 4): while agents work, the line says how to watch them. */
24
+ const word = activityWord(kind, tool, agents);
25
+ const verb = (kind === 'agents' && agents && agents > 0 ? `${word} · ${t('screen.busy.agentsWatch')}` : word).padEnd(10, ' ');
20
26
  const elapsed = NO_ELAPSED.has(kind) ? ' ' : stableElapsedLabel(elapsedMs);
21
27
  return `${glyph} ${verb} ${elapsed}`;
22
28
  }
@@ -191,6 +191,15 @@ export function frameRowsFor(rows, platform = process.platform, policy = WIN32_F
191
191
  const measured = Math.max(1, Math.floor(Number(rows) || 1));
192
192
  return platform === 'win32' && policy === 'rows-1' ? Math.max(1, measured - 1) : measured;
193
193
  }
194
+ /*
195
+ * P11 (owner, 2026-09-25, "changing theme crashes the process"): Ink's Box wraps its children in a background Provider
196
+ * ONLY when it has a colour (ink/build/components/Box.js:27). `mono` has no background, so crossing it in /theme changed
197
+ * the tree's shape, React remounted the whole app, and its unmount closed the session controller (app.ts, the
198
+ * `controller.close()` cleanup): the conversation was lost. When the session paints the background the frame therefore
199
+ * ALWAYS has a background colour; for a theme without one it is a name chalk does not know, which Ink leaves unpainted
200
+ * (ink/build/colorize.js returns the text unchanged), so mono stays colourless and the shape never changes.
201
+ */
202
+ export const UNPAINTED_BACKGROUND = 'transparent';
194
203
  export function terminalFrameProps({ session, background, rows, columns, platform = process.platform, }) {
195
204
  if (session.mode === 'plain')
196
205
  return { flexDirection: 'column' };
@@ -200,6 +209,6 @@ export function terminalFrameProps({ session, background, rows, columns, platfor
200
209
  flexDirection: 'column',
201
210
  width,
202
211
  height,
203
- ...(session.paintBackground && background ? { backgroundColor: background } : {}),
212
+ ...(session.paintBackground ? { backgroundColor: background || UNPAINTED_BACKGROUND } : {}),
204
213
  };
205
214
  }
@@ -0,0 +1,104 @@
1
+ import { createTuiEventAdapter } from "./event-adapter.js";
2
+ /** The sessions below `rootId`, at every level, from a session list. */
3
+ export function descendantsOf(rows, rootId) {
4
+ const idOf = (row) => typeof row.sessionId === 'string' && row.sessionId ? row.sessionId : typeof row.id === 'string' && row.id ? row.id : null;
5
+ const below = new Set([rootId]), found = [];
6
+ for (let grew = true; grew;) {
7
+ grew = false;
8
+ for (const row of rows) {
9
+ const id = idOf(row);
10
+ if (!id || below.has(id) || typeof row.parentId !== 'string' || !below.has(row.parentId))
11
+ continue;
12
+ below.add(id);
13
+ grew = true;
14
+ found.push({ sessionId: id, task: typeof row.delegatedTask === 'string' && row.delegatedTask.trim() ? row.delegatedTask : null, depth: typeof row.depth === 'number' ? row.depth : 1 });
15
+ }
16
+ }
17
+ return found;
18
+ }
19
+ export function createDescendantApprovals({ runtime, secretValues = [], intervalMs = 500, onRequired, onResolved, onError = () => { } }) {
20
+ let rootId = null, timer = null, syncing = false, generation = 0;
21
+ const watched = new Map();
22
+ function watch(origin) {
23
+ const adapter = createTuiEventAdapter(secretValues);
24
+ adapter.bindSession(origin.sessionId);
25
+ /* The replay is synchronous inside `subscribe`: what it asks and answers is settled first, and only the requests still
26
+ waiting at its end are handed over. */
27
+ let replaying = true;
28
+ const waiting = new Map();
29
+ const handle = (event) => {
30
+ if (event.type === 'approval.required') {
31
+ if (replaying)
32
+ waiting.set(event.requestId, event);
33
+ else
34
+ onRequired(origin, event);
35
+ }
36
+ else if (event.type === 'approval.resolved') {
37
+ if (replaying)
38
+ waiting.delete(event.requestId);
39
+ else
40
+ onResolved(origin, event.requestId);
41
+ }
42
+ else if (replaying && (event.type === 'run.completed' || event.type === 'run.failed' || event.type === 'run.cancelled'))
43
+ waiting.clear();
44
+ };
45
+ let off = () => { };
46
+ try {
47
+ off = runtime.subscribe(origin.sessionId, raw => { for (const event of adapter.translateAll(raw))
48
+ handle(event); });
49
+ }
50
+ finally {
51
+ replaying = false;
52
+ }
53
+ watched.set(origin.sessionId, off);
54
+ for (const event of waiting.values())
55
+ onRequired(origin, event);
56
+ }
57
+ async function sync() {
58
+ if (!rootId || syncing)
59
+ return;
60
+ syncing = true;
61
+ const mine = generation;
62
+ try {
63
+ const rows = await runtime.listSessions();
64
+ if (mine !== generation || !rootId)
65
+ return;
66
+ for (const origin of descendantsOf(rows, rootId))
67
+ if (!watched.has(origin.sessionId))
68
+ watch(origin);
69
+ }
70
+ catch (error) {
71
+ onError(error);
72
+ }
73
+ finally {
74
+ syncing = false;
75
+ }
76
+ }
77
+ function stop() {
78
+ generation++;
79
+ rootId = null;
80
+ if (timer) {
81
+ clearInterval(timer);
82
+ timer = null;
83
+ }
84
+ for (const off of watched.values()) {
85
+ try {
86
+ off();
87
+ }
88
+ catch { /* an unsubscribe that fails leaves nothing to undo */ }
89
+ }
90
+ watched.clear();
91
+ }
92
+ /** Watches the descendants of `sessionId` until `stop`; a second call for the same session changes nothing. */
93
+ function start(sessionId) {
94
+ if (rootId === sessionId && timer)
95
+ return;
96
+ stop();
97
+ rootId = sessionId;
98
+ timer = setInterval(() => void sync(), Math.max(50, intervalMs));
99
+ timer.unref?.();
100
+ void sync();
101
+ }
102
+ /** `sync` asks the session list now (tests, and a delegation that has just started). */
103
+ return { start, stop, sync, watching: () => [...watched.keys()] };
104
+ }
@@ -49,6 +49,15 @@ function writeRecord(dataRoot, record) {
49
49
  catch { /* the temporary file was never created */ }
50
50
  }
51
51
  }
52
+ /** P17: whether this profile has never launched the interface, read without recording a launch (the trust question asks). */
53
+ export function isFirstLaunch(dataRoot) {
54
+ try {
55
+ return readRecord(launchStatePath(dataRoot)) === null;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
52
61
  export function bootPlan(input) {
53
62
  const previous = readRecord(launchStatePath(input.dataRoot));
54
63
  const build = typeof input.build === 'string' && input.build !== '' ? input.build : null;
@@ -1,6 +1,14 @@
1
1
  import { t } from "../i18n/index.js";
2
2
  import { toolDisplay } from "./tool-display.js";
3
- const ACTIVE_TOOL = new Set(['running', 'streaming']);
3
+ /*
4
+ * P14 (owner, 2026-09-25, "lo spinner thinking rimane in conteggio quando gli agenti vengono dispacciati"): a tool row is
5
+ * born `queued` (state.ts `tool.started`) and becomes `running` only after an approval, `streaming` only when it prints.
6
+ * A tool that runs silently without asking — the delegation, which waits for its sub-agents, a long read, a web fetch —
7
+ * stayed `queued`, was not counted as active, and the line said "thinking" with its clock running. `queued` is active
8
+ * now; and while delegations are running the line says how many agents are working.
9
+ */
10
+ const ACTIVE_TOOL = new Set(['queued', 'running', 'streaming']);
11
+ const DELEGATION_TOOLS = new Set(['delega_sottotask']);
4
12
  export function deriveLiveActivity(state, busySince) {
5
13
  if (!state.running) {
6
14
  if (typeof state.sentAt === 'number')
@@ -14,6 +22,9 @@ export function deriveLiveActivity(state, busySince) {
14
22
  /* R5a S4 (E5): a retry is its own state, never "thinking": attempt 2 and later of a request the provider refused. */
15
23
  if (isRetry(state.retry))
16
24
  return { kind: 'retrying', startedAt: state.retry.at };
25
+ const delegations = state.tools.filter(row => ACTIVE_TOOL.has(row.status) && row.name !== undefined && DELEGATION_TOOLS.has(row.name)).length;
26
+ if (delegations > 0)
27
+ return { kind: 'agents', startedAt: busySince, agents: delegations };
17
28
  const tool = [...state.tools].reverse().find(row => ACTIVE_TOOL.has(row.status));
18
29
  if (tool)
19
30
  return tool.name ? { kind: 'tool', startedAt: busySince, tool: toolDisplay(tool.name).label } : { kind: 'tool', startedAt: busySince };
@@ -41,5 +52,5 @@ export function retryLine(retry, nowMs) {
41
52
  /** Kinds that change while you look at them: the only ones that start the motion clock (idle writes nothing, R1 E5).
42
53
  * `retrying` is not one of them: its countdown ticks once a second, and only while the wait lasts (app.ts). */
43
54
  export function isAnimatedActivity(kind) {
44
- return kind === 'starting' || kind === 'thinking' || kind === 'writing' || kind === 'tool' || kind === 'waiting' || kind === 'compacting' || kind === 'connecting' || kind === 'updating';
55
+ return kind === 'starting' || kind === 'thinking' || kind === 'writing' || kind === 'tool' || kind === 'agents' || kind === 'waiting' || kind === 'compacting' || kind === 'connecting' || kind === 'updating';
45
56
  }
@@ -27,9 +27,13 @@ export function agentTreeOverlayLines(model, input) {
27
27
  const selected = selectedAgentRow(model);
28
28
  if (!selected)
29
29
  return [...lines, fit(t('screen.agents.empty'))];
30
- const detail = selected.id === input.currentSessionId && input.running
31
- ? t('screen.agents.steerHint')
32
- : t('screen.agents.inspectOnly');
30
+ /* P13: Enter watches the selected agent live; on the session in use it goes back to it (Claude Code's "Enter to view",
31
+ not offered on the agent already watched). */
32
+ const detail = selected.id === input.viewingId
33
+ ? t('screen.agents.watchingHint')
34
+ : selected.id === input.currentSessionId
35
+ ? (input.viewingId ? t('screen.agents.backHint') : input.running ? t('screen.agents.steerHint') : t('screen.agents.inspectOnly'))
36
+ : t('screen.agents.watchHint');
33
37
  lines.push(fit(detail));
34
38
  return lines;
35
39
  }
@@ -240,6 +240,8 @@ export function approvalDialogModel(pending, input) {
240
240
  const question = t(questionId in QUESTION_IDS ? questionId : 'approval.question');
241
241
  /* Q-R3-13 (R0 decision 4): a command approval on a host with no verified sandbox says so in its title. */
242
242
  const title = t('tool.approval.title', { label }) + (input.unsandboxed === true && action.tool === 'Bash' ? ` ${t('approval.unsandboxed')}` : '');
243
+ /* P13 (owner decision 8): an approval a sub-agent asks for says so, as Claude Code's does ("from a subagent"). */
244
+ const origin = pending.origin?.kind === 'agent' ? ` ${pending.origin.task ? t('approval.origin.agent', { task: pending.origin.task }) : t('approval.origin.agentUnnamed')}` : '';
243
245
  const change = approvalChange(pending);
244
246
  const ruleLine = pending.rule === PATH_BOUNDARY_RULE ? 'decided by the path boundary' : pending.rule ? `rule ${pending.rule}` : undefined;
245
247
  const explanation = pending.explanation;
@@ -253,7 +255,7 @@ export function approvalDialogModel(pending, input) {
253
255
  segments = rows;
254
256
  }
255
257
  return {
256
- title, summary, fullPayload, expanded: input.expanded, reason: pending.reason, ...(pending.rule ? { rule: pending.rule } : {}), ...(pending.runtimeHint ? { runtimeHint: pending.runtimeHint } : {}),
258
+ title: title + origin, summary, fullPayload, expanded: input.expanded, reason: pending.reason, ...(pending.rule ? { rule: pending.rule } : {}), ...(pending.runtimeHint ? { runtimeHint: pending.runtimeHint } : {}),
257
259
  ...(ruleLine ? { ruleLine } : {}), segments, ...(segmentsWithheld ? { segmentsWithheld } : {}), ...(pending.explanationError ? { explanationError: pending.explanationError } : {}),
258
260
  ...(input.expanded && pending.alwaysSimulation ? { alwaysPreview: alwaysPreview(pending, pending.alwaysSimulation) } : {}),
259
261
  question, selected, ...(change ? { change } : {}),
@@ -12,6 +12,7 @@ export function createModelPickerModel(catalog, input) {
12
12
  let verified = false;
13
13
  let notice = '';
14
14
  let label = input.provider ?? '';
15
+ let engine = null;
15
16
  let abort = null;
16
17
  return {
17
18
  async open() {
@@ -19,6 +20,7 @@ export function createModelPickerModel(catalog, input) {
19
20
  loadError = null;
20
21
  models = [];
21
22
  verified = false;
23
+ engine = null;
22
24
  if (!input.provider) {
23
25
  notice = t('screen.model.chooseProviderFirst');
24
26
  return;
@@ -33,6 +35,7 @@ export function createModelPickerModel(catalog, input) {
33
35
  verified = list.verified;
34
36
  notice = list.notice;
35
37
  label = list.label;
38
+ engine = list.engine ?? null;
36
39
  const current = input.current;
37
40
  if (current && current.startsWith(`${input.provider}:`) && !models.some(row => row.id === current)) {
38
41
  models = [...models, ...normalizeModels([{ id: current }], input.provider, 'configured')];
@@ -52,6 +55,8 @@ export function createModelPickerModel(catalog, input) {
52
55
  error: () => loadError,
53
56
  verified: () => verified,
54
57
  notice: () => notice,
58
+ /* P17: a local engine that did not answer (the setup offers R to retry). */
59
+ engineDown: () => engine === 'down',
55
60
  label: () => label,
56
61
  };
57
62
  }
@@ -133,6 +133,32 @@ export function providerConsentOverlay({ provider, choice }) {
133
133
  foot: [{ text: t('screen.provider.consentFoot'), tone: 'dim' }],
134
134
  };
135
135
  }
136
+ export function providerLoginChoiceOverlay({ label, choice, notice = null }) {
137
+ const options = [{ choice: 'browser', text: t('screen.provider.loginBrowser', { provider: label }) }, { choice: 'paste-key', text: t('screen.provider.loginPasteKey') }];
138
+ return {
139
+ head: [{ text: t('screen.provider.titleNamed', { provider: label }), tone: 'title' }],
140
+ /* A saved key that does not work now (benched) is said here too, before the person picks a way. */
141
+ sections: [{ lines: [...(notice ? [{ text: notice, tone: 'plain' }] : []), ...options.map(option => ({ text: `${option.choice === choice ? '›' : ' '} ${option.text}`, tone: option.choice === choice ? 'selected' : 'accent' }))], selected: (notice ? 1 : 0) + Math.max(0, options.findIndex(option => option.choice === choice)) }],
142
+ foot: [{ text: t('screen.provider.loginChoiceKeys'), tone: 'dim' }],
143
+ };
144
+ }
145
+ /* The sign-in under way: the address (always shown, the browser may not open), then the code the person may paste. Only the
146
+ MASK of a pasted code reaches this view. */
147
+ export function providerOAuthOverlay({ label, url, mode, opened, phase, masked, message }) {
148
+ const lines = [];
149
+ if (mode === 'paste')
150
+ lines.push(t('screen.provider.oauthRemote', { provider: label }));
151
+ else
152
+ lines.push(t(opened ? 'screen.provider.oauthOpened' : 'screen.provider.oauthNotOpened', { provider: label }));
153
+ lines.push(url);
154
+ const typing = phase === 'paste' || (mode === 'paste' && phase !== 'failed');
155
+ if (typing)
156
+ lines.push(t('screen.provider.oauthPaste', { masked: masked || ' ' }));
157
+ if (message)
158
+ lines.push(message);
159
+ const foot = phase === 'exchanging' ? t('screen.provider.oauthExchanging') : phase === 'failed' ? t('screen.provider.oauthFailedKeys') : typing ? t('screen.provider.oauthPasteKeys') : t('screen.provider.oauthWaiting');
160
+ return { head: [{ text: t('screen.provider.titleNamed', { provider: label }), tone: 'title' }], sections: [{ lines: lines.map(text => ({ text, tone: 'plain' })), selected: typing ? lines.findIndex(line => line.startsWith(t('screen.provider.oauthPaste', { masked: '' }).trim())) : 0 }], foot: [{ text: foot, tone: 'dim' }] };
161
+ }
136
162
  // Only the MASK reaches this view, never the typed key. The key line is the "selected" row: it is where the person types.
137
163
  export function providerKeyOverlay({ label, remediation, benched, masked, keyTestNotice, testing, result }) {
138
164
  const lines = [...(remediation ? [remediation] : []), ...(benched ? [benched] : [])];
@@ -4,6 +4,7 @@ import { detectTerminalCapabilities } from "./terminal-capabilities.js";
4
4
  import { createTerminalSessionPlan } from "./terminal-session.js";
5
5
  import { displayWidth, truncateDisplay, truncateMiddle, wrapDisplay } from "./text-width.js";
6
6
  import { liveTheme } from "./theme-store.js";
7
+ import { DEFAULT_THEME_ACCENT } from "./theme-catalog.js";
7
8
  export const PROJECT_TRUST_GRACE_MS = 300;
8
9
  /* E4: paths are shown as the person types them. Control, C1, line/paragraph separators, bidi controls and
9
10
  default-ignorable characters are shown as `\uXXXX`; a backslash is doubled only where it would read as such an escape
@@ -38,7 +39,7 @@ function resourceLine(resource) {
38
39
  const where = inertProjectTrustText(resource.relativePath);
39
40
  return resource.id === resource.relativePath ? `${resource.kind} · ${where}` : `${resource.kind} · ${inertProjectTrustText(resource.id)} · ${where}`;
40
41
  }
41
- export function projectTrustPromptModel({ assessment, nestedRepositories }) {
42
+ export function projectTrustPromptModel({ assessment, nestedRepositories, setupStep }) {
42
43
  const counts = new Map();
43
44
  for (const resource of assessment.resources)
44
45
  counts.set(resource.kind, (counts.get(resource.kind) ?? 0) + 1);
@@ -55,6 +56,7 @@ export function projectTrustPromptModel({ assessment, nestedRepositories }) {
55
56
  const firstTime = assessment.trusted || assessment.reason === 'PROJECT_UNTRUSTED';
56
57
  return {
57
58
  title: t(firstTime ? 'firstrun.trust.title' : 'firstrun.trust.titleChanged'),
59
+ ...(setupStep ? { setupStep: true } : {}),
58
60
  project: inertProjectTrustText(assessment.workspace.canonicalRoot),
59
61
  reason: reasonText(assessment),
60
62
  resourceSummary: assessment.resources.length ? tn('firstrun.trust.files', assessment.resources.length, { detail }) : '',
@@ -118,7 +120,7 @@ export function layoutProjectTrustPrompt(model, { columns, rows, selected, platf
118
120
  }
119
121
  return { kind: 'choice', text: truncateDisplay(`${choice.id === selected ? '>' : ' '} ${label}`, width), selected: choice.id === selected };
120
122
  });
121
- const title = truncateDisplay(t('firstrun.trust.heading', { title: model.title }), width);
123
+ const title = truncateDisplay(t(model.setupStep ? 'firstrun.setup.trustHeading' : 'firstrun.trust.heading', { title: model.title }), width);
122
124
  let path = wrapDisplay(model.project, width);
123
125
  let reason = wrapWords(model.reason, width);
124
126
  let summary = model.resourceSummary ? wrapWords(model.resourceSummary, width) : [];
@@ -209,7 +211,7 @@ export async function runProjectTrustPrompt(input, io = {}) {
209
211
  }
210
212
  const capabilities = detectTerminalCapabilities({ stdinIsTTY: true, stdoutIsTTY: true, color: input.color !== false, env });
211
213
  const session = createTerminalSessionPlan({ capabilities, env });
212
- const theme = liveTheme(capabilities, input.theme ?? 'calm').theme;
214
+ const theme = liveTheme(capabilities, input.theme ?? DEFAULT_THEME_ACCENT).theme;
213
215
  const model = projectTrustPromptModel(input);
214
216
  const ids = model.choices.map(choice => choice.id);
215
217
  const sizeStore = createTerminalSizeStore(stdout, { rows: 24, columns: 80 });
@@ -3,6 +3,7 @@ import { buildAgentTree } from "../runtime/agent-tree.js";
3
3
  import { createPermissionEngine } from "../security/permission-engine.js";
4
4
  import { createApprovalCoordinator, decisionFromChoice, decisionFromSource } from "./approval.js";
5
5
  import { createTuiEventAdapter, denialFromEngine, isolationOf } from "./event-adapter.js";
6
+ import { createDescendantApprovals } from "./descendant-approvals.js";
6
7
  import { simulatePermission } from "../security/permission-explanation.js";
7
8
  import { KERNEL_RUN_TEXT } from "../i18n/kernel-map.js";
8
9
  import { permissionActionFromApproval } from "../security/from-approval.js";
@@ -33,6 +34,11 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
33
34
  beforeDeny: (rid, decision) => { const target = denialTargets.get(rid); if (target)
34
35
  adapter.noteDenial(target.toolCallId, denialFromEngine(decision, { rule: () => simulatePermission({ rules, action: target.action, projectRoot }).explanation.rule ?? null })); } });
35
36
  const adapter = createTuiEventAdapter(secretValuesFromEnvironment(process.env));
37
+ /* P13: the approvals the current run's sub-agents ask for (descendant-approvals.ts), watched while that run is open. */
38
+ const descendants = createDescendantApprovals({ runtime, secretValues: secretValuesFromEnvironment(process.env),
39
+ onRequired: (origin, event) => requestApproval(origin.sessionId, event, origin),
40
+ onResolved: (_origin, requestId) => approvalResolved(requestId),
41
+ onError: error => developmentLogError('descendants.failure', error, { sessionId: current }, 'tui-controller') });
36
42
  let unsubscribe = () => { };
37
43
  let closed = false;
38
44
  let current = null;
@@ -143,10 +149,13 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
143
149
  openRun = event.runId ? { runId: event.runId } : {};
144
150
  if (replayingHistory)
145
151
  event = { ...event, history: true };
152
+ else
153
+ descendants.start(id);
146
154
  }
147
155
  else if (event.type === 'run.completed' || event.type === 'run.failed' || event.type === 'run.cancelled') {
148
156
  openRun = null;
149
157
  runEpoch++;
158
+ descendants.stop();
150
159
  }
151
160
  onEvent(event);
152
161
  bypassWhy(event);
@@ -161,14 +170,8 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
161
170
  else
162
171
  requestApproval(id, event);
163
172
  }
164
- else if (event.type === 'approval.resolved') {
165
- replayedApprovals.delete(event.requestId);
166
- approvalTools.delete(event.requestId);
167
- answeredApprovals.add(event.requestId);
168
- if (answeredApprovals.size > 256)
169
- answeredApprovals.delete(answeredApprovals.values().next().value);
170
- onApproval?.(null);
171
- }
173
+ else if (event.type === 'approval.resolved')
174
+ approvalResolved(event.requestId);
172
175
  else if (allowDrain && (event.type === 'run.completed' || event.type === 'run.failed') && !pendingSteer && !cancelling)
173
176
  requestQueuedDrain(id);
174
177
  }
@@ -214,7 +217,15 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
214
217
  const replayedApprovals = new Map();
215
218
  const answeredApprovals = new Set();
216
219
  const approvalTools = new Map();
217
- function requestApproval(id, event) {
220
+ function approvalResolved(requestId) {
221
+ replayedApprovals.delete(requestId);
222
+ approvalTools.delete(requestId);
223
+ answeredApprovals.add(requestId);
224
+ if (answeredApprovals.size > 256)
225
+ answeredApprovals.delete(answeredApprovals.values().next().value);
226
+ onApproval?.(null);
227
+ }
228
+ function requestApproval(id, event, origin) {
218
229
  const action = permissionActionFromApproval(event.payload);
219
230
  const epoch = runEpoch;
220
231
  answeredApprovals.delete(event.requestId);
@@ -222,12 +233,12 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
222
233
  denialTargets.set(event.requestId, { toolCallId: event.toolCallId, action });
223
234
  void coordinator.request(id, event.requestId, action).finally(() => denialTargets.delete(event.requestId)).then(result => {
224
235
  if (result.pending) {
225
- if (answeredApprovals.has(event.requestId) || epoch !== runEpoch || current !== id)
236
+ if (answeredApprovals.has(event.requestId) || epoch !== runEpoch || (origin ? !descendants.watching().includes(id) : current !== id))
226
237
  return;
227
238
  if (event.toolCallId)
228
239
  approvalTools.set(event.requestId, event.toolCallId);
229
240
  const runtimeHint = event.payload.trifecta === true ? 'trifecta' : undefined;
230
- onApproval?.({ requestId: event.requestId, sessionId: id, action: result.action, rawPayload: event.payload, reason: result.reason, ...(result.rule ? { rule: result.rule } : {}), ...(runtimeHint ? { runtimeHint } : {}), ...explainApproval({ rules, action: result.action, projectRoot, persists: Boolean(paths) }) });
241
+ onApproval?.({ requestId: event.requestId, sessionId: id, ...(origin ? { origin: { kind: 'agent', task: origin.task } } : {}), action: result.action, rawPayload: event.payload, reason: result.reason, ...(result.rule ? { rule: result.rule } : {}), ...(runtimeHint ? { runtimeHint } : {}), ...explainApproval({ rules, action: result.action, projectRoot, persists: Boolean(paths) }) });
231
242
  }
232
243
  else {
233
244
  /* R3 gate E4 (Q-R3-3): why the engine let it run, on the tool's row. */
@@ -524,6 +535,7 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
524
535
  let openRun = null;
525
536
  function attach(id, from = 0, history = false) {
526
537
  unsubscribe();
538
+ descendants.stop();
527
539
  adapter.bindSession(id);
528
540
  current = id;
529
541
  openRun = null;
@@ -564,6 +576,7 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
564
576
  if (current === id && openRun === open && outcome === 'running') {
565
577
  for (const event of waiting)
566
578
  requestApproval(id, event);
579
+ descendants.start(id);
567
580
  return;
568
581
  }
569
582
  if (current !== id || openRun !== open || typeof outcome !== 'string' || outcome === 'running' || outcome === 'unknown')
@@ -774,5 +787,5 @@ export function createTuiSessionController({ runtime, projectRoot, model, mode,
774
787
  finally {
775
788
  cancelling = false;
776
789
  } }, close() { if (closed)
777
- return; closed = true; developmentLog('controller.close', { sessionId: current, queueCount: queuedEntries.length }, 'info', 'tui-controller'); unsubscribe(); } };
790
+ return; closed = true; developmentLog('controller.close', { sessionId: current, queueCount: queuedEntries.length }, 'info', 'tui-controller'); descendants.stop(); unsubscribe(); } };
778
791
  }
@@ -0,0 +1,46 @@
1
+ /*
2
+ * P17 (owner, 2026-09-25: "mini procedura guidata semplicissima al fresh install"; decisions of 2026-09-26). The first launch
3
+ * chains the windows that already exist: trust (the screen before anything loads) → provider → key or local engine → model,
4
+ * one window at a time with "Step N of 4"; Enter goes on, Esc skips the step, Ctrl+C quits, and what was done stays saved.
5
+ * Only the missing steps run: a key already usable (from the environment too) or a local engine that answers skips them; a
6
+ * credential that exists but is benched is explained by the first frame's line, not asked again (Hermes #113720).
7
+ * Read on 2026-09-26: Claude Code (welcome, theme, login, security notes, terminal setup, trust), Codex (welcome, auth,
8
+ * trust), Hermes (provider + model through the same picker as `hermes model`, skippable, `hermes setup` to redo), Pi (theme
9
+ * and analytics, providers later), OpenCode (no wizard), Gemini CLI (theme, then auth). `/setup` redoes it (Hermes'
10
+ * `hermes setup`). Without an interactive terminal it never runs (clig.dev: "never require a prompt").
11
+ */
12
+ import { t } from "../i18n/index.js";
13
+ export const SETUP_TOTAL = 4;
14
+ const INDEX = { trust: 1, provider: 2, key: 3, model: 4 };
15
+ const NAME = { trust: 'firstrun.setup.stepName.trust', provider: 'firstrun.setup.stepName.provider', key: 'firstrun.setup.stepName.key', model: 'firstrun.setup.stepName.model' };
16
+ /** The line that tells where the person is in the setup; `escSkips:false` when Esc does something else there. */
17
+ export function setupStepLine(step, { escSkips = true } = {}) {
18
+ return t(escSkips ? 'firstrun.setup.stepLine' : 'firstrun.setup.stepLineOnly', { index: INDEX[step], total: SETUP_TOTAL, name: t(NAME[step]) });
19
+ }
20
+ /** The same window, with the setup's step on top (the window's own title stays under it). */
21
+ export function setupOverlay(content, step, options = {}) {
22
+ return { ...content, head: [{ text: setupStepLine(step, options), tone: 'title' }, ...(content.head ?? [])] };
23
+ }
24
+ /**
25
+ * Whether the first launch runs the setup, and from which step. `providerUsable`: the chosen provider has a key usable now
26
+ * (from the environment too) or needs none; `keyBenched`: it has a key that does not work now (explained, not asked again).
27
+ */
28
+ export function setupStart(input) {
29
+ if (!input.firstLaunch)
30
+ return { start: false };
31
+ if (!input.providerChosen)
32
+ return { start: true, from: 'provider' };
33
+ if (input.keyBenched)
34
+ return { start: false };
35
+ if (!input.providerUsable)
36
+ return { start: true, from: 'key' };
37
+ if (!input.modelChosen)
38
+ return { start: true, from: 'model' };
39
+ return { start: false };
40
+ }
41
+ /** What the setup says when it ends: ready with the model, or where to pick it up again. */
42
+ export function setupEndLines(outcome) {
43
+ if (!outcome.ready)
44
+ return [t('firstrun.setup.skipped')];
45
+ return [t('firstrun.setup.ready', { model: outcome.model }), t('firstrun.setup.readyHints')];
46
+ }
@@ -2,6 +2,8 @@ import { t } from "../i18n/index.js";
2
2
  const SLASH_COMMAND_META = [
3
3
  { name: 'help', usage: '' },
4
4
  { name: 'provider', usage: '' },
5
+ /* P17 (owner decision 4): the first launch's guided setup, again. */
6
+ { name: 'setup', usage: '' },
5
7
  { name: 'model', usage: '[provider:model]' },
6
8
  /* R5a (owner 2026-09-24): the reasoning effort of the current model, as a line of nodes. */
7
9
  { name: 'effort', usage: '[level]' },
@@ -55,7 +57,7 @@ export const SLASH_COMMANDS = SLASH_COMMAND_META.map(row => ({
55
57
  const SLASH_GROUPS = ['session', 'turn', 'view', 'work', 'extensions'];
56
58
  const GROUP_OF = {
57
59
  resume: 'session', fork: 'session', rename: 'session', export: 'session', undo: 'session', history: 'session', clear: 'session', compact: 'session', exit: 'session',
58
- provider: 'turn', model: 'turn', effort: 'turn', busy: 'turn', queue: 'turn', retry: 'turn', plan: 'turn', permissions: 'turn',
60
+ provider: 'turn', setup: 'turn', model: 'turn', effort: 'turn', busy: 'turn', queue: 'turn', retry: 'turn', plan: 'turn', permissions: 'turn',
59
61
  help: 'view', status: 'view', context: 'view', usage: 'view', diff: 'view', copy: 'view', theme: 'view', redraw: 'view',
60
62
  memory: 'work', notes: 'work', tasks: 'work', library: 'work', research: 'work', automations: 'work', forge: 'work',
61
63
  mcp: 'extensions', hooks: 'extensions', plugins: 'extensions', doctor: 'extensions',
@@ -6,7 +6,9 @@ export function mixHex(a, amount, b) {
6
6
  return `#${x.map((v, i) => Math.round(v * t + y[i] * (1 - t)).toString(16).padStart(2, '0')).join('')}`;
7
7
  }
8
8
  const theme = (id, label, accent, background) => ({ id, label, accent, background, muted: mixHex(accent, 62, background) });
9
- export const DEFAULT_THEME_ACCENT = 'calm';
9
+ /* P15 (owner, 2026-09-25, "vorrei mettere forge come tema principale di default"): Forge is the default from 0.2.0. A theme
10
+ the person chose stays theirs (it is in their configuration); the legacy migrations keep mapping to what they mapped to. */
11
+ export const DEFAULT_THEME_ACCENT = 'forge';
10
12
  export const TALOS_THEME_ACCENTS = Object.freeze([
11
13
  theme('calm', 'Calm', '#c08b3c', '#1e1f22'),
12
14
  theme('forge', 'Forge', '#c98b32', '#080b11'),
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const CLI_VERSION = '0.1.0';
1
+ export const CLI_VERSION = '0.2.1';
2
2
  export const TALOS_BASE_COMMIT = '0c432153a288f64237e98403869d02d20d8fabc7';
@@ -60,6 +60,24 @@ function parseRecord(text, expectedRoot) {
60
60
  throw coded('CHECKPOINT_STORE_INVALID');
61
61
  return value;
62
62
  }
63
+ const STAT_INDEX_SCHEMA = 'talos.cli.checkpoint-stat-index.v1';
64
+ async function loadStatIndex(indexPath) {
65
+ const rows = new Map();
66
+ try {
67
+ const value = JSON.parse(await readFile(indexPath, 'utf8'));
68
+ if (value?.schema !== STAT_INDEX_SCHEMA || !Array.isArray(value.rows))
69
+ return rows;
70
+ for (const row of value.rows) {
71
+ if (!Array.isArray(row) || row.length !== 6)
72
+ continue;
73
+ const [p, size, mtimeMs, ctimeMs, ref, verifiedAtMs] = row;
74
+ if (typeof p === 'string' && typeof ref === 'string' && /^[0-9a-f]{64}$/u.test(ref) && [size, mtimeMs, ctimeMs, verifiedAtMs].every(n => typeof n === 'number' && Number.isFinite(n)))
75
+ rows.set(p, { size, mtimeMs, ctimeMs, ref, verifiedAtMs });
76
+ }
77
+ }
78
+ catch { /* missing or torn: an empty index, every file is read again */ }
79
+ return rows;
80
+ }
63
81
  function dedupeEvidence(rows) { const byKey = new Map(); for (const row of rows)
64
82
  byKey.set(`${row.path}\0${row.beforeHash ?? ''}\0${row.afterHash ?? ''}`, row); return [...byKey.values()]; }
65
83
  export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOINT_LIMITS }) {
@@ -85,7 +103,20 @@ export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOIN
85
103
  } }
86
104
  async function replace(record) { const x = await init(); if (record.canonicalRoot !== x.canonicalRoot)
87
105
  throw coded('CHECKPOINT_WORKSPACE_MISMATCH'); await atomicWrite(await recordPath(record.id), `${JSON.stringify(record)}\n`); }
88
- async function capture() { const x = await init(); return captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits }); }
106
+ /* P18: the stat index of the last capture (checkpoint.ts StatCache), kept in memory and in the workspace's folder so a new
107
+ `talos -p` process starts from it too. It is only a cache: unreadable means empty (every file read again), and a
108
+ failed save costs speed on the next turn, never a checkpoint. */
109
+ let statRows = null;
110
+ async function capture() {
111
+ const x = await init();
112
+ const indexPath = path.join(x.workspaceDir, 'stat-index.json');
113
+ statRows ??= await loadStatIndex(indexPath);
114
+ const next = new Map();
115
+ const snapshot = await captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits, statCache: { previous: statRows, next } });
116
+ statRows = next;
117
+ await atomicWrite(indexPath, `${JSON.stringify({ schema: STAT_INDEX_SCHEMA, rows: [...next].map(([p, r]) => [p, r.size, r.mtimeMs, r.ctimeMs, r.ref, r.verifiedAtMs]) })}\n`).catch(() => { });
118
+ return snapshot;
119
+ }
89
120
  async function list() { const x = await init(); let names = []; try {
90
121
  names = await readdir(x.recordsDir);
91
122
  }