@aragon-agent/cli 0.6.24 → 0.6.26

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 (77) hide show
  1. package/CHANGELOG.md +1924 -1786
  2. package/README.md +2730 -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.d.ts +22 -0
  12. package/dist/agent/reducer.js +49 -14
  13. package/dist/cli.js +214 -2
  14. package/dist/commands/branch-command.d.ts +61 -0
  15. package/dist/commands/branch-command.js +117 -0
  16. package/dist/commands/builtins.js +62 -40
  17. package/dist/commands/clear-command.d.ts +27 -0
  18. package/dist/commands/clear-command.js +107 -0
  19. package/dist/commands/registry.d.ts +48 -1
  20. package/dist/commands/session-command.d.ts +54 -0
  21. package/dist/commands/session-command.js +109 -0
  22. package/dist/config/load.d.ts +15 -0
  23. package/dist/config/model-profiles.d.ts +8 -0
  24. package/dist/config/model-profiles.js +10 -0
  25. package/dist/exec/index.js +10 -1
  26. package/dist/logging/logger.d.ts +1 -1
  27. package/dist/proc/terminal-plan.d.ts +155 -0
  28. package/dist/proc/terminal-plan.js +360 -0
  29. package/dist/session/branch-invocation.d.ts +83 -0
  30. package/dist/session/branch-invocation.js +109 -0
  31. package/dist/session/branch-launch.d.ts +70 -0
  32. package/dist/session/branch-launch.js +248 -0
  33. package/dist/session/checkpoint.d.ts +257 -0
  34. package/dist/session/checkpoint.js +512 -0
  35. package/dist/session/clear-archive.d.ts +51 -0
  36. package/dist/session/clear-archive.js +160 -0
  37. package/dist/session/cli-commands.d.ts +2 -0
  38. package/dist/session/cli-commands.js +36 -5
  39. package/dist/session/identity.d.ts +72 -0
  40. package/dist/session/identity.js +148 -0
  41. package/dist/session/limits.d.ts +66 -0
  42. package/dist/session/limits.js +66 -0
  43. package/dist/session/persist.d.ts +30 -5
  44. package/dist/session/startup-session.d.ts +107 -0
  45. package/dist/session/startup-session.js +315 -0
  46. package/dist/session/store.d.ts +60 -1
  47. package/dist/session/store.js +95 -6
  48. package/dist/todo/types.d.ts +5 -3
  49. package/dist/ui/App.d.ts +54 -2
  50. package/dist/ui/App.js +154 -28
  51. package/dist/ui/Header.js +8 -16
  52. package/dist/ui/StatusBar.d.ts +8 -0
  53. package/dist/ui/StatusBar.js +12 -3
  54. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  55. package/dist/ui/entries/CompactionCard.js +5 -4
  56. package/dist/ui/layout/header-hints.d.ts +68 -0
  57. package/dist/ui/layout/header-hints.js +138 -0
  58. package/dist/ui/layout/session-chip.d.ts +52 -0
  59. package/dist/ui/layout/session-chip.js +64 -0
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +8 -1
  62. package/dist/ui/overlays/HelpOverlay.js +17 -5
  63. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  64. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  65. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  66. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  67. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  68. package/dist/ui/overlays/SettingsScreen.js +34 -2
  69. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  70. package/dist/ui/overlays/model-probe-view.js +225 -0
  71. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  72. package/dist/ui/overlays/model-profile-state.js +10 -0
  73. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  74. package/dist/ui/overlays/use-model-probe.js +109 -0
  75. package/dist/ui/session-bridge.d.ts +26 -0
  76. package/dist/ui/session-bridge.js +32 -0
  77. package/package.json +2 -2
