@aragon-agent/cli 0.6.24 → 0.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +1835 -1786
  2. package/README.md +2578 -2562
  3. package/dist/agent/controller.d.ts +49 -3
  4. package/dist/agent/controller.js +78 -3
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/reducer.js +45 -13
  12. package/dist/commands/builtins.js +14 -34
  13. package/dist/commands/clear-command.d.ts +27 -0
  14. package/dist/commands/clear-command.js +107 -0
  15. package/dist/commands/registry.d.ts +20 -1
  16. package/dist/config/model-profiles.d.ts +8 -0
  17. package/dist/config/model-profiles.js +10 -0
  18. package/dist/session/clear-archive.d.ts +51 -0
  19. package/dist/session/clear-archive.js +160 -0
  20. package/dist/todo/types.d.ts +5 -3
  21. package/dist/ui/App.js +39 -7
  22. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  23. package/dist/ui/entries/CompactionCard.js +5 -4
  24. package/dist/ui/overlays/HelpOverlay.js +8 -2
  25. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  26. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  27. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  28. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  29. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  30. package/dist/ui/overlays/SettingsScreen.js +34 -2
  31. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  32. package/dist/ui/overlays/model-probe-view.js +225 -0
  33. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  34. package/dist/ui/overlays/model-profile-state.js +10 -0
  35. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  36. package/dist/ui/overlays/use-model-probe.js +109 -0
  37. package/package.json +2 -2
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `/clear` - start a new conversation - plus its `/reset` alias and the
3
+ * `/clear screen` form that keeps the old "just wipe the screen" meaning
4
+ * (clear-command-context-reset §3.5 / D-1 / D-2 / D-3).
5
+ *
6
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`. The one
7
+ * localized string below is written with `\u` escapes for that reason, exactly
8
+ * as the `/reset` body it replaces did.
9
+ *
10
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
11
+ * reason `/compact` and `/context` do (D-8). `builtins.ts` is 1511 lines against
12
+ * `CLAUDE.md`'s 1000-line cap, and this command is no longer a two-line
13
+ * projection reset - it interrupts a run, waits for it, archives a conversation
14
+ * and then clears. A two-line edit belongs inline; this does not.
15
+ *
16
+ * THE COMMAND'S ONLY JOB IS SEQUENCING AND SPEAKING. Every state change happens
17
+ * on the controller port (`AgentController.resetConversation`, which delegates
18
+ * the ORDER to `agent/conversation-reset.ts`), and every word the user reads is
19
+ * decided here, in one place, in the exact order they will read it.
20
+ */
21
+ import { getLogger } from '../logging/logger.js';
22
+ /** The toast a successful clear shows. Pinned verbatim by an existing test. */
23
+ const CLEARED_TOAST = 'Started a new conversation.';
24
+ /**
25
+ * Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
26
+ * what stops the two from drifting back into "one clears the screen, one clears
27
+ * the session" - the drift that produced the defect this feature fixes.
28
+ */
29
+ export async function runClearCommand(ctx) {
30
+ const verb = ctx.args.trim().toLowerCase();
31
+ if (verb === 'screen') {
32
+ // The old behaviour, byte for byte (I-3): silent, transcript plus the todo
33
+ // panel, history untouched.
34
+ clearScreenOnly(ctx);
35
+ return;
36
+ }
37
+ if (verb !== '') {
38
+ // Rejected rather than ignored. A command that silently accepts and
39
+ // discards an argument is a surface that lies about what it understood.
40
+ ctx.notify('warn', `Unknown argument "${ctx.args.trim()}" - use /clear [screen].`);
41
+ return;
42
+ }
43
+ // READ BEFORE CLEARING (P1-2 / I-9). `clearAllQueues()` empties
44
+ // `pendingUserSteering`, so a count taken afterwards is always zero - and a
45
+ // notice that says "0 messages cancelled" is worse than no notice at all.
46
+ const cancelled = ctx.state.pendingSteering.length;
47
+ const result = await ctx.controller.resetConversation({
48
+ interrupt: true,
49
+ // A THUNK, NOT A VALUE (P0-1 / §3.3). `ctx.state` is the view as of command
50
+ // entry, and the archive happens AFTER the interrupted engine unwinds - so
51
+ // the last tool cards of the abandoned run are written to the transcript in
52
+ // between. A value captured here would archive everything except the part
53
+ // the user was looking at when they typed the command.
54
+ readTranscript: () => ctx.transcriptEntries?.() ?? ctx.state.entries,
55
+ });
56
+ if (!result.ok) {
57
+ // ZERO SIDE EFFECTS (I-4), and a trace (P1-5). The user gets one sentence
58
+ // naming the next action; the log gets the reason, because "my /clear did
59
+ // nothing" is otherwise unanswerable after the fact.
60
+ getLogger().warn('agent', 'clear_still_running', { reason: result.reason });
61
+ ctx.notify('warn', result.message);
62
+ return;
63
+ }
64
+ // THE VIEW GOES LAST (B-5 / §3.3). `App`'s dispatch wrapper zeroes the run
65
+ // metrics, the interaction phase and the scroll anchor on this action, so
66
+ // dispatching it after the engine has settled is what makes "cleared" mean
67
+ // "nothing is still writing into it".
68
+ ctx.dispatch({ type: 'resetConversation' });
69
+ // THE ONE CONSUMER OF `messagesCleared` (P2-1). It cannot join the toast:
70
+ // `clear-command-todo.test.ts` pins that string verbatim, and a count nobody
71
+ // asked for is noise in the one place the user reads confirmations.
72
+ getLogger().info('agent', 'conversation_cleared', {
73
+ messagesCleared: result.messagesCleared,
74
+ interrupted: result.interrupted,
75
+ });
76
+ ctx.toast('info', CLEARED_TOAST);
77
+ // REINSTATED FROM THE OLD `/reset` (P1-1 / C-3). Dropping it would silently
78
+ // delete a user-visible acknowledgement that already existed and was already
79
+ // pinned by test.
80
+ if (cancelled) {
81
+ ctx.notify('warn', `\u5df2\u53d6\u6d88 ${cancelled} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
82
+ }
83
+ if (result.archivedPath) {
84
+ // A PATH RATHER THAN A PROMISE: the user can copy it, and `/resume` accepts
85
+ // an absolute path. "Archived" with no way to find the file is a reassurance,
86
+ // not a recovery.
87
+ ctx.notify('info', `Previous conversation archived: ${result.archivedPath} (/resume <path> to restore)`);
88
+ }
89
+ }
90
+ /**
91
+ * Today's `/clear`, unchanged and silent (D-3 / I-3).
92
+ *
93
+ * Kept as a real form rather than as a separate command because "clear the
94
+ * screen" and "clear the conversation" are two different intentions, and a user
95
+ * who wants the first must not have to risk the second. It also gives
96
+ * `clearTranscript` a live dispatcher: `CLAUDE.md` forbids orphaned actions, and
97
+ * this was the last one that would have had none.
98
+ */
99
+ function clearScreenOnly(ctx) {
100
+ // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
101
+ // reuses reason `'user'`; the distinction against `clearMessages()`'s `'reset'`
102
+ // is recorded on `TodoStore`. Clearing the STORE and not merely the view
103
+ // mirror is what keeps `/save`, `/todo status|continue` and follow-through
104
+ // agreeing with the screen - they read the store directly.
105
+ ctx.controller.clearTodos();
106
+ ctx.dispatch({ type: 'clearTranscript' });
107
+ }
@@ -6,7 +6,7 @@ import type { SubmitMessageOptions } from '../agent/prompt-options.js';
6
6
  import type { CopyRequest, CopyRequestResult } from '../ui/clipboard-task.js';
