cawdev-cli 1.2.0 → 1.2.2

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.
package/runner/attach.mjs CHANGED
@@ -46,9 +46,11 @@
46
46
  // curses library.
47
47
 
48
48
  import { spawn } from 'node:child_process';
49
- import { rm } from 'node:fs/promises';
49
+ import { existsSync } from 'node:fs';
50
+ import { readFile, rm } from 'node:fs/promises';
51
+ import { homedir } from 'node:os';
50
52
  import { connect } from 'node:net';
51
- import { basename, dirname } from 'node:path';
53
+ import { basename, dirname, join } from 'node:path';
52
54
  import { listSockets, socketPathFor } from './control.mjs';
53
55
  import {
54
56
  RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
@@ -60,21 +62,31 @@ import {
60
62
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
61
63
  import { restartNeededFor } from '../lib/runner-config.mjs';
62
64
  import {
63
- BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
64
- carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
65
- windowLines,
65
+ BOARD_COLUMNS, PROMPT_ROWS, SplitScreen, boardPreviewLines, moveInStrip, readerFrame, stripColumns, tabsLine, visibleSessions, actionsFor, composeFrame,
66
+ batchStartedLines, carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, titleFor,
67
+ windowLines, showWaitingLine, advancedBlocker,
66
68
  } from './split.mjs';
69
+ import {
70
+ CARD_MIN_HEIGHT, CLOSE_AFTER_TICKS, cardGrid, cardStatus, nowLine, seedOf, sessionCardLines, stageWalk,
71
+ } from './session-cards.mjs';
67
72
  import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
68
73
  import { loadPrefs, savePrefs } from './prefs.mjs';
69
74
  import { oneLine } from './brand.mjs';
70
75
  import { Scrollback } from './scrollback.mjs';
76
+ import { removeStaleLock, staleLockOf } from './git-lock.mjs';
71
77
  import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
72
78
  import { clearSession } from './session-store.mjs';
79
+ import { branchDiff, fileAt, foldedByDefault, layoutDiff } from './diff-view.mjs';
80
+ import { cardsFrom, importCards, resultLines } from './card-import.mjs';
73
81
  import { clearHistory, loadHistory, pushHistory } from './history.mjs';
74
82
  import {
75
83
  History, KeyStream, Line, Pastes, commonPrefix, completionLines, completions, keysIn,
76
84
  } from './input.mjs';
77
85
  import { Select, WRITE_MY_OWN, pickFromLine, plainLines } from './select.mjs';
86
+ import {
87
+ isImage, localCopy, openerFor, parseComments, printable, roundListing, shotsDir, showGate,
88
+ thumbnailEscape, thumbnailProtocol,
89
+ } from './shots.mjs';
78
90
 
79
91
  // Re-exported because they were this file's before R81 moved them into the
80
92
  // shared ANSI helpers — and `keysIn` and `keyList` before R83 moved them beside
@@ -82,8 +94,30 @@ import { Select, WRITE_MY_OWN, pickFromLine, plainLines } from './select.mjs';
82
94
  // arithmetic import them here.
83
95
  export { clip, keyList, keysIn, stripAnsi, visibleWidth, wrap };
84
96
 
97
+ /** Colored key parts packed into rows of `width`, between keys — none is cut off. */
98
+ function packKeys(parts, width, ink) {
99
+ const sep = ink.muted(' · ');
100
+ const rows = [];
101
+ let row = '';
102
+ for (const part of parts) {
103
+ const next = row ? row + sep + part : part;
104
+ if (row && visibleWidth(next) > width) { rows.push(row); row = part; } else row = next;
105
+ }
106
+ if (row) rows.push(row);
107
+ return rows;
108
+ }
109
+
85
110
  const ESC = '\x1b';
86
111
 
112
+ /** How many times `/review`'s merge is read again (five seconds apart) before it stops following. */
113
+ const REVIEW_FOLLOW_TRIES = 120;
114
+ /**
115
+ * What a session merging its card refuses (`refuseWhileMerging`): every act on
116
+ * its branch or its checkout. `/view`, `/open` and the commands that name
117
+ * another card stay.
118
+ */
119
+ const DISABLED_WHILE_MERGING = new Set(['pr', 'continue', 'clean', 'drop', 'close', 'cancel', 'develop', 'review']);
120
+
87
121
  /** Colours for a run's state. Muted, because the transcript is the content. */
88
122
  const STATE_COLOUR = {
89
123
  running: 'success',
@@ -216,6 +250,19 @@ export function permissionDecision(approval, key, machine = {}) {
216
250
  }
217
251
  }
218
252
 
253
+ /** What the "started" line adds when `--show` or `--no-show` was given. */
254
+ function shownWord(show) {
255
+ return show === true ? ', shown before its PR' : show === false ? ', not shown before its PR' : '';
256
+ }
257
+
258
+ const A_SHOW_GATE = 'a SHOW gate is decided after looking — a, /accept or /comments';
259
+
260
+ /** What a window's title says a session is stopped on. */
261
+ function waitingWord(kind) {
262
+ return kind === 'show' ? 'waiting for you'
263
+ : kind === 'question' ? 'waiting for an answer' : 'waiting for permission';
264
+ }
265
+
219
266
  /**
220
267
  * A pending permission request, drawn — R51 and R60's three answers.
221
268
  *
@@ -243,6 +290,22 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
243
290
  // Called with a pending record by the client, and with the approval itself by
244
291
  // the rule's own tests — the banner is about the request either way.
245
292
  const approval = pending.approval ?? pending;
293
+ // R605. A SHOW gate is not a permission: nobody is asked to allow a call, they
294
+ // are asked to look. None of y / s / Y / M / n does anything on it.
295
+ const gate = showGate(approval);
296
+ if (gate) {
297
+ const count = gate.ids.length;
298
+ return [
299
+ `${ink.bold(ink.warn(' show '))} ${clip(`waiting for you — ${count} screenshot${count === 1 ? '' : 's'}, round ${gate.round}`, Math.max(8, width - 8))}`,
300
+ ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
301
+ ...wrapChoices([
302
+ `${ink.success('a')} look and decide`,
303
+ `${ink.success('/accept')}`,
304
+ `${ink.success('/comments')} …`,
305
+ `${ink.muted('/open on cawdev')}`,
306
+ ], width, ' · '),
307
+ ];
308
+ }
246
309
  const covers = approval.suggestion ?? `every ${approval.toolName}`;
247
310
 
248
311
  // The two standing-rule keys, asked here exactly as `permissionDecision`
@@ -301,7 +364,7 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
301
364
  // A stage gate's summary is the whole PLAN, many lines long: one row here,
302
365
  // and how much more there is — `/view` reads it whole. Clipped as one
303
366
  // string, its newlines went through and filled the prompt.
304
- const [first = '', ...more] = String(approval.summary ?? '').split('\n');
367
+ let [first = '', ...more] = String(approval.summary ?? '').split('\n');
305
368
  const rest = more.filter((line) => line.trim()).length;
306
369
  const tail = rest ? ` (+${rest} lines)` : '';
307
370
  return [
@@ -613,7 +676,7 @@ export async function attach(argv, { url, ...options } = {}) {
613
676
  // Spread second so `--attach`'s own wiring wins: there the daemon IS this
614
677
  // process and quitting has always stopped it.
615
678
  const ui = new Attached(socket, session,
616
- { leaveRunning: argv.includes('--leave-running'), ...options });
679
+ { leaveRunning: argv.includes('--leave-running'), simple: argv.includes('--simple'), ...options });
617
680
  await ui.start();
618
681
  }
619
682
 
@@ -823,6 +886,20 @@ function withAgent(body, agent) {
823
886
  return agent ? { ...body, agent } : body;
824
887
  }
825
888
 
889
+ /**
890
+ * The note a branch is sent back with when its merge hit a conflict — the
891
+ * order the next session on the card reads first. What to do, in that order,
892
+ * and what not to touch.
893
+ */
894
+ export function conflictOrder(review, why) {
895
+ return [
896
+ `The merge of ${review.branch} into ${review.base} stopped on a conflict: ${why}`,
897
+ '',
898
+ `Merge origin/${review.base} into ${review.branch} and resolve every conflict, keeping what both sides meant.`,
899
+ 'Change nothing else on this card. Run the tests, then commit the merge and push the branch.',
900
+ ].join('\n');
901
+ }
902
+
826
903
  /** What was started, saying with which agent when one was named. */
827
904
  function withWho(what, agent) {
828
905
  return agent ? `${what} with ${agent}` : what;
@@ -838,6 +915,10 @@ function refOf(word) {
838
915
 
839
916
  /** How many matching commands advanced mode's prompt shows at once. */
840
917
  const SPLIT_COMPLETIONS = 6;
918
+ /** How often the cards' rain moves a step (session-cards.mjs `RAIN`): eight a second. */
919
+ const RAIN_STEP_MS = 120;
920
+ /** The cards' clock, from the wall: a tick stamped while the reader was up is still the right tick. */
921
+ const clockTick = () => Math.floor(Date.now() / RAIN_STEP_MS);
841
922
 
842
923
  /** The slash commands, and the one place they are described. */
843
924
  const COMMANDS = [
@@ -848,17 +929,27 @@ const COMMANDS = [
848
929
  ['/config', "this machine's config — the same as c"],
849
930
  ['/cancel', 'cancel the session you are watching'],
850
931
  ['/log', "the daemon's own log, on or off"],
851
- ['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
852
- ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
853
- ['/card', 'add an idea to Considering — no session: /card <the idea> (@project)'],
932
+ ['/advanced-mode', 'a card per session above the prompt, for the projects you tick (@slug names them), or back; /view shows one session\'s log'],
933
+ ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project); --show / --no-show on dev: shown before its PR, or not; several cards in order: /new plan|dev|plan+dev R3 R5 R7 (dev: --in-order [--branch name] for one branch)'],
934
+ ['/import', 'file the cards of a JSON file: /import <file.json> (@project); --dry-run only checks; --status <S> where cards naming none land; a taken title is skipped, --allow-duplicates files it'],
935
+ ['/card', 'add an idea to Considering — no session: /card <the idea> (@project) (--show: shown before its PR)'],
854
936
  ['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
855
937
  ['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
856
938
  ['/pr', "open the pull request of a finished session's branch"],
939
+ ['/review', "the branch's diff, here: /review for the session's card, /review R42 (@project); then d Done, c changes requested, m merge — or /review done|changes <note>|merge|agent-merge"],
857
940
  ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
941
+ ['/clean', 'remove the git lock a crash left in a stopped session\'s checkout (index.lock: File exists), then /continue'],
858
942
  ['/projects', 'which projects advanced mode shows — tick one or several'],
943
+ ['/board', 'focus advanced mode\'s roadmap strip (b too): arrows browse cards and show their full title and description'],
859
944
  ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
860
- ['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you, its page on cawdev"],
861
- ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full, or opens it on cawdev"],
945
+ ['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you or at SHOW, its page on cawdev"],
946
+ ['/view', "in advanced mode, a session's log over the whole screen: /view for the focused one, /view 2 for card 2, /view R29 for the session on R29 (v too); a card here in the terminal: /view R29 with no session on it, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full (at SHOW, looks and decides), or, outside advanced mode, opens it on cawdev"],
947
+ ['/matrix', 'the rain on advanced mode\'s cards, off by default: /matrix on|off, remembered; the spinner and the robots move either way'],
948
+ ['/simple', 'the scrollback terminal (cawdev --simple starts in it); /advanced-mode is the way back; remembered'],
949
+ ['/export', "lay the focused session's log into the scrollback so the terminal's own search and copy reach it: /export, /export 3, /export R29"],
950
+ ['/shots', 'the screenshots a session waiting at SHOW took: /shots, /shots 2, /shots all, /shots round 1'],
951
+ ['/accept', 'accept the screenshots — the pull request opens'],
952
+ ['/comments', 'send comments on the screenshots: /comments <text>, #2 for one screenshot'],
862
953
  ['/drop', 'delete the checkout a finished session was made on demand for'],
863
954
  ['/close', "close an ended session's window"],
864
955
  ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
@@ -977,6 +1068,15 @@ export class Attached {
977
1068
  * otherwise make a picker titled with the first one's command look current.
978
1069
  */
979
1070
  this.deciding = null;
1071
+ /**
1072
+ * The round a SHOW gate names, as the platform's sealed list confirms it —
1073
+ * R605. `{runId, approvalId, slug, round, shots, confirmed, rounds, select}`.
1074
+ * Read when the person asks to look, not before: nobody is asked about
1075
+ * screenshots on a terminal that never opens them.
1076
+ */
1077
+ this.showing = null;
1078
+ /** The comment lines written so far at a SHOW gate — R605 — `{lines}`. */
1079
+ this.commenting = null;
980
1080
  this.status = '';
981
1081
  this.showLog = false;
982
1082
  this.asked = new Set();
@@ -1068,7 +1168,14 @@ export class Attached {
1068
1168
  this.screen.open();
1069
1169
  this.history = new History(await loadHistory(this.session.url).catch(() => []));
1070
1170
  // `/agent`'s choice, from last time — see prefs.mjs.
1071
- this.agent = (await loadPrefs(this.session.url).catch(() => ({}))).agent ?? null;
1171
+ const prefs = await loadPrefs(this.session.url).catch(() => ({}));
1172
+ this.agent = prefs.agent ?? null;
1173
+ // The rain is off unless asked for (`/matrix on`); the spinner and the
1174
+ // robots move either way.
1175
+ this.matrix = prefs.matrix === true;
1176
+ // Advanced mode is the default where the terminal can draw it; the choice
1177
+ // `/simple` and `/advanced-mode` made last time is remembered.
1178
+ this.screenMode = this.options.simple ? 'simple' : prefs.mode === 'simple' ? 'simple' : 'advanced';
1072
1179
  // Raw mode only where there is a terminal to put in it. Through a pipe
1073
1180
  // there are no keys, the transcript is the whole output, and that is the
1074
1181
  // honest degradation rather than a broken one.
@@ -1118,12 +1225,34 @@ export class Attached {
1118
1225
  // `/reload`'s other half: the state the terminal was in before it
1119
1226
  // restarted — advanced mode, the focus, the windows of sessions that had
1120
1227
  // ended — so a reload changes the code and nothing else.
1121
- this.restore(this.options.restore ?? readReloadState());
1228
+ const restored = this.options.restore ?? readReloadState();
1229
+ this.restore(restored);
1230
+ // A fresh launch opens in advanced mode wherever the terminal can draw it;
1231
+ // a reload keeps the mode it was in.
1232
+ if (!this.split && !(restored && typeof restored === 'object' && 'advanced' in restored)) {
1233
+ this.openByDefault();
1234
+ }
1122
1235
 
1123
1236
  // One write per tick at most, carrying everything that happened in it. A
1124
1237
  // busy session emits hundreds of lines a second, and a write per line means
1125
1238
  // an erase-and-redraw of the whole footer per line.
1126
1239
  const tick = setInterval(() => {
1240
+ // The cards' rain moves one step every RAIN_STEP_MS, and only while
1241
+ // somebody can see it: not under the reader, not with `/matrix off`.
1242
+ if (this.split && !this.reader) {
1243
+ const step = clockTick();
1244
+ if (step !== this.rainTick) {
1245
+ this.rainTick = step;
1246
+ this.dirty = true;
1247
+ // A landed card closes itself three seconds after the robot waves.
1248
+ for (const meta of this.windows()) {
1249
+ if (meta.merging?.state === 'landed' && step - meta.merging.landedTick >= CLOSE_AFTER_TICKS) {
1250
+ meta.merging = null;
1251
+ this.closeWindowOf(meta);
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1127
1256
  if (this.dirty || this.pending.length) {
1128
1257
  this.flush();
1129
1258
  }
@@ -1441,6 +1570,12 @@ export class Attached {
1441
1570
  return run ? this.approvals.get(run.id) ?? null : null;
1442
1571
  }
1443
1572
 
1573
+ /** The pending approval, when it is a SHOW gate — R605 — else null. */
1574
+ showOn(run) {
1575
+ const pending = this.pendingOn(run);
1576
+ return pending && showGate(pending.approval) ? pending : null;
1577
+ }
1578
+
1444
1579
  /**
1445
1580
  * What the watched session is stopped on — R58, and since R137 both shapes.
1446
1581
  *
@@ -1606,7 +1741,9 @@ export class Attached {
1606
1741
  this.dirty = true;
1607
1742
 
1608
1743
  const mine = this.deciding?.approvalId === pending.approval.id;
1609
- const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason';
1744
+ const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason'
1745
+ || this.select?.kind === 'show' || this.select?.kind === 'show-round'
1746
+ || this.input?.kind === 'comments';
1610
1747
  if (mine && onScreen) {
1611
1748
  this.mode = 'keys';
1612
1749
  this.select = null;
@@ -1614,6 +1751,10 @@ export class Attached {
1614
1751
  this.history.reset();
1615
1752
  this.deciding = null;
1616
1753
  }
1754
+ if (mine) {
1755
+ this.showing = null;
1756
+ this.commenting = null;
1757
+ }
1617
1758
 
1618
1759
  const by = record?.decidedByEmail;
1619
1760
  const verb = record?.state === 'DENIED' ? 'refused'
@@ -1650,9 +1791,19 @@ export class Attached {
1650
1791
  }
1651
1792
  this.interrupting = false;
1652
1793
 
1794
+ if (this.help && this.mode === 'keys') {
1795
+ // The key table is one window: any of these closes it, nothing else acts.
1796
+ if (key === ESC || key === '?' || key === 'q' || key === '\r') { this.help = false; this.dirty = true; }
1797
+ return undefined;
1798
+ }
1653
1799
  if (this.reader && this.mode === 'keys') {
1654
1800
  return this.onReaderKey(key);
1655
1801
  }
1802
+ if (this.split && key === '?' && this.mode === 'keys' && !this.select) {
1803
+ this.help = true;
1804
+ this.dirty = true;
1805
+ return undefined;
1806
+ }
1656
1807
  if (this.mode === 'typing') {
1657
1808
  return this.onTyping(key);
1658
1809
  }
@@ -1665,9 +1816,31 @@ export class Attached {
1665
1816
  this.confirming = null;
1666
1817
  }
1667
1818
 
1819
+ if (this.split?.boardFocus && this.mode === 'keys') {
1820
+ return this.onBoardKey(key);
1821
+ }
1822
+ if (this.split && key === 'b') {
1823
+ return void this.toggleBoard();
1824
+ }
1668
1825
  if (this.split && (key === '\t' || key === `${ESC}[Z`)) {
1669
1826
  return this.cycleFocus(key === '\t' ? 1 : -1);
1670
1827
  }
1828
+ if (['\r', '\n', 'i', 'd', 'x'].includes(key) && this.refuseWhileMerging()) {
1829
+ return undefined;
1830
+ }
1831
+ // `n` still refuses a permission request the focused card holds: that act came first.
1832
+ if (this.split && (key === 'n' || key === 'N') && this.mode === 'keys' && !this.split.boardFocus
1833
+ && !this.pendingOn(this.current())) {
1834
+ return this.focusNeedsYou(key === 'n' ? 1 : -1);
1835
+ }
1836
+ if (this.split && key === 'w') {
1837
+ return this.closeWindow();
1838
+ }
1839
+ if (this.split && key === 'v') {
1840
+ // A card's log — `/view`'s, over the whole screen.
1841
+ const meta = this.commandTarget();
1842
+ return meta ? this.viewSession(meta) : this.note('no session yet — /new starts one');
1843
+ }
1671
1844
  if (this.split && key === 'd') {
1672
1845
  // On a finished plan's window, `d` is that plan's `/develop`: the card
1673
1846
  // it planned, by the agent that planned it, in the window it leaves.
@@ -1711,6 +1884,12 @@ export class Attached {
1711
1884
  this.showLog = !this.showLog;
1712
1885
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
1713
1886
  case 'a': {
1887
+ // R605: at a SHOW gate `a` is look-and-decide, not an answer.
1888
+ const shown = this.showOn(this.current());
1889
+ if (shown) {
1890
+ if (!this.requireSignIn('look and decide')) return undefined;
1891
+ return void this.lookAndDecide(this.current());
1892
+ }
1714
1893
  // R58: the refusal is here rather than at the API, so nobody types an
1715
1894
  // answer into a session that is not going to take it.
1716
1895
  const asking = this.askingOn(this.current());
@@ -1727,8 +1906,12 @@ export class Attached {
1727
1906
  case 's':
1728
1907
  case 'Y':
1729
1908
  case 'M':
1909
+ // R605: `s` on a SHOW gate used to accept the round and open the pull
1910
+ // request in one key, without anybody having looked.
1911
+ if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
1730
1912
  return void this.allow(key);
1731
1913
  case 'n': {
1914
+ if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
1732
1915
  if (!this.pendingOn(this.current())) {
1733
1916
  return this.note('nothing is waiting for permission on this one');
1734
1917
  }
@@ -1783,13 +1966,20 @@ export class Attached {
1783
1966
 
1784
1967
  /** Everything open, closed. Returns whether there was anything. */
1785
1968
  closeEverything() {
1786
- const open = this.mode !== 'keys' || Boolean(this.reader);
1969
+ const open = this.mode !== 'keys' || Boolean(this.reader) || Boolean(this.split?.boardFocus);
1787
1970
  this.reader = null;
1971
+ this.help = false;
1972
+ if (this.split?.boardFocus) {
1973
+ this.split.lastBoardFocus = this.split.boardFocus;
1974
+ this.split.boardFocus = null;
1975
+ }
1788
1976
  this.mode = 'keys';
1789
1977
  this.select = null;
1790
1978
  this.input = null;
1791
1979
  this.answering = null;
1792
1980
  this.deciding = null;
1981
+ this.showing = null;
1982
+ this.commenting = null;
1793
1983
  this.history.reset();
1794
1984
  this.dirty = true;
1795
1985
  // A config question closed this way is cancelled, not left waiting — R288.
@@ -2032,6 +2222,15 @@ export class Attached {
2032
2222
  this.mode = 'keys';
2033
2223
  this.history.reset();
2034
2224
 
2225
+ if (kind === 'comments') {
2226
+ // R605. Before the empty-line rule, on purpose: here an empty line SENDS.
2227
+ return this.commentLine(this.pastes.expand(typed), back);
2228
+ }
2229
+ if (kind === 'review-note') {
2230
+ // Empty sends nothing and goes back to the diff: a verdict with no note is refused anyway.
2231
+ if (!typed) return this.reopenReview();
2232
+ return void this.reviewChanges(this.pastes.expand(typed));
2233
+ }
2035
2234
  if (kind === 'config') {
2036
2235
  // Before the empty-line rule, on purpose: every config question shows a
2037
2236
  // default in its label, and enter on an empty line TAKES it — R288.
@@ -2114,7 +2313,8 @@ export class Attached {
2114
2313
  // A question's or a permission's list holds only the start of what is
2115
2314
  // asked; `/` leaves it for a command — `/view` reads it whole, `/open`
2116
2315
  // answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
2117
- if (key === '/' && (select.kind === 'question' || select.kind === 'permission')) {
2316
+ if (key === '/' && (select.kind === 'question' || select.kind === 'permission'
2317
+ || select.kind === 'show' || select.kind === 'show-round')) {
2118
2318
  this.mode = 'keys';
2119
2319
  this.select = null;
2120
2320
  this.answering = null;
@@ -2138,6 +2338,9 @@ export class Attached {
2138
2338
  // is being written when none is is one the next reader will believe.
2139
2339
  this.answering = null;
2140
2340
  this.deciding = null;
2341
+ if (select.kind === 'show' || select.kind === 'show-round') {
2342
+ this.commenting = null;
2343
+ }
2141
2344
  if (select.kind === 'config') {
2142
2345
  this.configuring?.cancel?.();
2143
2346
  }
@@ -2191,6 +2394,9 @@ export class Attached {
2191
2394
  }
2192
2395
  return void this.answer(row.label);
2193
2396
  }
2397
+ if (select.kind === 'show' || select.kind === 'show-round') {
2398
+ return this.choseShow(select, row, typed);
2399
+ }
2194
2400
  if (select.kind === 'permission') {
2195
2401
  if (row.id === 'refuse') {
2196
2402
  return this.type('reason', 'refuse, because ▸', '', select);
@@ -2206,7 +2412,9 @@ export class Attached {
2206
2412
  }
2207
2413
  if (select.kind === 'planned') {
2208
2414
  const [slug, ref] = String(row.id).split(' ');
2209
- return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
2415
+ const flags = this.plannedFlags ?? [];
2416
+ this.plannedFlags = null;
2417
+ return void this.startNew(['dev', ref, `@${slug}`, ...flags]).then(() => this.refreshPlanned());
2210
2418
  }
2211
2419
  if (select.kind === 'card-project') {
2212
2420
  // `/card` asked which project; the same card, with it named.
@@ -2244,6 +2452,13 @@ export class Attached {
2244
2452
  this.configuring?.answer?.(typed);
2245
2453
  return undefined;
2246
2454
  }
2455
+ if (this.input?.kind === 'comments' && !this.select) {
2456
+ // The plain path's half of R605: lines are held until an empty one.
2457
+ const back = this.input.back;
2458
+ this.input = null;
2459
+ this.mode = 'keys';
2460
+ return this.commentLine(String(text), back);
2461
+ }
2247
2462
  if (this.select) {
2248
2463
  const picked = pickFromLine(this.select, typed);
2249
2464
  if (picked) {
@@ -2403,6 +2618,22 @@ export class Attached {
2403
2618
  if (!run) return;
2404
2619
 
2405
2620
  const pending = this.pendingOn(run);
2621
+ if (pending && !this.announced.has(pending.approval.id) && showGate(pending.approval)) {
2622
+ // R605. Not a permission: nobody is shown three lengths of yes. It says
2623
+ // what is waiting and, in a terminal that can draw a picker, opens the
2624
+ // one that looks and decides.
2625
+ this.announced.add(pending.approval.id);
2626
+ const ink = this.ink;
2627
+ const about = pending.approval.id;
2628
+ const gate = showGate(pending.approval);
2629
+ this.sayAbout(about, '');
2630
+ this.sayAbout(about, `${ink.bold(ink.warn(' show '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
2631
+ this.sayAbout(about, ` ${ink.text(`${gate.ids.length} screenshot${gate.ids.length === 1 ? '' : 's'}, round ${gate.round} — a looks and decides`)}`);
2632
+ if (this.mode === 'keys' && this.session.signedIn) {
2633
+ void this.lookAndDecide(run);
2634
+ }
2635
+ return;
2636
+ }
2406
2637
  if (pending && !this.announced.has(pending.approval.id)) {
2407
2638
  this.announced.add(pending.approval.id);
2408
2639
  const ink = this.ink;
@@ -2838,6 +3069,10 @@ export class Attached {
2838
3069
 
2839
3070
  async runCommand(text) {
2840
3071
  const [word, ...rest] = commandWords(text.slice(1));
3072
+ // A merge asked for once is confirmed by the very next command, or not at all.
3073
+ if (this.reviewing && !(word === 'review' && rest[0] === 'merge')) this.reviewing.confirmingMerge = false;
3074
+ // `/develop R30` and `/review R30` name another card; bare, they are this one's.
3075
+ if (DISABLED_WHILE_MERGING.has(word) && !rest.length && this.refuseWhileMerging()) return undefined;
2841
3076
  switch (word) {
2842
3077
  case 'help':
2843
3078
  this.say('');
@@ -2848,7 +3083,7 @@ export class Attached {
2848
3083
  this.say('');
2849
3084
  this.say(this.ink.bold(' keys'));
2850
3085
  this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run · c config'));
2851
- this.say(this.ink.muted(' a answer · y/s/Y/n permission · x cancel · g log · q quit'));
3086
+ this.say(this.ink.muted(' a answer, or look and decide at SHOW · y/s/Y/n permission · x cancel · g log · q quit'));
2852
3087
  this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
2853
3088
  this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
2854
3089
  this.say('');
@@ -2873,39 +3108,67 @@ export class Attached {
2873
3108
  case 'log':
2874
3109
  this.showLog = !this.showLog;
2875
3110
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
3111
+ case 'simple':
3112
+ return void this.chooseMode('simple');
3113
+ case 'export':
3114
+ return this.exportLog(rest[0]);
2876
3115
  case 'advanced-mode':
2877
3116
  case 'advanced': {
2878
3117
  const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : !this.split;
2879
- if (!want) return this.leaveSplit();
3118
+ if (!want) return void this.chooseMode('simple');
3119
+ void savePrefs(this.session.url, { mode: null }).catch(() => undefined);
2880
3120
  // `@slug` names the projects outright; otherwise they are asked.
2881
3121
  const named = rest.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
2882
3122
  return named.length ? this.enterSplit(named) : this.askSplitProjects();
2883
3123
  }
2884
3124
  case 'projects':
2885
3125
  return this.askSplitProjects();
3126
+ case 'board': {
3127
+ const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : undefined;
3128
+ return this.toggleBoard(want);
3129
+ }
2886
3130
  case 'refresh':
2887
3131
  return void this.refreshCards();
2888
3132
  case 'new':
2889
3133
  return void this.startNew(rest);
2890
3134
  case 'card':
2891
3135
  return void this.addCard(rest);
3136
+ case 'import':
3137
+ return void this.importFile(rest);
2892
3138
  case 'agent':
2893
3139
  return void this.chooseAgent(rest[0]);
2894
3140
  case 'develop':
2895
3141
  return void this.develop(rest);
2896
3142
  case 'pr':
2897
3143
  return void this.openPullRequest();
3144
+ case 'review':
3145
+ return void this.review(rest);
2898
3146
  case 'continue':
2899
3147
  return void this.carryOn();
3148
+ case 'clean':
3149
+ return void this.cleanLock();
2900
3150
  case 'open':
2901
3151
  return void this.openInBrowser();
2902
- case 'view':
3152
+ case 'shots':
3153
+ case 'accept':
3154
+ case 'comments':
3155
+ return void this.showCommand(word, rest);
3156
+ case 'view': {
3157
+ // In advanced mode a window's number, or a ref one of them is on, is
3158
+ // that session's log; any other words are a card, as everywhere.
3159
+ const session = this.split ? this.windowNamed(rest) : null;
3160
+ if (session) return this.viewSession(session);
2903
3161
  if (rest.length) return void this.viewCard(rest);
3162
+ if (this.showOn(this.runOf(this.commandTarget()))) return this.showCommand('view', []);
3163
+ if (this.split && this.commandTarget()) return this.viewSession(this.commandTarget());
2904
3164
  return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
3165
+ }
3166
+ case 'matrix':
3167
+ return void this.chooseMatrix(rest[0]);
2905
3168
  case 'drop':
2906
3169
  return void this.dropCheckout();
2907
3170
  case 'close':
2908
- return this.closeWindow();
3171
+ return this.closeWindow(rest[0]);
2909
3172
  case 'reload':
2910
3173
  return void this.reload();
2911
3174
  case 'quit':
@@ -3006,9 +3269,17 @@ export class Attached {
3006
3269
  // what was known.
3007
3270
  agent: run.agent !== undefined ? run.agent : meta.agent ?? null,
3008
3271
  model: run.model !== undefined ? run.model : meta.model ?? null,
3272
+ // The stage it is on and the walk it is part of, for its card's
3273
+ // progress line; a daemon older than the field sends neither.
3274
+ stage: run.stage ?? null,
3275
+ stages: run.stages ?? meta.stages ?? null,
3276
+ // A stage is a process with its own start; the card's clock is the
3277
+ // session's, from the first one this terminal saw.
3278
+ startedAt: meta.startedAt ?? run.startedAt ?? null,
3009
3279
  ended: false,
3010
3280
  // A carried-on run the daemon has taken: the ask is answered.
3011
3281
  carryAsked: false,
3282
+ lockCleaned: false,
3012
3283
  });
3013
3284
  if (!known) {
3014
3285
  this.sessions.set(run.id, meta);
@@ -3027,6 +3298,7 @@ export class Attached {
3027
3298
  for (const meta of this.sessions.values()) {
3028
3299
  if (!meta.ended && !live.has(meta.id)) {
3029
3300
  meta.ended = true;
3301
+ meta.changedTick = this.stamp();
3030
3302
  void this.readView(meta, 5);
3031
3303
  }
3032
3304
  }
@@ -3231,6 +3503,9 @@ export class Attached {
3231
3503
  if (!this.windows().some((meta) => meta.id === this.split.focus)) {
3232
3504
  this.split.focus = this.windows()[0]?.id ?? null;
3233
3505
  }
3506
+ if (this.split.boardFocus && !this.split.projects.has(this.split.boardFocus.slug)) {
3507
+ this.leaveBoard();
3508
+ }
3234
3509
  this.leaveHiddenWatch();
3235
3510
  this.say(this.ink.muted(`showing ${slugs.join(', ')} — /projects changes it`));
3236
3511
  this.dirty = true;
@@ -3251,6 +3526,11 @@ export class Attached {
3251
3526
  // The projects this mode shows; null is every one this machine serves.
3252
3527
  projects: shown,
3253
3528
  focus: null,
3529
+ boardFocus: null,
3530
+ lastBoardFocus: null,
3531
+ boardScroll: 0,
3532
+ cardCache: new Map(),
3533
+ cardLoading: new Set(),
3254
3534
  messages: [],
3255
3535
  enteredWatching: this.watching,
3256
3536
  mark: new Map([...this.lines].map(([id, lines]) => [id, lines.length])),
@@ -3265,8 +3545,8 @@ export class Attached {
3265
3545
  void this.refreshPlanned();
3266
3546
  this.split.plannedTimer = setInterval(() => void this.refreshPlanned(), 30_000);
3267
3547
  this.split.plannedTimer.unref?.();
3268
- this.say(this.ink.muted('advanced mode — tab moves between windows · /new starts a session'
3269
- + ' · /projects picks what is shown · /advanced-mode leaves'));
3548
+ this.say(this.ink.muted('advanced mode — tab moves between cards · b browses the roadmap · v shows one\'s log'
3549
+ + ' · /new starts a session · /projects picks what is shown · /advanced-mode leaves'));
3270
3550
  if (shown) {
3271
3551
  this.say(this.ink.muted(`showing ${[...shown].join(', ')}`));
3272
3552
  }
@@ -3279,12 +3559,13 @@ export class Attached {
3279
3559
  * said meanwhile is laid into it now — or, if the focus moved, that
3280
3560
  * session's history, which is what switching sessions has always done.
3281
3561
  */
3282
- leaveSplit() {
3562
+ leaveSplit({ quiet = false } = {}) {
3283
3563
  if (!this.split) return this.note('advanced mode is not on — /advanced-mode starts it');
3284
3564
  const { screen, enteredWatching, mark, plannedTimer } = this.split;
3285
3565
  clearInterval(plannedTimer);
3286
3566
  this.split = null;
3287
3567
  screen.leave();
3568
+ if (quiet) return undefined;
3288
3569
  const watched = this.watching;
3289
3570
  const lines = this.lines.get(watched) ?? [];
3290
3571
  if (watched && watched === enteredWatching) {
@@ -3295,7 +3576,7 @@ export class Attached {
3295
3576
  this.printed.delete(watched);
3296
3577
  this.layHistoryIn(watched, lines);
3297
3578
  }
3298
- this.note('back to the scrollback');
3579
+ this.note('back to the scrollback · /advanced-mode returns');
3299
3580
  return undefined;
3300
3581
  }
3301
3582
 
@@ -3309,17 +3590,330 @@ export class Attached {
3309
3590
  this.note('');
3310
3591
  }
3311
3592
 
3593
+ /** The tick to stamp a change with: the wall clock's once the cards' clock runs (tests, which never start it, stay at 0). */
3594
+ stamp() {
3595
+ return this.rainTick === undefined ? 0 : clockTick();
3596
+ }
3597
+
3598
+ /** The sessions whose card waits on a person, in card order. */
3599
+ needYou() {
3600
+ return this.windows().filter((meta) => {
3601
+ const run = this.runs.find((each) => each.id === meta.id);
3602
+ return Boolean(run && (this.showOn(run) || this.pendingOn(run) || this.askingOn(run)));
3603
+ });
3604
+ }
3605
+
3606
+ /** `n` / `N`: focus moves to the next / previous card that needs you. */
3607
+ focusNeedsYou(step) {
3608
+ const waiting = this.needYou();
3609
+ if (!waiting.length) return this.note('nothing needs you');
3610
+ const windows = this.windows();
3611
+ const from = windows.findIndex((meta) => meta.id === this.split.focus);
3612
+ for (let i = 1; i <= windows.length; i++) {
3613
+ const meta = windows[(from + step * i + windows.length * i) % windows.length];
3614
+ if (waiting.includes(meta)) return this.focusWindow(meta.id);
3615
+ }
3616
+ return undefined;
3617
+ }
3618
+
3312
3619
  cycleFocus(step) {
3620
+ if (this.split?.boardFocus) this.leaveBoard();
3313
3621
  const windows = this.windows();
3314
3622
  if (!windows.length) return this.note('no session windows yet — /new starts one');
3315
3623
  const at = windows.findIndex((meta) => meta.id === this.split.focus);
3316
3624
  return this.focusWindow(windows[(at + step + windows.length) % windows.length].id);
3317
3625
  }
3318
3626
 
3627
+ /**
3628
+ * `b` or `/board`: put focus into the roadmap strip at the top so arrows walk
3629
+ * its columns and cards, showing each card's full title and description in a
3630
+ * preview panel directly below the strip.
3631
+ */
3632
+ async toggleBoard(want = !this.split?.boardFocus) {
3633
+ if (!this.split) {
3634
+ return this.note('the roadmap strip is in advanced mode — /advanced-mode starts it');
3635
+ }
3636
+ if (!want) {
3637
+ return this.leaveBoard();
3638
+ }
3639
+ const width = this.split.screen.width;
3640
+ let columns = stripColumns(width, this.cards, this.shownProjects());
3641
+ if (!columns.length && this.session.signedIn) {
3642
+ await this.refreshPlanned();
3643
+ if (!this.split) return undefined;
3644
+ columns = stripColumns(width, this.cards, this.shownProjects());
3645
+ }
3646
+ if (!columns.length) {
3647
+ return this.note('no cards on the roadmap strip here yet');
3648
+ }
3649
+ const picked = moveInStrip(columns, this.split.lastBoardFocus, 0, 0);
3650
+ this.split.boardFocus = picked;
3651
+ this.split.boardScroll = 0;
3652
+ this.dirty = true;
3653
+ void this.loadBoardCard(picked.slug, picked.ref);
3654
+ return this.note('');
3655
+ }
3656
+
3657
+ /** Return focus from the roadmap strip to the session cards. */
3658
+ leaveBoard() {
3659
+ if (!this.split?.boardFocus) return this.note('');
3660
+ this.split.lastBoardFocus = this.split.boardFocus;
3661
+ this.split.boardFocus = null;
3662
+ this.split.boardScroll = 0;
3663
+ this.dirty = true;
3664
+ return this.note('');
3665
+ }
3666
+
3667
+ /** Step `(dCol, dRow)` in the roadmap strip and fetch the newly focused card's description. */
3668
+ moveBoardFocus(dCol, dRow) {
3669
+ if (!this.split) return undefined;
3670
+ const columns = stripColumns(this.split.screen.width, this.cards, this.shownProjects());
3671
+ const next = moveInStrip(columns, this.split.boardFocus, dCol, dRow);
3672
+ if (!next) {
3673
+ return this.leaveBoard();
3674
+ }
3675
+ this.split.boardFocus = next;
3676
+ this.split.lastBoardFocus = next;
3677
+ this.split.boardScroll = 0;
3678
+ this.dirty = true;
3679
+ void this.loadBoardCard(next.slug, next.ref);
3680
+ return this.note('');
3681
+ }
3682
+
3683
+ /** Scroll the focused card's description inside the preview panel. */
3684
+ scrollBoard(by) {
3685
+ if (!this.split?.boardFocus) return undefined;
3686
+ const max = this.split.boardMax ?? 0;
3687
+ this.split.boardScroll = Math.min(Math.max(0, (this.split.boardScroll ?? 0) + by), max);
3688
+ this.dirty = true;
3689
+ return undefined;
3690
+ }
3691
+
3692
+ /** Read and cache a card's full record (`body`, metadata) for the board preview panel. */
3693
+ async loadBoardCard(slug, ref) {
3694
+ if (!this.split) return;
3695
+ const key = `${slug}:${ref}`;
3696
+ if (this.split.cardCache.has(key) || this.split.cardLoading.has(key)) return;
3697
+ const existing = this.cards.find((card) => card.slug === slug && card.ref === ref);
3698
+ if (existing?.body !== undefined) {
3699
+ this.split.cardCache.set(key, existing);
3700
+ return;
3701
+ }
3702
+ if (!this.session.signedIn) return;
3703
+ this.split.cardLoading.add(key);
3704
+ this.dirty = true;
3705
+ try {
3706
+ const detail = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
3707
+ if (detail && this.split) {
3708
+ this.split.cardCache.set(key, detail);
3709
+ }
3710
+ } catch {
3711
+ // The strip's own title and status remain shown even if reading the body fails.
3712
+ } finally {
3713
+ if (this.split) {
3714
+ this.split.cardLoading.delete(key);
3715
+ this.dirty = true;
3716
+ }
3717
+ }
3718
+ }
3719
+
3720
+ /** The focused roadmap card, merged with its cached full record (`body`, `section`, …). */
3721
+ boardCardOf(focus) {
3722
+ if (!focus) return null;
3723
+ const base = this.cards.find((card) => card.slug === focus.slug && card.ref === focus.ref);
3724
+ const cached = this.split?.cardCache?.get(`${focus.slug}:${focus.ref}`);
3725
+ if (!base && !cached) return null;
3726
+ return { ...base, ...cached, slug: focus.slug, ref: focus.ref };
3727
+ }
3728
+
3729
+ /** Keys while focus is in the top roadmap strip (`this.split.boardFocus`). */
3730
+ onBoardKey(key) {
3731
+ const focus = this.split.boardFocus;
3732
+ switch (key) {
3733
+ case `${ESC}[A`: case `${ESC}OA`: case 'k':
3734
+ return this.moveBoardFocus(0, -1);
3735
+ case `${ESC}[B`: case `${ESC}OB`: case 'j':
3736
+ return this.moveBoardFocus(0, 1);
3737
+ case `${ESC}[D`: case `${ESC}OD`: case 'h':
3738
+ return this.moveBoardFocus(-1, 0);
3739
+ case `${ESC}[C`: case `${ESC}OC`: case 'l':
3740
+ return this.moveBoardFocus(1, 0);
3741
+ case `${ESC}[6~`: case ' ':
3742
+ return this.scrollBoard(this.split.boardRoom ?? 1);
3743
+ case `${ESC}[5~`:
3744
+ return this.scrollBoard(-(this.split.boardRoom ?? 1));
3745
+ case 'p':
3746
+ this.leaveBoard();
3747
+ return void this.startNew(['plan', focus.ref, `@${focus.slug}`]);
3748
+ case 'd':
3749
+ this.leaveBoard();
3750
+ return void this.startNew(['dev', focus.ref, `@${focus.slug}`]);
3751
+ case 'v': case '\r': case '\n':
3752
+ return void this.showCard(focus.slug, focus.ref);
3753
+ case ESC: case 'b':
3754
+ return this.leaveBoard();
3755
+ case '\t': case `${ESC}[Z`:
3756
+ this.leaveBoard();
3757
+ return this.cycleFocus(key === '\t' ? 1 : -1);
3758
+ case '/':
3759
+ return this.type('command', 'cawdev ▸', '/');
3760
+ default:
3761
+ if (/^[1-9]$/.test(key)) {
3762
+ this.leaveBoard();
3763
+ return this.onKey(key);
3764
+ }
3765
+ return undefined;
3766
+ }
3767
+ }
3768
+
3769
+ /**
3770
+ * What a session's card says (session-cards.mjs `cardLines`): its kind, its
3771
+ * card, where it stands, the agent and checkout, the stage it is on, and ONE
3772
+ * line of what it is doing — or, while it waits on you, what it waits on.
3773
+ * Its log is `/view`'s.
3774
+ */
3775
+ cardOf(meta, n, rect, now = Date.now()) {
3776
+ const ink = this.ink;
3777
+ const run = this.runs.find((each) => each.id === meta.id);
3778
+ const shownGate = this.showOn(run);
3779
+ // A SHOW gate is an approval too, but not one to allow: it is looked at.
3780
+ const pending = shownGate ? null : this.pendingOn(run);
3781
+ const asking = this.askingOn(run);
3782
+ const view = meta.view ?? {};
3783
+ const room = rect.width - 4;
3784
+ const waiting = shownGate ? 'waiting · screenshots'
3785
+ : pending ? 'waiting · permission'
3786
+ : asking ? (asking.yours ? 'waiting on you' : 'waiting · a question') : null;
3787
+ const who = agentModelFor(meta);
3788
+ // R605. At SHOW the card says what it is waiting for, in amber; the agent,
3789
+ // model and path come back once the gate is settled.
3790
+ const gate = shownGate ? showGate(shownGate.approval) : null;
3791
+ const subtitle = gate
3792
+ ? ink.warn(showWaitingLine(gate, gate.ids.length))
3793
+ : (who ? `${ink.strong(who)} ` : '') + ink.muted(subtitleFor(meta, room - (who ? who.length + 1 : 0)));
3794
+ const tick = this.rainTick ?? 0;
3795
+ const status = cardStatus(meta, waiting, now, tick);
3796
+ // A status that changes is drawn bright for a few ticks: when its key moves.
3797
+ if (meta.lastKey !== undefined && meta.lastKey !== status.key) meta.changedTick = tick;
3798
+ meta.lastKey = status.key;
3799
+ const progress = !meta.ended
3800
+ ? stageWalk(meta.stages, meta.stage, room, ink, tick)
3801
+ : meta.commits !== undefined && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
3802
+ ? ink.muted(`${meta.commits} commit${meta.commits === 1 ? '' : 's'}${view.branch ? ` on ${view.branch}` : ''}`)
3803
+ : '';
3804
+ const doing = pending
3805
+ ? ink.warn(`◆ ${pending.approval?.toolName ?? 'a tool'}: ${String(pending.approval?.summary ?? '').split('\n')[0]}`)
3806
+ : asking
3807
+ ? ink.warn(`◆ ${String(asking.question?.question ?? '').split('\n')[0]}`)
3808
+ : nowLine(meta, this.lines.get(meta.id) ?? []);
3809
+ const keys = pending ? 'y once · s session · n refuse · /view reads it'
3810
+ : asking?.yours ? 'a answers · /view reads it whole'
3811
+ : shownGate ? 'a to look and decide' : actionsFor(meta);
3812
+ const merging = meta.merging;
3813
+ if (merging?.state === 'landed') {
3814
+ const left = Math.max(0, Math.ceil((CLOSE_AFTER_TICKS - (tick - merging.landedTick)) * RAIN_STEP_MS / 1000));
3815
+ return {
3816
+ n,
3817
+ profile: view.profile ?? meta.profile ?? null,
3818
+ title: `${merging.ref} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim(),
3819
+ status: { key: 'landed', mark: '✓', word: `merged · closes in ${left}s` },
3820
+ lines: [
3821
+ ink.muted(`landed on ${merging.base}${merging.sha ? ` as ${merging.sha}` : ''} · the card is MERGED`),
3822
+ ],
3823
+ footer: '/open the merge · /close now',
3824
+ focused: meta.id === this.split.focus,
3825
+ seed: seedOf(meta.id),
3826
+ tick,
3827
+ since: tick - merging.landedTick,
3828
+ changedAt: merging.landedTick,
3829
+ rain: false,
3830
+ base: merging.base,
3831
+ };
3832
+ }
3833
+ if (merging) {
3834
+ const failed = merging.state === 'failed';
3835
+ const resolving = merging.state === 'resolving';
3836
+ return {
3837
+ n,
3838
+ profile: view.profile ?? meta.profile ?? null,
3839
+ title: `${merging.ref} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim(),
3840
+ status: failed ? { key: 'stopped', mark: '✕', word: 'merge failed' }
3841
+ : resolving ? { key: 'waiting', mark: '⟳', word: 'conflict · back to dev' }
3842
+ : { key: 'merging', mark: '⇢', word: 'merging' },
3843
+ lines: [
3844
+ ink.muted(`${merging.branch} → ${merging.base}`),
3845
+ failed ? ink.danger(`why: ${merging.why}`)
3846
+ : resolving ? ink.warn(`why: ${merging.why}`)
3847
+ : ink.muted('nothing to do here: it closes once it lands'),
3848
+ ],
3849
+ footer: failed ? '/review to look again'
3850
+ : resolving ? `its development starts again here to resolve it` : '',
3851
+ focused: meta.id === this.split.focus,
3852
+ seed: seedOf(meta.id),
3853
+ tick,
3854
+ rain: this.matrix === true,
3855
+ base: merging.base,
3856
+ };
3857
+ }
3858
+ return {
3859
+ n,
3860
+ profile: view.profile ?? meta.profile ?? null,
3861
+ title: view.entryRef
3862
+ ? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
3863
+ : (view.label ?? meta.label ?? 'a session'),
3864
+ status,
3865
+ lines: [subtitle, progress || null, doing ? (pending || asking ? doing : ink.text(doing)) : null],
3866
+ footer: keys,
3867
+ focused: !this.split.boardFocus && meta.id === this.split.focus,
3868
+ seed: seedOf(meta.id),
3869
+ tick,
3870
+ since: meta.ended && Number.isFinite(meta.changedTick) ? tick - meta.changedTick : undefined,
3871
+ changedAt: meta.changedTick,
3872
+ ref: view.entryRef ?? null,
3873
+ rain: this.matrix === true,
3874
+ };
3875
+ }
3876
+
3877
+ /** `?` — every key and command in one window, two columns. */
3878
+ helpScreen(width, height) {
3879
+ const ink = this.ink;
3880
+ const keys = [
3881
+ ['enter', 'prompt the focused card'], ['tab ⇧tab 1–9', 'next / previous / pick a card'],
3882
+ ['n N', 'next / previous card needing you'], ['v', 'its log'],
3883
+ ['b', 'the board: ←→ ↑↓ p d esc'], ['↑↓ PgUp PgDn G', 'scroll the log, G follows the end'],
3884
+ ['ctrl+f', 'find in the log'], ['r', 'fold the roadmap'], ['d', 'develop a planned card'],
3885
+ ['a y s Y', 'answer · permission'], ['x x', 'cancel the running session'],
3886
+ ['w', 'close the ended card'], ['c', 'config'], ['L', 'runs'], ['?', 'this window'], ['q', 'quit'],
3887
+ ];
3888
+ const commands = [
3889
+ ['/new', 'plan · dev · ask'], ['/view v', 'read a session'], ['/board b', 'the roadmap'],
3890
+ ['/continue /pr', 'carry on · open the PR'], ['/open', 'on cawdev'], ['/export', 'log to scrollback'],
3891
+ ['/simple', 'the scrollback terminal'], ['/matrix', 'the rain, off by default'],
3892
+ ['/projects /refresh', 'the roadmap'], ['/agent', 'which agent'], ['/quit q', 'leave'],
3893
+ ['/close N|all', 'close ended cards'],
3894
+ ];
3895
+ const half = Math.max(20, Math.floor((width - 4) / 2));
3896
+ const cell = (pair, w) => {
3897
+ if (!pair) return ' '.repeat(w);
3898
+ const key = ink.accent(pair[0]);
3899
+ const gap = Math.max(1, 16 - visibleWidth(pair[0]));
3900
+ return padVisible(clip(`${key}${' '.repeat(gap)}${ink.muted(pair[1])}`, w), w);
3901
+ };
3902
+ const body = [ink.bold(padVisible(' keys', half)) + ink.bold(' commands')];
3903
+ for (let i = 0; i < Math.max(keys.length, commands.length); i++) {
3904
+ body.push(` ${cell(keys[i], half - 1)}${cell(commands[i], half - 1)}`);
3905
+ }
3906
+ body.push('', ink.muted(' the key line at the bottom shows what fits · this is all of it · esc closes'));
3907
+ return windowLines({ width, height }, { title: 'cawdev ▸ keys', body, focused: true }, ink);
3908
+ }
3909
+
3319
3910
  /** The whole alternate screen, as rows — see split.mjs. */
3320
3911
  splitFrame() {
3321
3912
  const { width, height } = this.split.screen;
3322
3913
  const ink = this.ink;
3914
+ if (this.help && this.mode === 'keys') {
3915
+ return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.helpScreen(width, height) }]);
3916
+ }
3323
3917
  if (this.reader) {
3324
3918
  return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
3325
3919
  }
@@ -3338,8 +3932,8 @@ export class Attached {
3338
3932
  const input = this.mode === 'typing'
3339
3933
  ? `${tag}${this.input.label} ${this.input.line.render(
3340
3934
  Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
3341
- : tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3342
- + ' · / commands · tab next window');
3935
+ // Only what enter acts on: the keys live once, in the window's footer.
3936
+ : [`${tag}${ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`)}`];
3343
3937
  // A question or a request on screen says which window it came from.
3344
3938
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3345
3939
  // Under the banner and right above the line, so a long one never pushes
@@ -3356,61 +3950,85 @@ export class Attached {
3356
3950
  const completing = completionLines(
3357
3951
  matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
3358
3952
  );
3359
- const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
3360
- const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
3953
+ const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, ...[input].flat()];
3954
+ // The window's key footer wraps between keys, so the prompt grows with it.
3955
+ const footerRows = this.status
3956
+ ? [ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)]
3957
+ : packKeys(this.keys(), inner, ink);
3958
+ const promptRows = Math.max(PROMPT_ROWS, fixed.length + 4 + footerRows.length);
3361
3959
  // The roadmap across the top: as tall as its fullest column needs, never
3362
3960
  // more than a third of the screen, and never so tall that no window fits
3363
3961
  // under it — a short terminal drops it before it drops the sessions.
3364
- const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
3962
+ const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - CARD_MIN_HEIGHT);
3963
+ if (this.split.boardFocus) {
3964
+ this.split.boardFocus = moveInStrip(
3965
+ stripColumns(width, this.cards, this.shownProjects()),
3966
+ this.split.boardFocus,
3967
+ 0,
3968
+ 0,
3969
+ );
3970
+ }
3365
3971
  // A rule under it, from the strip's own room: the roadmap is the board and
3366
3972
  // the windows are the sessions, and without it the strip's last card line
3367
3973
  // ran straight into the first window's border.
3368
3974
  const stripLines = stripRoom >= 4
3369
- ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
3975
+ ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects(), this.split.boardFocus),
3370
3976
  ink.muted('─'.repeat(width))]
3371
3977
  : [];
3372
3978
  const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
3373
- // Four windows at most; the rest are named on one line above the prompt,
3374
- // and Tab onto one of them brings it up in place of the least recent.
3375
- const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus);
3979
+
3980
+ // When focus is in the roadmap strip (`b` / `/board`), a preview box
3981
+ // directly under it shows the focused card's full title and description.
3982
+ const avail = Math.max(0, height - strip - promptRows);
3983
+ const focusedCard = this.split.boardFocus ? this.boardCardOf(this.split.boardFocus) : null;
3984
+ const previewMax = windows.length && avail >= CARD_MIN_HEIGHT + 5
3985
+ ? Math.max(5, avail - CARD_MIN_HEIGHT)
3986
+ : Math.max(5, avail);
3987
+ let previewLines = [];
3988
+ if (focusedCard && avail >= 5) {
3989
+ const loading = Boolean(this.split.cardLoading?.has(`${focusedCard.slug}:${focusedCard.ref}`));
3990
+ const drawn = boardPreviewLines(width, previewMax, focusedCard, ink, {
3991
+ top: this.split.boardScroll ?? 0,
3992
+ loading,
3993
+ });
3994
+ this.split.boardScroll = drawn.top;
3995
+ this.split.boardMax = drawn.max;
3996
+ this.split.boardRoom = drawn.room;
3997
+ previewLines = drawn.lines;
3998
+ }
3999
+ const preview = previewLines.length && previewLines.length <= avail ? previewLines.length : 0;
4000
+
4001
+ // The sessions are CARDS (session-cards.mjs): as many as fit, the rest
4002
+ // named on one line above the prompt, and Tab onto one of them brings it
4003
+ // up in place of the least recent. The prompt has every row they leave.
4004
+ const room = Math.max(0, height - strip - preview - promptRows);
4005
+ let grid = cardGrid(width, room, windows.length);
4006
+ if (grid.hidden) grid = cardGrid(width, room - 1, windows.length);
4007
+ const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus, grid.zones.length);
3376
4008
  const hidden = windows.filter((meta) => !shown.includes(meta));
3377
4009
  const tabs = hidden.length ? 1 : 0;
3378
- const layout = splitLayout(width, height - strip - tabs, shown.length, promptRows);
3379
- for (const rect of layout.zones) {
3380
- rect.top += strip;
3381
- }
3382
- layout.prompt.top += strip + tabs;
4010
+ const layout = {
4011
+ zones: grid.zones.map((rect) => ({ ...rect, top: rect.top + strip + preview })),
4012
+ prompt: {
4013
+ top: strip + preview + grid.height + tabs,
4014
+ left: 0,
4015
+ width,
4016
+ height: height - strip - preview - grid.height - tabs,
4017
+ },
4018
+ hidden: 0,
4019
+ };
3383
4020
 
4021
+ const now = Date.now();
3384
4022
  const pieces = layout.zones.map((rect, i) => {
3385
4023
  const meta = shown[i];
3386
- const n = windows.indexOf(meta) + 1;
3387
- const asking = this.askingOn(this.runs.find((run) => run.id === meta.id));
3388
- const waiting = this.pendingOn(this.runs.find((run) => run.id === meta.id))
3389
- ? 'waiting for permission'
3390
- : asking ? 'waiting for an answer' : null;
3391
- const lines = (this.lines.get(meta.id) ?? []).map((line) => this.render(line));
3392
- const body = lines.length ? lines : [ink.muted(meta.state === 'queued'
3393
- ? ` waiting — ${meta.why ?? 'no reason recorded'}`
3394
- : meta.ended ? ' nothing was said on this session here' : ' nothing said yet')];
3395
- return {
3396
- rect,
3397
- lines: windowLines(rect, {
3398
- title: `${n} ${titleFor(meta, waiting)}`,
3399
- // The agent and its model first, bold and underlined — which CLI and
3400
- // which model is what somebody comparing two windows looks for.
3401
- subtitle: (() => {
3402
- const who = agentModelFor(meta);
3403
- const lead = who ? `${ink.strong(who)} ` : '';
3404
- return lead + ink.muted(subtitleFor(meta, rect.width - 4 - (who ? who.length + 1 : 0)));
3405
- })(),
3406
- body,
3407
- footer: actionsFor(meta),
3408
- focused: meta.id === this.split.focus,
3409
- dotted: meta.id !== this.split.focus,
3410
- tone: toneFor(meta),
3411
- }, ink),
3412
- };
4024
+ return { rect, lines: sessionCardLines(rect, this.cardOf(meta, windows.indexOf(meta) + 1, rect, now), ink) };
3413
4025
  });
4026
+ if (preview) {
4027
+ pieces.push({
4028
+ rect: { top: strip, left: 0, width, height: preview },
4029
+ lines: previewLines,
4030
+ });
4031
+ }
3414
4032
 
3415
4033
  if (tabs) {
3416
4034
  pieces.push({
@@ -3430,7 +4048,8 @@ export class Attached {
3430
4048
  });
3431
4049
  const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
3432
4050
  + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
3433
- + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
4051
+ + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`
4052
+ + (this.needYou().length ? ` · ${ink.warn(`${this.needYou().length} need you`)}` : '');
3434
4053
  pieces.push({
3435
4054
  rect: layout.prompt,
3436
4055
  lines: windowLines(layout.prompt, {
@@ -3443,9 +4062,7 @@ export class Attached {
3443
4062
  ...messages,
3444
4063
  ...fixed,
3445
4064
  ],
3446
- footer: this.status
3447
- ? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
3448
- : keyList(this.keys(), '', inner, ink),
4065
+ footer: footerRows,
3449
4066
  }, ink),
3450
4067
  });
3451
4068
  if (strip) {
@@ -3479,7 +4096,10 @@ export class Attached {
3479
4096
  try {
3480
4097
  const cards = await this.session.request(`/api/projects/${slug}/roadmap?brief=true`);
3481
4098
  for (const card of Array.isArray(cards) ? cards : []) {
3482
- all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
4099
+ all.push({
4100
+ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun),
4101
+ ...(card.body !== undefined ? { body: card.body } : {}),
4102
+ });
3483
4103
  }
3484
4104
  // The issues are another list on the platform (R85), and were never
3485
4105
  // read here: the strip drew a project's ideas and not one of its bugs.
@@ -3488,6 +4108,7 @@ export class Attached {
3488
4108
  all.push({
3489
4109
  ref: issue.ref, title: issue.title, slug, status: issue.status,
3490
4110
  live: Boolean(issue.activeRun), severity: issue.severity ?? 'MINOR',
4111
+ ...(issue.body !== undefined ? { body: issue.body } : {}),
3491
4112
  });
3492
4113
  }
3493
4114
  read.push(slug);
@@ -3588,9 +4209,12 @@ export class Attached {
3588
4209
  if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
3589
4210
  words = [...words, `@${shown[0]}`];
3590
4211
  }
3591
- const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again');
4212
+ const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again'
4213
+ && word !== '--show' && word !== '--no-show');
3592
4214
  if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
3593
4215
  const named = words.find((word) => word.startsWith('@'));
4216
+ // R605: the choice of card comes later; the answer about SHOW does not wait for it.
4217
+ this.plannedFlags = words.filter((word) => word === '--show' || word === '--no-show');
3594
4218
  await this.pickPlanned(named ? named.slice(1) : null);
3595
4219
  return;
3596
4220
  }
@@ -3598,10 +4222,20 @@ export class Attached {
3598
4222
  // R369: the answer to "a session already finished here" — `--anyway`'s
3599
4223
  // shape, for a different question.
3600
4224
  const again = words.includes('--again');
4225
+ // R605: this run is shown before its PR, or is not, whatever the card says.
4226
+ // Absent is the card's own switch (R604).
4227
+ const asked = words.includes('--show');
4228
+ const refused = words.includes('--no-show');
4229
+ if (asked && refused) {
4230
+ this.note('--show or --no-show, not both');
4231
+ return;
4232
+ }
4233
+ const show = asked ? true : refused ? false : undefined;
3601
4234
  // `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
3602
4235
  const flagged = words.find((word) => AGENT_FLAGS.has(word));
3603
4236
  let agent = flagged ? flagged.slice(2) : this.agent;
3604
- const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again' && !AGENT_FLAGS.has(word)), {
4237
+ const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again'
4238
+ && word !== '--show' && word !== '--no-show' && !AGENT_FLAGS.has(word)), {
3605
4239
  // In advanced mode, among the projects it shows.
3606
4240
  projects: this.shownProjects(),
3607
4241
  });
@@ -3622,13 +4256,21 @@ export class Attached {
3622
4256
  this.note(parsed.error);
3623
4257
  return;
3624
4258
  }
4259
+ if (parsed.refs) {
4260
+ await this.startBatch(parsed, { anyway, again, show, agent });
4261
+ return;
4262
+ }
4263
+ if (show !== undefined && parsed.kind !== 'dev') {
4264
+ this.note('--show is for a dev session — a plan or a question shows nothing');
4265
+ return;
4266
+ }
3625
4267
  if (parsed.kind === 'dev' && !parsed.ref) {
3626
4268
  // A dev from words writes its CARD first, then develops it exactly as
3627
4269
  // `/new dev R12` would. It used to be a manual session on a `task-…`
3628
4270
  // branch with no card: no number, nothing on the development board,
3629
4271
  // and a prompt that never said "commit" — so the work ended
3630
4272
  // uncommitted and `/pr` had nothing to propose.
3631
- await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
4273
+ await this.developNewCard(parsed.slug, parsed.text, anyway, agent, show);
3632
4274
  return;
3633
4275
  }
3634
4276
  // Developing a card whose finished plan has a window here: the agent that
@@ -3648,7 +4290,8 @@ export class Attached {
3648
4290
  ? ['runs/plan', { entryNumber: parsed.ref }, `a plan of ${parsed.ref}`]
3649
4291
  // R150: the session writes the card first, then plans it.
3650
4292
  : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3651
- : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
4293
+ : ['runs', { entryNumber: parsed.ref, ...(show === undefined ? {} : { show }) },
4294
+ `the development of ${parsed.ref}${shownWord(show)}`];
3652
4295
  const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
3653
4296
  if (started && plan) {
3654
4297
  this.closeWindowOf(plan);
@@ -3656,6 +4299,55 @@ export class Attached {
3656
4299
  }
3657
4300
  }
3658
4301
 
4302
+ /**
4303
+ * `/new plan|dev|plan+dev R3 R5 R7` — several cards as ONE batch, on this
4304
+ * machine (R606). The platform holds the order (the runs' chain), so the
4305
+ * terminal can be closed and the cards still go one after another; posting
4306
+ * N single starts would hold nothing.
4307
+ *
4308
+ * A dev batch is a branch each, queued here, or `--in-order` one branch;
4309
+ * a plan batch is in order; `plan+dev` builds each card after its plan
4310
+ * before the next is planned. `--again` and `--show` are one card's answers,
4311
+ * so a batch refuses them rather than applying them to every card.
4312
+ */
4313
+ async startBatch(parsed, { anyway, again, show, agent }) {
4314
+ if (again) {
4315
+ this.note('--again is for one card — a batch skips a card whose session already finished, and names it');
4316
+ return;
4317
+ }
4318
+ if (show !== undefined) {
4319
+ this.note("--show is for one card — on several, each card's own switch decides");
4320
+ return;
4321
+ }
4322
+ const body = {
4323
+ strategy: parsed.inOrder ? 'ONE_BRANCH_IN_ORDER' : 'QUEUED_ON_ONE',
4324
+ cards: parsed.refs.map((ref) => ({ entryNumber: ref })),
4325
+ ...(parsed.kind === 'dev' ? {} : { profile: 'PLAN' }),
4326
+ ...(parsed.kind === 'plan+dev' ? { thenDevelop: true } : {}),
4327
+ ...(parsed.branch ? { branch: parsed.branch } : {}),
4328
+ ...(agent ? { agent } : {}),
4329
+ ...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
4330
+ ...(anyway ? { acknowledgeUsage: true } : {}),
4331
+ };
4332
+ try {
4333
+ const outcome = await this.session.request(`/api/projects/${parsed.slug}/runs/batch`, {
4334
+ method: 'POST', body,
4335
+ });
4336
+ const [first, ...skipped] = batchStartedLines(parsed, outcome, parsed.slug, agent);
4337
+ this.say(outcome?.started?.length
4338
+ ? this.ink.success(` ${first} — each window opens when this machine takes it`)
4339
+ : this.ink.warn(` ${first}`));
4340
+ for (const line of skipped) {
4341
+ this.say(this.ink.warn(` ${line}`));
4342
+ }
4343
+ this.note('');
4344
+ } catch (failure) {
4345
+ this.note(/usage|window/i.test(failure.message)
4346
+ ? `${failure.message} — add --anyway to start them regardless`
4347
+ : failure.message);
4348
+ }
4349
+ }
4350
+
3659
4351
  /** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
3660
4352
  finishedPlan(meta) {
3661
4353
  return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
@@ -3711,7 +4403,8 @@ export class Attached {
3711
4403
  async addCard(words) {
3712
4404
  if (!this.requireSignIn('write a card')) return;
3713
4405
  const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
3714
- const text = wordsText(words.filter((word) => word && !word.startsWith('@')));
4406
+ const show = words.includes('--show');
4407
+ const text = wordsText(words.filter((word) => word && !word.startsWith('@') && word !== '--show'));
3715
4408
  if (!text) {
3716
4409
  this.note('/card what? — /card <the idea in words> (@project)');
3717
4410
  return;
@@ -3740,25 +4433,74 @@ export class Attached {
3740
4433
  try {
3741
4434
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
3742
4435
  method: 'POST',
3743
- body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
4436
+ body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING', ...(show ? { showBeforePr: true } : {}) },
3744
4437
  });
3745
4438
  } catch (failure) {
3746
4439
  this.note(`could not write the card: ${failure.message}`);
3747
4440
  return;
3748
4441
  }
3749
4442
  const ref = card?.ref ?? (card?.number ? `R${card.number}` : 'the card');
3750
- this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}`));
4443
+ this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}${show ? ' · shown before its PR' : ''}`));
3751
4444
  this.note('');
3752
4445
  void this.refreshPlanned();
3753
4446
  }
3754
4447
 
4448
+ /**
4449
+ * `/import <file.json> (@project)` — the cards of a JSON file, filed as you
4450
+ * (`card-import.mjs`). The project is `@slug`, else advanced mode narrowed to
4451
+ * one, else the one project served here; with several it is asked for by
4452
+ * name, never guessed. `--dry-run` says what would be filed and files nothing.
4453
+ */
4454
+ async importFile(words) {
4455
+ if (!this.requireSignIn('import cards')) return;
4456
+ const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
4457
+ const dryRun = words.includes('--dry-run');
4458
+ const allowDuplicates = words.includes('--allow-duplicates');
4459
+ const statusAt = words.indexOf('--status');
4460
+ const status = statusAt === -1 ? undefined : words[statusAt + 1];
4461
+ const path = words.find((word, at) => word && !word.startsWith('@') && !word.startsWith('--')
4462
+ && at !== statusAt + 1);
4463
+ if (!path) {
4464
+ this.note('/import what? — /import <file.json> (@project) (--dry-run)');
4465
+ return;
4466
+ }
4467
+ const served = this.servedProjects();
4468
+ const shown = this.shownProjects();
4469
+ const slug = named ?? (shown.length === 1 ? shown[0] : null);
4470
+ if (!slug) {
4471
+ this.note(`which project? — /import ${path} @${shown[0] ?? 'project'}`);
4472
+ return;
4473
+ }
4474
+ if (named && !served.includes(named)) {
4475
+ this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
4476
+ return;
4477
+ }
4478
+ let cards;
4479
+ try {
4480
+ cards = cardsFrom(await readFile(absolute(path), 'utf8'));
4481
+ } catch (failure) {
4482
+ this.note(`could not import ${path}: ${failure.message}`);
4483
+ return;
4484
+ }
4485
+ let result;
4486
+ try {
4487
+ result = await importCards(this.session, slug, cards, { dryRun, status, allowDuplicates });
4488
+ } catch (failure) {
4489
+ this.note(`could not import ${path}: ${failure.message}`);
4490
+ return;
4491
+ }
4492
+ for (const line of resultLines(result, slug, this.ink)) this.say(line);
4493
+ this.note('');
4494
+ if (!dryRun) void this.refreshPlanned();
4495
+ }
4496
+
3755
4497
  /**
3756
4498
  * `/new dev <the task in words>`: the card, then its development. Two
3757
4499
  * requests, and the first is said on its own line — a card that was written
3758
4500
  * but whose run was refused (a usage window, say) is still on the board,
3759
4501
  * and the person should know which half happened.
3760
4502
  */
3761
- async developNewCard(slug, text, anyway, agent = this.agent) {
4503
+ async developNewCard(slug, text, anyway, agent = this.agent, show = undefined) {
3762
4504
  let card;
3763
4505
  try {
3764
4506
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
@@ -3775,8 +4517,9 @@ export class Attached {
3775
4517
  return;
3776
4518
  }
3777
4519
  this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
3778
- await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
3779
- withWho(`the development of ${ref}`, agent), anyway);
4520
+ await this.startSession(slug, 'runs',
4521
+ withAgent({ entryNumber: ref, ...(show === undefined ? {} : { show }) }, agent),
4522
+ withWho(`the development of ${ref}${shownWord(show)}`, agent), anyway);
3780
4523
  void this.refreshPlanned();
3781
4524
  }
3782
4525
 
@@ -3831,6 +4574,9 @@ export class Attached {
3831
4574
  this.say(this.ink.muted(this.split
3832
4575
  ? ' focus its window and type /continue'
3833
4576
  : ' /continue picks up the latest — /advanced-mode gives each one a window'));
4577
+ if (found.some((meta) => staleLockOf(meta.view?.exitSummary, meta.view?.workspace))) {
4578
+ this.say(this.ink.muted(' a git lock a crash left behind stopped it — /clean removes it first'));
4579
+ }
3834
4580
  this.dirty = true;
3835
4581
  }
3836
4582
 
@@ -3872,6 +4618,43 @@ export class Attached {
3872
4618
  }
3873
4619
  }
3874
4620
 
4621
+ /**
4622
+ * `/clean` — remove the git lock a crash left in the focused (or last ended)
4623
+ * session's checkout, so `/continue` does not fail on it again. The checks
4624
+ * are git-lock.mjs's: the lock must be inside the run's own checkout and old
4625
+ * enough to be nobody's. Done here, not by the daemon, because the control
4626
+ * socket changes nothing (R52); the lock is a file on this machine, and the
4627
+ * run must have been HERE (its runner is this one) for that to be true.
4628
+ * Every session stopped on the same lock is freed by one /clean.
4629
+ */
4630
+ async cleanLock() {
4631
+ const meta = this.commandTarget();
4632
+ const view = meta?.view;
4633
+ if (!meta || !view || !meta.ended) {
4634
+ this.note('/clean needs a session that stopped — focus its window');
4635
+ return;
4636
+ }
4637
+ const lock = staleLockOf(view.exitSummary, view.workspace ?? meta.workspace);
4638
+ if (!lock) {
4639
+ this.note('that session did not stop on a git lock — nothing to clean');
4640
+ return;
4641
+ }
4642
+ if (view.runnerId && this.runner?.id && view.runnerId !== this.runner.id) {
4643
+ this.note(`that checkout is on ${view.runnerName ?? 'another machine'} — clean it there`);
4644
+ return;
4645
+ }
4646
+ const done = await (this.options.removeLock ?? removeStaleLock)(lock);
4647
+ if (!done.removed) {
4648
+ this.note(done.said);
4649
+ return;
4650
+ }
4651
+ for (const other of this.sessions.values()) {
4652
+ if (staleLockOf(other.view?.exitSummary, other.view?.workspace ?? other.workspace) === lock) other.lockCleaned = true;
4653
+ }
4654
+ this.say(this.ink.success(` ${done.said} — /continue picks it back up`));
4655
+ this.note('');
4656
+ }
4657
+
3875
4658
  /**
3876
4659
  * `/develop` — start the development of a card on this machine: the card a
3877
4660
  * finished plan was about (the focused session's), or the one named —
@@ -4097,37 +4880,524 @@ export class Attached {
4097
4880
  opener(url);
4098
4881
  }
4099
4882
 
4100
- /** What a session is stopped on — a question or a permission request — or null. */
4101
- waitingOn(meta) {
4102
- const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
4103
- if (!run) return null;
4104
- const pending = this.pendingOn(run);
4105
- if (pending) return { kind: 'permission', pending };
4106
- const asking = this.askingOn(run);
4107
- return asking ? { kind: 'question', asking } : null;
4108
- }
4883
+ // --- /review: the branch's diff, its verdict and its merge, here ---------------------------
4109
4884
 
4110
4885
  /**
4111
- * `/view` on a session waiting for you: what it is waiting on, in FULL. In
4112
- * advanced mode the reader — the session's output and the request under
4113
- * it, over the whole screen, scrolled with the arrows (the wheel arrives as
4114
- * arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
4115
- * and does what it does (`a` answers, `y`/`n` decide). In the scrollback
4116
- * the request is printed whole, where the terminal scrolls it.
4886
+ * `/review` — the diff of a card's branch in this terminal, and the three
4887
+ * acts a reviewer takes on it, each the platform's own endpoint: Done
4888
+ * (`work-items/{id}/verdict` APPROVED), changes requested (the same, with the
4889
+ * note the next session is given), and merge (`work-items/{id}/merge`, which a
4890
+ * machine of yours performs). The diff is local git (`diff-view.mjs`), so
4891
+ * nothing about a branch's contents crosses the API.
4892
+ *
4893
+ * The card is the focused session's, or one named (`/review R42 @slug`). In
4894
+ * advanced mode the diff is the reader over the whole screen and the acts are
4895
+ * keys; otherwise it is printed, and the acts are `/review done`,
4896
+ * `/review changes <note>`, `/review merge`. Merge asks twice either way: it
4897
+ * is the one act here nothing undoes.
4117
4898
  */
4118
- openReader() {
4119
- const meta = this.commandTarget();
4120
- if (!this.split) {
4121
- this.say('');
4122
- for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
4123
- this.say('');
4124
- return this.note('');
4899
+ async review(words) {
4900
+ if (!this.requireSignIn('review a branch')) return undefined;
4901
+ const [verb, ...rest] = words;
4902
+ switch (verb) {
4903
+ case 'done':
4904
+ return this.reviewDone();
4905
+ case 'changes':
4906
+ return rest.length ? this.reviewChanges(wordsText(rest)) : this.askReviewNote();
4907
+ case 'merge':
4908
+ return this.reviewMerge();
4909
+ case 'agent-merge':
4910
+ return this.reviewAgentMerge();
4911
+ default:
4912
+ return this.openReview(words);
4125
4913
  }
4126
- this.reader = { runId: meta.id, top: null, room: 1, max: 0 };
4127
- this.dirty = true;
4914
+ }
4915
+
4916
+ /** The card a review is of: named, else the focused session's. Null after saying why. */
4917
+ async reviewTarget(words) {
4918
+ const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
4919
+ const bare = words.filter((word) => word && !word.startsWith('@'));
4920
+ if (bare.length) {
4921
+ if (bare.length > 1 || !/^[ri]?\d+$/i.test(bare[0])) {
4922
+ this.note('/review which card? /review R42 (@project), or /review alone for the session\'s');
4923
+ return null;
4924
+ }
4925
+ const ref = refOf(bare[0]);
4926
+ const projects = named.length ? named
4927
+ : this.shownProjects().length ? this.shownProjects() : this.servedProjects();
4928
+ const reads = await Promise.all(projects.map((slug) => this.session
4929
+ .request(`/api/projects/${slug}/roadmap/${ref}`)
4930
+ .then((card) => (card ? { slug, card } : null))
4931
+ .catch(() => null)));
4932
+ const found = reads.filter(Boolean);
4933
+ const building = found.filter((each) => each.card.workItem);
4934
+ const pick = building.length ? building : found;
4935
+ if (pick.length > 1) {
4936
+ this.note(`${ref} is in ${pick.map((each) => each.slug).join(', ')} — name one: /review ${ref} @${pick[0].slug}`);
4937
+ return null;
4938
+ }
4939
+ if (!pick.length) {
4940
+ this.note(`no ${ref} in ${projects.join(', ') || 'any project here'}`);
4941
+ return null;
4942
+ }
4943
+ return { ...pick[0], meta: null };
4944
+ }
4945
+ const meta = this.commandTarget();
4946
+ const ref = meta?.view?.entryRef;
4947
+ const slug = meta?.projectSlug ?? meta?.view?.projectSlug;
4948
+ if (!ref || !slug) {
4949
+ this.note(meta ? 'this session has no card — /review R42 names one' : '/review needs a session — focus one, or name a card: /review R42');
4950
+ return null;
4951
+ }
4952
+ try {
4953
+ return { slug, card: await this.session.request(`/api/projects/${slug}/roadmap/${ref}`), meta };
4954
+ } catch (failure) {
4955
+ this.note(`could not read ${ref}: ${failure.message}`);
4956
+ return null;
4957
+ }
4958
+ }
4959
+
4960
+ /** A checkout of the project on this machine: the session's own, else one the config names. */
4961
+ async checkoutFor(slug, meta) {
4962
+ const candidates = [];
4963
+ if (meta?.view?.workspace) candidates.push(meta.view.workspace);
4964
+ if (this.runner?.configPath) {
4965
+ const file = await readConfigFile(this.runner.configPath).catch(() => null);
4966
+ candidates.push(...pathsOf(file?.projects?.[slug]).map((path) => absolute(path)));
4967
+ }
4968
+ return candidates.find((path) => existsSync(join(path, '.git'))) ?? null;
4969
+ }
4970
+
4971
+ async openReview(words) {
4972
+ const target = await this.reviewTarget(words);
4973
+ if (!target) return undefined;
4974
+ const { slug, card, meta } = target;
4975
+ const ref = card.ref ?? '';
4976
+ const item = card.workItem;
4977
+ if (!item?.id || !item.branch) {
4978
+ return this.note(`${ref} has no branch in development${card.status ? ` — it is ${card.status}` : ''}`);
4979
+ }
4980
+ const cwd = await this.checkoutFor(slug, meta);
4981
+ if (!cwd) {
4982
+ return this.note(`no checkout of ${slug} on this machine to read the diff in — c → Add a workspace`);
4983
+ }
4984
+ const project = await this.session.request(`/api/projects/${slug}`).catch(() => null);
4985
+ const base = item.baseBranch ?? project?.defaultBranch ?? 'main';
4986
+ this.note(`reading ${item.branch} against ${base}…`);
4987
+ let diff;
4988
+ try {
4989
+ diff = await branchDiff(cwd, { branch: item.branch, base });
4990
+ } catch (failure) {
4991
+ return this.note(`could not read the diff: ${failure.message}`);
4992
+ }
4993
+ const files = diff.files;
4994
+ this.reviewing = {
4995
+ slug,
4996
+ ref,
4997
+ title: card.title ?? '',
4998
+ item,
4999
+ branch: item.branch,
5000
+ base,
5001
+ files,
5002
+ cut: diff.cut,
5003
+ stale: diff.stale,
5004
+ folded: new Set(files.filter(foldedByDefault).map((file) => file.path)),
5005
+ // A merge may be pressed only by somebody who holds Merge; absent, the API decides.
5006
+ mayMerge: Array.isArray(project?.yourPrivileges) ? project.yourPrivileges.includes('MERGE') : true,
5007
+ layout: null,
5008
+ confirmingMerge: false,
5009
+ };
5010
+ if (!this.split) {
5011
+ const width = Math.max(20, (this.screen?.width ?? 80) - 2);
5012
+ this.say('');
5013
+ this.say(this.ink.bold(` review ${ref} ${printable(this.reviewing.title)}`));
5014
+ for (const row of layoutDiff(this.reviewing, width, this.ink).rows) this.say(` ${row}`);
5015
+ this.say('');
5016
+ this.say(` ${this.reviewActions()}`);
5017
+ return this.note('');
5018
+ }
5019
+ return this.reopenReview();
5020
+ }
5021
+
5022
+ /** The reader on the review, where it was; also where a cancelled note goes back to. */
5023
+ reopenReview() {
5024
+ if (!this.reviewing) return this.note('');
5025
+ if (!this.split) return this.note('');
5026
+ this.reader = { kind: 'review', top: this.reviewing.top ?? 0, room: 1, max: 0 };
5027
+ this.dirty = true;
5028
+ return this.note('');
5029
+ }
5030
+
5031
+ /** What may be done next, in words — the scrollback's footer. */
5032
+ reviewActions() {
5033
+ const ink = this.ink;
5034
+ const item = this.reviewing?.item;
5035
+ const acts = [`${ink.success('/review done')} ${ink.muted('approves')}`,
5036
+ `${ink.warn('/review changes <note>')} ${ink.muted('sends it back')}`];
5037
+ if (item?.status === 'DONE' && this.reviewing.mayMerge) acts.push(`${ink.accent('/review merge')} ${ink.muted('lands it')}`);
5038
+ return acts.join(ink.muted(' · '));
5039
+ }
5040
+
5041
+ reviewScreen(width, height) {
5042
+ const review = this.reviewing;
5043
+ const ink = this.ink;
5044
+ const inner = Math.max(1, width - 2);
5045
+ if (!review.layout || review.layout.width !== inner) {
5046
+ review.layout = { width: inner, ...layoutDiff(review, inner, ink) };
5047
+ }
5048
+ const item = review.item;
5049
+ const keys = ['↑↓ PgUp/PgDn g/G', 'n/p file', 'f fold', 'd done', 'c changes'];
5050
+ if (item.status === 'DONE' && review.mayMerge) keys.push(review.confirmingMerge ? 'm AGAIN merges' : 'm merge');
5051
+ keys.push('esc close');
5052
+ const drawn = readerFrame(width, height, {
5053
+ title: `review ${review.ref} ${printable(review.title)} · ${item.statusDisplay ?? item.status ?? ''}`,
5054
+ lines: review.layout.rows,
5055
+ requestAt: 0,
5056
+ top: this.reader.top ?? 0,
5057
+ footer: keyList(keys, '', width - 2, ink),
5058
+ }, ink);
5059
+ Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
5060
+ review.top = drawn.top;
5061
+ return drawn.lines;
5062
+ }
5063
+
5064
+ onReviewKey(key) {
5065
+ const reader = this.reader;
5066
+ const review = this.reviewing;
5067
+ const move = (to) => {
5068
+ reader.top = Math.min(Math.max(0, to), reader.max);
5069
+ this.dirty = true;
5070
+ return undefined;
5071
+ };
5072
+ const at = reader.top ?? 0;
5073
+ const fileRows = review.layout?.fileRows ?? [];
5074
+ if (key !== 'm' && review.confirmingMerge) {
5075
+ review.confirmingMerge = false;
5076
+ this.note('not merged');
5077
+ }
5078
+ switch (key) {
5079
+ case `${ESC}[A`: case `${ESC}OA`: case 'k':
5080
+ return move(at - 1);
5081
+ case `${ESC}[B`: case `${ESC}OB`: case 'j':
5082
+ return move(at + 1);
5083
+ case `${ESC}[5~`: case 'b':
5084
+ return move(at - reader.room);
5085
+ case `${ESC}[6~`: case ' ':
5086
+ return move(at + reader.room);
5087
+ case `${ESC}[H`: case `${ESC}[1~`: case 'g':
5088
+ return move(0);
5089
+ case `${ESC}[F`: case `${ESC}[4~`: case 'G':
5090
+ return move(reader.max);
5091
+ case 'n': {
5092
+ const next = fileRows.find((row) => row > at);
5093
+ return next === undefined ? this.note('the last file') : move(next);
5094
+ }
5095
+ case 'p': {
5096
+ const previous = [...fileRows].reverse().find((row) => row < at);
5097
+ return previous === undefined ? move(0) : move(previous);
5098
+ }
5099
+ case 'f': {
5100
+ const index = Math.max(0, fileAt(fileRows, at));
5101
+ const file = review.files[index];
5102
+ if (!file) return undefined;
5103
+ if (review.folded.has(file.path)) review.folded.delete(file.path);
5104
+ else review.folded.add(file.path);
5105
+ review.layout = null;
5106
+ // Stay on the file: its header row is where it was, whatever folded under it.
5107
+ reader.top = fileRows[index] ?? at;
5108
+ this.dirty = true;
5109
+ return undefined;
5110
+ }
5111
+ case 'd':
5112
+ return void this.reviewDone();
5113
+ case 'c':
5114
+ return this.askReviewNote();
5115
+ case 'm':
5116
+ return void this.reviewMerge();
5117
+ case '/':
5118
+ this.reader = null;
5119
+ this.dirty = true;
5120
+ return this.type('command', 'cawdev ▸', '/');
5121
+ case ESC: case 'q':
5122
+ this.reader = null;
5123
+ this.dirty = true;
5124
+ return this.note('');
5125
+ default:
5126
+ return undefined;
5127
+ }
5128
+ }
5129
+
5130
+ /** The note a changes-requested verdict carries, asked in the prompt. */
5131
+ askReviewNote() {
5132
+ if (!this.reviewing) return this.note('/review a branch first');
5133
+ this.reader = null;
5134
+ this.dirty = true;
5135
+ return this.type('review-note', `changes for ${this.reviewing.ref} — what should the next session do? (empty goes back) ▸`, '');
5136
+ }
5137
+
5138
+ /** The work item as the platform has it now, read again after an act. */
5139
+ async rereadReview() {
5140
+ const review = this.reviewing;
5141
+ const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
5142
+ if (card?.workItem) review.item = card.workItem;
5143
+ this.dirty = true;
5144
+ return card;
5145
+ }
5146
+
5147
+ async reviewVerdict(verdict, note) {
5148
+ const review = this.reviewing;
5149
+ if (!review) return this.note('/review a branch first');
5150
+ try {
5151
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
5152
+ method: 'POST',
5153
+ body: { verdict, ...(note ? { note } : {}) },
5154
+ });
5155
+ } catch (failure) {
5156
+ return this.note(`could not decide ${review.ref}: ${failure.message}`);
5157
+ }
5158
+ await this.rereadReview();
5159
+ return true;
5160
+ }
5161
+
5162
+ async reviewDone() {
5163
+ if (await this.reviewVerdict('APPROVED') !== true) return undefined;
5164
+ const review = this.reviewing;
5165
+ this.say(this.ink.success(` ${review.ref} ${review.branch}: Done`));
5166
+ return this.note(review.mayMerge ? `Done — m merges it${this.split ? '' : ' (/review merge)'}` : 'Done');
5167
+ }
5168
+
5169
+ async reviewChanges(note) {
5170
+ if (await this.reviewVerdict('CHANGES_REQUESTED', note) !== true) return undefined;
5171
+ const review = this.reviewing;
5172
+ this.say(this.ink.warn(` ${review.ref} ${review.branch}: changes requested`));
5173
+ this.say(this.ink.muted(` ${printable(note)}`));
5174
+ // The verdict is given: the diff is no longer what anybody acts on.
5175
+ this.reader = null;
5176
+ this.dirty = true;
5177
+ return this.note(`sent back — the next session on ${review.ref} is given the note`);
5178
+ }
5179
+
5180
+ /** Twice: the first press says what will land where, the second lands it. */
5181
+ async reviewMerge() {
5182
+ const review = this.reviewing;
5183
+ if (!review) return this.note('/review a branch first');
5184
+ if (review.item.status !== 'DONE') {
5185
+ return this.note(`${review.ref} is ${review.item.statusDisplay ?? review.item.status} — say Done first (d)`);
5186
+ }
5187
+ if (!review.mayMerge) return this.note(`merging needs Merge on ${review.slug}, which you do not hold`);
5188
+ if (!review.confirmingMerge) {
5189
+ review.confirmingMerge = true;
5190
+ this.dirty = true;
5191
+ return this.note(`merge ${review.branch} into ${review.base}? ${this.split ? 'm again' : '/review merge again'} lands it — anything else keeps it`);
5192
+ }
5193
+ review.confirmingMerge = false;
5194
+ try {
5195
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/merge`, { method: 'POST' });
5196
+ } catch (failure) {
5197
+ return this.note(`could not merge ${review.ref}: ${failure.message}`);
5198
+ }
5199
+ this.reader = null;
5200
+ this.markMerging(review, { state: 'merging' });
5201
+ this.say(this.ink.accent(` ${review.ref}: merge of ${review.branch} queued on a machine of yours`));
5202
+ void this.followMerge(review);
5203
+ return this.note('merging — said here when it lands');
5204
+ }
5205
+
5206
+ /**
5207
+ * The sessions on the card being merged: their cards say so, and refuse
5208
+ * every act on the branch until it lands (`refuseWhileMerging`). `state` is
5209
+ * `merging`, or `failed` with `why`, which gives the acts back.
5210
+ */
5211
+ markMerging(review, { state, why = null }) {
5212
+ for (const meta of this.mergingCards(review)) {
5213
+ meta.merging = { state, why, ref: review.ref, branch: review.branch, base: review.base };
5214
+ }
5215
+ this.dirty = true;
5216
+ }
5217
+
5218
+ /**
5219
+ * A merge that hit a conflict goes back to the card's development: the
5220
+ * branch is sent back (Not done) with the order as its note, which the next
5221
+ * session on the card is handed (`RunReviews.latestRejection`, `task_current`),
5222
+ * and that session is started here, again, with the agent that built it.
5223
+ * The card is IN DEVELOPMENT again, the old session's card gives way to the
5224
+ * new one's, and the merge is asked for once it is Done again. Two endpoints
5225
+ * this terminal already uses; nothing new on the platform.
5226
+ */
5227
+ async resolveConflict(review, why) {
5228
+ const ink = this.ink;
5229
+ const sessions = this.mergingCards(review);
5230
+ this.markMerging(review, { state: 'resolving', why });
5231
+ this.say(ink.warn(` ${review.ref}: ${review.branch} conflicts with ${review.base} — sent back to its development to resolve it`));
5232
+ try {
5233
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
5234
+ method: 'POST',
5235
+ body: { verdict: 'CHANGES_REQUESTED', note: conflictOrder(review, why) },
5236
+ });
5237
+ } catch (failure) {
5238
+ this.markMerging(review, { state: 'failed', why: `${why} — and it could not be sent back: ${failure.message}` });
5239
+ return this.note(`${review.ref} is not merged — /review to look again`);
5240
+ }
5241
+ const agent = sessions.map((meta) => meta.agent ?? meta.view?.agent).find(Boolean) ?? null;
5242
+ this.focusOnArrival = review.slug;
5243
+ const started = await this.startSession(review.slug, 'runs', withAgent({ entryNumber: review.ref }, agent),
5244
+ withWho(`the development of ${review.ref}, to resolve the conflict with ${review.base}`, agent), false, true);
5245
+ if (!started) {
5246
+ this.focusOnArrival = null;
5247
+ this.markMerging(review, { state: 'failed', why: `${why} — sent back, but its development did not start: ${this.status}` });
5248
+ return this.note(`/new dev ${review.ref} --again starts it`);
5249
+ }
5250
+ // The development's own card takes over when this machine claims it.
5251
+ for (const meta of sessions) {
5252
+ meta.merging = null;
5253
+ if (meta.ended) this.closeWindowOf(meta);
5254
+ }
5255
+ this.dirty = true;
5256
+ return this.note(`${review.ref} is back in development — /review it again once it is Done`);
5257
+ }
5258
+
5259
+ /** The sessions here whose card is `review`'s. */
5260
+ mergingCards(review) {
5261
+ const ref = String(review.ref).toLowerCase();
5262
+ return [...this.sessions.values()].filter((meta) => meta.projectSlug === review.slug
5263
+ && String(meta.view?.entryRef ?? '').toLowerCase() === ref);
5264
+ }
5265
+
5266
+ /** True, having said why, when the session acted on is merging its card. */
5267
+ refuseWhileMerging() {
5268
+ const merging = this.commandTarget()?.merging;
5269
+ if (merging?.state !== 'merging' && merging?.state !== 'resolving') return false;
5270
+ this.note(`${merging.ref} is merging into ${merging.base} — its card closes by itself once it lands`);
5271
+ return true;
5272
+ }
5273
+
5274
+ async reviewAgentMerge() {
5275
+ const review = this.reviewing;
5276
+ if (!review) return this.note('/review a branch first');
5277
+ try {
5278
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/agent-merge`, { method: 'POST' });
5279
+ } catch (failure) {
5280
+ return this.note(`could not start the agent merge: ${failure.message}`);
5281
+ }
5282
+ this.say(this.ink.accent(` ${review.ref}: an agent merges ${review.base} into ${review.branch} and resolves the conflict`));
5283
+ return this.note('agent merge started — its session opens like any other');
5284
+ }
5285
+
5286
+ /**
5287
+ * The merge, followed until the machine says: the card MERGED, or the merge
5288
+ * FAILED (a conflict offers the agent). Polls the card, which is where the
5289
+ * outcome is written; gives up saying so after `REVIEW_FOLLOW_TRIES`.
5290
+ */
5291
+ async followMerge(review, tries = REVIEW_FOLLOW_TRIES) {
5292
+ const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
5293
+ const merge = card?.workItem?.merge;
5294
+ if (card?.status === 'MERGED' || card?.status === 'SHIPPED' || merge?.state === 'DONE') {
5295
+ this.say(this.ink.success(` ${review.ref} landed: ${review.branch} is in ${review.base}${merge?.result ? ` (${printable(merge.result)})` : ''}`));
5296
+ // Landed: the card's work is over, and so are its cards here. In
5297
+ // advanced mode the robot waves first and the card closes itself.
5298
+ const sha = /\b[0-9a-f]{7,40}\b/.exec(String(merge?.result ?? ''))?.[0]?.slice(0, 7) ?? null;
5299
+ for (const meta of this.mergingCards(review)) {
5300
+ if (this.split && meta.ended) {
5301
+ meta.merging = { state: 'landed', ref: review.ref, branch: review.branch, base: review.base, sha, landedTick: this.stamp() };
5302
+ continue;
5303
+ }
5304
+ meta.merging = null;
5305
+ if (meta.ended) this.closeWindowOf(meta);
5306
+ }
5307
+ this.dirty = true;
5308
+ return this.note(`${review.ref} merged`);
5309
+ }
5310
+ if (merge?.state === 'FAILED') {
5311
+ // `failure` is the KIND (R155: CONFLICT, NO_PULL_REQUEST…); `result` the machine's words.
5312
+ const why = printable(merge.result ?? merge.failure ?? 'no reason given');
5313
+ // An older runner names no kind: its words are all there is to go on.
5314
+ if (merge.failure === 'CONFLICT' || (!merge.failure && /conflict/i.test(merge.result ?? ''))) {
5315
+ return this.resolveConflict(review, why);
5316
+ }
5317
+ this.say(this.ink.danger(` ${review.ref}: the merge failed — ${why}`));
5318
+ this.markMerging(review, { state: 'failed', why });
5319
+ return this.note(`${review.ref} is not merged — /review to look again`);
5320
+ }
5321
+ if (tries <= 0) {
5322
+ return this.note(`${review.ref}: still merging — the board says when it lands`);
5323
+ }
5324
+ const again = setTimeout(() => void this.followMerge(review, tries - 1), this.options.reviewFollowMs ?? 5_000);
5325
+ again.unref?.();
5326
+ return undefined;
5327
+ }
5328
+
5329
+ /** What a session is stopped on — a question or a permission request — or null. */
5330
+ waitingOn(meta) {
5331
+ const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
5332
+ if (!run) return null;
5333
+ const shown = this.showOn(run);
5334
+ if (shown) return { kind: 'show', pending: shown };
5335
+ const pending = this.pendingOn(run);
5336
+ if (pending) return { kind: 'permission', pending };
5337
+ const asking = this.askingOn(run);
5338
+ return asking ? { kind: 'question', asking } : null;
5339
+ }
5340
+
5341
+ /**
5342
+ * `/view` on a session waiting for you: what it is waiting on, in FULL. In
5343
+ * advanced mode the reader — the session's output and the request under
5344
+ * it, over the whole screen, scrolled with the arrows (the wheel arrives as
5345
+ * arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
5346
+ * and does what it does (`a` answers, `y`/`n` decide). In the scrollback
5347
+ * the request is printed whole, where the terminal scrolls it.
5348
+ */
5349
+ /**
5350
+ * The window `/view 2` or `/view R42` names: a number is a card's, a ref
5351
+ * is the session on that card (the focused one first, then the latest). Null
5352
+ * for anything else, which is a card to print.
5353
+ */
5354
+ windowNamed(words) {
5355
+ if (words.length !== 1) return null;
5356
+ const windows = this.windows();
5357
+ const [word] = words;
5358
+ if (/^\d+$/.test(word)) return windows[Number(word) - 1] ?? null;
5359
+ const ref = word.toLowerCase();
5360
+ const on = windows.filter((meta) => String(meta.view?.entryRef ?? '').toLowerCase() === ref);
5361
+ return on.find((meta) => meta.id === this.split.focus) ?? on.at(-1) ?? null;
5362
+ }
5363
+
5364
+ /**
5365
+ * `/view` in advanced mode: the session takes the screen — its log, live,
5366
+ * following the end until it is scrolled up, and what it waits on under it
5367
+ * when it waits on you. Esc gives the cards back; tab goes to the next
5368
+ * session without leaving.
5369
+ */
5370
+ viewSession(meta) {
5371
+ this.focusWindow(meta.id);
5372
+ const waiting = Boolean(this.waitingOn(meta));
5373
+ this.reader = { runId: meta.id, top: null, room: 1, max: 0, session: true, follow: !waiting };
5374
+ this.dirty = true;
4128
5375
  return this.note('');
4129
5376
  }
4130
5377
 
5378
+ /** `/matrix on|off` — the cards' rain, remembered beside `/agent` (prefs.mjs). */
5379
+ async chooseMatrix(word) {
5380
+ const wanted = String(word ?? '').toLowerCase();
5381
+ if (!['on', 'off'].includes(wanted)) {
5382
+ return this.note(`the rain is ${this.matrix ? 'on' : 'off'} — /matrix on|off`);
5383
+ }
5384
+ this.matrix = wanted === 'on';
5385
+ this.dirty = true;
5386
+ await savePrefs(this.session.url, { matrix: this.matrix ? true : null }).catch(() => undefined);
5387
+ return this.note(this.matrix ? 'the cards rain' : 'no rain — the spinners and the robots still move');
5388
+ }
5389
+
5390
+ openReader() {
5391
+ const meta = this.commandTarget();
5392
+ if (!this.split) {
5393
+ this.say('');
5394
+ for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
5395
+ this.say('');
5396
+ return this.note('');
5397
+ }
5398
+ return this.viewSession(meta);
5399
+ }
5400
+
4131
5401
  /** The request a session waits on, every word of it. */
4132
5402
  requestLines(waiting, width) {
4133
5403
  const ink = this.ink;
@@ -4150,6 +5420,14 @@ export class Attached {
4150
5420
  ];
4151
5421
  }
4152
5422
  const approval = waiting.pending.approval ?? waiting.pending;
5423
+ if (waiting.kind === 'show') {
5424
+ return [
5425
+ ...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(0, 2),
5426
+ ...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(printable(part), room)).map((line) => ` ${line}`),
5427
+ '',
5428
+ ...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
5429
+ ];
5430
+ }
4153
5431
  return [
4154
5432
  `${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
4155
5433
  ...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
@@ -4163,22 +5441,31 @@ export class Attached {
4163
5441
 
4164
5442
  /** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
4165
5443
  readerScreen(width, height) {
5444
+ if (this.reader.kind === 'review') return this.reviewScreen(width, height);
4166
5445
  const ink = this.ink;
4167
5446
  const meta = this.windows().find((each) => each.id === this.reader.runId)
4168
5447
  ?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
4169
5448
  const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
4170
5449
  const waiting = this.waitingOn(meta);
4171
- const request = this.requestLines(waiting, width - 2);
5450
+ // What it waits on goes under its log; a session waiting on nobody is its
5451
+ // log alone, followed at the end as it grows.
5452
+ const request = waiting || !this.reader.session ? this.requestLines(waiting, width - 2) : [];
4172
5453
  const n = this.windowNumberOf(meta.id);
4173
- const keys = waiting?.kind === 'permission'
4174
- ? 'y once · s session · n refuse'
4175
- : waiting?.asking?.yours ? 'a answer' : '';
5454
+ const keys = waiting?.kind === 'show'
5455
+ ? 'a look and decide'
5456
+ : waiting?.kind === 'permission'
5457
+ ? 'y once · s session · n refuse'
5458
+ : waiting?.asking?.yours ? 'a answer' : '';
4176
5459
  const drawn = readerFrame(width, height, {
4177
- title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? `waiting for ${waiting.kind === 'question' ? 'an answer' : 'permission'}` : null)}`,
4178
- lines: [...output, ink.muted('─'.repeat(Math.min(40, width - 2))), ...request],
5460
+ title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? waitingWord(waiting.kind) : null)}`,
5461
+ lines: [
5462
+ ...(output.length ? output : [ink.muted(' nothing said on this session here yet')]),
5463
+ ...(request.length ? [ink.muted('─'.repeat(Math.min(40, width - 2))), ...request] : []),
5464
+ ],
4179
5465
  requestAt: output.length,
4180
- top: this.reader.top,
4181
- footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end', 'esc close',
5466
+ top: this.reader.follow ? Infinity : this.reader.top,
5467
+ footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end',
5468
+ ...(this.reader.session ? ['tab next session'] : []), 'esc close',
4182
5469
  ...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
4183
5470
  }, ink);
4184
5471
  Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
@@ -4187,9 +5474,12 @@ export class Attached {
4187
5474
 
4188
5475
  /** Keys while the reader is open. */
4189
5476
  onReaderKey(key) {
5477
+ if (this.reader.kind === 'review') return this.onReviewKey(key);
4190
5478
  const reader = this.reader;
4191
5479
  const move = (to) => {
4192
5480
  reader.top = Math.min(Math.max(0, to), reader.max);
5481
+ // At the end a session's log is followed again; above it, it holds still.
5482
+ reader.follow = Boolean(reader.session) && reader.top >= reader.max;
4193
5483
  this.dirty = true;
4194
5484
  return undefined;
4195
5485
  };
@@ -4212,12 +5502,20 @@ export class Attached {
4212
5502
  this.dirty = true;
4213
5503
  return this.note('');
4214
5504
  default:
4215
- // Anything else is what it always is — `a` answers, `y`/`n` decide,
4216
- // `/` a command — with the reader closed first.
4217
- this.reader = null;
4218
- this.dirty = true;
4219
- return this.onKey(key);
5505
+ break;
5506
+ }
5507
+ if (reader.session && (key === '\t' || key === `${ESC}[Z`)) {
5508
+ // The next session's log, without going back to the cards.
5509
+ const windows = this.windows();
5510
+ const here = windows.findIndex((meta) => meta.id === reader.runId);
5511
+ const next = windows[(here + (key === '\t' ? 1 : -1) + windows.length) % windows.length];
5512
+ return next ? this.viewSession(next) : undefined;
4220
5513
  }
5514
+ // Anything else is what it always is — `a` answers, `y`/`n` decide,
5515
+ // `/` a command — with the reader closed first.
5516
+ this.reader = null;
5517
+ this.dirty = true;
5518
+ return this.onKey(key);
4221
5519
  }
4222
5520
 
4223
5521
  /**
@@ -4414,14 +5712,64 @@ export class Attached {
4414
5712
  }
4415
5713
 
4416
5714
  /** `/close` — an ended session's window goes; a live one's stays. */
4417
- closeWindow() {
4418
- const meta = this.commandTarget();
5715
+ closeWindow(which = null) {
5716
+ // `/close now` is the landed card's own footer: the focused card, without the countdown.
5717
+ const word = String(which ?? '').toLowerCase().replace(/^now$/, '');
5718
+ if (word === 'all') {
5719
+ const ended = this.windows().filter((meta) => meta.ended);
5720
+ for (const meta of ended) this.closeWindowOf(meta);
5721
+ return this.note(ended.length ? `closed ${ended.length} ended card${ended.length === 1 ? '' : 's'}` : 'no ended card to close');
5722
+ }
5723
+ const meta = word && this.split ? this.windowNamed([word]) : this.commandTarget();
5724
+ if (word && this.split && !meta) return this.note(`no card ${word} — /close, /close 3 or /close all`);
4419
5725
  if (!meta) return this.note('no window to close');
4420
- if (!meta.ended) return this.note('that session is still running — /cancel stops it');
5726
+ if (!meta.ended) return this.note('that session is still running · x x cancels it');
4421
5727
  this.closeWindowOf(meta);
4422
5728
  return this.note('closed');
4423
5729
  }
4424
5730
 
5731
+ /** `/simple` and `/advanced-mode off`: the scrollback, remembered for next time. */
5732
+ async chooseMode(mode) {
5733
+ this.screenMode = mode;
5734
+ // Left first and remembered after: the next command must find it left.
5735
+ const left = this.split ? this.leaveSplit() : this.note('already the scrollback — /advanced-mode is the way back');
5736
+ await savePrefs(this.session.url, { mode: 'simple' }).catch(() => undefined);
5737
+ return left;
5738
+ }
5739
+
5740
+ /**
5741
+ * A fresh launch: advanced mode where this terminal can draw it, else the
5742
+ * scrollback with one line saying why. `--simple` and a remembered `/simple`
5743
+ * are the person's own choice and need no line.
5744
+ */
5745
+ openByDefault() {
5746
+ if (this.screenMode === 'simple' || this.stopped) return;
5747
+ const out = this.options.out ?? process.stdout;
5748
+ const why = advancedBlocker({
5749
+ tty: !this.plain && Boolean(process.stdin.isTTY), term: process.env.TERM, columns: out.columns, rows: out.rows,
5750
+ });
5751
+ if (why) {
5752
+ this.say(this.ink.muted(` the scrollback, not advanced mode: ${why}`));
5753
+ return;
5754
+ }
5755
+ this.enterSplit(null);
5756
+ }
5757
+
5758
+ /**
5759
+ * `/export`: a session's log laid into the normal screen's scrollback, so
5760
+ * the terminal's own search and copy reach it. Leaves advanced mode's screen
5761
+ * for the scrollback and says how to come back.
5762
+ */
5763
+ exportLog(which = null) {
5764
+ const meta = which && this.split ? this.windowNamed([which]) : this.commandTarget();
5765
+ if (!meta) return this.note(which ? `no session ${which} here` : 'no session to export');
5766
+ const lines = this.lines.get(meta.id) ?? [];
5767
+ if (this.split) this.leaveSplit({ quiet: true });
5768
+ this.printed.delete(meta.id);
5769
+ this.layHistoryIn(meta.id, lines);
5770
+ return this.note('exported — /advanced-mode is the way back');
5771
+ }
5772
+
4425
5773
  /** An ended session's window goes; focus moves to the first one left. */
4426
5774
  closeWindowOf(meta) {
4427
5775
  this.closed.add(meta.id);
@@ -4489,6 +5837,7 @@ export class Attached {
4489
5837
  if (!pending) {
4490
5838
  return this.note('nothing is waiting for permission on this one');
4491
5839
  }
5840
+ if (showGate(pending.approval)) return this.note(A_SHOW_GATE);
4492
5841
  const decision = permissionDecision(pending.approval, key, this.runner ?? {});
4493
5842
  if (!decision) {
4494
5843
  // Two ways to get here and they need different sentences. `Y` or `M` on a
@@ -4548,6 +5897,438 @@ export class Attached {
4548
5897
  return undefined;
4549
5898
  }
4550
5899
 
5900
+ // --- looking and deciding at a SHOW gate — R605 -----------------------------
5901
+
5902
+ /** The daemon's record of a window's session, or null. */
5903
+ runOf(meta) {
5904
+ return meta ? this.runs.find((each) => each.id === meta.id) ?? null : null;
5905
+ }
5906
+
5907
+ /** This round's attachments live under the run; every URL here is built from it. */
5908
+ attachmentsPath(run) {
5909
+ return `/api/projects/${run.projectSlug}/runs/${run.id}/attachments`;
5910
+ }
5911
+
5912
+ /**
5913
+ * The round a gate names, confirmed against what the platform SEALED.
5914
+ *
5915
+ * A gate's `toolInput` is the session's own claim — the run token can raise
5916
+ * a `stage:SHOW` approval naming anything — so a screenshot is shown only
5917
+ * when the sealed list for that round holds its id, and a gate with an id
5918
+ * that does not match offers no Accept.
5919
+ */
5920
+ async loadRound(run, pending) {
5921
+ const gate = showGate(pending.approval);
5922
+ const base = `/api/projects/${run.projectSlug}/runs/${run.id}`;
5923
+ const listed = await this.session.request(`${base}/attachments`);
5924
+ const all = (Array.isArray(listed) ? listed : []).map((each) => ({ ...each, runId: run.id }));
5925
+ const byId = new Map(all.filter((each) => each.round === gate.round).map((each) => [each.id, each]));
5926
+ const shots = gate.ids.map((id) => byId.get(id)).filter(Boolean);
5927
+ // Confirmed only when the gate names exactly what the platform sealed for
5928
+ // that round: no id that was never sealed, none twice, none left out, and
5929
+ // something to look at. A claim naming some of the shots is a claim.
5930
+ const confirmed = Number.isInteger(gate.round) && gate.round > 0
5931
+ && gate.ids.length > 0 && new Set(gate.ids).size === gate.ids.length
5932
+ && shots.length === gate.ids.length && byId.size === gate.ids.length;
5933
+
5934
+ const seen = await this.session.request(`${base}/approvals`).catch(() => []);
5935
+ const rounds = (Array.isArray(seen) ? seen : [])
5936
+ .filter((each) => each.state === 'DENIED' && showGate(each))
5937
+ .map((each) => ({
5938
+ round: showGate(each).round,
5939
+ comments: each.reason ?? '',
5940
+ by: each.decidedByEmail ? printable(each.decidedByEmail) : null,
5941
+ shots: all.filter((shot) => shot.round === showGate(each).round),
5942
+ }))
5943
+ .filter((each) => each.round < gate.round)
5944
+ .sort((a, b) => b.round - a.round);
5945
+
5946
+ return {
5947
+ runId: run.id, approvalId: pending.approval.id, slug: run.projectSlug, round: gate.round,
5948
+ shots, confirmed, rounds, select: null,
5949
+ };
5950
+ }
5951
+
5952
+ /** The loaded round for this gate — read once, kept while it is the same gate. */
5953
+ async roundFor(run, pending) {
5954
+ if (this.showing?.approvalId !== pending.approval.id) {
5955
+ this.showing = await this.loadRound(run, pending);
5956
+ }
5957
+ return this.showing;
5958
+ }
5959
+
5960
+ /** `a`, and `/view`: the picker that looks and decides. */
5961
+ async lookAndDecide(run) {
5962
+ const pending = this.showOn(run);
5963
+ if (!pending) return this.note('nothing is waiting at SHOW on this one');
5964
+ // So R137's `decidedElsewhere` can close this picker when somebody decides
5965
+ // the gate in the console first.
5966
+ this.deciding = { runId: run.id, approvalId: pending.approval.id };
5967
+ try {
5968
+ await this.roundFor(run, pending);
5969
+ } catch (failure) {
5970
+ if (this.deciding?.approvalId === pending.approval.id) this.deciding = null;
5971
+ return this.note(printable(failure.message));
5972
+ }
5973
+ // The gate may have been decided, or the person may have started typing,
5974
+ // while the round loaded: a picker for a settled gate is a lie.
5975
+ if (this.showOn(run)?.approval.id !== pending.approval.id || this.mode !== 'keys') {
5976
+ return undefined;
5977
+ }
5978
+ return this.openShowPicker();
5979
+ }
5980
+
5981
+ openShowPicker() {
5982
+ const round = this.showing;
5983
+ const ink = this.ink;
5984
+ const count = round.shots.length;
5985
+ const rows = [
5986
+ ...round.shots.map((shot, at) => ({
5987
+ id: `shot:${at}`,
5988
+ label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
5989
+ ...(isImage(shot) ? {} : { hint: 'html — opens on cawdev' }),
5990
+ })),
5991
+ ...(count ? [{ id: 'all', label: 'Open all', hint: 'images in the viewer, html on cawdev' }] : []),
5992
+ ...(round.confirmed ? [{ id: 'accept', label: 'Accept — open the pull request' }] : []),
5993
+ ...round.rounds.map((each) => ({
5994
+ id: `round:${each.round}`,
5995
+ label: `Round ${each.round} — ${each.shots.length} screenshot${each.shots.length === 1 ? '' : 's'}, comments sent`,
5996
+ })),
5997
+ { id: WRITE_MY_OWN, label: 'Send comments…', hint: '#2 … is a note on screenshot 2; an empty line sends' },
5998
+ ];
5999
+ const select = new Select({
6000
+ kind: 'show',
6001
+ title: `${this.tagOf(this.runs.find((each) => each.id === round.runId))} · round ${round.round} · ${count} screenshot${count === 1 ? '' : 's'}`
6002
+ + (round.confirmed ? '' : ' — this gate names screenshots that were never sealed — /open looks on cawdev'),
6003
+ hint: '/open looks on cawdev',
6004
+ rows,
6005
+ });
6006
+ round.select = select;
6007
+ if (!round.confirmed) {
6008
+ this.say(ink.warn(' this gate names screenshots that were never sealed — /open looks on cawdev'));
6009
+ }
6010
+ return this.openPicker(select);
6011
+ }
6012
+
6013
+ /** One row of the show picker. */
6014
+ choseShow(select, row, typed) {
6015
+ const round = this.showing;
6016
+ if (!round) return this.note('');
6017
+ const back = () => (select.kind === 'show-round' ? select : round.select);
6018
+ const reopenWith = async (work) => {
6019
+ const said = await work;
6020
+ // Not over a gate decided meanwhile, nor over something being typed.
6021
+ if (this.showing?.approvalId !== round.approvalId || this.mode !== 'keys') return;
6022
+ this.reopen(back());
6023
+ this.note(said);
6024
+ };
6025
+ if (select.kind === 'show-round') {
6026
+ const earlier = select.earlier;
6027
+ if (row.id === 'back') return this.reopen(round.select);
6028
+ if (row.id === 'all') return void reopenWith(this.openShots(earlier.shots, round));
6029
+ if (row.id.startsWith('shot:')) {
6030
+ return void reopenWith(this.openShots([earlier.shots[Number(row.id.slice(5))]], round));
6031
+ }
6032
+ return this.note('');
6033
+ }
6034
+ if (row.id.startsWith('shot:')) {
6035
+ return void reopenWith(this.openShots([round.shots[Number(row.id.slice(5))]], round));
6036
+ }
6037
+ if (row.id === 'all') return void reopenWith(this.openShots(round.shots, round));
6038
+ if (row.id === 'accept') return void this.acceptShow(this.runs.find((each) => each.id === round.runId));
6039
+ if (row.id.startsWith('round:')) {
6040
+ return this.showRound(Number(row.id.slice(6)), round, true);
6041
+ }
6042
+ if (row.id === WRITE_MY_OWN) {
6043
+ this.commenting = { approvalId: round.approvalId, lines: [] };
6044
+ if (typed) {
6045
+ return void this.sendComments(this.runs.find((each) => each.id === round.runId), typed);
6046
+ }
6047
+ return this.type('comments', 'comments ▸', '', round.select);
6048
+ }
6049
+ return this.note('');
6050
+ }
6051
+
6052
+ /** An earlier round: what was sent on it printed ONCE, then its screenshots. */
6053
+ showRound(number, round, picker) {
6054
+ const earlier = round.rounds.find((each) => each.round === number);
6055
+ if (!earlier) return this.note(`there is no round ${number} to show — ${round.rounds.map((each) => each.round).join(', ') || 'none earlier'}`);
6056
+ const ink = this.ink;
6057
+ this.say('');
6058
+ this.say(`${ink.bold(ink.warn(` round ${number} `))} ${ink.muted(`${earlier.shots.length} screenshot${earlier.shots.length === 1 ? '' : 's'}${earlier.by ? ` · comments by ${earlier.by}` : ''}`)}`);
6059
+ for (const line of roundListing(earlier.shots, ink)) this.say(line);
6060
+ // The reason alone: it already holds each screenshot's note ("On `x` — caption:
6061
+ // note"), so printing the notes as well would say each one twice.
6062
+ for (const line of printable(earlier.comments, { lines: true }).split('\n')) this.say(` ${ink.text(line)}`);
6063
+ this.say('');
6064
+ if (!picker) return this.note('');
6065
+ const nested = new Select({
6066
+ kind: 'show-round',
6067
+ title: `round ${number}`,
6068
+ rows: [
6069
+ ...earlier.shots.map((shot, at) => ({
6070
+ id: `shot:${at}`,
6071
+ label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
6072
+ })),
6073
+ ...(earlier.shots.length ? [{ id: 'all', label: 'Open all' }] : []),
6074
+ { id: 'back', label: 'Back' },
6075
+ ],
6076
+ });
6077
+ nested.earlier = earlier;
6078
+ return this.openPicker(nested);
6079
+ }
6080
+
6081
+ /**
6082
+ * Hand screenshots to the OS viewer. Images come from the local cache —
6083
+ * downloaded the first time and never again while the bytes match their
6084
+ * checksum; HTML is NEVER written to disk or opened as a file: it opens at
6085
+ * the platform's sandboxed URL, which is the only origin it may run on.
6086
+ *
6087
+ * @returns the sentence for the status line.
6088
+ */
6089
+ async openShots(shots, round) {
6090
+ const run = this.runs.find((each) => each.id === round.runId) ?? { id: round.runId, projectSlug: round.slug };
6091
+ const base = this.attachmentsPath(run);
6092
+ const dir = shotsDir();
6093
+ const paths = [];
6094
+ let downloaded = 0;
6095
+ try {
6096
+ for (const shot of shots.filter(isImage)) {
6097
+ const copy = await localCopy(shot, {
6098
+ dir, fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
6099
+ });
6100
+ paths.push(copy.path);
6101
+ if (copy.downloaded) downloaded++;
6102
+ }
6103
+ } catch (failure) {
6104
+ return printable(failure.message);
6105
+ }
6106
+ if (paths.length) this.launch(paths);
6107
+ const pages = shots.filter((shot) => !isImage(shot));
6108
+ for (const page of pages) this.openLink(`${this.session.url}${base}/${encodeURIComponent(page.id)}`);
6109
+
6110
+ const home = homedir();
6111
+ const where = (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir);
6112
+ const said = [];
6113
+ if (paths.length) {
6114
+ said.push(`opened ${paths.length} in the viewer (${downloaded
6115
+ ? `downloaded ${downloaded} into ${where}/${round.runId}/${round.round}` : `from ${where}`})`);
6116
+ }
6117
+ if (pages.length) {
6118
+ said.push(`${pages.length === 1 ? 'the html one opens' : 'the html ones open'} on cawdev — sign in there in your browser if it asks`);
6119
+ }
6120
+ return said.join(' · ');
6121
+ }
6122
+
6123
+ /** The viewer, with files. Injectable, like `openUrl`. */
6124
+ launch(paths) {
6125
+ if (this.options.openFiles) {
6126
+ this.options.openFiles(paths);
6127
+ return;
6128
+ }
6129
+ for (const [command, args] of openerFor(process.platform)(paths)) {
6130
+ spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
6131
+ }
6132
+ }
6133
+
6134
+ /** A line (or several, pasted) written at the comments prompt. An empty one sends. */
6135
+ commentLine(text, back) {
6136
+ const ink = this.ink;
6137
+ if (this.commenting?.approvalId !== this.showing?.approvalId) {
6138
+ this.commenting = { approvalId: this.showing?.approvalId ?? null, lines: [] };
6139
+ }
6140
+ const lines = String(text).split('\n').map((line) => line.trim()).filter(Boolean);
6141
+ if (!lines.length) {
6142
+ if (this.commenting.lines.length) {
6143
+ const run = this.runs.find((each) => each.id === this.showing?.runId);
6144
+ return void this.sendComments(run, this.commenting.lines.join('\n'), back);
6145
+ }
6146
+ return back ? this.reopen(back) : this.note('');
6147
+ }
6148
+ for (const line of lines) {
6149
+ this.commenting.lines.push(line);
6150
+ this.say(ink.muted(` ▸ ${printable(line)}`));
6151
+ }
6152
+ const n = this.commenting.lines.length;
6153
+ return this.type('comments', `comments (${n} line${n === 1 ? '' : 's'} · empty line sends) ▸`, '', back);
6154
+ }
6155
+
6156
+ /** Mark a gate decided, here: the poll must not raise it again (R137). */
6157
+ settleGate(run, approvalId) {
6158
+ this.settled.add(approvalId);
6159
+ this.approvals.delete(run.id);
6160
+ if (this.deciding?.approvalId === approvalId) this.deciding = null;
6161
+ this.showing = null;
6162
+ this.commenting = null;
6163
+ // Only what this gate opened: a prompt or another run's picker is not ours.
6164
+ if (this.select?.kind === 'show' || this.select?.kind === 'show-round'
6165
+ || this.input?.kind === 'comments') {
6166
+ this.mode = 'keys';
6167
+ this.select = null;
6168
+ this.input = null;
6169
+ }
6170
+ this.dirty = true;
6171
+ }
6172
+
6173
+ /** The gate to decide, still the same gate the round was read for. */
6174
+ stillAt(run) {
6175
+ const pending = this.showOn(run);
6176
+ if (!pending || (this.showing && this.showing.approvalId !== pending.approval.id)) {
6177
+ this.note('no longer waiting at SHOW — decided elsewhere, or gone');
6178
+ return null;
6179
+ }
6180
+ return pending;
6181
+ }
6182
+
6183
+ async sendComments(run, text, back = null) {
6184
+ if (!run || !this.requireSignIn('send comments')) return undefined;
6185
+ const pending = this.stillAt(run);
6186
+ if (!pending) return undefined;
6187
+ let round;
6188
+ try {
6189
+ round = await this.roundFor(run, pending);
6190
+ } catch (failure) {
6191
+ return this.note(printable(failure.message));
6192
+ }
6193
+ const parsed = parseComments(text, round.shots);
6194
+ if (parsed.error) {
6195
+ // Nothing is sent. Back on the line, with what was written still held.
6196
+ if (back || this.commenting) {
6197
+ this.type('comments', 'comments ▸', '', back ?? round.select);
6198
+ }
6199
+ return this.note(parsed.error);
6200
+ }
6201
+ try {
6202
+ await this.session.request(
6203
+ `/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
6204
+ { method: 'POST', body: { allow: false, scope: 'ONCE', reason: parsed.reason, notes: parsed.notes } },
6205
+ );
6206
+ } catch (failure) {
6207
+ // A 409 for a round that was replaced says so in its own sentence.
6208
+ return this.note(printable(failure.message));
6209
+ }
6210
+ this.settleGate(run, pending.approval.id);
6211
+ // The run is read again rather than assumed: with no stages to walk again
6212
+ // the daemon ends it FAILED, and "goes back to coding" would be untrue.
6213
+ const view = await this.session.request(`/api/projects/${run.projectSlug}/runs/${run.id}`)
6214
+ .catch(() => null);
6215
+ const alive = view && (view.live ?? !['FAILED', 'FINISHED', 'CANCELLED'].includes(view.state));
6216
+ const ref = view?.entryRef ?? this.sessions.get(run.id)?.view?.entryRef ?? null;
6217
+ return this.note(alive
6218
+ ? `sent — ${ref ?? 'the run'} goes back to coding (round ${round.round + 1})`
6219
+ : 'sent');
6220
+ }
6221
+
6222
+ async acceptShow(run) {
6223
+ if (!run || !this.requireSignIn('accept the screenshots')) return undefined;
6224
+ const pending = this.stillAt(run);
6225
+ if (!pending) return undefined;
6226
+ let round;
6227
+ try {
6228
+ round = await this.roundFor(run, pending);
6229
+ } catch (failure) {
6230
+ return this.note(printable(failure.message));
6231
+ }
6232
+ if (!round.confirmed) {
6233
+ return this.note('this gate names screenshots that were never sealed — /open looks on cawdev');
6234
+ }
6235
+ const dropped = this.commenting?.lines.length
6236
+ ? 'the comments you wrote are dropped — accepting · ' : '';
6237
+ try {
6238
+ await this.session.request(
6239
+ `/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
6240
+ { method: 'POST', body: { allow: true, scope: 'ONCE' } },
6241
+ );
6242
+ } catch (failure) {
6243
+ return this.note(printable(failure.message));
6244
+ }
6245
+ this.settleGate(run, pending.approval.id);
6246
+ return this.note(`${dropped}accepted — the pull request opens when the run finishes`);
6247
+ }
6248
+
6249
+ /**
6250
+ * `/shots`, `/accept`, `/comments` — and `/view` at a SHOW gate. Each acts on
6251
+ * the focused session, and each says why and does nothing when it is not
6252
+ * stopped at SHOW.
6253
+ */
6254
+ async showCommand(word, rest) {
6255
+ const meta = this.commandTarget();
6256
+ const label = meta ? this.tagOf(this.runOf(meta) ?? meta) || meta.label || 'this session' : null;
6257
+ if (!meta) return this.note(`/${word} needs a session — focus one`);
6258
+ const run = this.runOf(meta);
6259
+ const pending = this.showOn(run);
6260
+ if (!pending) return this.note(`${label} is not waiting at SHOW — nothing to look at`);
6261
+ if (word === 'view') {
6262
+ if (!this.requireSignIn('look and decide')) return undefined;
6263
+ return this.lookAndDecide(run);
6264
+ }
6265
+ if (word === 'accept') return this.acceptShow(run);
6266
+ if (word === 'comments') {
6267
+ // `#2` anywhere starts a note on screenshot 2, which runs to the next
6268
+ // `#n` or the end; what comes before the first is the round's note.
6269
+ const text = wordsText(rest).replace(/[ \t]+(#\d+)(?=[\s:—-]|$)/g, '\n$1');
6270
+ return this.sendComments(run, text);
6271
+ }
6272
+ if (!this.requireSignIn('look at the screenshots')) return undefined;
6273
+ let round;
6274
+ try {
6275
+ round = await this.roundFor(run, pending);
6276
+ } catch (failure) {
6277
+ return this.note(printable(failure.message));
6278
+ }
6279
+ const usage = '/shots · /shots 2 · /shots all · /shots round 1 (all | 2)';
6280
+ let from = round;
6281
+ let shots = round.shots;
6282
+ let words = rest;
6283
+ if (words[0] === 'round') {
6284
+ const wanted = Number(words[1]);
6285
+ const earlier = round.rounds.find((each) => each.round === wanted);
6286
+ if (!earlier) return this.note(Number.isInteger(wanted) ? `there is no round ${wanted} to show — ${usage}` : usage);
6287
+ if (words.length === 2) return this.showRound(wanted, round, false);
6288
+ shots = earlier.shots;
6289
+ words = words.slice(2);
6290
+ from = round;
6291
+ }
6292
+ if (!words.length) {
6293
+ const ink = this.ink;
6294
+ this.say('');
6295
+ this.say(`${ink.bold(ink.warn(' shots '))} ${ink.muted(`round ${round.round} · ${shots.length} screenshot${shots.length === 1 ? '' : 's'}`)}`);
6296
+ for (const line of roundListing(shots, ink)) this.say(line);
6297
+ this.say('');
6298
+ if (!this.split && !this.plain) await this.printThumbnails(shots, run);
6299
+ return this.note(round.confirmed ? '' : 'this gate names screenshots that were never sealed — /open looks on cawdev');
6300
+ }
6301
+ if (words.length === 1 && words[0] === 'all') {
6302
+ return this.note(await this.openShots(shots, from));
6303
+ }
6304
+ if (words.length === 1 && /^\d+$/.test(words[0])) {
6305
+ const at = Number(words[0]);
6306
+ if (at < 1 || at > shots.length) return this.note(`there is no screenshot ${at} — ${shots.length} in this round`);
6307
+ return this.note(await this.openShots([shots[at - 1]], from));
6308
+ }
6309
+ return this.note(usage);
6310
+ }
6311
+
6312
+ /** Inline thumbnails — iTerm2, WezTerm, kitty — into the scrollback, never the live region. */
6313
+ async printThumbnails(shots, run) {
6314
+ const protocol = thumbnailProtocol(process.env);
6315
+ if (!protocol) return;
6316
+ const base = this.attachmentsPath(run);
6317
+ for (const shot of shots.filter(isImage)) {
6318
+ try {
6319
+ const copy = await localCopy(shot, {
6320
+ dir: shotsDir(), fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
6321
+ });
6322
+ // Pushed as a committed line, not through `say`: `wrap` counts the
6323
+ // base64 as visible text and would cut the escape in half.
6324
+ this.pending.push(thumbnailEscape(await readFile(copy.path), protocol));
6325
+ this.dirty = true;
6326
+ } catch (failure) {
6327
+ this.say(this.ink.muted(` ${printable(shot.name)}: ${printable(failure.message)}`));
6328
+ }
6329
+ }
6330
+ }
6331
+
4551
6332
  async cancel() {
4552
6333
  const run = this.current();
4553
6334
  if (!run || run.state === 'queued') {
@@ -4614,8 +6395,17 @@ export class Attached {
4614
6395
  // lands on a buffer that vanishes the moment the program exits.
4615
6396
  if (this.split) {
4616
6397
  clearInterval(this.split.plannedTimer);
6398
+ const { enteredWatching, mark } = this.split;
4617
6399
  this.split.screen.release();
4618
6400
  this.split = null;
6401
+ // What the watched session said while the screen was up goes into the
6402
+ // scrollback, as `leaveSplit` does: the terminal's own search reaches it.
6403
+ const watched = this.watching;
6404
+ if (watched && watched === enteredWatching) {
6405
+ for (const line of (this.lines.get(watched) ?? []).slice(mark.get(watched) ?? 0)) {
6406
+ this.say(this.render(line));
6407
+ }
6408
+ }
4619
6409
  }
4620
6410
  try {
4621
6411
  this.client?.destroy();
@@ -4922,9 +6712,19 @@ export class Attached {
4922
6712
  // it under the first.
4923
6713
  return [];
4924
6714
  }
6715
+ if (this.split?.boardFocus) {
6716
+ return [
6717
+ `${ink.text('←→')} ${ink.muted('column')}`,
6718
+ `${ink.text('↑↓')} ${ink.muted('card')}`,
6719
+ `${ink.text('p')} ${ink.muted(`plan ${this.split.boardFocus.ref}`)}`,
6720
+ `${ink.text('d')} ${ink.muted(`develop ${this.split.boardFocus.ref}`)}`,
6721
+ `${ink.text('esc/b')} ${ink.muted('back to sessions')}`,
6722
+ ];
6723
+ }
4925
6724
  const parts = [
4926
6725
  `${ink.text('enter')} ${ink.muted('prompt')}`,
4927
6726
  `${ink.text('/')} ${ink.muted('commands')}`,
6727
+ ...(this.split ? [`${ink.text('?')} ${ink.muted('help')}`] : []),
4928
6728
  `${ink.text('L')} ${ink.muted('runs')}`,
4929
6729
  ];
4930
6730
  if (this.askingOn(this.current())?.yours) {
@@ -4938,7 +6738,11 @@ export class Attached {
4938
6738
  + `${ink.muted('permission')}`);
4939
6739
  }
4940
6740
  if (this.split) {
4941
- parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
6741
+ parts.push(`${ink.text('b')} ${ink.muted('board')}`);
6742
+ parts.push(`${ink.text('tab')} ${ink.muted('next card')}`);
6743
+ parts.push(`${ink.text('v')} ${ink.muted('its log')}`);
6744
+ if (this.needYou().length) parts.push(`${ink.warn('n')} ${ink.muted('next needing you')}`);
6745
+ if (this.commandTarget()?.ended) parts.push(`${ink.text('w')} ${ink.muted('close card')}`);
4942
6746
  parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
4943
6747
  ? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
4944
6748
  }