@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
package/dist/cli.js CHANGED
@@ -42,6 +42,11 @@ import { registerExecCommand } from './exec/cli-commands.js';
42
42
  import { registerSessionsCommand } from './session/cli-commands.js';
43
43
  import { registerDoctorCommand, registerInfoCommand } from './diagnostics/cli-commands.js';
44
44
  import { App } from './ui/App.js';
45
+ import { readSessionView, publishSessionView } from './ui/session-bridge.js';
46
+ import { SessionCheckpointWriter } from './session/checkpoint.js';
47
+ import { resolveStartupSession, rotateToChildOf } from './session/startup-session.js';
48
+ import { createBranchLauncher } from './session/branch-launch.js';
49
+ import { EMPTY_USAGE_TOTAL } from './agent/reducer.js';
45
50
  import { createFrameDiffer } from './ui/frame-differ.js';
46
51
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
47
52
  import { setFrameStatsProvider } from './commands/perf.js';
@@ -200,6 +205,13 @@ function toFlags(opts) {
200
205
  // reports nothing, does nothing, and leaves compaction armed.
201
206
  compaction: opts.compaction,
202
207
  compactionThreshold: opts.compactionThreshold,
208
+ // Site three of three for `--resume` / `--save` -- ELEVEN features, one trap
209
+ // (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, update,
210
+ // compaction, now session identity). A flag needs the `.option()`
211
+ // declaration below, a field on `CliFlags`, AND a line here; miss this one
212
+ // and `aragon --resume <id>` starts a brand new session with no error at all.
213
+ resumeSession: opts.resume,
214
+ saveSessionAs: opts.save,
203
215
  };
204
216
  }
205
217
  // ---------------------------------------------------------------------------
@@ -388,7 +400,33 @@ function runInteractive(flags, extras = {}) {
388
400
  process.exitCode = 2;
389
401
  return;
390
402
  }
403
+ // --- Session identity, resolved BEFORE the controller exists (D-9). ------
404
+ //
405
+ // BEFORE, not after, because every failure here is exit 2 with one copyable
406
+ // sentence and NO writes: a controller that already exists has to be disposed
407
+ // on a path that never rendered anything. The only write this performs is the
408
+ // `wx` claim of the new id.
409
+ const startup = resolveStartupSession({
410
+ ...(flags.resumeSession === undefined ? {} : { resume: flags.resumeSession }),
411
+ ...(flags.saveSessionAs === undefined ? {} : { saveAs: flags.saveSessionAs }),
412
+ cwd: process.cwd(),
413
+ // An explicit `--model` / `--provider` outranks the model recorded in the
414
+ // file: the user said what they wanted more recently than the file did.
415
+ explicitModel: flags.model !== undefined || flags.provider !== undefined,
416
+ });
417
+ if (!startup.ok) {
418
+ process.stderr.write(`${startup.message}\n`);
419
+ process.exitCode = 2;
420
+ return;
421
+ }
422
+ // MUTABLE BECAUSE `/resume` ROTATES IT (D-15). The chip follows through the
423
+ // App's `onIdentity`, and the writer reads `parentId` from here on every write.
424
+ let identity = startup.identity;
391
425
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
426
+ // THE RESTORE HAPPENS BEFORE THE FIRST FRAME. `initialEntries` seeds the
427
+ // reducer, so a resumed transcript is complete on frame one instead of filling
428
+ // in behind the user's eyes.
429
+ startup.apply(controller);
392
430
  void controller.refreshModelMetadata();
393
431
  const config = controller.getConfig();
394
432
  const caps = resolveTuiCapabilities({}, config.color);