7
7
  import type { AgentController } from '../agent/controller.js';
8
8
  import type { AgentMode } from '../agent/agent-mode.js';
9
- import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
9
+ import type { Entry, NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
10
10
  import type { PersistedConfig } from '../config/schema.js';
11
11
  import type { FollowThroughBudget } from '../todo/follow-through.js';
12
12
  import type { UpdateCommandPort } from '../update/types.js';
@@ -108,6 +108,25 @@ export interface CommandContext {
108
108
  * stream is handed over directly.
109
109
  */
110
110
  writeForeign?: (text: string) => void;
111
+ /**
112
+ * The LIVE transcript rows, for `/clear`'s archive (clear-command-context-reset
113
+ * P0-1 / §3.5).
114
+ *
115
+ * A THUNK, AND THAT IS THE WHOLE POINT. The `state` field above is a snapshot
116
+ * of `ViewState` taken when the command started; `/clear` interrupts a run and
117
+ * then waits for the engine to unwind, so the last tool cards of that run are
118
+ * written to the transcript AFTER `state` was captured. Reading `state.entries`
119
+ * would archive everything except the part the user was just looking at.
120
+ *
121
+ * OPTIONAL, like `followBudget` / `update` / `mouse`: every existing
122
+ * `CommandContext` fixture in this package builds the object by literal, and a
123
+ * required field would break all of them at compile time for a feature none of
124
+ * them are about. Absent means the archive degrades to history-only.
125
+ *
126
+ * `Entry` arrives as a TYPE-ONLY import so this module keeps its runtime shape
127
+ * (the same rule the `update/types.js` import above follows).
128
+ */
129
+ transcriptEntries?: () => Entry[];
111
130
  }
112
131
  /** What `/mouse` can see and do. Read at command time, never captured. */
113
132
  export interface MouseCommandPort {
@@ -34,6 +34,14 @@ export interface ProfileValidationIssue {
34
34
  export declare function emptyModelProfiles(): ModelProfilesConfig;
35
35
  /** Return independently mutable role state. */
36
36
  export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
37
+ /**
38
+ * The endpoint rule published as a predicate (model-profile-connection-test §5 row 10).
39
+ *
40
+ * The probe's `invalid_base_url` guard has to use THE SAME ruler as validation, or a
41
+ * URL the editor accepts can still be refused by the test (and the reverse). A second
42
+ * copy of these four clauses is one edit away from disagreeing with the config layer.
43
+ */
44
+ export declare function isValidEndpointUrl(value: string | null | undefined): boolean;
37
45
  /** Validate untrusted input; diagnostics never include supplied values. */
38
46
  export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
39
47
  /**
@@ -32,6 +32,16 @@ function validUrl(value) {
32
32
  return false;
33
33
  }
34
34
  }
35
+ /**
36
+ * The endpoint rule published as a predicate (model-profile-connection-test §5 row 10).
37
+ *
38
+ * The probe's `invalid_base_url` guard has to use THE SAME ruler as validation, or a
39
+ * URL the editor accepts can still be refused by the test (and the reverse). A second
40
+ * copy of these four clauses is one edit away from disagreeing with the config layer.
41
+ */
42
+ export function isValidEndpointUrl(value) {
43
+ return validUrl(value);
44
+ }
35
45
  function validateEntry(value, index) {
36
46
  const path = `modelProfiles.entries[${index}]`;
37
47
  const invalid = (field, message) => ({
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Archive the conversation a `/clear` is about to destroy
3
+ * (clear-command-context-reset §3.8 / D-5 / I-5).
4
+ *
5
+ * ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
8
+ * command a user will eventually fire by accident, one keystroke from `/cwd`. The
9
+ * difference between "I lost an hour of work" and "I resumed it" is one file, and
10
+ * `saveSession()` already knows how to write it.
11
+ *
12
+ * WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
13
+ * `--continue` POINTER and writes `meta`. An archive is not a session you continue;
14
+ * it is a record you resume BY PATH. Writing `meta` would also make the file look
15
+ * `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
16
+ * `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
17
+ * start deleting the user's cleared conversations.
18
+ *
19
+ * ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
20
+ * `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
21
+ * non-recursively and skip anything not ending in `.json` (`store.ts`), so a
22
+ * SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
23
+ * the second fence, for the day someone makes that listing recursive or gives
24
+ * `--all` a wider meaning.
25
+ */
26
+ import type { Message, ModelRef } from '@aragon-agent/core';
27
+ import type { Entry } from '../agent/reducer.js';
28
+ import type { TodoItem } from '../todo/types.js';
29
+ import type { CompactionIdentity } from '../compaction/memory-identity.js';
30
+ /** Everything the archive writes. `entries` is supplied by the command layer. */
31
+ export interface ClearedArchiveInput {
32
+ model: ModelRef;
33
+ messages: Message[];
34
+ compactionIdentity?: CompactionIdentity;
35
+ /** The screen transcript, which lives only in `ViewState` (P0-1). */
36
+ entries: Entry[];
37
+ todos: TodoItem[];
38
+ }
39
+ /** Bounded retention: the newest N cleared conversations survive. */
40
+ export declare const CLEAR_ARCHIVE_LIMITS: {
41
+ readonly maxArchives: 20;
42
+ };
43
+ /**
44
+ * Best-effort archive of the conversation being cleared.
45
+ *
46
+ * @returns the absolute path of the written file, or `null` when there was
47
+ * nothing to save (an empty conversation) or the write failed. NEVER THROWS:
48
+ * a full disk must not be able to stop a user from starting a new conversation
49
+ * (I-5), and a caller that had to wrap this in `try` would eventually forget.
50
+ */
51
+ export declare function archiveClearedConversation(input: ClearedArchiveInput): string | null;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Archive the conversation a `/clear` is about to destroy
3
+ * (clear-command-context-reset §3.8 / D-5 / I-5).
4
+ *
5
+ * ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
8
+ * command a user will eventually fire by accident, one keystroke from `/cwd`. The
9
+ * difference between "I lost an hour of work" and "I resumed it" is one file, and
10
+ * `saveSession()` already knows how to write it.
11
+ *
12
+ * WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
13
+ * `--continue` POINTER and writes `meta`. An archive is not a session you continue;
14
+ * it is a record you resume BY PATH. Writing `meta` would also make the file look
15
+ * `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
16
+ * `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
17
+ * start deleting the user's cleared conversations.
18
+ *
19
+ * ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
20
+ * `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
21
+ * non-recursively and skip anything not ending in `.json` (`store.ts`), so a
22
+ * SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
23
+ * the second fence, for the day someone makes that listing recursive or gives
24
+ * `--all` a wider meaning.
25
+ */
26
+ import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import process from 'node:process';
29
+ import { getLogger } from '../logging/logger.js';
30
+ import { saveSession } from './persist.js';
31
+ import { sessionsDir } from './store.js';
32
+ /** Bounded retention: the newest N cleared conversations survive. */
33
+ export const CLEAR_ARCHIVE_LIMITS = { maxArchives: 20 };
34
+ /** `<sessions>/cleared` - a subdirectory, which is the first isolation fence. */
35
+ const CLEARED_DIR_NAME = 'cleared';
36
+ /** Every archive's filename starts with this, which is what `prune` selects on. */
37
+ const ARCHIVE_PREFIX = 'cleared-';
38
+ /**
39
+ * Best-effort archive of the conversation being cleared.
40
+ *
41
+ * @returns the absolute path of the written file, or `null` when there was
42
+ * nothing to save (an empty conversation) or the write failed. NEVER THROWS:
43
+ * a full disk must not be able to stop a user from starting a new conversation
44
+ * (I-5), and a caller that had to wrap this in `try` would eventually forget.
45
+ */
46
+ export function archiveClearedConversation(input) {
47
+ // Clearing an empty conversation produces no file. Without this, every
48
+ // `/clear` on a fresh session would litter the directory with empty archives
49
+ // and push a real one out of the retention window.
50
+ if (input.messages.length === 0)
51
+ return null;
52
+ const dir = join(sessionsDir(), CLEARED_DIR_NAME);
53
+ const path = join(dir, `${archiveId(new Date())}.json`);
54
+ try {
55
+ mkdirSync(dir, { recursive: true });
56
+ }
57
+ catch (err) {
58
+ getLogger().debug('agent', 'clear_archive_failed', { reason: reasonOf(err) });
59
+ return null;
60
+ }
61
+ // DEGRADE AND RETRY (P1-6 / C-8). `saveSession` VALIDATES the compaction
62
+ // identity and THROWS on a damaged or stale one, and an interrupted
63
+ // conversation - the one a user most wants back - is exactly the one most
64
+ // likely to carry a stale identity. Losing a whole conversation to an
65
+ // accessory field would break the only promise this module makes, so the
66
+ // second attempt drops it: `messages` is what makes the archive recoverable,
67
+ // the identity only lets the NEXT compaction reuse a prefix.
68
+ //
69
+ // The retry runs ONLY when there is an identity to drop. A disk that refused
70
+ // the first write will refuse an identical second one, so retrying without one
71
+ // would just double the log line and the wait without changing the outcome.
72
+ const wrote = writeArchive(path, input, true)
73
+ || (input.compactionIdentity !== undefined && writeArchive(path, input, false));
74
+ if (!wrote)
75
+ return null;
76
+ pruneArchives(dir);
77
+ return path;
78
+ }
79
+ /**
80
+ * One write attempt. `withIdentity` is the only difference between the first and
81
+ * the degraded second attempt, which is why it is a parameter rather than a
82
+ * duplicated body.
83
+ */
84
+ function writeArchive(path, input, withIdentity) {
85
+ const identity = withIdentity ? input.compactionIdentity : undefined;
86
+ try {
87
+ saveSession(path, {
88
+ model: input.model,
89
+ messages: input.messages,
90
+ ...(identity ? { compactionIdentity: identity } : {}),
91
+ entries: input.entries,
92
+ todos: input.todos,
93
+ });
94
+ return true;
95
+ }
96
+ catch (err) {
97
+ getLogger().debug('agent', 'clear_archive_write_failed', {
98
+ withIdentity, reason: reasonOf(err),
99
+ });
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Deterministic, collision-free id (P2-3).
105
+ *
106
+ * `cleared-<yyyyMMdd>-<HHmmss>-<pid>` is ASCII, matches `isValidSessionId`
107
+ * (`[A-Za-z0-9][A-Za-z0-9._-]*`), and is legible enough to type after
108
+ * `/resume `. The pid removes cross-process collisions; within one process a
109
+ * second `cleared-` file in the same second is unreachable because the FIRST
110
+ * clear leaves an empty conversation and `archiveClearedConversation` returns
111
+ * early on that. So no `-2`/`-3` suffix ladder is needed, and none is here.
112
+ */
113
+ function archiveId(at) {
114
+ const pad = (n) => String(n).padStart(2, '0');
115
+ const day = `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}`;
116
+ const clock = `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`;
117
+ return `${ARCHIVE_PREFIX}${day}-${clock}-${process.pid}`;
118
+ }
119
+ /**
120
+ * Keep the newest `maxArchives` files; delete the rest.
121
+ *
122
+ * A sweep, not a scheduled job: retention that runs on the write path cannot be
123
+ * forgotten, and the failure mode of a missed sweep is bounded disk usage rather
124
+ * than unbounded. Every error here is swallowed on purpose - pruning is
125
+ * housekeeping and must never be able to fail a clear that already succeeded.
126
+ */
127
+ function pruneArchives(dir) {
128
+ const survivors = [];
129
+ for (const name of safeReaddir(dir)) {
130
+ if (!name.startsWith(ARCHIVE_PREFIX) || !name.endsWith('.json'))
131
+ continue;
132
+ const path = join(dir, name);
133
+ try {
134
+ survivors.push({ path, modified: statSync(path).mtimeMs });
135
+ }
136
+ catch {
137
+ // A file that vanished between listing and stat is already deleted.
138
+ }
139
+ }
140
+ survivors.sort((a, b) => b.modified - a.modified);
141
+ for (const doomed of survivors.slice(CLEAR_ARCHIVE_LIMITS.maxArchives)) {
142
+ try {
143
+ unlinkSync(doomed.path);
144
+ }
145
+ catch (err) {
146
+ getLogger().debug('agent', 'clear_archive_prune_failed', { reason: reasonOf(err) });
147
+ }
148
+ }
149
+ }
150
+ function safeReaddir(dir) {
151
+ try {
152
+ return readdirSync(dir);
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ }
158
+ function reasonOf(err) {
159
+ return err instanceof Error ? err.message : String(err);
160
+ }
@@ -42,9 +42,11 @@ export interface TodoSnapshot {
42
42
  /**
43
43
  * Why a list went away.
44
44
  *
45
- * `user` - `/todo clear`, or `/clear`. Both are an explicit user instruction,
46
- * which is I-2's standing exception; neither touches `messages`.
47
- * `reset` - `/reset`, or a `/resume` whose file carried no list (§3.13).
45
+ * `user` - `/todo clear`, or `/clear screen` (the screen-only form). Both are
46
+ * an explicit user instruction, which is I-2's standing exception;
47
+ * neither touches `messages`.
48
+ * `reset` - `/clear` (or its alias `/reset`) through `clearMessages()`, or a
49
+ * `/resume` whose file carried no list (§3.13).
48
50
  * `turn` - a new TUI task started, or a legacy caller began a turn after
49
51
  * every item was completed.
50
52
  * `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
package/dist/ui/App.js CHANGED
@@ -55,6 +55,7 @@ import { publishExitSnapshot } from './exit-snapshot.js';
55
55
  import { OverlayFrame } from './layout/OverlayFrame.js';
56
56
  import { helpRows } from './overlays/HelpOverlay.js';
57
57
  import { ModelPicker } from './overlays/ModelPicker.js';
58
+ import { createModelProbe } from '../agent/model-probe.js';
58
59
  import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
59
60
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
60
61
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
@@ -1388,6 +1389,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1388
1389
  requestCopy: copyCoordinator.requestCopy,
1389
1390
  controller,
1390
1391
  state: stateRef.current,
1392
+ // THE ARCHIVE'S ONLY WAY TO SEE THE SCREEN (P0-1 / §3.7), and a thunk for a
1393
+ // reason `state` above illustrates: `state` is a snapshot of this render,
1394
+ // while `/clear` interrupts a run and then waits for it to unwind, so the last
1395
+ // tool cards of the abandoned run are appended AFTER the command starts. A
1396
+ // value here would archive the screen minus the part the user was watching.
1397
+ transcriptEntries: () => stateRef.current.entries,
1391
1398
  dispatch: (action) => {
1392
1399
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1393
1400
  metrics.current = createRunMetrics();
@@ -1535,12 +1542,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1535
1542
  };
1536
1543
  submitRef.current = submitMessage;
1537
1544
  const executeSlashInput = async (raw) => {
1538
- const switchWhileBusy = controller.isCompactionBusy?.()
1539
- && /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1540
- if (switchWhileBusy) {
1545
+ // A CONVERSATION SWITCH OWNS THE QUEUE FOR THE WHOLE COMMAND (B-6 / §3.7).
1546
+ //
1547
+ // The guard used to be raised only when a compaction happened to be busy,
1548
+ // which left the one window this feature is about unwatched: `/clear` on an
1549
+ // IDLE session took the flag nowhere, and `drainCompactionQueue` was still
1550
+ // free to `continue()` a message queued in the previous conversation into
1551
+ // the one the user had just started. Whether there is work to cancel is a
1552
+ // separate question, so the two are now separate values: `switching` decides
1553
+ // the lock, `switchWhileBusy` decides the cancellation.
1554
+ //
1555
+ // `/resume` is still refused mid-run by `handleSubmit` below, so for it the
1556
+ // lock only ever matters while idle - that is the minimum this invariant
1557
+ // asks for, not a contradiction.
1558
+ const switching = /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1559
+ const switchWhileBusy = switching && Boolean(controller.isCompactionBusy?.());
1560
+ if (switching)
1541
1561
  switchingConversation.current = true;
1562
+ if (switchWhileBusy)
1542
1563
  await controller.cancelCompaction();
1543
- }
1544
1564
  // Retain a recoverable command before any asynchronous handler can fail.
1545
1565
  const safe = redactText(raw, getSecrets());
1546
1566
  recordPrompt(safe);
@@ -1553,7 +1573,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1553
1573
  notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1554
1574
  }
1555
1575
  finally {
1556
- if (switchWhileBusy) {
1576
+ if (switching) {
1557
1577
  switchingConversation.current = false;
1558
1578
  drainCompactionQueue.current();
1559
1579
  }
@@ -1563,7 +1583,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1563
1583
  const trimmed = raw.trimStart();
1564
1584
  const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
1565
1585
  if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
1566
- /^\/(reset|resume)(?:\s|$)/.test(trimmed)) {
1586
+ /^\/resume(?:\s|$)/.test(trimmed)) {
1567
1587
  return { accepted: false,
1568
1588
  reason: 'Interrupt the current run before switching conversations.' };
1569
1589
  }
@@ -2050,6 +2070,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2050
2070
  const overlay = state.overlay;
2051
2071
  const settingsDraft = useMemo(() => overlay === 'settings'
2052
2072
  ? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
2073
+ // ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
2074
+ // controller's provider registry, which the overlay has no business owning.
2075
+ // Built only while the settings overlay is open, so no other screen pays for it.
2076
+ const probe = useMemo(() => {
2077
+ if (overlay !== 'settings')
2078
+ return undefined;
2079
+ // A test double or an embedded host may predate this port; absent means the
2080
+ // two probe rows read `(unavailable)` rather than the screen crashing.
2081
+ if (typeof controller.getProviderRegistry !== 'function')
2082
+ return undefined;
2083
+ return createModelProbe({ registry: controller.getProviderRegistry() });
2084
+ }, [controller, overlay]);
2053
2085
  const hasKey = controller.hasApiKey();
2054
2086
  const empty = state.entries.length === 0;
2055
2087
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
@@ -2155,7 +2187,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2155
2187
  // the save handler cannot disagree about what `(inherit)` means.
2156
2188
  ...fastSettingsFrom(cfg.fast),
2157
2189
  ...compactionSettingsFrom(cfg.compaction),
2158
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2190
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2159
2191
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2160
2192
  // while the screen is open shows the new value on the next paint.
2161
2193
  retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
@@ -14,10 +14,11 @@
14
14
  * kind — without it the card renders a hint that does nothing and expands an
15
15
  * older tool card instead (C-15 / P1-8 / AC-21).
16
16
  *
17
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
18
- * empties the transcript view but not `messages`, so this card can legitimately
19
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
20
- * "above" or "earlier in this transcript".
17
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
18
+ * `/clear screen` empties the transcript view but not `messages`, so this card
19
+ * can legitimately report a message count the visible rows do not add up to.
20
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
21
+ * the conversation boundary now: it drops the history and this card with it.
21
22
  */
22
23
  import React from 'react';
23
24
  import type { Theme } from '../theme.js';
@@ -15,10 +15,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  * kind — without it the card renders a hint that does nothing and expands an
16
16
  * older tool card instead (C-15 / P1-8 / AC-21).
17
17
  *
18
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
19
- * empties the transcript view but not `messages`, so this card can legitimately
20
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
21
- * "above" or "earlier in this transcript".
18
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
19
+ * `/clear screen` empties the transcript view but not `messages`, so this card
20
+ * can legitimately report a message count the visible rows do not add up to.
21
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
22
+ * the conversation boundary now: it drops the history and this card with it.
22
23
  */
23
24
  import React from 'react';
24
25
  import { Box, Text } from 'ink';
@@ -68,8 +68,14 @@ const COMMANDS = [
68
68
  'Todo planning: status, switches, the rail, follow-through',
69
69
  ],
70
70
  ['/tools', 'List active tools'],
71
- ['/clear', 'Clear the visible transcript and the todo panel'],
72
- ['/reset', 'New conversation'],
71
+ // `/clear` IS NOT "CLEAR THE SCREEN" ANY MORE (clear-command-context-reset D-1).
72
+ // Two rows, not one, because the intentions differ: `/clear` starts a NEW
73
+ // CONVERSATION (interrupt the run, archive the old one, drop the history), while
74
+ // `/clear screen` only wipes what is on screen. A user who wants the second has
75
+ // to be able to see that it exists before reaching for the first.
76
+ ['/clear', 'Start a new conversation: archive, then clear context and transcript'],
77
+ ['/clear screen', 'Clear the visible transcript only; keeps the conversation'],
78
+ ['/reset', 'Alias of /clear'],
73
79
  ['/cwd [dir]', 'Show or change the tool working directory'],
74
80
  ['/save [file]', 'Save the session to JSON'],
75
81
  ['/resume [file]', 'Load a saved session'],
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
3
+ import { type DiscoveryView } from './model-probe-view.js';
4
+ interface ModelDiscoveryListProps extends ProfileFrameProps {
5
+ provider: string;
6
+ /** The draft's current model: the cursor starts on it when the list has it. */
7
+ currentModel: string;
8
+ view: DiscoveryView;
9
+ busy: boolean;
10
+ onChoose: (model: string) => void;
11
+ onRetry: () => void;
12
+ onBack: () => void;
13
+ onCancelProbe?: () => void;
14
+ }
15
+ /**
16
+ * The discovered-model page (model-profile-connection-test §3.5).
17
+ *
18
+ * Pure display plus input: it receives the discovery outcome and three intents and
19
+ * issues no request itself. No spinner - the census of animated sites is a closed
20
+ * list, and this overlay can be open while a run is in flight.
21
+ */
22
+ export declare function ModelDiscoveryList(props: ModelDiscoveryListProps): React.ReactElement;
23
+ export {};