@aragon-agent/cli 0.6.12 → 0.6.14

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 (119) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -31
  3. package/dist/agent/controller.d.ts +38 -2
  4. package/dist/agent/controller.js +185 -34
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +208 -0
  7. package/dist/agent/queued-messages.d.ts +12 -0
  8. package/dist/agent/queued-messages.js +18 -0
  9. package/dist/agent/reducer.d.ts +13 -1
  10. package/dist/agent/reducer.js +52 -12
  11. package/dist/boot/guard.d.ts +3 -3
  12. package/dist/boot/guard.js +3 -3
  13. package/dist/cli.js +48 -16
  14. package/dist/commands/builtins.js +66 -39
  15. package/dist/commands/queue-command.d.ts +3 -0
  16. package/dist/commands/queue-command.js +5 -0
  17. package/dist/commands/registry.d.ts +3 -0
  18. package/dist/compaction/child.d.ts +4 -2
  19. package/dist/compaction/child.js +8 -1
  20. package/dist/compaction/compactor.d.ts +7 -2
  21. package/dist/compaction/compactor.js +21 -16
  22. package/dist/compaction/context-command.js +10 -3
  23. package/dist/compaction/meter.js +2 -0
  24. package/dist/compaction/pressure.d.ts +1 -0
  25. package/dist/compaction/pressure.js +1 -0
  26. package/dist/compaction/types.d.ts +2 -0
  27. package/dist/compaction/wiring.d.ts +4 -3
  28. package/dist/compaction/wiring.js +10 -2
  29. package/dist/config/cli-commands.d.ts +2 -0
  30. package/dist/config/cli-commands.js +16 -4
  31. package/dist/config/load.d.ts +5 -2
  32. package/dist/config/load.js +23 -12
  33. package/dist/config/model-profile-resolution.d.ts +20 -0
  34. package/dist/config/model-profile-resolution.js +81 -0
  35. package/dist/config/model-profile-store.d.ts +67 -0
  36. package/dist/config/model-profile-store.js +153 -0
  37. package/dist/config/model-profiles.d.ts +44 -0
  38. package/dist/config/model-profiles.js +121 -0
  39. package/dist/config/schema.d.ts +11 -2
  40. package/dist/config/store.d.ts +11 -1
  41. package/dist/config/store.js +43 -27
  42. package/dist/fast/resolve.d.ts +2 -1
  43. package/dist/fast/resolve.js +6 -4
  44. package/dist/fast/wiring.d.ts +5 -3
  45. package/dist/fast/wiring.js +8 -4
  46. package/dist/input/stdin-filter.js +3 -2
  47. package/dist/logging/install.js +4 -2
  48. package/dist/logging/secret-registry.d.ts +2 -0
  49. package/dist/logging/secret-registry.js +18 -0
  50. package/dist/team/runtime.d.ts +2 -1
  51. package/dist/team/subagent.d.ts +3 -1
  52. package/dist/team/subagent.js +3 -1
  53. package/dist/ui/ActivityLine.d.ts +6 -70
  54. package/dist/ui/ActivityLine.js +12 -34
  55. package/dist/ui/App.d.ts +1 -0
  56. package/dist/ui/App.js +116 -215
  57. package/dist/ui/BottomStatusRow.d.ts +6 -86
  58. package/dist/ui/BottomStatusRow.js +11 -26
  59. package/dist/ui/Composer.d.ts +16 -83
  60. package/dist/ui/Composer.js +9 -100
  61. package/dist/ui/Logo.d.ts +3 -5
  62. package/dist/ui/Logo.js +4 -8
  63. package/dist/ui/PromptInput.d.ts +4 -1
  64. package/dist/ui/PromptInput.js +44 -35
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +2 -0
  68. package/dist/ui/QueueStatusRow.js +1 -1
  69. package/dist/ui/StatusBar.d.ts +13 -149
  70. package/dist/ui/StatusBar.js +27 -142
  71. package/dist/ui/capabilities.d.ts +2 -0
  72. package/dist/ui/capabilities.js +6 -0
  73. package/dist/ui/clipboard-task.d.ts +29 -0
  74. package/dist/ui/clipboard-task.js +66 -0
  75. package/dist/ui/clipboard.d.ts +29 -50
  76. package/dist/ui/clipboard.js +141 -75
  77. package/dist/ui/color-runtime.d.ts +5 -0
  78. package/dist/ui/color-runtime.js +29 -0
  79. package/dist/ui/composer-limits.d.ts +3 -4
  80. package/dist/ui/composer-limits.js +4 -9
  81. package/dist/ui/composer-rows.d.ts +1 -1
  82. package/dist/ui/composer-rows.js +24 -137
  83. package/dist/ui/editor-navigation.d.ts +45 -0
  84. package/dist/ui/editor-navigation.js +85 -0
  85. package/dist/ui/editor-reducer.d.ts +4 -0
  86. package/dist/ui/editor-reducer.js +29 -15
  87. package/dist/ui/glyphs.js +10 -8
  88. package/dist/ui/ink-runtime.d.ts +3 -0
  89. package/dist/ui/ink-runtime.js +30 -0
  90. package/dist/ui/interaction-copy.d.ts +77 -0
  91. package/dist/ui/interaction-copy.js +40 -0
  92. package/dist/ui/interaction-hints.d.ts +18 -0
  93. package/dist/ui/interaction-hints.js +92 -0
  94. package/dist/ui/layout/budget.js +1 -1
  95. package/dist/ui/layout/queue-layout.d.ts +19 -0
  96. package/dist/ui/layout/queue-layout.js +26 -0
  97. package/dist/ui/layout/status-layout.d.ts +55 -0
  98. package/dist/ui/layout/status-layout.js +110 -0
  99. package/dist/ui/layout/todo-layout.d.ts +1 -0
  100. package/dist/ui/layout/todo-layout.js +1 -1
  101. package/dist/ui/overlays/HelpOverlay.js +8 -8
  102. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  103. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  104. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  105. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  106. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  107. package/dist/ui/overlays/QueueOverlay.js +79 -0
  108. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  109. package/dist/ui/overlays/SettingsScreen.js +347 -3
  110. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  111. package/dist/ui/overlays/model-profile-state.js +119 -0
  112. package/dist/ui/render-cache.d.ts +2 -2
  113. package/dist/ui/render-cache.js +4 -3
  114. package/dist/ui/run-status-row.d.ts +7 -19
  115. package/dist/ui/run-status-row.js +2 -18
  116. package/dist/ui/selection/selection-controller.d.ts +2 -2
  117. package/dist/ui/selection/selection-controller.js +1 -0
  118. package/dist/ui/use-wheel-routing.js +2 -1
  119. package/package.json +2 -2