@@ -91,6 +91,19 @@ export function sessionPathFor(id) {
91
91
  * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
92
92
  * out of a file, and files are inputs.
93
93
  *
94
+ * A TUI CHECKPOINT NEVER NAMES ITSELF (D-20 / R-1). This is the single line that
95
+ * keeps `aragon exec --resume <tui-id>` from OVERWRITING the parent checkpoint:
96
+ * before this check, a TUI file carrying `meta.id` made `exec` take that id,
97
+ * write its own result into `a4k2m9.json` and move the `--continue` pointer -
98
+ * silent, unrecoverable loss of the one conversation the user was still having,
99
+ * arriving through a directory the design had declared out of scope. The rule
100
+ * belongs HERE rather than in `exec/**` because every exec write-back path
101
+ * already funnels through this function, so one check covers all of them and
102
+ * `exec/**` stays untouched.
103
+ *
104
+ * The fallthrough is the "stranger file" branch that already existed below:
105
+ * mint a fresh id rather than build a path out of the leftovers.
106
+ *
94
107
  * The order is the one that keeps every legitimate resume landing where the user
95
108
  * expects: an exec-written session names itself, a TUI `/save` is named by its
96
109
  * file, and anything else gets a fresh id rather than a guess - reported back in
@@ -98,6 +111,8 @@ export function sessionPathFor(id) {
98
111
  * wondering which session it just wrote.
99
112
  */
100
113
  export function resumedSessionId(path, saved) {
114
+ if (saved.meta?.origin === 'tui')
115
+ return randomUUID();
101
116
  const declared = saved.meta?.id;
102
117
  if (typeof declared === 'string' && isValidSessionId(declared))
103
118
  return declared;
@@ -106,6 +121,17 @@ export function resumedSessionId(path, saved) {
106
121
  return base;
107
122
  return randomUUID();
108
123
  }
124
+ /**
125
+ * Whether a `--resume` value is meant as a PATH rather than an id.
126
+ *
127
+ * EXPORTED SO THE FAILURE MESSAGE CANNOT DISAGREE WITH THE LOOKUP: the startup
128
+ * path prints one sentence for "no such id" and another for "no such file", and
129
+ * a second copy of this predicate is a copy that can drift (`isAbsolute` was
130
+ * missing from exactly such a copy).
131
+ */
132
+ export function looksLikeSessionPath(value) {
133
+ return (isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json'));
134
+ }
109
135
  function ensureDir() {
110
136
  const dir = getSessionsDir();
111
137
  if (!existsSync(dir))
@@ -135,8 +161,7 @@ export function resolveSessionRef(idOrPath, cwd) {
135
161
  const value = idOrPath.trim();
136
162
  if (value.length === 0)
137
163
  return null;
138
- const looksLikePath = isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json');
139
- const path = looksLikePath
164
+ const path = looksLikeSessionPath(value)
140
165
  ? resolve(cwd, value)
141
166
  : isValidSessionId(value)
142
167
  ? sessionPathFor(value)
@@ -199,9 +224,24 @@ function describe(path, session) {
199
224
  provider: meta?.provider ?? session.model?.providerId ?? '',
200
225
  model: meta?.model ?? session.model?.modelId ?? '',
201
226
  managed: typeof meta?.id === 'string' && meta.id.length > 0,
227
+ // UNTRUSTED INPUT, DEGRADED TO THE OLD BEHAVIOUR. `origin` is read out of a
228
+ // file, so an unknown or hostile value must not be able to make a TUI
229
+ // checkpoint look like an exec one (or the reverse); anything that is not
230
+ // exactly `'tui'` reads as `'exec'`, which is what every pre-feature file
231
+ // means.
232
+ origin: meta?.origin === 'tui' ? 'tui' : 'exec',
233
+ pinned: meta?.pinned === true,
234
+ live: meta?.live === true,
202
235
  };
203
236
  }
204
- function basenameId(path) {
237
+ /**
238
+ * The file name without its directory or `.json` suffix.
239
+ *
240
+ * Exported because session IDENTITY derives the parent of a branch from this
241
+ * value and NOT from `meta.id` (D-5): the contents of a file are input, so a
242
+ * hand-written `meta.id` must not be able to name where this process writes.
243
+ */
244
+ export function basenameId(path) {
205
245
  const name = path.replace(/\\/g, '/').split('/').pop() ?? path;
206
246
  return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
207
247
  }
@@ -242,8 +282,12 @@ function safeReaddir(dir) {
242
282
  /** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
243
283
  export function pruneSessions(opts = {}) {
244
284
  const now = (opts.now ?? Date.now)();
285
+ const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
245
286
  const cutoff = opts.olderThanDays === undefined ? now : now - opts.olderThanDays * 24 * 60 * 60 * 1000;
246
- const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff);
287
+ const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff &&
288
+ isInPruneScope(entry, opts.origin ?? 'exec') &&
289
+ !entry.pinned &&
290
+ !isOwnedByALiveProcess(entry, isAlive));
247
291
  if (opts.dryRun)
248
292
  return doomed;
249
293
  for (const entry of doomed) {
@@ -257,6 +301,41 @@ export function pruneSessions(opts = {}) {
257
301
  }
258
302
  return doomed;
259
303
  }
304
+ /**
305
+ * The origin half of the truth table. Files WITHOUT metadata are governed by
306
+ * `all` alone (they were already included or already excluded by the listing),
307
+ * so they pass here rather than being re-judged by a `meta` they do not have.
308
+ */
309
+ function isInPruneScope(entry, origin) {
310
+ if (origin === 'all')
311
+ return true;
312
+ if (!entry.managed)
313
+ return true;
314
+ return (entry.origin ?? 'exec') === origin;
315
+ }
316
+ /**
317
+ * `live: true` PLUS A LIVE OWNER PID IS NEVER DELETED (§3.7 / T-33).
318
+ *
319
+ * The pid comes from the file's `.lock` payload, which is the only per-session
320
+ * process identity this directory carries and the same source
321
+ * `tryAcquireSessionLock` trusts. A `live: true` file with NO lock is deletable,
322
+ * and that is the honest answer rather than a gap: the flag says a writer
323
+ * believed itself live, not that one is alive now - a process killed in the
324
+ * night leaves the flag behind, and a protection that could never expire is a
325
+ * file nobody can ever clean up.
326
+ */
327
+ function isOwnedByALiveProcess(entry, isAlive) {
328
+ if (!entry.live)
329
+ return false;
330
+ // THE FILE NAME, NOT `meta.id`: the lock is named after the file, and `meta.id`
331
+ // comes out of that file (I-12). Reading it here would probe the wrong lock for
332
+ // a file whose metadata disagrees with its name - and, since `join` normalises,
333
+ // a hostile id containing separators would read outside this directory.
334
+ const holder = readLock(join(getSessionsDir(), `${basenameId(entry.path)}.lock`));
335
+ if (!holder || holder.host !== hostname())
336
+ return false;
337
+ return isAlive(holder.pid);
338
+ }
260
339
  // ---------------------------------------------------------------------------
261
340
  // `--continue`: the pointer, and the scan that makes it safe to keep
262
341
  // ---------------------------------------------------------------------------
@@ -299,16 +378,26 @@ export function latestSessionForCwd(cwd) {
299
378
  if (hinted && isValidSessionId(hinted.id)) {
300
379
  const path = sessionPathFor(hinted.id);
301
380
  const session = existsSync(path) ? readSessionFile(path) : null;
302
- if (session?.meta && sameCwd(session.meta.cwd, cwd))
381
+ // BOTH BRANCHES EXCLUDE TUI CHECKPOINTS (tui-session-id-resume §3.7). The
382
+ // pointer branch is not defensive padding: `--continue` promises "the newest
383
+ // EXEC session", and TUI sessions are resumed by id. Today only
384
+ // `writeSession()` moves the pointer, so a TUI file cannot be named there -
385
+ // and that is precisely the kind of "nobody writes it today" assumption this
386
+ // repository has already paid for. One predicate, applied twice, is cheap.
387
+ if (session?.meta && originOf(session.meta) === 'exec' && sameCwd(session.meta.cwd, cwd)) {
303
388
  return describe(path, session);
389
+ }
304
390
  }
305
391
  const target = resolve(cwd);
306
392
  for (const entry of listSessions()) {
307
- if (sameCwd(entry.cwd, target))
393
+ if (originOf(entry) === 'exec' && sameCwd(entry.cwd, target))
308
394
  return entry;
309
395
  }
310
396
  return null;
311
397
  }
398
+ function originOf(value) {
399
+ return value.origin === 'tui' ? 'tui' : 'exec';
400
+ }
312
401
  function sameCwd(a, b) {
313
402
  if (!a || !b)
314
403
  return false;
@@ -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.d.ts CHANGED
@@ -8,10 +8,13 @@ import React from 'react';
8
8
  import type { ScrollbarBridge } from './scrollbar-controller.js';
9
9
  import type { AgentController } from '../agent/controller.js';
10
10
  import type { ConfirmRequest } from '../tools/index.js';
11
- import { type Overlay, type ToastLevel } from '../agent/reducer.js';
11
+ import { type Entry, type Overlay, type ToastLevel } from '../agent/reducer.js';
12
12
  import type { UpdateBridge } from '../update/types.js';
13
13
  import type { MouseSource } from '../input/stdin-filter.js';
14
14
  import { type PasteBridge } from '../input/limits.js';
15
+ import type { CheckpointResult } from '../session/checkpoint.js';
16
+ import type { BranchCommandPort } from '../commands/branch-command.js';
17
+ import type { SessionIdentity } from '../session/startup-session.js';
15
18
  import type { SelectionBridge } from './selection/selection-controller.js';
16
19
  import type { HumanInputBridge } from '../tools/human-input.js';
17
20
  export interface ConfirmBridge {
@@ -83,6 +86,55 @@ export interface AppProps {
83
86
  */
84
87
  pasteBridge?: PasteBridge;
85
88
  terminal?: TerminalBridge;
89
+ /**
90
+ * The transcript to seed the first frame with, from a resumed session
91
+ * (tui-session-id-resume 3.12).
92
+ *
93
+ * ABSENT IN EVERY ORDINARY LAUNCH, and absent is what keeps the first frame
94
+ * byte-identical to the pre-feature build (I-6).
95
+ */
96
+ initialEntries?: Entry[];
97
+ /** This process's session identity. `undefined` means "no chip, no checkpoint". */
98
+ session?: SessionIdentity;
99
+ /** The two actions `cli.tsx` owns: checkpoint now, and rotate after `/resume`. */
100
+ sessionBridge?: SessionBridge;
101
+ /**
102
+ * One line to show when the identity is NOT the obvious one (D-13): a branch,
103
+ * or a `--save` name. An ordinary new session says nothing - the chip is the
104
+ * affordance, and a notice on every launch trains users to ignore notices.
105
+ */
106
+ sessionNotice?: string;
107
+ }
108
+ /**
109
+ * `App`'s and `cli.tsx`'s small two-way surface for the session, shaped exactly
110
+ * like `confirmBridge` / `pasteBridge`.
111
+ *
112
+ * `checkpoint` and `rotate` are INSTALLED BY `cli.tsx` because the writer lives
113
+ * outside React (two of its callers are process-level); `onIdentity` is
114
+ * installed by the App so a rotation can move the chip, which is the only piece
115
+ * of the identity the view owns.
116
+ */
117
+ export interface SessionBridge {
118
+ /**
119
+ * Write a checkpoint now; `pin` also marks the file as never-prunable, and
120
+ * `force` skips the fingerprint short circuit so `/branch` can guarantee that
121
+ * the file the new process is about to read is current (D-15).
122
+ */
123
+ checkpoint?: (opts?: {
124
+ pin?: boolean;
125
+ force?: boolean;
126
+ }) => CheckpointResult;
127
+ /** Adopt another conversation's identity (D-15); `undefined` if it cannot. */
128
+ rotate?: (loadedPath: string) => SessionIdentity | undefined;
129
+ /**
130
+ * Ctrl+L / `/branch`: branch this session into a NEW terminal. Installed by
131
+ * `cli.tsx`, because opening a window is a process-level act the App only asks
132
+ * for (tui-branch-terminal-shortcut 3.11). Absent in a build with no session,
133
+ * which is what lets `/branch` say so instead of throwing.
134
+ */
135
+ branch?: BranchCommandPort;
136
+ /** Installed by `App`; called by `rotate` after the new id is claimed. */
137
+ onIdentity?: (identity: SessionIdentity) => void;
86
138
  }
87
139
  export interface TerminalBridge {
88
140
  scrollbar?: ScrollbarBridge;
@@ -96,4 +148,4 @@ export interface TerminalBridge {
96
148
  writeForeign?: (text: string) => void;
97
149
  selection?: SelectionBridge;
98
150
  }
99
- export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }: AppProps): React.ReactElement;
151
+ export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }: AppProps): React.ReactElement;
package/dist/ui/App.js CHANGED
@@ -52,9 +52,11 @@ import { INDEX_KEY_FRAME } from '../input/limits.js';
52
52
  import { INDEX_SKILL_NAME, buildIndexConfirmSummary, hasProjectIndex, indexBuildInvocationArgs, } from '../commands/index-build.js';
53
53
  import { installConsoleBridge } from './console-bridge.js';
54
54
  import { publishExitSnapshot } from './exit-snapshot.js';
55
+ import { publishSessionView } from './session-bridge.js';
55
56
  import { OverlayFrame } from './layout/OverlayFrame.js';
56
57
  import { helpRows } from './overlays/HelpOverlay.js';
57
58
  import { ModelPicker } from './overlays/ModelPicker.js';
59
+ import { createModelProbe } from '../agent/model-probe.js';
58
60
  import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
59
61
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
60
62
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
@@ -120,10 +122,17 @@ function liveServiceCount(services) {
120
122
  * The lazy `useReducer` initializer. Module scope, so the reference is stable
121
123
  * across renders and React never re-runs it.
122
124
  */
123
- function seedViewState(thinkingVisible) {
124
- return initialViewState({ thinkingVisible });
125
+ function seedViewState(seed) {
126
+ const base = initialViewState({ thinkingVisible: seed.thinkingVisible });
127
+ // THE REDUCER'S OWN ADOPTION LOGIC, NOT A COPY OF IT (3.12): `restoreEntries`
128
+ // already lifts `seq` past the restored ids, trims to the retain ring and
129
+ // clears the live-target pointers, and a second implementation of those thirty
130
+ // lines is how the two would drift.
131
+ return seed.entries && seed.entries.length > 0
132
+ ? viewReducer(base, { type: 'restoreEntries', entries: seed.entries })
133
+ : base;
125
134
  }
126
- export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
135
+ export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }) {
127
136
  const { exit } = useApp();
128
137
  const { stdout } = useStdout();
129
138
  const size = useTerminalSize();
@@ -156,7 +165,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
156
165
  // `toggleThinking` would let `/resume` flash a restored thinking block on
157
166
  // frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
158
167
  // function reference.
159
- const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
168
+ const [state, dispatch] = useReducer(viewReducer, { thinkingVisible: cfg.showThinking, ...(initialEntries ? { entries: initialEntries } : {}) }, seedViewState);
169
+ // THE IDENTITY, SEEDED FROM THE PROP AND REPLACED ON ROTATION (D-15). A ref
170
+ // travels beside it because both readers -- the publish effect and the
171
+ // `/session` port -- must see the CURRENT id, and a rotation can land between
172
+ // two renders.
173
+ const [identity, setIdentity] = useState(session);
174
+ const identityRef = useRef(session);
175
+ const sessionId = identity?.id;
160
176
  const metrics = useRef(createRunMetrics());
161
177
  const manualMetrics = useRef(createRunMetrics());
162
178
  const [, setMetricsClock] = useState(0);
@@ -1024,8 +1040,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1024
1040
  : undefined);
1025
1041
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
1026
1042
  useEffect(() => {
1043
+ const merged = mergePendingEntries(state.entries, state.pendingSteering);
1027
1044
  publishExitSnapshot({
1028
- entries: mergePendingEntries(state.entries, state.pendingSteering),
1045
+ entries: merged,
1029
1046
  usageTotal: state.usageTotal,
1030
1047
  provider: cfg.provider,
1031
1048
  model: cfg.model,
@@ -1035,7 +1052,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1035
1052
  // count (K-6).
1036
1053
  droppedEntries: state.droppedEntries,
1037
1054
  });
1038
- }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
1055
+ // THE SAME `merged`, THE SAME RENDER (D-2 / 3.12). The checkpoint describes
1056
+ // exactly what the exit summary would, because it is built from the same
1057
+ // array in the same pass - and no second effect or subscription is added.
1058
+ //
1059
+ // `sessionId` IN THE DEPENDENCY ARRAY IS LOAD-BEARING (R-16): a rotation
1060
+ // that leaves the `entries` reference untouched would otherwise never
1061
+ // re-publish, and the writer would keep aiming at the OLD id's file - the
1062
+ // "A's conversation written into B's file" failure I-5 rules out.
1063
+ if (sessionId) {
1064
+ publishSessionView({ id: sessionId, entries: merged, usageTotal: state.usageTotal });
1065
+ }
1066
+ }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model, sessionId]);
1039
1067
  // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
1040
1068
  //
1041
1069
  // The deps are the two `useCallback`d members, NOT the handle objects: both
@@ -1145,6 +1173,33 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1145
1173
  // --- Helpers -----------------------------------------------------------
1146
1174
  const notify = (level, text) => dispatch({ type: 'notice', level, text });
1147
1175
  const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
1176
+ // THE BRANCH / `--save` NOTICE (D-13). Once, on mount, and only when the
1177
+ // caller supplied one: "the session you branched from is unchanged" is the
1178
+ // sentence that makes `--resume` safe to reach for, and it belongs in the
1179
+ // transcript where it can be read again rather than in a toast that expires.
1180
+ useEffect(() => {
1181
+ if (sessionNotice)
1182
+ notify('info', sessionNotice);
1183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1184
+ }, []);
1185
+ // --- Session bridge: identity write-back (D-15 / 3.12). -------------------
1186
+ //
1187
+ // INSTALLED AND CLEARED EXACTLY LIKE `confirmBridge.handler`: the rotation is
1188
+ // performed by `cli.tsx` (which owns the writer's target file), and the only
1189
+ // thing the view owns is the id it draws. Clearing on unmount keeps a late
1190
+ // rotation from dispatching into a dead reducer.
1191
+ useEffect(() => {
1192
+ if (!sessionBridge)
1193
+ return undefined;
1194
+ const bridge = sessionBridge;
1195
+ bridge.onIdentity = (next) => {
1196
+ identityRef.current = next;
1197
+ setIdentity(next);
1198
+ };
1199
+ return () => {
1200
+ bridge.onIdentity = undefined;
1201
+ };
1202
+ }, [sessionBridge]);
1148
1203
  // --- Paste bridge (tui-paste-handling section 5.1.2 / I-15). -------------
1149
1204
  //
1150
1205
  // The filter exists before this component does, so it holds a slot rather than
@@ -1388,6 +1443,35 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1388
1443
  requestCopy: copyCoordinator.requestCopy,
1389
1444
  controller,
1390
1445
  state: stateRef.current,
1446
+ // THE ARCHIVE'S ONLY WAY TO SEE THE SCREEN (P0-1 / §3.7), and a thunk for a
1447
+ // reason `state` above illustrates: `state` is a snapshot of this render,
1448
+ // while `/clear` interrupts a run and then waits for it to unwind, so the last
1449
+ // tool cards of the abandoned run are appended AFTER the command starts. A
1450
+ // value here would archive the screen minus the part the user was watching.
1451
+ transcriptEntries: () => stateRef.current.entries,
1452
+ // THE SESSION PORT (D-14 / D-15). `identity()` reads the ref at command time
1453
+ // and never a captured value: `/resume` rotates the id under a live tree, and
1454
+ // a captured one would report the previous id for the rest of the session.
1455
+ // `checkpoint` / `rotate` are the writer's, installed by `cli.tsx`; the port
1456
+ // is absent when this build has no session at all, which is what lets
1457
+ // `/session` say so honestly instead of throwing.
1458
+ ...(sessionBridge
1459
+ ? {
1460
+ session: {
1461
+ identity: () => identityRef.current,
1462
+ checkpoint: (opts) => sessionBridge.checkpoint?.(opts) ?? {
1463
+ ok: false,
1464
+ reason: 'no-identity',
1465
+ message: 'This session has no id.',
1466
+ },
1467
+ rotate: (loadedPath) => sessionBridge.rotate?.(loadedPath),
1468
+ },
1469
+ }
1470
+ : {}),
1471
+ // The branch port is projected from the bridge here rather than captured, so
1472
+ // this object stays a view of the current bridge, exactly like `session`
1473
+ // above. Absent means `/branch` reports that the build has none.
1474
+ ...(sessionBridge?.branch ? { branch: sessionBridge.branch } : {}),
1391
1475
  dispatch: (action) => {
1392
1476
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1393
1477
  metrics.current = createRunMetrics();
@@ -1535,12 +1619,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1535
1619
  };
1536
1620
  submitRef.current = submitMessage;
1537
1621
  const executeSlashInput = async (raw) => {
1538
- const switchWhileBusy = controller.isCompactionBusy?.()
1539
- && /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1540
- if (switchWhileBusy) {
1622
+ // A CONVERSATION SWITCH OWNS THE QUEUE FOR THE WHOLE COMMAND (B-6 / §3.7).
1623
+ //
1624
+ // The guard used to be raised only when a compaction happened to be busy,
1625
+ // which left the one window this feature is about unwatched: `/clear` on an
1626
+ // IDLE session took the flag nowhere, and `drainCompactionQueue` was still
1627
+ // free to `continue()` a message queued in the previous conversation into
1628
+ // the one the user had just started. Whether there is work to cancel is a
1629
+ // separate question, so the two are now separate values: `switching` decides
1630
+ // the lock, `switchWhileBusy` decides the cancellation.
1631
+ //
1632
+ // `/resume` is still refused mid-run by `handleSubmit` below, so for it the
1633
+ // lock only ever matters while idle - that is the minimum this invariant
1634
+ // asks for, not a contradiction.
1635
+ const switching = /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1636
+ const switchWhileBusy = switching && Boolean(controller.isCompactionBusy?.());
1637
+ if (switching)
1541
1638
  switchingConversation.current = true;
1639
+ if (switchWhileBusy)
1542
1640
  await controller.cancelCompaction();
1543
- }
1544
1641
  // Retain a recoverable command before any asynchronous handler can fail.
1545
1642
  const safe = redactText(raw, getSecrets());
1546
1643
  recordPrompt(safe);
@@ -1553,7 +1650,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1553
1650
  notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1554
1651
  }
1555
1652
  finally {
1556
- if (switchWhileBusy) {
1653
+ if (switching) {
1557
1654
  switchingConversation.current = false;
1558
1655
  drainCompactionQueue.current();
1559
1656
  }
@@ -1563,7 +1660,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1563
1660
  const trimmed = raw.trimStart();
1564
1661
  const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
1565
1662
  if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
1566
- /^\/(reset|resume)(?:\s|$)/.test(trimmed)) {
1663
+ /^\/resume(?:\s|$)/.test(trimmed)) {
1567
1664
  return { accepted: false,
1568
1665
  reason: 'Interrupt the current run before switching conversations.' };
1569
1666
  }
@@ -1848,6 +1945,23 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1848
1945
  requestIndexBuild();
1849
1946
  return;
1850
1947
  }
1948
+ // Ctrl+L: branch this session into a NEW terminal (tui-branch-terminal-
1949
+ // shortcut D-1 / D-2). Guarded exactly like Ctrl+G and Ctrl+I above it: an
1950
+ // overlay owns the keyboard, and on a too-small terminal the feedback has
1951
+ // nowhere to go. It has to sit BEFORE the `tooSmall` return below, because
1952
+ // after that line the transcript and overlay paging own every key - and
1953
+ // "the key did nothing" must stay distinguishable from "the key never
1954
+ // arrived".
1955
+ //
1956
+ // NO `isRunning()` GUARD, unlike Ctrl+I: branching mid-run is the most likely
1957
+ // moment to want it (D-22), and the command body is where running decides
1958
+ // that the success needs a second, durable channel.
1959
+ if (key.ctrl && (input === 'l' || input === 'L')) {
1960
+ if (tooSmall || stateRef.current.overlay)
1961
+ return;
1962
+ void executeSlashInput('/branch');
1963
+ return;
1964
+ }
1851
1965
  if (tooSmall)
1852
1966
  return;
1853
1967
  // R-P1-7: the overlay branch MUST come before the transcript scroll branch
@@ -1893,21 +2007,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1893
2007
  applyMode(nextMode(controller.getAgentMode()));
1894
2008
  return;
1895
2009
  }
1896
- if (key.ctrl && (input === 'l' || input === 'L')) {
1897
- {
1898
- // Writing \x1B[2J here would erase the screen and then leave it blank:
1899
- // Ink sees no output change and skips the repaint (ink.js:132 +
1900
- // log-update.js:13), possibly forever on an idle empty session. Bump
1901
- // the nonce instead and let Ink redraw the frame normally (§4.13).
1902
- setRedrawNonce((n) => n + 1);
2010
+ // Moved off Ctrl+L by D-1, because Ctrl+L is the branch key now.
2011
+ //
2012
+ // Ctrl+R, NOT Ctrl+Shift+L: legacy encodings deliver 0x0c for both, so a
2013
+ // Shift binding would exist only behind keyboard enhancement - exactly the
2014
+ // terminals least likely to need a recovery key. The overlay guard keeps the
2015
+ // settings screen's own Ctrl+R (Test connection) unambiguous.
2016
+ //
2017
+ // Writing \x1B[2J here would erase the screen and then leave it blank: Ink
2018
+ // sees no output change and skips the repaint (ink.js:132 +
2019
+ // log-update.js:13), possibly forever on an idle empty session. Bump the
2020
+ // nonce instead and let Ink redraw the frame normally (§4.13).
2021
+ if (key.ctrl && (input === 'r' || input === 'R')) {
2022
+ if (stateRef.current.overlay)
1903
2023
  return;
1904
- }
1905
- try {
1906
- stdout.write('\x1B[2J\x1B[3J\x1B[H');
1907
- }
1908
- catch {
1909
- /* ignore */
1910
- }
2024
+ setRedrawNonce((n) => n + 1);
1911
2025
  return;
1912
2026
  }
1913
2027
  {
@@ -2050,6 +2164,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2050
2164
  const overlay = state.overlay;
2051
2165
  const settingsDraft = useMemo(() => overlay === 'settings'
2052
2166
  ? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
2167
+ // ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
2168
+ // controller's provider registry, which the overlay has no business owning.
2169
+ // Built only while the settings overlay is open, so no other screen pays for it.
2170
+ const probe = useMemo(() => {
2171
+ if (overlay !== 'settings')
2172
+ return undefined;
2173
+ // A test double or an embedded host may predate this port; absent means the
2174
+ // two probe rows read `(unavailable)` rather than the screen crashing.
2175
+ if (typeof controller.getProviderRegistry !== 'function')
2176
+ return undefined;
2177
+ return createModelProbe({ registry: controller.getProviderRegistry() });
2178
+ }, [controller, overlay]);
2053
2179
  const hasKey = controller.hasApiKey();
2054
2180
  const empty = state.entries.length === 0;
2055
2181
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
@@ -2155,7 +2281,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2155
2281
  // the save handler cannot disagree about what `(inherit)` means.
2156
2282
  ...fastSettingsFrom(cfg.fast),
2157
2283
  ...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 }),
2284
+ }, 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
2285
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2160
2286
  // while the screen is open shows the new value on the next paint.
2161
2287
  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;
@@ -2249,7 +2375,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2249
2375
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2250
2376
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2251
2377
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2252
- return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2378
+ return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2253
2379
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2254
2380
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2255
2381
  // projection of "many sources of truth" into the props layer, and a
package/dist/ui/Header.js CHANGED
@@ -2,21 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { basename } from 'node:path';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Logo } from './Logo.js';
5
+ import { planHeaderHints } from './layout/header-hints.js';
5
6
  export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
6
- const scroll = Number.isFinite(scrolledLines) && scrolledLines > 0
7
- ? (scrolledLines > 9999 ? '^9999+' : '^' + Math.floor(scrolledLines)) + (overlayOpen ? ' ' : ' ') : '';
8
- // The Ctrl+I project-index affordance sits LEFT of the ^G detail toggle:
9
- // collapsed it names the key and the noun ('^I Index'); expanded - the
10
- // same Ctrl+G state that adds the status detail row - it spells the action
11
- // ('^I build index'). Hidden while an overlay owns the header and below the
12
- // 72-column cutoff the composer hints also use: on a narrow terminal the
13
- // scroll chip and the ^G toggle are the two affordances that must survive.
14
- const indexHint = overlayOpen || columns < 72
15
- ? ''
16
- : (statusExpanded ? '^I build index ' : '^I Index ');
17
- const detail = overlayOpen ? (statusExpanded ? 'Details on' : 'Details off')
18
- : indexHint + '^G ' + (statusExpanded ? 'less' : 'more');
19
- // Widest content: '^9999+ ' (8) + '^I build index ^G less' (23) = 31.
20
- const detailCells = indexHint ? 32 : 18;
21
- return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - detailCells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: detailCells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: scroll + detail }) })] });
7
+ // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
+ // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
9
+ // scroll chip's own trailing spacing, so this component can no longer clip a
10
+ // hint by guessing one cell short - and the ladder that decides which hints
11
+ // survive a narrow terminal lives in a pure function with its own tests.
12
+ const plan = planHeaderHints({ columns, statusExpanded, overlayOpen, scrolledLines });
13
+ return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
22
14
  }
@@ -37,6 +37,14 @@ export interface StatusBarProps {
37
37
  caps: TermCapabilities;
38
38
  scrolledLines?: number;
39
39
  redrawNonce?: number;
40
+ /**
41
+ * This session's id, rendered at the right end of the primary row
42
+ * (tui-session-id-resume D-11 / D-12).
43
+ *
44
+ * OPTIONAL AND ABSENT BY DEFAULT, so a build without the feature and every
45
+ * existing test render exactly what they rendered before (I-6).
46
+ */
47
+ sessionId?: string;
40
48
  agentMode?: AgentMode;
41
49
  pendingAgentMode?: AgentMode | null;
42
50
  teamActive?: {