@aragon-agent/cli 0.6.25 → 0.6.27

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 (48) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +194 -13
  3. package/dist/agent/reducer.d.ts +22 -0
  4. package/dist/agent/reducer.js +4 -1
  5. package/dist/cli.js +214 -2
  6. package/dist/commands/branch-command.d.ts +61 -0
  7. package/dist/commands/branch-command.js +117 -0
  8. package/dist/commands/builtins.js +48 -6
  9. package/dist/commands/registry.d.ts +28 -0
  10. package/dist/commands/session-command.d.ts +54 -0
  11. package/dist/commands/session-command.js +109 -0
  12. package/dist/config/load.d.ts +15 -0
  13. package/dist/exec/index.js +10 -1
  14. package/dist/logging/logger.d.ts +1 -1
  15. package/dist/proc/terminal-plan.d.ts +173 -0
  16. package/dist/proc/terminal-plan.js +393 -0
  17. package/dist/session/branch-invocation.d.ts +127 -0
  18. package/dist/session/branch-invocation.js +153 -0
  19. package/dist/session/branch-launch.d.ts +79 -0
  20. package/dist/session/branch-launch.js +265 -0
  21. package/dist/session/checkpoint.d.ts +257 -0
  22. package/dist/session/checkpoint.js +512 -0
  23. package/dist/session/cli-commands.d.ts +2 -0
  24. package/dist/session/cli-commands.js +36 -5
  25. package/dist/session/identity.d.ts +72 -0
  26. package/dist/session/identity.js +148 -0
  27. package/dist/session/limits.d.ts +66 -0
  28. package/dist/session/limits.js +66 -0
  29. package/dist/session/persist.d.ts +30 -5
  30. package/dist/session/startup-session.d.ts +107 -0
  31. package/dist/session/startup-session.js +315 -0
  32. package/dist/session/store.d.ts +60 -1
  33. package/dist/session/store.js +95 -6
  34. package/dist/ui/App.d.ts +54 -2
  35. package/dist/ui/App.js +115 -21
  36. package/dist/ui/Header.js +8 -16
  37. package/dist/ui/StatusBar.d.ts +8 -0
  38. package/dist/ui/StatusBar.js +12 -3
  39. package/dist/ui/layout/header-hints.d.ts +68 -0
  40. package/dist/ui/layout/header-hints.js +138 -0
  41. package/dist/ui/layout/session-chip.d.ts +52 -0
  42. package/dist/ui/layout/session-chip.js +64 -0
  43. package/dist/ui/layout/status-layout.d.ts +10 -0
  44. package/dist/ui/layout/status-layout.js +8 -1
  45. package/dist/ui/overlays/HelpOverlay.js +9 -3
  46. package/dist/ui/session-bridge.d.ts +26 -0
  47. package/dist/ui/session-bridge.js +32 -0
  48. 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,6 +15,9 @@ 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';
@@ -1037,15 +1040,38 @@ const COMMANDS = [
1037
1040
  }
1038
1041
  // Loading has validated and normalized every external value. Commit
1039
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.
1040
1051
  ctx.controller.clearAllQueues();
1041
- ctx.controller.replaceMessages(session.messages, session.compactionIdentity);
1042
- // Absence clears the previous conversation's list, including old files.
1043
- ctx.controller.restoreTodos(session.todos ?? []);
1052
+ applyRestoredSession(ctx.controller, session);
1044
1053
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
1045
- if (session.model?.providerId && session.model?.modelId) {
1046
- 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.');
1047
1071
  }