@@ -0,0 +1,208 @@
1
+ import { loadConfig } from '../config/load.js';
2
+ import { DEFAULT_CONFIG } from '../config/schema.js';
3
+ import { mergePersistedConfig } from '../config/store.js';
4
+ import { adaptLegacyConnectionPatch, assertLegacyFastTarget, commitModelSettings, readModelSettingsDisk, } from '../config/model-profile-store.js';
5
+ import { projectModelProfiles, resolveModelProfileState } from '../config/model-profile-resolution.js';
6
+ import { validateModelProfiles } from '../config/model-profiles.js';
7
+ import { registerProfileSecrets } from '../logging/secret-registry.js';
8
+ /** Capture both baselines when the editor opens, without migrating or writing anything. */
9
+ export function createModelSettingsDraft(live, liveRevision) {
10
+ const disk = readModelSettingsDisk();
11
+ return {
12
+ diskRevision: disk.revision, liveRevision,
13
+ baseline: structuredClone(disk.config ?? DEFAULT_CONFIG),
14
+ liveBaseline: structuredClone(live),
15
+ profiles: disk.config?.modelProfiles
16
+ ? structuredClone(disk.config.modelProfiles) : undefined,
17
+ activateRoles: [], patch: {},
18
+ ...(!disk.ok ? { readError: 'Cannot read config. Repair it with aragon config edit.' } : {}),
19
+ };
20
+ }
21
+ /** Map stable failure codes to safe UI text; never include parser or SDK error messages. */
22
+ export function modelSettingsResultMessage(result) {
23
+ if (result.status === 'saved_apply_failed') {
24
+ return result.persisted
25
+ ? 'Saved, but this session could not apply all settings. Restart required.'
26
+ : 'Could not apply the reloaded settings completely. Restart required.';
27
+ }
28
+ if (result.status === 'restart_required') {
29
+ return 'Saved. Fast tier requires a restart; check its model and credentials.';
30
+ }
31
+ const messages = {
32
+ busy: 'Wait for the run, team, review, or compaction to finish before saving.',
33
+ conflict: 'Settings changed elsewhere. Reload before saving.',
34
+ read_failed: 'Cannot read config. Repair it with aragon config edit.',
35
+ write_failed: 'Could not write config. Your draft is preserved.',
36
+ invalid: 'Invalid model settings. Check the highlighted field or aragon config edit.',
37
+ profile_key_required: 'This profile has a dedicated key. Edit or copy it in /settings.',
38
+ legacy_conversion_unsupported: 'This endpoint needs a profile. Edit it in /settings.',
39
+ };
40
+ return result.ok ? 'Settings saved.' : messages[result.code ?? 'invalid'];
41
+ }
42
+ function rejected(code) {
43
+ return { ok: false, persisted: false, status: 'rejected', code };
44
+ }
45
+ function connectionChanged(draft, role) {
46
+ if (draft.profiles === undefined)
47
+ return false;
48
+ const before = draft.baseline.modelProfiles;
49
+ const after = draft.profiles;
50
+ if ((before?.[`${role}Id`] ?? null) !== (after?.[`${role}Id`] ?? null))
51
+ return true;
52
+ const id = after?.[`${role}Id`];
53
+ if (!id)
54
+ return false;
55
+ const a = before?.entries.find((entry) => entry.id === id);
56
+ const b = after?.entries.find((entry) => entry.id === id);
57
+ return ['provider', 'model', 'baseUrl', 'apiKey']
58
+ .some((key) => a?.[key] !== b?.[key]);
59
+ }
60
+ function hasConnectionPatch(patch, role) {
61
+ const section = role === 'main' ? patch : patch.fast;
62
+ return !!section && ['provider', 'model', 'baseUrl'].some((key) => key in section);
63
+ }
64
+ function liveCandidate(live, candidate, draft) {
65
+ const patch = draft.patch;
66
+ const normalizedPatch = {};
67
+ for (const key of Object.keys(patch)) {
68
+ normalizedPatch[key] = candidate[key];
69
+ }
70
+ const next = { ...live, ...normalizedPatch };
71
+ for (const section of ['apiKeys', 'fast', 'compaction', 'log']) {
72
+ const normalized = { ...live[section] };
73
+ for (const key of Object.keys(patch[section] ?? {})) {
74
+ normalized[key] = candidate[section][key];
75
+ }
76
+ Object.assign(next, { [section]: normalized });
77
+ }
78
+ if (patch.apiKeys && Object.hasOwn(patch.apiKeys, live.provider)) {
79
+ next.apiKeyOverride = undefined;
80
+ next.apiKeyOverrideTarget = undefined;
81
+ }
82
+ // These fields have intentionally different disk and runtime representations.
83
+ if ('maxTokens' in patch)
84
+ next.maxTokens = candidate.maxTokens ?? undefined;
85
+ if ('baseUrl' in patch)
86
+ next.baseUrl = candidate.baseUrl ?? undefined;
87
+ const projected = projectModelProfiles(candidate).file;
88
+ if (draft.profiles !== undefined || candidate.modelProfiles !== undefined) {
89
+ next.modelProfiles = structuredClone(projected.modelProfiles);
90
+ }
91
+ next.liveToolOutput = live.liveToolOutput;
92
+ for (const role of ['main', 'fast']) {
93
+ if (!draft.activateRoles.includes(role) && !connectionChanged(draft, role)
94
+ && !hasConnectionPatch(patch, role))
95
+ continue;
96
+ if (role === 'main') {
97
+ next.provider = projected.provider;
98
+ next.model = projected.model;
99
+ next.baseUrl = projected.baseUrl ?? undefined;
100
+ next.apiKeyOverride = undefined;
101
+ next.apiKeyOverrideTarget = undefined;
102
+ }
103
+ else {
104
+ next.fast = { ...next.fast, provider: projected.fast.provider,
105
+ model: projected.fast.model, baseUrl: projected.fast.baseUrl };
106
+ }
107
+ }
108
+ if (next.apiKeyOverride && !next.apiKeyOverrideTarget
109
+ && (next.modelProfiles?.mainId || next.modelProfiles?.fastId)) {
110
+ next.apiKeyOverrideTarget = { provider: live.provider, baseUrl: live.baseUrl ?? null };
111
+ }
112
+ next.modelProfileState = resolveModelProfileState(next);
113
+ return next;
114
+ }
115
+ /** Validate and prepare everything before the one synchronous, atomic disk commit. */
116
+ export function saveModelSettings(input) {
117
+ const { draft, controller } = input;
118
+ if (controller.isModelSettingsBusy())
119
+ return rejected('busy');
120
+ if (draft.liveRevision !== controller.getSettingsRevision())
121
+ return rejected('conflict');
122
+ const disk = readModelSettingsDisk();
123
+ if (!disk.ok || draft.readError)
124
+ return rejected('read_failed');
125
+ if (disk.revision !== draft.diskRevision)
126
+ return rejected('conflict');
127
+ const issues = validateModelProfiles(draft.profiles ?? disk.config?.modelProfiles);
128
+ if (issues.length)
129
+ return { ...rejected('invalid'), issues };
130
+ let snapshot;
131
+ let candidate;
132
+ try {
133
+ const current = disk.config ?? structuredClone(DEFAULT_CONFIG);
134
+ const profilesChanged = draft.activateRoles.length > 0
135
+ || JSON.stringify(draft.profiles) !== JSON.stringify(draft.baseline.modelProfiles);
136
+ const implicitLegacy = draft.profiles === undefined || !profilesChanged;
137
+ const patch = adaptLegacyConnectionPatch({ current, patch: implicitLegacy
138
+ ? draft.patch : { ...draft.patch, modelProfiles: draft.profiles } });
139
+ candidate = mergePersistedConfig(current, patch);
140
+ registerProfileSecrets(candidate);
141
+ const next = liveCandidate(controller.getConfig(), candidate, draft);
142
+ // Disk preferences cannot reveal startup/session gateways retained by the live main role.
143
+ if (implicitLegacy && current.modelProfiles?.fastId && hasConnectionPatch(draft.patch, 'fast')) {
144
+ assertLegacyFastTarget(next.fast, next);
145
+ }
146
+ snapshot = controller.prepareModelSettingsSnapshot(next);
147
+ }
148
+ catch (error) {
149
+ const code = error.code ?? 'invalid';
150
+ return rejected(code);
151
+ }
152
+ const commit = commitModelSettings({ candidate, diskRevision: draft.diskRevision });
153
+ if (!commit.ok)
154
+ return rejected(commit.code ?? 'write_failed');
155
+ return applyPreparedSnapshot(controller, snapshot, true);
156
+ }
157
+ function applyPreparedSnapshot(controller, snapshot, persisted) {
158
+ try {
159
+ controller.applyModelSettingsSnapshot(snapshot);
160
+ }
161
+ catch {
162
+ controller.blockModelSettingsRequests();
163
+ return { ok: false, persisted, status: 'saved_apply_failed', code: 'apply_failed', restartRequired: true };
164
+ }
165
+ try {
166
+ controller.applyModelSettingsEffects();
167
+ }
168
+ catch {
169
+ return { ok: false, persisted, status: 'saved_apply_failed', code: 'apply_failed', restartRequired: true };
170
+ }
171
+ const restartRequired = snapshot.config.fast.enabled && !controller.isFastRegistered();
172
+ return { ok: true, persisted, status: restartRequired ? 'restart_required' : 'applied',
173
+ restartRequired };
174
+ }
175
+ /** Reload the complete model snapshot; invalid disk content never replaces a live session. */
176
+ export function reloadModelSettings(controller) {
177
+ if (controller.isModelSettingsBusy())
178
+ return rejected('busy');
179
+ const disk = readModelSettingsDisk();
180
+ if (!disk.ok)
181
+ return rejected('read_failed');
182
+ if (validateModelProfiles(disk.config?.modelProfiles).length)
183
+ return rejected('invalid');
184
+ try {
185
+ const live = controller.getConfig();
186
+ const resolved = loadConfig({ cwd: live.cwd });
187
+ resolved.liveToolOutput = live.liveToolOutput;
188
+ // The mounted renderer owns these session preferences. Reloading model
189
+ // settings must not re-detect the shell or discard a startup --no-color.
190
+ resolved.color = live.color;
191
+ if (live.colorLevel !== undefined)
192
+ resolved.colorLevel = live.colorLevel;
193
+ if (live.unicode !== undefined)
194
+ resolved.unicode = live.unicode;
195
+ resolved.reducedMotion ||= !live.color;
196
+ const after = readModelSettingsDisk();
197
+ if (!after.ok)
198
+ return rejected('read_failed');
199
+ if (after.revision !== disk.revision)
200
+ return rejected('conflict');
201
+ if (resolved.modelProfileState?.main.invalid)
202
+ return rejected('invalid');
203
+ return applyPreparedSnapshot(controller, controller.prepareModelSettingsSnapshot(resolved), false);
204
+ }
205
+ catch {
206
+ return rejected('invalid');
207
+ }
208
+ }
@@ -4,5 +4,17 @@ export interface PendingSteering {
4
4
  readonly queueId: string;
5
5
  readonly text: string;
6
6
  }