@@ -403,6 +441,76 @@ function runInteractive(flags, extras = {}) {
403
441
  // outcome for the one feature here that runs five agents the user cannot
404
442
  // watch (§3.13 / P1-2 / R-19).
405
443
  attachTeamEvents(logger, controller);
444
+ // --- The checkpoint writer (D-2 / D-21 / I-10). --------------------------
445
+ //
446
+ // BUILT HERE, BEFORE EVERY EXIT PATH BELOW, because all three of them flush
447
+ // through it and one of them is a `process.on('exit')` handler that can fire
448
+ // while the frame is still on screen.
449
+ //
450
+ // `readView` reads the module-level slot `App` publishes into; with no frame
451
+ // yet (or after unmount) it answers `undefined` and the writer reports
452
+ // `no-identity` instead of writing a guess.
453
+ const checkpointWriter = createSessionWriter({
454
+ controller,
455
+ readView: readSessionView,
456
+ readParentId: () => identity.parentId,
457
+ startPinned: identity.pinned === true,
458
+ });
459
+ // --- The branch port (D-1 / D-2 / D-5 / D-16). ---------------------------
460
+ //
461
+ // BUILT HERE FOR THE SAME REASON THE WRITER IS: it needs the writer (to flush
462
+ // before spawning) and it needs `process.env` / `process.argv`, both of which
463
+ // belong to the host rather than to the view. The App only forwards it.
464
+ //
465
+ // The `checkpoint` closure is a THIN WRAPPER, not the writer itself: the port
466
+ // asks for `force: true` and reports `{ok, message}`, while the writer returns
467
+ // a richer result whose `reason` the launcher must not interpret.
468
+ const branchLauncher = createBranchLauncher({
469
+ checkpoint: (opts) => {
470
+ const result = checkpointWriter.writeNow(opts);
471
+ return result.ok ? { ok: true } : { ok: false, message: result.reason };
472
+ },
473
+ platform: process.platform,
474
+ env: process.env,
475
+ argv: process.argv,
476
+ execPath: process.execPath,
477
+ });
478
+ const sessionBridge = {
479
+ branch: branchLauncher,
480
+ checkpoint: (opts) => {
481
+ const result = checkpointWriter.writeNow(opts ?? {});
482
+ // A PIN THE REPORT DOES NOT KNOW ABOUT IS A LIE ON SCREEN. `writeNow` sets
483
+ // the writer's sticky flag, and `/session` afterwards reads
484
+ // `port.identity()`, which is React state - so without this the file says
485
+ // `pinned: true` while `/session` still answers `pinned no`.
486
+ if (opts?.pin === true && result.ok) {
487
+ identity = { ...identity, pinned: true };
488
+ sessionBridge.onIdentity?.(identity);
489
+ }
490
+ return result;
491
+ },
492
+ rotate: (loadedPath) => {
493
+ // CLAIM THE NEW ID FIRST, THEN TELL THE VIEW (D-15). If the claim fails the
494
+ // chip keeps the id whose file it is still writing, which is the only safe
495
+ // half of a failed rotation.
496
+ const next = rotateToChildOf(loadedPath);
497
+ if (!next)
498
+ return undefined;
499
+ identity = next;
500
+ // REPUBLISH SYNCHRONOUSLY, because the writer reads the id from the
501
+ // published slot while it reads `parentId` from `identity` above. Between
502
+ // this line and the next render those two would disagree, and a flush in
503
+ // that window (exit, signal, `/session pin`) would write the conversation
504
+ // into the OLD id's file carrying the NEW `branchOf` - the exact
505
+ // inconsistency I-5 / I-12 forbid (the view's `onIdentity` below is what
506
+ // keeps the chip in step; this is what keeps the WRITER in step).
507
+ const view = readSessionView();
508
+ if (view)
509
+ publishSessionView({ ...view, id: next.id });
510
+ sessionBridge.onIdentity?.(next);
511
+ return next;
512
+ },
513
+ };
406
514
  let screen = null;
407
515
  let replayed = false;
408
516
  // --- Mouse wheel region routing (mouse-wheel-region-routing §4.2). -------
@@ -624,7 +732,14 @@ function runInteractive(flags, extras = {}) {
624
732
  // Without it, `kill <pid>`, closing the terminal window (SIGHUP), or a dying
625
733
  // parent all leave the user staring at a blank alternate screen with `reset`
626
734
  // as their only way out.
627
- process.on('exit', restore);
735
+ // THE THIRD PATH, AND NOT A REDUNDANT ONE (R-17 / I-10): `waitUntilExit()` is
736
+ // a microtask that a killed process never reaches, and `failInteractive`
737
+ // reaches neither it nor the signal hook. `flushSync` is idempotent, so the
738
+ // three can coexist without writing three times.
739
+ process.on('exit', () => {
740
+ checkpointWriter.flushSync('exit');
741
+ restore();
742
+ });
628
743
  // The signal listeners themselves now belong to `installLogging()`, which
629
744
  // registered them before this function ever ran — and a second listener per
630
745
  // signal would race the first. This branch owns the SCREEN, so it supplies
@@ -633,6 +748,9 @@ function runInteractive(flags, extras = {}) {
633
748
  // the alternate screen), and the signal path restores before exiting.
634
749
  setScreenRestore(restore);
635
750
  setSignalTerminator((signo) => {
751
+ // BEFORE `restore()`: both are synchronous, but only this one needs the
752
+ // controller and the published view to still be live.
753
+ checkpointWriter.flushSync('signal');
636
754
  restore(); // Synchronous: stdout.write on a TTY is sync on every platform.
637
755
  process.exit(128 + signo);
638
756
  });
@@ -747,7 +865,7 @@ function runInteractive(flags, extras = {}) {
747
865
  vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
748
866
  // Absent when nothing can refuse a paste, so a `--no-paste` tree is
749
867
  // byte-identical to a build without this feature.
750
- pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
868
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge, session: identity, sessionBridge: sessionBridge, ...(startup.initialEntries.length > 0 ? { initialEntries: startup.initialEntries } : {}), ...(startup.notice ? { sessionNotice: startup.notice } : {}) }),
751
869
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
752
870
  // straight to stdout and permanently shifts the fixed frame's accounting.
753
871
  {
@@ -775,6 +893,17 @@ function runInteractive(flags, extras = {}) {
775
893
  // never armed it this is one read and one string compare. Gating it would mean
776
894
  // a user who switches `mode` to `off` between the install and the next launch
777
895
  // leaves the guard armed with nothing able to disarm it.
896
+ // THE FIRST WRITE, IMMEDIATELY AFTER `render()` (D-2). This is what makes the
897
+ // id on screen resumable BEFORE the first message: a zero-turn session is a
898
+ // legal session, and `--resume` of one is a legitimate thing to want.
899
+ checkpointWriter.start();
900
+ // ONE SUBSCRIPTION, ON ONE EVENT (I-10). `agent_end` is the only moment a
901
+ // checkpoint is honest: mid-stream the turn has not settled, and every extra
902
+ // event subscription is another timer source to reason about.
903
+ checkpointWriter.attach((onAgentEnd) => controller.subscribe((event) => {
904
+ if (event.type === 'agent_end')
905
+ onAgentEnd();
906
+ }));
778
907
  setImmediate(() => { if (!interactiveClosed)
779
908
  markBootHealthy(VERSION); });
780
909
  if (updateEligible) {
@@ -819,6 +948,10 @@ function runInteractive(flags, extras = {}) {
819
948
  .waitUntilExit()
820
949
  .then(() => {
821
950
  interactiveClosed = true;
951
+ // FIRST LINE, AND BEFORE `controller.dispose()`: this flush needs the live
952
+ // history, the live todos and the published view, all of which the
953
+ // disposers below take apart.
954
+ checkpointWriter.flushSync('exit');
822
955
  // Also cover a successful immediate exit when launched via dist/cli.js,
823
956
  // which bypasses the launcher's code-zero boot-guard backstop.
824
957
  markBootHealthy(VERSION);
@@ -852,11 +985,57 @@ function runInteractive(flags, extras = {}) {
852
985
  })
853
986
  .catch(failInteractive);
854
987
  }
988
+ /**
989
+ * Build the checkpoint writer for one process (D-2 / D-21).
990
+ *
991
+ * ONE CONSTRUCTION SITE FOR TWO PATHS. The TUI and `-p` differ in exactly one
992
+ * thing - where `readView` gets its transcript - and in nothing else: the same
993
+ * `meta` fields, the same live model, the same todos, the same cwd. Writing the
994
+ * eight deps twice is how `meta.turns` would come to mean two things, which is
995
+ * the drift class D-9 exists to prevent for `applyRestoredSession`.
996
+ */
997
+ function createSessionWriter(input) {
998
+ const { controller } = input;
999
+ return new SessionCheckpointWriter({
1000
+ readView: input.readView,
1001
+ readHistory: () => controller.getSessionSnapshot(),
1002
+ readTodos: () => controller.getTodoSnapshot()?.items ?? [],
1003
+ // READ AT WRITE TIME: `/model` rebuilds the config, and a checkpoint that
1004
+ // recorded the model from launch would name the wrong one after a switch.
1005
+ readModel: () => {
1006
+ const live = controller.getConfig();
1007
+ return {
1008
+ providerId: live.provider,
1009
+ modelId: live.model,
1010
+ ...(live.baseUrl ? { baseUrl: live.baseUrl } : {}),
1011
+ };
1012
+ },
1013
+ readCwd: () => controller.getCwd(),
1014
+ version: VERSION,
1015
+ readParentId: input.readParentId,
1016
+ startPinned: input.startPinned,
1017
+ });
1018
+ }
855
1019
  async function runOneShot(flags, prompt, quiet) {
1020
+ // THE SAME RESOLUTION THE TUI USES (D-9 / D-21). A `-p` run has no Ink, no
1021
+ // `/session` and no `agent_end` subscription, so `--resume` here is one read
1022
+ // and one write - not a different feature.
1023
+ const startup = resolveStartupSession({
1024
+ ...(flags.resumeSession === undefined ? {} : { resume: flags.resumeSession }),
1025
+ ...(flags.saveSessionAs === undefined ? {} : { saveAs: flags.saveSessionAs }),
1026
+ cwd: process.cwd(),
1027
+ explicitModel: flags.model !== undefined || flags.provider !== undefined,
1028
+ });
1029
+ if (!startup.ok) {
1030
+ process.stderr.write(`${startup.message}\n`);
1031
+ process.exitCode = 2;
1032
+ return;
1033
+ }
856
1034
  // Headless: no App is ever rendered, so there is no human to approve a skill
857
1035
  // install. The gate must know that up front rather than discovering a null
858
1036
  // handler and defaulting to "yes" (§7.4 / AC-13).
859
1037
  const { controller } = makeController(flags, { interactive: false });
1038
+ startup.apply(controller);
860
1039
  void controller.refreshModelMetadata();
861
1040
  const logger = getLogger();
862
1041
  logger.info('cli', 'run_oneshot', { quiet });
@@ -876,6 +1055,27 @@ async function runOneShot(flags, prompt, quiet) {
876
1055
  // `followThrough` is read off the REAL controller here rather than through
877
1056
  // `HeadlessController`, which is deliberately the minimal surface a test can
878
1057
  // satisfy with an object literal (todo-plan-followthrough §3.6).
1058
+ // THE ONE WRITE `-p` MAKES (D-21), and it is a deliberate trade: `entries` is
1059
+ // empty because there is no transcript, while `messages` is authoritative, so a
1060
+ // later `--resume` inherits the full context and replays no scrollback. Faking
1061
+ // entries would put something on screen that was never displayed.
1062
+ const checkpointWriter = createSessionWriter({
1063
+ controller,
1064
+ readView: () => ({
1065
+ id: startup.identity.id,
1066
+ entries: [],
1067
+ usageTotal: EMPTY_USAGE_TOTAL,
1068
+ }),
1069
+ readParentId: () => startup.identity.parentId,
1070
+ startPinned: startup.identity.pinned === true,
1071
+ });
1072
+ // STDERR, NOT STDOUT (D-21): stdout may be a pipe whose consumer expects the
1073
+ // answer and nothing else, while a human running `-p` by hand still gets told
1074
+ // which session they branched into.
1075
+ if (startup.identity.kind === 'branch' && startup.identity.parentId) {
1076
+ process.stderr.write(`[session] Resumed ${startup.identity.parentId} as a new branch: ` +
1077
+ `${startup.identity.id} (headless: context only, no transcript).\n`);
1078
+ }
879
1079
  let code;
880
1080
  try {
881
1081
  code = await runHeadless(controller, prompt, {
@@ -884,6 +1084,8 @@ async function runOneShot(flags, prompt, quiet) {
884
1084
  });
885
1085
  }
886
1086
  finally {
1087
+ // BEFORE `dispose()`: the flush needs the live history and todos.
1088
+ checkpointWriter.flushSync('exit');
887
1089
  controller.dispose();
888
1090
  }
889
1091
  // Flushed HERE and not inside `runHeadless`: that module is deliberately
@@ -1461,6 +1663,16 @@ function buildProgram() {
1461
1663
  .version(VERSION, '-v, --version', 'Print the version')
1462
1664
  .argument('[prompt]', 'Task prompt (starts the TUI, or a one-shot run with -p / piped stdin)')
1463
1665
  .option('-p, --print', 'Headless: stream the answer to stdout, then exit')
1666
+ // ROOT-LEVEL, AND `exec` KEEPS ITS OWN (commander does not inherit parent
1667
+ // options: `copyInheritedSettings` copies output/help configuration only, not
1668
+ // `.option()` definitions), so the two can coexist. T-39 asserts both option
1669
+ // tables structurally rather than by parsing help text.
1670
+ //
1671
+ // `--resume` ALWAYS BRANCHES. It reads the named session and continues it under
1672
+ // a NEW id, so the session it came from is never written again by this process;
1673
+ // `--save` names THIS run's persistent id and pins it against `sessions prune`.
1674
+ .option('--resume <id|path>', 'Resume a saved session as a new branch (TUI or -p)')
1675
+ .option('--save <id>', 'Persist this session under this id (pinned against prune)')
1464
1676
  .option('--provider <id>', 'anthropic | openai | google')
1465
1677
  .option('--model <id>', 'Model id')
1466
1678
  .option('--base-url <url>', 'Override the provider base URL')
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `/branch` - open a new terminal on this session (tui-branch-terminal-shortcut
3
+ * 3.5 / D-1 / D-2 / D-12 / D-13 / D-21 / D-22).
4
+ *
5
+ * ASCII ONLY - `src/commands/**` is inside `glyphs.test.ts::inScope`, which also
6
+ * means every sentence in `BRANCH_COPY` is plain ASCII (no em dashes, no arrows).
7
+ *
8
+ * WHY A COMMAND AND NOT A KEY HANDLER: `Ctrl+L` dispatches `/branch` exactly the
9
+ * way `Ctrl+I` dispatches `/skill:project-indexer` (`App.tsx` is a documented
10
+ * oversize file, so new logic goes into a module and the key handler keeps doing
11
+ * only guard + dispatch). The side benefit is that `/branch` becomes typeable,
12
+ * testable, listed by `/help`, and inherits `executeSlashInput`'s try/catch - a
13
+ * throwing command becomes one `Command failed: ...` line instead of an unhandled
14
+ * rejection. The cost is ONE transcript line per press (D-21), which `Ctrl+I`
15
+ * already pays today.
16
+ *
17
+ * THIS BODY NEVER TOUCHES SESSION DATA (I-5): no draft read, no queue change, no
18
+ * message edit, no id change, no write to the parent file. It calls `identity()`
19
+ * and `open()` and reports what came back.
20
+ */
21
+ import type { CommandContext } from './registry.js';
22
+ import type { BranchOpenResult, BranchRequest } from '../session/branch-launch.js';
23
+ /** What `/branch` can do. The one seam a test needs. */
24
+ export interface BranchCommandPort {
25
+ open(request: BranchRequest): Promise<BranchOpenResult>;
26
+ }
27
+ /**
28
+ * Every sentence `/branch` can print, verbatim.
29
+ *
30
+ * ONE SOURCE, THREE CHANNELS (D-12): success goes to the toast (plus a notice while
31
+ * the agent runs, D-22), failures go to `notify` because a user has to READ a full
32
+ * sentence and possibly copy a command out of it, and cooldown goes to the toast
33
+ * because it is not a failure - it is "you already did this", which expires in two
34
+ * seconds and would be permanent noise in the transcript.
35
+ *
36
+ * `noIdentity` REUSES `/session`'s SENTENCE (R-10). The same state must not read
37
+ * two different ways depending on which key the user pressed; only the consequence
38
+ * clause ("so there is nothing to branch from" / "") differs.
39
+ *
40
+ * There is deliberately NO `stopped` entry (R-9): nothing ever called it.
41
+ */
42
+ export declare const BRANCH_COPY: {
43
+ readonly noIdentity: string;
44
+ readonly unavailable: "Branching into a new terminal is not available in this build.";
45
+ readonly cooldown: (ms: number) => string;
46
+ readonly opened: (id: string) => string;
47
+ readonly checkpointFailed: (reason: string) => string;
48
+ readonly noLauncher: (manual: string) => string;
49
+ readonly spawnFailed: (reason: string, manual: string) => string;
50
+ readonly lateError: (reason: string) => string;
51
+ };
52
+ /**
53
+ * Run `/branch`.
54
+ *
55
+ * ASYNC BY NECESSITY, NOT BY STYLE: "the window opened" and "`wt.exe` does not
56
+ * exist" are distinguishable only after the child emits `'spawn'` or `'error'`,
57
+ * and ENOENT is reported asynchronously. `SlashCommand.run` already allows
58
+ * `Promise<void>`, and `App` calls this as `void executeSlashInput('/branch')`, so
59
+ * the UI never waits.
60
+ */
61
+ export declare function runBranchCommand(ctx: CommandContext): Promise<void>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `/branch` - open a new terminal on this session (tui-branch-terminal-shortcut
3
+ * 3.5 / D-1 / D-2 / D-12 / D-13 / D-21 / D-22).
4
+ *
5
+ * ASCII ONLY - `src/commands/**` is inside `glyphs.test.ts::inScope`, which also
6
+ * means every sentence in `BRANCH_COPY` is plain ASCII (no em dashes, no arrows).
7
+ *
8
+ * WHY A COMMAND AND NOT A KEY HANDLER: `Ctrl+L` dispatches `/branch` exactly the
9
+ * way `Ctrl+I` dispatches `/skill:project-indexer` (`App.tsx` is a documented
10
+ * oversize file, so new logic goes into a module and the key handler keeps doing
11
+ * only guard + dispatch). The side benefit is that `/branch` becomes typeable,
12
+ * testable, listed by `/help`, and inherits `executeSlashInput`'s try/catch - a
13
+ * throwing command becomes one `Command failed: ...` line instead of an unhandled
14
+ * rejection. The cost is ONE transcript line per press (D-21), which `Ctrl+I`
15
+ * already pays today.
16
+ *
17
+ * THIS BODY NEVER TOUCHES SESSION DATA (I-5): no draft read, no queue change, no
18
+ * message edit, no id change, no write to the parent file. It calls `identity()`
19
+ * and `open()` and reports what came back.
20
+ */
21
+ /**
22
+ * Every sentence `/branch` can print, verbatim.
23
+ *
24
+ * ONE SOURCE, THREE CHANNELS (D-12): success goes to the toast (plus a notice while
25
+ * the agent runs, D-22), failures go to `notify` because a user has to READ a full
26
+ * sentence and possibly copy a command out of it, and cooldown goes to the toast
27
+ * because it is not a failure - it is "you already did this", which expires in two
28
+ * seconds and would be permanent noise in the transcript.
29
+ *
30
+ * `noIdentity` REUSES `/session`'s SENTENCE (R-10). The same state must not read
31
+ * two different ways depending on which key the user pressed; only the consequence
32
+ * clause ("so there is nothing to branch from" / "") differs.
33
+ *
34
+ * There is deliberately NO `stopped` entry (R-9): nothing ever called it.
35
+ */
36
+ export const BRANCH_COPY = {
37
+ noIdentity: 'This session has no id (session recording is off for this build), ' +
38
+ 'so there is nothing to branch from.',
39
+ unavailable: 'Branching into a new terminal is not available in this build.',
40
+ cooldown: (ms) => `A branch was just opened - try again in ${ms} ms.`,
41
+ opened: (id) => `New terminal opened - it resumes ${id} as a new branch.`,
42
+ checkpointFailed: (reason) => `Could not checkpoint this session (${reason}), so a new branch would start from ` +
43
+ 'stale context. Nothing was opened.',
44
+ noLauncher: (manual) => `No terminal window could be opened here. Run this in another terminal:\n${manual}`,
45
+ spawnFailed: (reason, manual) => `Could not start the terminal (${reason}). Run this in another terminal:\n${manual}`,
46
+ lateError: (reason) => `The new terminal reported an error after opening: ${reason}`,
47
+ };
48
+ /**
49
+ * Run `/branch`.
50
+ *
51
+ * ASYNC BY NECESSITY, NOT BY STYLE: "the window opened" and "`wt.exe` does not
52
+ * exist" are distinguishable only after the child emits `'spawn'` or `'error'`,
53
+ * and ENOENT is reported asynchronously. `SlashCommand.run` already allows
54
+ * `Promise<void>`, and `App` calls this as `void executeSlashInput('/branch')`, so
55
+ * the UI never waits.
56
+ */
57
+ export async function runBranchCommand(ctx) {
58
+ // 1. No identity, no branch (D-13). Without an id there is nothing for the new
59
+ // window to resume, and a silent no-op reads as a bug.
60
+ const identity = ctx.session?.identity();
61
+ if (!identity) {
62
+ ctx.notify('warn', BRANCH_COPY.noIdentity);
63
+ return;
64
+ }
65
+ // 2. A build without the port says so rather than pretending.
66
+ const port = ctx.branch;
67
+ if (!port) {
68
+ ctx.notify('warn', BRANCH_COPY.unavailable);
69
+ return;
70
+ }
71
+ // 3. The cwd is the CONTROLLER's (`/cwd` can change it), never `process.cwd()`,
72
+ // and `--resume` does not restore a cwd - so this value is the only chance
73
+ // the new window has to land in the right directory (D-16).
74
+ const result = await port.open({
75
+ id: identity.id,
76
+ cwd: ctx.controller.getCwd(),
77
+ onLateError: (message) => ctx.notify('warn', message),
78
+ });
79
+ if (result.ok) {
80
+ reportOpened(ctx, identity.id);
81
+ return;
82
+ }
83
+ reportFailure(ctx, result);
84
+ }
85
+ /**
86
+ * Success: the toast confirms the action; while the agent runs it ALSO gets one
87
+ * notice line (D-22 / R-5).
88
+ *
89
+ * THE SECOND CHANNEL IS NOT REDUNDANCY. The status bar's feedback slot shows the
90
+ * activity phrase during a run, so a toast at that moment is invisible; a user who
91
+ * sees nothing presses again, gets caught by the cooldown, and sees nothing again.
92
+ * The notice is also where "the child inherited the last settled turn, not the one
93
+ * being generated" becomes visible after the fact.
94
+ */
95
+ function reportOpened(ctx, id) {
96
+ const text = BRANCH_COPY.opened(id);
97
+ ctx.toast('info', text);
98
+ if (ctx.controller.isRunning())
99
+ ctx.notify('info', text);
100
+ }
101
+ /**
102
+ * Failure: one notice, one sentence, and a command the user can paste (I-4).
103
+ *
104
+ * `cooldown` is the exception that goes to the toast (D-12): it is a time hint
105
+ * about something that just succeeded, not a defect. `checkpoint-failed` and the
106
+ * two spawn failures already carry their COMPLETE sentence - the launcher composes
107
+ * it from `BRANCH_COPY` because only the launcher knows whether the checkpoint
108
+ * failed because the history was unsendable or because this frame has no id yet
109
+ * (R-19), and a body that re-wrapped the text would print an internal reason code.
110
+ */
111
+ function reportFailure(ctx, result) {
112
+ if (result.reason === 'cooldown') {
113
+ ctx.toast('info', result.message);
114
+ return;
115
+ }
116
+ ctx.notify('warn', result.message);
117
+ }
@@ -15,12 +15,16 @@ import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
15
15
  import { buildContinuationMessage } from '../todo/follow-through.js';
16
16
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
17
17
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
18
+ import { applyRestoredSession } from '../session/startup-session.js';
19
+ import { runSessionCommand } from './session-command.js';
20
+ import { runBranchCommand } from './branch-command.js';
18
21
  import { getLogger } from '../logging/logger.js';
19
22
  import { resolveFastTier } from '../fast/resolve.js';
20
23
  import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
21
24
  import { registerQueueCommand } from './queue-command.js';
22
25
  import { perfCommand } from './perf.js';
23
26
  import { runCompactCommand } from '../compaction/command.js';
27
+ import { runClearCommand } from './clear-command.js';
24
28
  import { runContextCommand } from '../compaction/context-command.js';
25
29
  /**
26
30
  * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
@@ -957,44 +961,23 @@ const COMMANDS = [
957
961
  },
958
962
  },
959
963
  {
964
+ // REGISTRATION ONLY. The body lives in `commands/clear-command.ts`: this file
965
+ // is already past the 1000-line guideline, and `/clear` is no longer a
966
+ // two-line projection reset - it interrupts a run, waits for it, archives the
967
+ // conversation and then clears (clear-command-context-reset D-8).
960
968
  name: 'clear',
961
- description: 'Clear the visible transcript and the todo panel',
962
- run: (ctx) => {
963
- // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
964
- // reuses reason `'user'`; I-2 and its standing exception are stated on
965
- // `TodoStore`. Clearing the STORE and not merely the view mirror is what
966
- // keeps `/save`, `/todo status|continue` and follow-through agreeing with
967
- // the screen — they read the store directly. `messages` is untouched:
968
- // clearing the conversation is still `/reset` alone.
969
- //
970
- // DELIBERATELY WITHOUT `/todo clear`'s two guards: an `isRunning()` refusal
971
- // would leave `/clear` unable to clear even the transcript mid-run, and an
972
- // early return on an empty list would skip the dispatch below entirely.
973
- // Clearing an empty store is already silent — it emits into a reducer
974
- // branch that writes two fields that are both already at their defaults.
975
- //
976
- // Bounded side effect: the emptied store re-arms `todo_write`'s "a fresh
977
- // plan has at least two items" gate, so a model that shrinks a plan to one
978
- // item right after a mid-run `/clear` gets the refusal text and retries.
979
- ctx.controller.clearTodos();
980
- ctx.dispatch({ type: 'clearTranscript' });
981
- },
969
+ description: 'Start a new conversation; "/clear screen" only clears the screen',
970
+ run: runClearCommand,
982
971
  },
983
972
  {
973
+ // ALIAS BY DESIGN (D-2), AND THIS IS THE WHOLE FIX. Until this change `/clear`
974
+ // cleared the screen while `/reset` cleared the session, so the command a
975
+ // user reaches for in a long conversation was the one that kept the history.
976
+ // One implementation makes that drift structurally impossible rather than
977
+ // merely fixed: there is no second body left to diverge.
984
978
  name: 'reset',
985
- description: 'Start a new conversation',
986
- run: (ctx) => {
987
- if (ctx.controller.isRunning()) {
988
- ctx.notify('warn', 'Cannot reset while the agent is running. Wait for it to stop.');
989
- return;
990
- }
991
- ctx.controller.clearMessages();
992
- ctx.controller.clearAllQueues();
993
- ctx.dispatch({ type: 'resetConversation' });
994
- ctx.toast('info', 'Started a new conversation.');
995
- if (ctx.state.pendingSteering.length)
996
- ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
997
- },
979
+ description: 'Alias of /clear',
980
+ run: runClearCommand,
998
981
  },
999
982
  {
1000
983
  name: 'cwd',
@@ -1057,15 +1040,38 @@ const COMMANDS = [
1057
1040
  }
1058
1041
  // Loading has validated and normalized every external value. Commit
1059
1042
  // synchronously so no new run can start between clearing and restoring.
1043
+ //
1044
+ // THE THREE CONTROLLER CALLS COME FROM THE SHARED PORT (tui-session-id-resume
1045
+ // 3.8 / D-9): the startup `--resume` path and this command must never be
1046
+ // two implementations of "adopt a saved conversation", which is exactly
1047
+ // how `/clear` and `/reset` drifted apart once. `clearAllQueues` and the
1048
+ // `restoreEntries` dispatch stay HERE: the first is indispensable for a
1049
+ // live TUI and a no-op at startup, and the second is a VIEW action with
1050
+ // no controller to hang it on.
1060
1051
  ctx.controller.clearAllQueues();
1061
- ctx.controller.replaceMessages(session.messages, session.compactionIdentity);
1062
- // Absence clears the previous conversation's list, including old files.
1063
- ctx.controller.restoreTodos(session.todos ?? []);
1052
+ applyRestoredSession(ctx.controller, session);
1064
1053
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
1065
- if (session.model?.providerId && session.model?.modelId) {
1066
- ctx.controller.setModel(session.model.providerId, session.model.modelId, session.model.baseUrl);
1054
+ // A NEW ID, AND A ZEROED COUNTER, IN THAT ORDER (D-15 / D-10 / R-9).
1055
+ // Without the rotation the next checkpoint would write THIS conversation
1056
+ // into the previous id's file - silently destroying its disk record - and
1057
+ // without the reset the new id's `meta.usage` would bill the previous
1058
+ // conversation's spend to this one.
1059
+ const rotated = ctx.session?.rotate(path);
1060
+ // GATED ON THE ROTATION, NOT ON THE COMMAND (D-10: the criterion
1061
+ // is 'the id changed'). With no session port - a host without this
1062
+ // feature - or a failed claim the id is unchanged, and restarting
1063
+ // the counter there would misreport this conversation's spend. A
1064
+ // failed claim is also the one case where the NEXT checkpoint writes
1065
+ // another conversation into this id's file, so it is said out loud
1066
+ // rather than left to be discovered later.
1067
+ if (rotated)
1068
+ ctx.dispatch({ type: 'resetUsage' });
1069
+ if (ctx.session && !rotated) {
1070
+ ctx.notify('warn', 'Could not claim a new session id; this conversation keeps the current one.');
1067
1071
  }
1068
- ctx.notify('info', `Resumed session from ${path}`);
1072
+ ctx.notify('info', rotated
1073
+ ? `Resumed session from ${path} as ${rotated.id}.`
1074
+ : `Resumed session from ${path}`);
1069
1075
  if (ctx.state.pendingSteering.length)
1070
1076
  ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
1071
1077
  }
@@ -1074,6 +1080,22 @@ const COMMANDS = [
1074
1080
  }
1075
1081
  },
1076
1082
  },
1083
+ {
1084
+ // REGISTRATION ONLY - the body lives in `commands/session-command.ts`, for
1085
+ // the reason `/clear` and `/compact` do: this file is past the 1000-line
1086
+ // guideline and `/session` is a report with three branches, not a one-liner.
1087
+ name: 'session',
1088
+ description: 'Show this session id and file; "/session copy" copies the resume command',
1089
+ run: runSessionCommand,
1090
+ },
1091
+ {
1092
+ // REGISTRATION ONLY - the body lives in `commands/branch-command.ts`, for the
1093
+ // reason `/session` above does. `Ctrl+L` dispatches this same command, so the
1094
+ // key and the typed word are one code path including the cooldown (D-2).
1095
+ name: 'branch',
1096
+ description: 'Open a new terminal on this session (Ctrl+L); the branch gets its own id',
1097
+ run: (ctx) => runBranchCommand(ctx),
1098
+ },
1077
1099
  {
1078
1100
  name: 'copy',
1079
1101
  description: 'Copy the last answer to the clipboard',
@@ -0,0 +1,27 @@
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 type { CommandContext } from './registry.js';
22
+ /**
23
+ * Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
24
+ * what stops the two from drifting back into "one clears the screen, one clears
25
+ * the session" - the drift that produced the defect this feature fixes.
26
+ */
27
+ export declare function runClearCommand(ctx: CommandContext): Promise<void>;