1048
- 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}`);
1049
1075
  if (ctx.state.pendingSteering.length)
1050
1076
  ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
1051
1077
  }
@@ -1054,6 +1080,22 @@ const COMMANDS = [
1054
1080
  }
1055
1081
  },
1056
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
+ },
1057
1099
  {
1058
1100
  name: 'copy',
1059
1101
  description: 'Copy the last answer to the clipboard',
@@ -10,6 +10,8 @@ import type { Entry, NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } f
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';
13
+ import type { SessionCommandPort } from './session-command.js';
14
+ import type { BranchCommandPort } from './branch-command.js';
13
15
  export interface CommandContext {
14
16
  /** App-owned shared lock and single feedback path for command and selection copies. */
15
17
  requestCopy?: (request: CopyRequest) => Promise<CopyRequestResult>;
@@ -127,6 +129,32 @@ export interface CommandContext {
127
129
  * (the same rule the `update/types.js` import above follows).
128
130
  */
129
131
  transcriptEntries?: () => Entry[];
132
+ /**
133
+ * The session's read-only identity plus the two actions `/session` and
134
+ * `/resume` need (tui-session-id-resume 3.11 / D-14 / D-15).
135
+ *
136
+ * OPTIONAL, like `followBudget` / `update` / `mouse` / `transcriptEntries`
137
+ * above: every existing `CommandContext` fixture in this package builds this
138
+ * object by literal, and a required field would break all of them at compile
139
+ * time for a feature none of them is about. Absent means the build has no
140
+ * session identity, and `/session` says exactly that rather than pretending.
141
+ */
142
+ session?: SessionCommandPort;
143
+ /**
144
+ * The `/branch` port: open a new terminal on this session (tui-branch-terminal-
145
+ * shortcut 3.11 / #8).
146
+ *
147
+ * OPTIONAL for the same reason `session` above is: every existing
148
+ * `CommandContext` fixture in this package builds this object by literal, and a
149
+ * required field would break all of them at compile time for a feature none of
150
+ * them is about. Absent means `/branch` says "not available in this build"
151
+ * rather than failing silently.
152
+ *
153
+ * TYPE-ONLY import, following the rule the `session-command.js` import above
154
+ * records: the command needs the SHAPE of the port and of its result, never a
155
+ * value at run time.
156
+ */
157
+ branch?: BranchCommandPort;
130
158
  }
131
159
  /** What `/mouse` can see and do. Read at command time, never captured. */
132
160
  export interface MouseCommandPort {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `/session` - report this session's identity, and the two things a user does
3
+ * with it (tui-session-id-resume §3.11 / D-13 / D-14).
4
+ *
5
+ * ASCII ONLY - `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
8
+ * reason `/clear` and `/compact` do. `builtins.ts` is already past the
9
+ * 1000-line guideline, and this command is a report, not a one-liner.
10
+ *
11
+ * COPYING THE RESUME COMMAND IS THE POINT, NOT A CONVENIENCE (D-14). Typing 6
12
+ * characters off a status bar is the only step of this whole feature that can
13
+ * fail for a human, and `/session copy` removes it: one keystroke, the exact
14
+ * command in the clipboard, no transcription.
15
+ */
16
+ import type { CheckpointResult } from '../session/checkpoint.js';
17
+ import type { SessionIdentity } from '../session/startup-session.js';
18
+ import type { CommandContext } from './registry.js';
19
+ /**
20
+ * What `/session` can see and do.
21
+ *
22
+ * THREE MEMBERS AND NO MORE. `identity()` is read at command time rather than
23
+ * captured, because `/resume` rotates the id under a live App (D-15) and a
24
+ * captured value would report the previous one for the rest of the session.
25
+ */
26
+ export interface SessionCommandPort {
27
+ /** The current identity, or `undefined` when this build has none. */
28
+ identity(): SessionIdentity | undefined;
29
+ /** Write once now; `pin` also marks the file as never-prunable. */
30
+ checkpoint(opts?: {
31
+ pin?: boolean;
32
+ }): CheckpointResult;
33
+ /** Adopt another conversation and move to a derived id; `undefined` if it cannot. */
34
+ rotate(loadedPath: string): SessionIdentity | undefined;
35
+ }
36
+ /**
37
+ * The one path an id maps to.
38
+ *
39
+ * THROUGH `sessionPathFor` RATHER THAN A SECOND `join` (§2.2): that function is
40
+ * the single choke point where an id becomes a file name, and a report that
41
+ * built its own path would be the first thing to disagree with the writer about
42
+ * where a session lives.
43
+ */
44
+ export declare function sessionFilePath(id: string): string;
45
+ /**
46
+ * Run `/session`, `/session copy` or `/session pin`.
47
+ *
48
+ * ONE NOTICE FOR THE WHOLE REPORT (§3.11 / R-20): six separate notices would put
49
+ * six cards in the transcript and replay them as six fragments on exit. The
50
+ * `file` line is NOT truncated even when it overflows: it is a path the user
51
+ * needs to be able to copy, and it is in the same family as `/save`'s
52
+ * `Saved session to <path>`.
53
+ */
54
+ export declare function runSessionCommand(ctx: CommandContext): void;