7
+ /** Hide only active pending entries, using the same projection for measurement and paint. */
8
+ export declare function selectTranscriptEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
9
+ /** Restore trimmed messages in FIFO order before atomically accepting known receipts. */
10
+ export declare function reconcileSteeringReceipt(options: {
11
+ entries: Entry[];
12
+ pending: readonly PendingSteering[];
13
+ ids: readonly string[];
14
+ }): {
15
+ entries: Entry[];
16
+ pending: PendingSteering[];
17
+ changed: boolean;
18
+ };
7
19
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
8
20
  export declare function mergePendingEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -1,3 +1,21 @@
1
+ /** Hide only active pending entries, using the same projection for measurement and paint. */
2
+ export function selectTranscriptEntries(entries, pending) {
3
+ if (pending.length === 0)
4
+ return entries;
5
+ const ids = new Set(pending.map((item) => item.queueId));
6
+ return entries.filter((entry) => entry.kind !== 'queued' || !ids.has(entry.queueId ?? ''));
7
+ }
8
+ /** Restore trimmed messages in FIFO order before atomically accepting known receipts. */
9
+ export function reconcileSteeringReceipt(options) {
10
+ const receipts = new Set(options.ids);
11
+ const ids = new Set(options.pending.filter((item) => receipts.has(item.queueId))
12
+ .map((item) => item.queueId));
13
+ if (!ids.size)
14
+ return { entries: options.entries, pending: [...options.pending], changed: false };
15
+ const entries = mergePendingEntries(options.entries, options.pending).map((entry) => entry.kind === 'queued' && ids.has(entry.queueId ?? '')
16
+ ? { id: entry.id, kind: 'user', text: entry.text } : entry);
17
+ return { entries, pending: options.pending.filter((item) => !ids.has(item.queueId)), changed: true };
18
+ }
1
19
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
2
20
  export function mergePendingEntries(entries, pending) {
3
21
  if (pending.length === 0)
@@ -22,7 +22,7 @@ import type { RetryPhase } from './retry-view.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
23
  export type NoticeLevel = 'info' | 'warn' | 'error';
24
24
  export type ToastLevel = NoticeLevel | 'success';
25
- export type Overlay = null | 'settings' | 'model' | 'help' | 'confirm' | 'question' | 'plan';
25
+ export type Overlay = null | 'settings' | 'model' | 'help' | 'queue' | 'confirm' | 'question' | 'plan';
26
26
  export type ToolStatus = 'pending' | 'running' | 'done' | 'error';
27
27
  /** Ephemeral, auto-dismissed acknowledgement (spec §3.9). */
28
28
  export interface Toast {
@@ -278,6 +278,13 @@ export interface RetrySnapshot {
278
278
  export interface ViewState {
279
279
  entries: Entry[];
280
280
  pendingSteering: PendingSteering[];
281
+ seenSteeringIds: ReadonlySet<string>;
282
+ runPhase: 'idle' | 'starting' | 'waiting' | 'thinking' | 'generating' | 'preparing-tool' | 'tool';
283
+ activeTool?: {
284
+ toolCallId: string;
285
+ name: string;
286
+ };
287
+ runOutcome: 'none' | 'ended' | 'interrupted' | 'failed';
281
288
  status: 'idle' | 'running';
282
289
  usageTotal: UsageTotal;
283
290
  /**
@@ -412,6 +419,10 @@ export type ViewAction = {
412
419
  ids: readonly string[];
413
420
  } | {
414
421
  type: 'runStart';
422
+ } | {
423
+ type: 'runStarting';
424
+ } | {
425
+ type: 'runError';
415
426
  } | {
416
427
  type: 'turnStart';
417
428
  } | {
@@ -438,6 +449,7 @@ export type ViewAction = {
438
449
  } | {
439
450
  type: 'toolExecStart';
440
451
  toolCallId: string;
452
+ toolName?: string;
441
453
  } | {
442
454
  type: 'toolExecEnd';
443
455
  toolCallId: string;
@@ -11,6 +11,7 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import process from 'node:process';
14
+ import { reconcileSteeringReceipt } from './queued-messages.js';
14
15
  import { emptyContextUsage } from '../compaction/meter.js';
15
16
  import { addUsage, computeCost } from './usage.js';
16
17
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
@@ -47,6 +48,9 @@ export function initialViewState(seed = {}) {
47
48
  return {
48
49
  entries: [],
49
50
  pendingSteering: [],
51
+ seenSteeringIds: new Set(),
52
+ runPhase: 'idle',
53
+ runOutcome: 'none',
50
54
  status: 'idle',
51
55
  usageTotal: EMPTY_USAGE_TOTAL,
52
56
  context: emptyContextUsage(),
@@ -145,7 +149,7 @@ export function reduceEvent(event, cost, patches) {
145
149
  case 'agent_end':
146
150
  return [{ type: 'runEnd' }];
147
151
  case 'tool_execution_start':
148
- return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
152
+ return [{ type: 'toolExecStart', toolCallId: event.toolCallId, toolName: event.toolName }];
149
153
  case 'tool_execution_end': {
150
154
  const patch = patches?.take(event.toolCallId);
151
155
  const preview = buildToolPreview(event.result);
@@ -200,8 +204,6 @@ function reduceStreamEvent(se) {
200
204
  case 'text_delta':
201
205
  return [{ type: 'textDelta', delta: se.delta }];
202
206
  case 'tool_call_start':
203
- if (SELF_RENDERING_TOOLS.has(se.toolName))
204
- return [];
205
207
  return [
206
208
  { type: 'toolCallStart', toolCallId: se.toolCallId, toolName: se.toolName },
207
209
  ];
@@ -213,6 +215,7 @@ function reduceStreamEvent(se) {
213
215
  return [{ type: 'toolCallEnd', toolCallId: se.toolCallId, args: se.args }];
214
216
  case 'error':
215
217
  return [
218
+ { type: 'runError' },
216
219
  {
217
220
  type: 'notice',
218
221
  level: 'error',
@@ -405,7 +408,40 @@ function findToolEntryId(state, toolCallId) {
405
408
  }
406
409
  return undefined;
407
410
  }
411
+ /** Runtime facts outlive transcript cards and are independent of command/clipboard notices. */
412
+ function reduceRunFacts(state, action) {
413
+ switch (action.type) {
414
+ case 'runStarting': return { ...state, runPhase: 'starting', runOutcome: 'none' };
415
+ case 'runStart': return { ...state, runPhase: 'waiting', runOutcome: 'none', activeTool: undefined };
416
+ case 'turnStart': return { ...state, runPhase: 'waiting' };
417
+ case 'thinkingStart':
418
+ case 'thinkingDelta': return { ...state, runPhase: 'thinking' };
419
+ case 'textDelta': return { ...state, runPhase: 'generating' };
420
+ case 'toolCallStart':
421
+ case 'toolCallDelta':
422
+ case 'toolCallEnd': return { ...state, runPhase: 'preparing-tool' };
423
+ case 'toolExecStart': {
424
+ const card = state.entries.find((entry) => entry.kind === 'tool' && entry.toolCallId === action.toolCallId);
425
+ return { ...state, runPhase: 'tool', activeTool: {
426
+ toolCallId: action.toolCallId,
427
+ name: action.toolName ?? (card?.kind === 'tool' ? card.name : 'tool'),
428
+ } };
429
+ }
430
+ case 'toolExecEnd': return state.activeTool?.toolCallId === action.toolCallId
431
+ ? { ...state, runPhase: 'waiting', activeTool: undefined } : state;
432
+ case 'runError': return { ...state, runOutcome: 'failed' };
433
+ case 'abortMark': return { ...state, runOutcome: 'interrupted' };
434
+ case 'runEnd': return { ...state, runPhase: 'idle', activeTool: undefined,
435
+ runOutcome: state.runOutcome !== 'none' ? state.runOutcome
436
+ : !state.turnProduced && !state.errorNoticed && !state.aborted ? 'failed' : 'ended' };
437
+ case 'resetConversation':
438
+ case 'restoreEntries': return { ...state, runPhase: 'idle', activeTool: undefined,
439
+ runOutcome: 'none', seenSteeringIds: new Set() };
440
+ default: return state;
441
+ }
442
+ }
408
443
  export function viewReducer(state, action) {
444
+ state = reduceRunFacts(state, action);
409
445
  switch (action.type) {
410
446
  case 'submit': {
411
447
  const { id, seq } = nextId(state);
@@ -429,6 +465,8 @@ export function viewReducer(state, action) {
429
465
  };
430
466
  }
431
467
  case 'steerQueued': {
468
+ if (state.seenSteeringIds.has(action.queueId))
469
+ return state;
432
470
  const { id, seq } = nextId(state);
433
471
  const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
434
472
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
@@ -437,20 +475,19 @@ export function viewReducer(state, action) {
437
475
  // guard misfire on a turn that produced plenty.
438
476
  return {
439
477
  ...state, seq, ...appendEntry(state, state.entries, entry),
478
+ seenSteeringIds: new Set([...state.seenSteeringIds, action.queueId]),
440
479
  pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
441
480
  };
442
481
  }
443
482
  case 'steeringAccepted': {
444
- const receipts = new Set(action.ids);
445
- const ids = new Set(state.pendingSteering
446
- .filter((item) => receipts.has(item.queueId)).map((item) => item.queueId));
447
- if (ids.size === 0)
483
+ const receipt = reconcileSteeringReceipt({
484
+ entries: state.entries, pending: state.pendingSteering, ids: action.ids,
485
+ });
486
+ if (!receipt.changed)
448
487
  return state;
449
- const pendingSteering = state.pendingSteering.filter((item) => !ids.has(item.queueId));
450
- const entries = state.entries.map((entry) => entry.kind === 'queued' && entry.queueId !== undefined && ids.has(entry.queueId)
451
- ? { id: entry.id, kind: 'user', text: entry.text }
452
- : entry);
453
- return { ...state, pendingSteering, entries };
488
+ const trimmed = trimEntries(receipt.entries, entryRetain());
489
+ return { ...state, pendingSteering: receipt.pending, entries: trimmed.entries,
490
+ droppedEntries: state.droppedEntries + trimmed.dropped };
454
491
  }
455
492
  case 'runStart':
456
493
  return {
@@ -517,6 +554,8 @@ export function viewReducer(state, action) {
517
554
  };
518
555
  }
519
556
  case 'toolCallStart': {
557
+ if (SELF_RENDERING_TOOLS.has(action.toolName))
558
+ return state;
520
559
  const { id, seq } = nextId(state);
521
560
  const entry = {
522
561
  id,
@@ -1151,6 +1190,7 @@ export function viewReducer(state, action) {
1151
1190
  prev.source === next.source &&
1152
1191
  prev.deltaTokens === next.deltaTokens &&
1153
1192
  prev.windowKnown === next.windowKnown &&
1193
+ prev.windowSource === next.windowSource &&
1154
1194
  prev.windowOverridden === next.windowOverridden) {
1155
1195
  return state;
1156
1196
  }
@@ -109,9 +109,9 @@ export declare function runBootGuard(deps?: BootGuardDeps): void;
109
109
  *
110
110
  * Called from two places, and both matter:
111
111
  *
112
- * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
113
- * earliest moment at which "this build starts" is PROVEN: the module graph
114
- * loaded, config resolved, Ink mounted. This is the primary, and it is what
112
+ * - `cli.tsx::runInteractive`, after mount-time exit promises settle, while
113
+ * the app is still live (or has exited successfully): the module graph
114
+ * loaded, config resolved, Ink mounted successfully. This primary path
115
115
  * makes a long session that is later `SIGKILL`ed (terminal window closed,
116
116
  * machine slept badly, OOM killer) still count as healthy.
117
117
  * - the exit hook installed by `runBootGuard`, when the code is `0` - the
@@ -178,9 +178,9 @@ export function runBootGuard(deps = {}) {
178
178
  *
179
179
  * Called from two places, and both matter:
180
180
  *
181
- * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
182
- * earliest moment at which "this build starts" is PROVEN: the module graph
183
- * loaded, config resolved, Ink mounted. This is the primary, and it is what
181
+ * - `cli.tsx::runInteractive`, after mount-time exit promises settle, while
182
+ * the app is still live (or has exited successfully): the module graph
183
+ * loaded, config resolved, Ink mounted successfully. This primary path
184
184
  * makes a long session that is later `SIGKILL`ed (terminal window closed,
185
185
  * machine slept badly, OOM killer) still count as healthy.
186
186
  * - the exit hook installed by `runBootGuard`, when the code is `0` - the
package/dist/cli.js CHANGED
@@ -16,8 +16,9 @@ import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
17
  import '../runtime/insecure-tls-warning.cjs';
18
18
  // Must precede every other dependency that imports Ink: CI detection is cached.
19
- import { render } from './ui/ink-runtime.js';
19
+ import { renderTui as render } from './ui/ink-runtime.js';
20
20
  import { Command } from 'commander';
21
+ import { ModelProfileConfigError } from './config/model-profile-store.js';
21
22
  import { loadConfig } from './config/load.js';
22
23
  import { getConfigPath, readConfigFile, updatePersistedConfig } from './config/store.js';
23
24
  import { formatMigrationNotice, migrateLegacyState } from './config/migrate-legacy-state.js';
@@ -53,7 +54,7 @@ import { supportsWindowsVtInput } from './ui/win-vt-input.js';
53
54
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
54
55
  import { readExitSnapshot } from './ui/exit-snapshot.js';
55
56
  import { renderTranscriptText } from './ui/transcript-text.js';
56
- import { detectCapabilities } from './ui/capabilities.js';
57
+ import { resolveTuiCapabilities } from './ui/capabilities.js';
57
58
  import { pickGlyphs } from './ui/glyphs.js';
58
59
  import { getTheme } from './ui/theme.js';
59
60
  import { createSelectionController, } from './ui/selection/selection-controller.js';
@@ -223,7 +224,7 @@ function toFlags(opts) {
223
224
  * instead of writing to disk unattended.
224
225
  */
225
226
  function makeController(flags, opts = { interactive: true }) {
226
- const config = loadConfig(flags);
227
+ const config = loadConfig(flags, { interactive: opts.interactive });
227
228
  // The authoritative log level lands here (§4.4.5). `installLogging()` only
228
229
  // resolved a bootstrap level — it ran before commander and before `.env` were
229
230
  // readable — and without this hand-off the fully resolved one never applies.
@@ -282,6 +283,7 @@ function makeController(flags, opts = { interactive: true }) {
282
283
  */
283
284
  function makeExecController(flags, deps) {
284
285
  const { controller } = makeController(flags, { interactive: false, ...deps });
286
+ void controller.refreshModelMetadata();
285
287
  const logger = getLogger();
286
288
  logger.info('cli', 'run_exec', {});
287
289
  logger.onFailure((reason) => process.stderr.write(`[log] ${reason}\n`));
@@ -387,9 +389,14 @@ function runInteractive(flags, extras = {}) {
387
389
  return;
388
390
  }
389
391
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
392
+ void controller.refreshModelMetadata();
390
393
  const config = controller.getConfig();
394
+ const caps = resolveTuiCapabilities({}, config.color);
391
395
  const logger = getLogger();
392
- logger.info('cli', 'run_interactive', { mode: 'fullscreen', provider: config.provider, model: config.model });
396
+ logger.info('cli', 'run_interactive', {
397
+ mode: 'fullscreen', provider: config.provider, model: config.model,
398
+ unicode: caps.unicode, colorLevel: caps.colorLevel,
399
+ });
393
400
  attachAgentEvents(logger, controller);
394
401
  // The team stream is CLI-local (D-10), so `attachAgentEvents` cannot see it.
395
402
  // Without this a dispatch leaves NO trace in the log file at all — the wrong
@@ -458,7 +465,7 @@ function runInteractive(flags, extras = {}) {
458
465
  // advising the user about a mode they just disabled.
459
466
  const wantPaste = config.paste && !!process.stdin.isTTY;
460
467
  const pasteBridge = { notify: null };
461
- const filter = wantMouse || wantPaste
468
+ const filter = process.stdin.isTTY
462
469
  ? tryCreateStdinFilter(process.stdin, { mouse: wantMouse, paste: wantPaste }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
463
470
  : null;
464
471
  /**
@@ -547,7 +554,7 @@ function runInteractive(flags, extras = {}) {
547
554
  return; // Never published (instant exit) — skip, do not throw.
548
555
  writeExitTranscript(process.stdout, renderTranscriptText(snapshot.entries, {
549
556
  // Same terminal the TUI just left, so the same glyph tier applies.
550
- glyphs: pickGlyphs(detectCapabilities(process.env, process.stdout)),
557
+ glyphs: pickGlyphs(caps),
551
558
  usageTotal: snapshot.usageTotal,
552
559
  provider: snapshot.provider,
553
560
  model: snapshot.model,
@@ -634,8 +641,8 @@ function runInteractive(flags, extras = {}) {
634
641
  // the SAME `copyText` door and the SAME bridge `onCopied` toast
635
642
  // funnel -- the controller keeps only the selection state machine.
636
643
  // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
637
- theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
638
- caps: detectCapabilities(process.env, process.stdout),
644
+ theme: () => getTheme(config.theme, caps),
645
+ caps,
639
646
  cols: () => process.stdout.columns ?? 80,
640
647
  // The STATIC half of the gate. The dynamic half — "no overlay is open" —
641
648
  // belongs to `App`, which owns that state and calls `setEnabled`.
@@ -648,6 +655,7 @@ function runInteractive(flags, extras = {}) {
648
655
  };
649
656
  const terminalBridge = {
650
657
  mouseSelect: wantSelect,
658
+ deleteDisambiguated: filter !== null,
651
659
  scrollbar,
652
660
  ...(mouseOn
653
661
  ? {
@@ -716,14 +724,15 @@ function runInteractive(flags, extras = {}) {
716
724
  // every existing path is provably unchanged.
717
725
  stdin: filter?.stdin ?? process.stdin,
718
726
  stdout: frameObserver.stdout,
719
- });
727
+ }, caps);
720
728
  }
721
729
  catch (error) {
722
730
  failInteractive(error);
723
731
  throw error;
724
732
  }
725
- // H1's primary disarm, and this is the earliest moment at which "this build
726
- // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
733
+ // H1's primary disarm. Let mount-time exit promises settle first: Ink may
734
+ // return from render after its error boundary has already unmounted the app.
735
+ // A live app on the next turn has loaded, resolved config, and mounted
727
736
  // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
728
737
  // that is later `SIGKILL`ed - terminal window closed, machine slept badly, OOM
729
738
  // killer - still count as healthy.
@@ -733,7 +742,8 @@ function runInteractive(flags, extras = {}) {
733
742
  // never armed it this is one read and one string compare. Gating it would mean
734
743
  // a user who switches `mode` to `off` between the install and the next launch
735
744
  // leaves the guard armed with nothing able to disarm it.
736
- markBootHealthy(VERSION);
745
+ setImmediate(() => { if (!interactiveClosed)
746
+ markBootHealthy(VERSION); });
737
747
  if (updateEligible) {
738
748
  // DYNAMIC, and fire-and-forget AFTER `render()` (D-25). Dynamic because a
739
749
  // static import would make AC-1 false for `aragon -p`, which shares this
@@ -776,6 +786,9 @@ function runInteractive(flags, extras = {}) {
776
786
  .waitUntilExit()
777
787
  .then(() => {
778
788
  interactiveClosed = true;
789
+ // Also cover a successful immediate exit when launched via dist/cli.js,
790
+ // which bypasses the launcher's code-zero boot-guard backstop.
791
+ markBootHealthy(VERSION);
779
792
  screen?.restore();
780
793
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
781
794
  // and after `restore()` for the reason that call records: the terminal must
@@ -811,6 +824,7 @@ async function runOneShot(flags, prompt, quiet) {
811
824
  // install. The gate must know that up front rather than discovering a null
812
825
  // handler and defaulting to "yes" (§7.4 / AC-13).
813
826
  const { controller } = makeController(flags, { interactive: false });
827
+ void controller.refreshModelMetadata();
814
828
  const logger = getLogger();
815
829
  logger.info('cli', 'run_oneshot', { quiet });
816
830
  // Headless has no frame, so a logging failure can safely reach stderr — the
@@ -829,10 +843,16 @@ async function runOneShot(flags, prompt, quiet) {
829
843
  // `followThrough` is read off the REAL controller here rather than through
830
844
  // `HeadlessController`, which is deliberately the minimal surface a test can
831
845
  // satisfy with an object literal (todo-plan-followthrough §3.6).
832
- const code = await runHeadless(controller, prompt, {
833
- quiet,
834
- followThrough: controller.getTodoConfig().followThrough,
835
- });
846
+ let code;
847
+ try {
848
+ code = await runHeadless(controller, prompt, {
849
+ quiet,
850
+ followThrough: controller.getTodoConfig().followThrough,
851
+ });
852
+ }
853
+ finally {
854
+ controller.dispose();
855
+ }
836
856
  // Flushed HERE and not inside `runHeadless`: that module is deliberately
837
857
  // injectable (its own controller interface, its own stdout/stderr) and has an
838
858
  // early `return 2`, so a singleton dependency in there would be both a new
@@ -1009,6 +1029,18 @@ function runConfigSetContextWindow(value) {
1009
1029
  process.stdout.write(`Set contextWindow = ${merged.contextWindow ?? 'auto'}\n`);
1010
1030
  }
1011
1031
  function runConfigSet(key, value) {
1032
+ try {
1033
+ runConfigSetValue(key, value);
1034
+ }
1035
+ catch (error) {
1036
+ const invalid = error instanceof ModelProfileConfigError
1037
+ && !['read_failed', 'write_failed'].includes(error.code);
1038
+ process.stderr.write(error instanceof ModelProfileConfigError
1039
+ ? `${error.message}\n` : 'Could not write config. Check file permissions.\n');
1040
+ process.exitCode = invalid ? 2 : 1;
1041
+ }
1042
+ }
1043
+ function runConfigSetValue(key, value) {
1012
1044
  if (!CONFIG_SET_KEYS.has(key)) {
1013
1045
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
1014
1046
  process.exitCode = 2;