cawdev-cli 1.0.3-beta → 1.0.5-beta

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,18 +46,25 @@
46
46
  // curses library.
47
47
 
48
48
  import { spawn } from 'node:child_process';
49
+ import { rm } from 'node:fs/promises';
49
50
  import { connect } from 'node:net';
50
51
  import { basename, dirname } from 'node:path';
51
52
  import { listSockets, socketPathFor } from './control.mjs';
52
53
  import {
53
54
  RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
54
- setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
55
+ setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
55
56
  } from './configure.mjs';
56
57
  import {
57
58
  TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
58
59
  } from './paths.mjs';
59
60
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
60
61
  import { restartNeededFor } from '../lib/runner-config.mjs';
62
+ import {
63
+ MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
64
+ carriesOnHere, cardTitleFor, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
65
+ windowLines,
66
+ } from './split.mjs';
67
+ import { KNOWN_AGENTS } from '../lib/agents.mjs';
61
68
  import { oneLine } from './brand.mjs';
62
69
  import { Scrollback } from './scrollback.mjs';
63
70
  import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
@@ -127,7 +134,7 @@ export function questionState(questions, email) {
127
134
  * let somebody type an answer and then refused it reads as cawdev being broken
128
135
  * rather than as the question belonging to a colleague.
129
136
  */
130
- export function questionBanner(asking, width, ink = painter(3)) {
137
+ export function questionBanner(asking, width, ink = painter(3), { entire = false } = {}) {
131
138
  if (!asking) return [];
132
139
 
133
140
  const options = asking.question.options?.length
@@ -135,10 +142,16 @@ export function questionBanner(asking, width, ink = painter(3)) {
135
142
  : '';
136
143
  // 12 is the label and its spaces: the same arithmetic the permission banner
137
144
  // uses, and for the same reason — clip counts visible characters.
138
- const head = `${ink.bold(ink.accent(' question '))} ${clip(asking.question.question, width - 12)}`;
145
+ // `entire` (advanced mode, where the banner is the only place the question
146
+ // is shown): every word of it, wrapped between words under the label.
147
+ const label = ink.bold(ink.accent(' question '));
148
+ const head = entire
149
+ ? wordWrap(asking.question.question, width - 12)
150
+ .map((line, i) => `${i === 0 ? label : ' '.repeat(10)} ${line}`)
151
+ : [`${label} ${clip(asking.question.question, width - 12)}`];
139
152
 
140
153
  if (asking.yours) {
141
- return [head, ` ${ink.success('a')} answer${options}`];
154
+ return [...head, ` ${ink.success('a')} answer${options}`];
142
155
  }
143
156
  // The NAME is the last thing to go, the way the session total is on R62's
144
157
  // bar: "waiting on alice@…" is the whole answer to "why is that not moving",
@@ -147,7 +160,7 @@ export function questionBanner(asking, width, ink = painter(3)) {
147
160
  // answer — which is what looking at this at 40 columns showed.
148
161
  const who = ` waiting on ${asking.waitingOn ?? 'somebody else'}`;
149
162
  const whole = `${who} — theirs to answer, or somebody they hand it to`;
150
- return [head, ink.muted(clip(whole.length <= width ? whole : who, width))];
163
+ return [...head, ink.muted(clip(whole.length <= width ? whole : who, width))];
151
164
  }
152
165
 
153
166
  /**
@@ -572,11 +585,13 @@ export function footerLines(state, width, ink = painter(3)) {
572
585
  * @param argv the command line, for `--runner`, `--url`, `--watch-only`
573
586
  * @param options set when the daemon is running IN THIS PROCESS (`--attach`):
574
587
  * its socket is already known, quitting means stopping it, and it can say how
575
- * much work would be lost if you did.
588
+ * much work would be lost if you did. `url` is where to sign in when the
589
+ * caller has read the config (`cawdev.mjs`'s `launchUrl`); without it,
590
+ * `urlFrom`, which cannot see a config.
576
591
  */
577
- export async function attach(argv, options = {}) {
592
+ export async function attach(argv, { url, ...options } = {}) {
578
593
  const socket = options.socketPath ?? (await chooseSocket(valueOf(argv, '--runner')));
579
- const session = await openSession(argv);
594
+ const session = await openSession(argv, url ?? urlFrom(argv));
580
595
 
581
596
  // Spread second so `--attach`'s own wiring wins: there the daemon IS this
582
597
  // process and quitting has always stopped it.
@@ -590,6 +605,14 @@ export function valueOf(argv, flag) {
590
605
  return at === -1 ? null : argv[at + 1];
591
606
  }
592
607
 
608
+ /**
609
+ * Whether a failed clone's output is ssh refusing to ask a person — BatchMode
610
+ * with a key the agent does not hold. Pure, for the test.
611
+ */
612
+ export function sshNeedsAPerson(said) {
613
+ return /Permission denied \(publickey|passphrase|Host key verification failed/i.test(String(said ?? ''));
614
+ }
615
+
593
616
  export function urlFrom(argv) {
594
617
  return (valueOf(argv, '--url') ?? process.env.CAWDEV_URL ?? 'https://app.cawdev.com')
595
618
  .replace(/\/+$/, '');
@@ -628,8 +651,7 @@ async function chooseSocket(wanted) {
628
651
  * expired session looks like, and the answer is the same as having none — the
629
652
  * browser, once, and then remembered again.
630
653
  */
631
- async function openSession(argv) {
632
- const url = urlFrom(argv);
654
+ async function openSession(argv, url) {
633
655
  if (argv.includes('--watch-only')) {
634
656
  return new Session(url);
635
657
  }
@@ -694,8 +716,6 @@ export function splitPrompt(prompt, limit = 40) {
694
716
  return { said: question, label: `${bracket ? `${bracket} ` : ''}▸` };
695
717
  }
696
718
 
697
- /** The agents the config screen offers a switch for, whether or not they are on. */
698
- const KNOWN_AGENTS = ['claude', 'agy'];
699
719
 
700
720
  /**
701
721
  * The config screen's rows, with the state of each thing on the row — R288.
@@ -705,6 +725,11 @@ const KNOWN_AGENTS = ['claude', 'agy'];
705
725
  * "enable" would ask the person to remember what the file says. The hint is
706
726
  * where the current value lives; the label is the change.
707
727
  */
728
+ /** A project entry's `quality` block, whatever shape the entry is — R300. */
729
+ function qualityOf(entry) {
730
+ return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
731
+ }
732
+
708
733
  export function configMenu(file, runner = {}) {
709
734
  const slugs = slugsOf(file);
710
735
  const agents = agentsOf(file);
@@ -737,6 +762,11 @@ export function configMenu(file, runner = {}) {
737
762
  rulesOn
738
763
  ? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
739
764
  : { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
765
+ // R300: one row per served project, saying whether its branches are
766
+ // scanned here now — the state the row would change, R288's rule.
767
+ ...slugs.map((slug) => (qualityOf(file.projects[slug])
768
+ ? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
769
+ : { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
740
770
  { id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
741
771
  ],
742
772
  });
@@ -751,6 +781,16 @@ const COMMANDS = [
751
781
  ['/config', "this machine's config — the same as c"],
752
782
  ['/cancel', 'cancel the session you are watching'],
753
783
  ['/log', "the daemon's own log, on or off"],
784
+ ['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
785
+ ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
786
+ ['/develop', 'start the development of the card a finished plan was about'],
787
+ ['/pr', "open the pull request of a finished session's branch"],
788
+ ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
789
+ ['/projects', 'which projects advanced mode shows — tick one or several'],
790
+ ['/open', "open the session's pull request in your browser"],
791
+ ['/drop', 'delete the checkout a finished session was made on demand for'],
792
+ ['/close', "close an ended session's window"],
793
+ ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
754
794
  ['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
755
795
  ];
756
796
 
@@ -764,6 +804,47 @@ const COMMANDS = [
764
804
  * lesson about rules in a renderer is that they can only be tested by looking at
765
805
  * them.
766
806
  */
807
+ /**
808
+ * Why a checkout must not be deleted, in words, or null when nothing in it
809
+ * would be lost: no uncommitted file, and no commit that is on no branch of
810
+ * origin.
811
+ */
812
+ async function checkoutState(path) {
813
+ const git = (args) => new Promise((done) => {
814
+ const child = spawn('git', ['-C', path, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
815
+ let out = '';
816
+ child.stdout.on('data', (chunk) => (out += chunk));
817
+ child.on('error', () => done(null));
818
+ child.on('close', (code) => done(code === 0 ? out.trim() : null));
819
+ });
820
+ const status = await git(['status', '--porcelain']);
821
+ if (status === null) return 'it could not be read as a git checkout';
822
+ if (status) return `${status.split('\n').length} file(s) are not committed`;
823
+ const unpushed = await git(['rev-list', '--count', 'HEAD', '--not', '--remotes=origin']);
824
+ if (unpushed === null) return 'whether its commits are on origin could not be read';
825
+ if (Number(unpushed) > 0) return `${unpushed} commit(s) are not on origin — /pr pushes the branch`;
826
+ return null;
827
+ }
828
+
829
+ /** The environment variable `/reload` hands its state over in. */
830
+ const RELOAD_STATE = 'CAWDEV_RELOAD_STATE';
831
+
832
+ /**
833
+ * The state a `/reload` handed this process, read once and taken out of the
834
+ * environment — a session this terminal spawns later must not inherit it, and
835
+ * a second reload writes its own.
836
+ */
837
+ function readReloadState() {
838
+ const raw = process.env[RELOAD_STATE];
839
+ delete process.env[RELOAD_STATE];
840
+ if (!raw) return null;
841
+ try {
842
+ return JSON.parse(raw);
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+
767
848
  export class Attached {
768
849
  /**
769
850
  * @param options `out` is where to draw — `process.stdout` in the real thing,
@@ -857,6 +938,24 @@ export class Attached {
857
938
  * laptop.
858
939
  */
859
940
  this.configuring = null;
941
+
942
+ /**
943
+ * Advanced mode, when it is on: the alternate screen, the window the
944
+ * prompt talks to, and what was said while it was up — see split.mjs.
945
+ */
946
+ this.split = null;
947
+ /**
948
+ * Every session this terminal has seen, ended ones included — the daemon
949
+ * forgets a run the moment it finishes, and "develop it now" or "open its
950
+ * pull request" is asked of exactly those. runId -> meta.
951
+ */
952
+ this.sessions = new Map();
953
+ /** Ended sessions whose window somebody closed. */
954
+ this.closed = new Set();
955
+ /** The PLANNED cards of the projects this machine serves — advanced mode's top strip. */
956
+ this.planned = [];
957
+ // Every card of every served project, whatever its status — the strip.
958
+ this.cards = [];
860
959
  }
861
960
 
862
961
  /**
@@ -918,6 +1017,7 @@ export class Attached {
918
1017
  }
919
1018
  process.stdout.on('resize', () => {
920
1019
  this.screen.resize();
1020
+ this.split?.screen.resize();
921
1021
  this.dirty = true;
922
1022
  });
923
1023
 
@@ -926,6 +1026,10 @@ export class Attached {
926
1026
  void this.watchInbox();
927
1027
  void this.watchQuestions();
928
1028
  }
1029
+ // `/reload`'s other half: the state the terminal was in before it
1030
+ // restarted — advanced mode, the focus, the windows of sessions that had
1031
+ // ended — so a reload changes the code and nothing else.
1032
+ this.restore(this.options.restore ?? readReloadState());
929
1033
 
930
1034
  // One write per tick at most, carrying everything that happened in it. A
931
1035
  // busy session emits hundreds of lines a second, and a write per line means
@@ -988,7 +1092,9 @@ export class Attached {
988
1092
  if (event.type === 'hello') {
989
1093
  this.runner = event.runner;
990
1094
  this.runs = event.runs;
1095
+ this.trackSessions();
991
1096
  this.chooseWatched();
1097
+ void this.findStopped();
992
1098
  } else if (event.type === 'runner') {
993
1099
  // R294: the daemon re-read its config — after a signal, or because the
994
1100
  // file's mtime moved — and says what it applied and what it could not.
@@ -998,18 +1104,21 @@ export class Attached {
998
1104
  this.runner = event.runner;
999
1105
  } else if (event.type === 'runs') {
1000
1106
  this.runs = event.runs;
1107
+ this.trackSessions();
1001
1108
  this.chooseWatched();
1002
1109
  } else if (event.type === 'output') {
1003
1110
  const kept = this.lines.get(event.runId) ?? [];
1004
1111
  kept.push(event.line);
1005
1112
  if (kept.length > 5000) kept.splice(0, kept.length - 5000);
1006
1113
  this.lines.set(event.runId, kept);
1007
- if (event.runId === this.watching && !this.showLog) {
1114
+ // In advanced mode the session's window is where its output goes; the
1115
+ // scrollback gets it when the mode is left.
1116
+ if (event.runId === this.watching && !this.showLog && !this.split) {
1008
1117
  this.say(this.render(event.line));
1009
1118
  }
1010
1119
  } else if (event.type === 'backlog') {
1011
1120
  this.lines.set(event.runId, event.lines);
1012
- if (event.runId === this.watching && !this.printed.has(event.runId)) {
1121
+ if (event.runId === this.watching && !this.printed.has(event.runId) && !this.split) {
1013
1122
  this.layHistoryIn(event.runId, event.lines);
1014
1123
  }
1015
1124
  } else if (event.type === 'log') {
@@ -1035,17 +1144,73 @@ export class Attached {
1035
1144
  * character and an escape sequence.
1036
1145
  */
1037
1146
  say(text) {
1147
+ // In advanced mode the prompt's window wraps what it shows, to its own
1148
+ // width; wrapping here first, to the whole terminal's, cut every long line
1149
+ // twice.
1150
+ if (this.split) {
1151
+ this.pending.push(...String(text).split('\n'));
1152
+ this.dirty = true;
1153
+ return;
1154
+ }
1038
1155
  for (const line of wrap(text, this.screen.width)) {
1039
1156
  this.pending.push(line);
1040
1157
  }
1041
1158
  this.dirty = true;
1042
1159
  }
1043
1160
 
1161
+ /**
1162
+ * A line that belongs to one question or one permission request. Outside
1163
+ * advanced mode it is printed like any other — the scrollback is a record.
1164
+ * In advanced mode it goes to the prompt window TAGGED, and the window drops
1165
+ * it as soon as what it is about is no longer open (`stillOpen`): answered
1166
+ * here, answered in the console, or its session gone. The prompt is where
1167
+ * the NEXT thing is read, and a question already answered sitting above it
1168
+ * is the text somebody reads by mistake.
1169
+ */
1170
+ sayAbout(about, text) {
1171
+ // `{ prefix, body, paint }` is prose to be wrapped between words at the
1172
+ // width it is DRAWN at — the window's, which changes — rather than now.
1173
+ const prose = typeof text === 'object' && text !== null;
1174
+ if (!this.split) {
1175
+ this.say(prose ? `${text.prefix}${text.paint(text.body)}` : text);
1176
+ return;
1177
+ }
1178
+ // Whatever was queued before it goes first, so the order on screen is the
1179
+ // order things happened in.
1180
+ this.split.messages.push(...this.pending);
1181
+ this.pending = [];
1182
+ if (prose) {
1183
+ this.split.messages.push({ ...text, about });
1184
+ } else {
1185
+ for (const line of String(text).split('\n')) {
1186
+ this.split.messages.push({ text: line, about });
1187
+ }
1188
+ }
1189
+ this.dirty = true;
1190
+ }
1191
+
1192
+ /** The ids of every question and permission request this terminal believes open. */
1193
+ stillOpen() {
1194
+ return new Set([
1195
+ ...[...this.questions.values()].map((state) => state?.question?.id),
1196
+ ...[...this.approvals.values()].map((pending) => pending?.approval?.id),
1197
+ ].filter(Boolean));
1198
+ }
1199
+
1044
1200
  /** Everything queued, plus the footer as it is right now, in one write. */
1045
1201
  flush() {
1046
1202
  const lines = this.pending;
1047
1203
  this.pending = [];
1048
1204
  this.dirty = false;
1205
+ if (this.split) {
1206
+ // What would have been printed is the prompt window's to show.
1207
+ this.split.messages.push(...lines);
1208
+ if (this.split.messages.length > 500) {
1209
+ this.split.messages.splice(0, this.split.messages.length - 500);
1210
+ }
1211
+ this.split.screen.draw(this.splitFrame());
1212
+ return;
1213
+ }
1049
1214
  this.screen.update(lines, this.footer());
1050
1215
  }
1051
1216
 
@@ -1398,6 +1563,13 @@ export class Attached {
1398
1563
  this.confirming = null;
1399
1564
  }
1400
1565
 
1566
+ if (this.split && (key === '\t' || key === `${ESC}[Z`)) {
1567
+ return this.cycleFocus(key === '\t' ? 1 : -1);
1568
+ }
1569
+ if (this.split && key === 'd') {
1570
+ return void this.pickPlanned();
1571
+ }
1572
+
1401
1573
  switch (key) {
1402
1574
  case 'q':
1403
1575
  return this.quit();
@@ -1460,6 +1632,10 @@ export class Attached {
1460
1632
  default:
1461
1633
  if (/^[1-9]$/.test(key)) {
1462
1634
  const at = Number(key) - 1;
1635
+ if (this.split) {
1636
+ const windows = this.windows();
1637
+ return at < windows.length ? this.focusWindow(windows[at].id) : this.note('');
1638
+ }
1463
1639
  if (at < this.runs.length) {
1464
1640
  this.watch(this.runs[at].id);
1465
1641
  }
@@ -1808,9 +1984,24 @@ export class Attached {
1808
1984
  // makes everywhere else.
1809
1985
  return this.note('');
1810
1986
  }
1987
+ if (select.many) {
1988
+ this.mode = 'keys';
1989
+ this.select = null;
1990
+ return this.choseMany(select, outcome.rows ?? []);
1991
+ }
1811
1992
  return this.chose(select, outcome.row);
1812
1993
  }
1813
1994
 
1995
+ /** Several rows were ticked and confirmed — a picker opened with `many`. */
1996
+ choseMany(select, rows) {
1997
+ if (select.kind === 'split-projects') {
1998
+ const slugs = rows.map((row) => row.id);
1999
+ if (!slugs.length) return this.note('');
2000
+ return this.split ? this.focusProjects(slugs) : this.enterSplit(slugs);
2001
+ }
2002
+ return this.note('');
2003
+ }
2004
+
1814
2005
  /**
1815
2006
  * A row was chosen, whichever way it was chosen.
1816
2007
  *
@@ -1845,6 +2036,19 @@ export class Attached {
1845
2036
  }
1846
2037
  return void this.allow(row.id);
1847
2038
  }
2039
+ if (select.kind === 'drop') {
2040
+ return void this.droppedCheckout(row);
2041
+ }
2042
+ if (select.kind === 'planned') {
2043
+ const [slug, ref] = String(row.id).split(' ');
2044
+ return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
2045
+ }
2046
+ if (select.kind === 'project') {
2047
+ // `/new` asked which project; the same command again, with it named.
2048
+ const words = this.newWaitingForProject ?? [];
2049
+ this.newWaitingForProject = null;
2050
+ return void this.startNew([...words, `@${row.id}`]);
2051
+ }
1848
2052
  if (select.kind === 'config') {
1849
2053
  // R288: the walk asked, the walk gets the row. Nothing is decided here.
1850
2054
  this.configuring?.answer?.(row);
@@ -1951,7 +2155,8 @@ export class Attached {
1951
2155
  }
1952
2156
  return this.openPicker(new Select({
1953
2157
  kind: 'question',
1954
- title: clip(asking.question.question, Math.max(20, this.screen.width - 2)),
2158
+ title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
2159
+ Math.max(20, this.screen.width - 2)),
1955
2160
  rows: [
1956
2161
  ...options.map((option) => ({ id: option, label: option })),
1957
2162
  // Always last, and always there.
@@ -1979,7 +2184,7 @@ export class Attached {
1979
2184
  kind: 'permission',
1980
2185
  // The call, on the title, as well as printed in full above: the picker
1981
2186
  // may still be on screen when the transcript under it has moved on.
1982
- title: `${approval.toolName} · ${approval.summary}`,
2187
+ title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${approval.summary}`,
1983
2188
  rows: [
1984
2189
  { id: 'y', label: 'Allow once', hint: 'this call and no more' },
1985
2190
  // R78: the middle grant NAMES what it covers, and it says so in the
@@ -2026,11 +2231,13 @@ export class Attached {
2026
2231
  if (pending && !this.announced.has(pending.approval.id)) {
2027
2232
  this.announced.add(pending.approval.id);
2028
2233
  const ink = this.ink;
2029
- this.say('');
2030
- this.say(`${ink.bold(ink.warn(' permission '))} ${ink.text(pending.approval.toolName)}`);
2234
+ const about = pending.approval.id;
2235
+ this.sayAbout(about, '');
2236
+ this.sayAbout(about, `${ink.bold(ink.warn(' permission '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
2237
+ this.sayAbout(about, ` ${ink.text(pending.approval.toolName)}`);
2031
2238
  // Not clipped: this is the thing being decided about, and a command cut
2032
2239
  // at the width is a command you have not read.
2033
- this.say(` ${ink.text(pending.approval.summary)}`);
2240
+ this.sayAbout(about, { prefix: ' ', body: pending.approval.summary, paint: ink.text });
2034
2241
  if (this.mode === 'keys') {
2035
2242
  this.askPermission(pending);
2036
2243
  }
@@ -2043,13 +2250,20 @@ export class Attached {
2043
2250
  }
2044
2251
  this.announced.add(asking.question.id);
2045
2252
  const ink = this.ink;
2046
- this.say('');
2047
- this.say(`${ink.bold(ink.accent(' question '))} ${ink.text(asking.question.question)}`);
2253
+ // In advanced mode the question is the prompt's BANNER alone, whole and
2254
+ // wrapped, and it goes when the question does: written into the messages
2255
+ // as well, it was on screen twice — once whole, once cut — and stayed
2256
+ // after it was answered.
2257
+ if (!this.split) {
2258
+ this.say('');
2259
+ this.say(`${ink.bold(ink.accent(' question '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
2260
+ this.say(` ${ink.text(asking.question.question)}`);
2261
+ }
2048
2262
  // R58: a watcher who does not own the question is told whose it is and is
2049
2263
  // offered nothing. A picker here would take an answer the platform then
2050
2264
  // refuses, which reads as cawdev being broken.
2051
2265
  if (!asking.yours) {
2052
- this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
2266
+ if (!this.split) this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
2053
2267
  return;
2054
2268
  }
2055
2269
  if (this.mode === 'keys' && this.session.signedIn && asking.question.options?.length) {
@@ -2187,6 +2401,13 @@ export class Attached {
2187
2401
  this.runner.agents = agentsOf(next);
2188
2402
  return this.reloadDaemon(file, next);
2189
2403
  }
2404
+ if (chosen.id.startsWith('quality:')) {
2405
+ const slug = chosen.id.slice('quality:'.length);
2406
+ const next = await setQuality({
2407
+ file, configPath, ask, say, ink, slug, enabled: !qualityOf(file.projects?.[slug]),
2408
+ });
2409
+ return this.reloadDaemon(file, next);
2410
+ }
2190
2411
  if (chosen.id === 'rules') {
2191
2412
  const next = await setAcceptsRulesFromConsole({
2192
2413
  file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
@@ -2360,14 +2581,27 @@ export class Attached {
2360
2581
  * a terminal in raw mode, and `GIT_TERMINAL_PROMPT=0` because a clone that
2361
2582
  * stops to ask for a password here would hang the screen with no prompt on
2362
2583
  * it: the credential helper may answer, a person cannot.
2584
+ *
2585
+ * ssh does not read that variable: a key with a passphrase is asked for on
2586
+ * `/dev/tty` directly, over this screen, while the screen's own raw-mode
2587
+ * reader takes half the keys. So ssh runs in BatchMode — a key the agent
2588
+ * holds (or one with no passphrase) still works, and one that would have to
2589
+ * ask fails at once and says how to make it not ask. A GIT_SSH_COMMAND the
2590
+ * person set themselves is theirs and left alone.
2363
2591
  */
2364
2592
  cloneInto(gitUrl, path) {
2365
2593
  return new Promise((done, fail) => {
2366
2594
  const child = spawn('git', ['clone', '--progress', gitUrl, path], {
2367
2595
  stdio: ['ignore', 'pipe', 'pipe'],
2368
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
2596
+ env: {
2597
+ ...process.env,
2598
+ GIT_TERMINAL_PROMPT: '0',
2599
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes',
2600
+ },
2369
2601
  });
2602
+ let said = '';
2370
2603
  const print = (chunk) => {
2604
+ said += chunk;
2371
2605
  for (const line of String(chunk).split(/\r?\n|\r/)) {
2372
2606
  if (line.trim()) this.say(` ${this.ink.muted(line)}`);
2373
2607
  }
@@ -2375,7 +2609,18 @@ export class Attached {
2375
2609
  child.stdout.on('data', print);
2376
2610
  child.stderr.on('data', print);
2377
2611
  child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
2378
- child.on('close', (code) => (code === 0 ? done() : fail(new Error(`git clone exited ${code}.`))));
2612
+ child.on('close', (code) => {
2613
+ if (code === 0) {
2614
+ done();
2615
+ return;
2616
+ }
2617
+ fail(new Error(`git clone exited ${code}.${sshNeedsAPerson(said)
2618
+ ? ' ssh needed to ask you something — a key\'s passphrase, or whether to trust the host '
2619
+ + '— and this screen cannot ask. Add the key to your agent once (macOS: `ssh-add '
2620
+ + '--apple-use-keychain ~/.ssh/id_ed25519`) and try again, or run `cawdev config '
2621
+ + 'add-project` from a shell, which can ask.'
2622
+ : ''}`));
2623
+ });
2379
2624
  });
2380
2625
  }
2381
2626
 
@@ -2418,6 +2663,32 @@ export class Attached {
2418
2663
  case 'log':
2419
2664
  this.showLog = !this.showLog;
2420
2665
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
2666
+ case 'advanced-mode':
2667
+ case 'advanced': {
2668
+ const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : !this.split;
2669
+ if (!want) return this.leaveSplit();
2670
+ // `@slug` names the projects outright; otherwise they are asked.
2671
+ const named = rest.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
2672
+ return named.length ? this.enterSplit(named) : this.askSplitProjects();
2673
+ }
2674
+ case 'projects':
2675
+ return this.askSplitProjects();
2676
+ case 'new':
2677
+ return void this.startNew(rest);
2678
+ case 'develop':
2679
+ return void this.develop();
2680
+ case 'pr':
2681
+ return void this.openPullRequest();
2682
+ case 'continue':
2683
+ return void this.carryOn();
2684
+ case 'open':
2685
+ return this.openInBrowser();
2686
+ case 'drop':
2687
+ return void this.dropCheckout();
2688
+ case 'close':
2689
+ return this.closeWindow();
2690
+ case 'reload':
2691
+ return void this.reload();
2421
2692
  case 'quit':
2422
2693
  case 'exit':
2423
2694
  return this.quit();
@@ -2435,6 +2706,9 @@ export class Attached {
2435
2706
  * the moment it is decided.
2436
2707
  */
2437
2708
  async signIn() {
2709
+ if (this.split) {
2710
+ this.leaveSplit();
2711
+ }
2438
2712
  // Everything queued goes out first and the footer comes down, so what
2439
2713
  // `runSignIn` prints with `console.log` lands under the transcript rather
2440
2714
  // than through the middle of a live region nothing is going to erase.
@@ -2449,11 +2723,846 @@ export class Attached {
2449
2723
  if (this.session.signedIn) {
2450
2724
  void this.watchInbox();
2451
2725
  void this.watchQuestions();
2726
+ void this.findStopped();
2727
+ }
2728
+ this.dirty = true;
2729
+ return undefined;
2730
+ }
2731
+
2732
+ // --- advanced mode — split.mjs ----------------------------------------------
2733
+
2734
+ /**
2735
+ * What this terminal knows of every session it has seen, after the daemon's
2736
+ * list changed. A session that left the list has ENDED — the daemon forgets
2737
+ * it there and then — and the platform is asked how, because what can be done
2738
+ * next (develop the plan, open the pull request) is decided by that.
2739
+ */
2740
+ trackSessions() {
2741
+ const live = new Set();
2742
+ for (const run of this.runs) {
2743
+ live.add(run.id);
2744
+ const known = this.sessions.get(run.id);
2745
+ if (known?.ended && this.lines.has(run.id)) {
2746
+ // Back to life (a carry-on): what was said about how it stopped is
2747
+ // over, and the new output is what the window is for.
2748
+ this.lines.set(run.id, this.lines.get(run.id).filter((line) => !line.seed));
2749
+ }
2750
+ const meta = known ?? { id: run.id, view: null, prAsked: null };
2751
+ Object.assign(meta, {
2752
+ projectSlug: run.projectSlug,
2753
+ label: run.label,
2754
+ profile: run.profile ?? meta.profile ?? null,
2755
+ state: run.state,
2756
+ why: run.why ?? null,
2757
+ // Where a live session is working — the daemon's word, and the one a
2758
+ // window names (`subtitleFor`); kept once it ends, with the view's.
2759
+ workspace: run.workspace ?? meta.workspace ?? null,
2760
+ // Which CLI and model it runs on — per stage, so kept up to date.
2761
+ // A daemon that sends the field is believed even when it is null (a
2762
+ // stage asking for no model); an older one that sends nothing keeps
2763
+ // what was known.
2764
+ agent: run.agent !== undefined ? run.agent : meta.agent ?? null,
2765
+ model: run.model !== undefined ? run.model : meta.model ?? null,
2766
+ ended: false,
2767
+ // A carried-on run the daemon has taken: the ask is answered.
2768
+ carryAsked: false,
2769
+ });
2770
+ if (!known) {
2771
+ this.sessions.set(run.id, meta);
2772
+ void this.readView(meta);
2773
+ }
2774
+ if (this.split) {
2775
+ this.requestBacklogFor(run.id);
2776
+ }
2777
+ }
2778
+ for (const meta of this.sessions.values()) {
2779
+ if (!meta.ended && !live.has(meta.id)) {
2780
+ meta.ended = true;
2781
+ void this.readView(meta, 5);
2782
+ }
2783
+ }
2784
+ if (this.split && !this.windows().some((meta) => meta.id === this.split.focus)) {
2785
+ this.split.focus = this.windows()[0]?.id ?? null;
2786
+ }
2787
+ }
2788
+
2789
+ /**
2790
+ * The platform's view of a session — its card, its kind, how it ended, its
2791
+ * pull request. Needs a person's session; without one a window says what the
2792
+ * daemon knows and no more.
2793
+ *
2794
+ * @param retries how many times to read again while the platform still says
2795
+ * the run is live: the daemon drops a run a moment before its last
2796
+ * transition lands, and an ended window saying "running" offers nothing.
2797
+ */
2798
+ async readView(meta, retries = 0) {
2799
+ if (!this.session.signedIn) return;
2800
+ try {
2801
+ meta.view = await this.session.request(`/api/projects/${meta.projectSlug}/runs/${meta.id}`);
2802
+ this.dirty = true;
2803
+ if (meta.ended && meta.view?.live && retries > 0) {
2804
+ const again = setTimeout(() => void this.readView(meta, retries - 1), 2000);
2805
+ again.unref?.();
2806
+ }
2807
+ } catch {
2808
+ // A window with the daemon's title is still a window.
2809
+ }
2810
+ }
2811
+
2812
+ /**
2813
+ * Which session a question or a request in the prompt comes from, by the
2814
+ * number its window carries (the position in the run list outside
2815
+ * advanced mode) — see split.mjs's `sessionTag`.
2816
+ */
2817
+ tagOf(run) {
2818
+ if (!run) return '';
2819
+ const meta = this.sessions.get(run.id) ?? run;
2820
+ const n = this.split
2821
+ ? this.windows().findIndex((each) => each.id === run.id) + 1
2822
+ : this.runs.findIndex((each) => each.id === run.id) + 1;
2823
+ return sessionTag(n, meta);
2824
+ }
2825
+
2826
+ /** The sessions that get a window: every one seen, less those closed. */
2827
+ windows() {
2828
+ const only = this.split?.projects;
2829
+ return [...this.sessions.values()].filter((meta) => !this.closed.has(meta.id)
2830
+ && (!only || only.has(meta.projectSlug)));
2831
+ }
2832
+
2833
+ /** The projects on screen: advanced mode's pick, else every one served here. */
2834
+ shownProjects() {
2835
+ const served = this.runner?.projects ?? [];
2836
+ return this.split?.projects ? served.filter((slug) => this.split.projects.has(slug)) : served;
2837
+ }
2838
+
2839
+ /** The session a command like /pr is about: the focused window, else the one watched. */
2840
+ commandTarget() {
2841
+ if (this.split) {
2842
+ return this.sessions.get(this.split.focus) ?? null;
2843
+ }
2844
+ return this.sessions.get(this.watching)
2845
+ ?? [...this.sessions.values()].reverse().find((meta) => meta.ended) ?? null;
2846
+ }
2847
+
2848
+ /** The backlog of any live session, once — a window needs its history too. */
2849
+ requestBacklogFor(runId) {
2850
+ if (!runId || this.asked.has(runId) || !this.connected
2851
+ || !this.runs.some((run) => run.id === runId)) {
2852
+ return;
2853
+ }
2854
+ this.asked.add(runId);
2855
+ try {
2856
+ this.client.write(`${JSON.stringify({ type: 'backlog', runId })}\n`);
2857
+ } catch {
2858
+ this.asked.delete(runId);
2859
+ }
2860
+ }
2861
+
2862
+ /**
2863
+ * `/advanced-mode` on a machine serving several projects: which of them to
2864
+ * show, ticked in a picker (all ticked to start with, so enter alone keeps
2865
+ * today's behaviour). One project served: nothing to ask. `/projects` asks
2866
+ * again from inside the mode.
2867
+ */
2868
+ askSplitProjects() {
2869
+ if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
2870
+ const served = this.runner?.projects ?? [];
2871
+ if (served.length <= 1) {
2872
+ return this.split ? this.note('this machine serves one project') : this.enterSplit(served);
2873
+ }
2874
+ const current = this.split?.projects ? [...this.split.projects] : served;
2875
+ return this.openPicker(new Select({
2876
+ kind: 'split-projects',
2877
+ title: 'which projects should advanced mode show?',
2878
+ rows: served.map((slug) => ({ id: slug, label: slug })),
2879
+ many: true,
2880
+ picked: current,
2881
+ // On what is shown now, so space alone takes it off.
2882
+ at: Math.max(0, served.findIndex((slug) => current.includes(slug))),
2883
+ }));
2884
+ }
2885
+
2886
+ /**
2887
+ * Never a prompt to a session of a project advanced mode is not showing: a
2888
+ * watched session KNOWN to be in a hidden project hands over to the focused
2889
+ * window. One not known yet (a reload, before the daemon has said hello) is
2890
+ * left alone — it may well be in a project that is shown.
2891
+ */
2892
+ leaveHiddenWatch() {
2893
+ const watched = this.sessions.get(this.watching);
2894
+ const only = this.split?.projects;
2895
+ if (watched && only && !only.has(watched.projectSlug) && this.split.focus) {
2896
+ this.watching = this.split.focus;
2897
+ }
2898
+ }
2899
+
2900
+ /** Advanced mode, narrowed to `slugs` — its windows, its roadmap, its `/new`. */
2901
+ focusProjects(slugs) {
2902
+ if (!this.split) return this.enterSplit(slugs);
2903
+ this.split.projects = new Set(slugs);
2904
+ if (!this.windows().some((meta) => meta.id === this.split.focus)) {
2905
+ this.split.focus = this.windows()[0]?.id ?? null;
2906
+ }
2907
+ this.leaveHiddenWatch();
2908
+ this.say(this.ink.muted(`showing ${slugs.join(', ')} — /projects changes it`));
2909
+ this.dirty = true;
2910
+ return undefined;
2911
+ }
2912
+
2913
+ enterSplit(projects = null) {
2914
+ if (this.split) return this.note('already in advanced mode — /advanced-mode off to leave');
2915
+ if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
2916
+ const shown = projects?.length ? new Set(projects) : null;
2917
+ // The footer comes down first, so the normal screen is left as a clean
2918
+ // scrollback for when this mode is left.
2919
+ this.screen.update(this.pending, []);
2920
+ this.pending = [];
2921
+ const screen = new SplitScreen(this.options.out ?? process.stdout);
2922
+ this.split = {
2923
+ screen,
2924
+ // The projects this mode shows; null is every one this machine serves.
2925
+ projects: shown,
2926
+ focus: null,
2927
+ messages: [],
2928
+ enteredWatching: this.watching,
2929
+ mark: new Map([...this.lines].map(([id, lines]) => [id, lines.length])),
2930
+ };
2931
+ const watched = this.windows().find((meta) => meta.id === this.watching);
2932
+ this.split.focus = watched?.id ?? this.windows()[0]?.id ?? null;
2933
+ this.leaveHiddenWatch();
2934
+ screen.enter();
2935
+ for (const meta of this.windows()) {
2936
+ this.requestBacklogFor(meta.id);
2937
+ }
2938
+ void this.refreshPlanned();
2939
+ this.split.plannedTimer = setInterval(() => void this.refreshPlanned(), 30_000);
2940
+ this.split.plannedTimer.unref?.();
2941
+ this.say(this.ink.muted('advanced mode — tab moves between windows · /new starts a session'
2942
+ + ' · /projects picks what is shown · /advanced-mode leaves'));
2943
+ if (shown) {
2944
+ this.say(this.ink.muted(`showing ${[...shown].join(', ')}`));
2945
+ }
2946
+ this.dirty = true;
2947
+ return undefined;
2948
+ }
2949
+
2950
+ /**
2951
+ * Back to the scrollback, which is as it was left. What the watched session
2952
+ * said meanwhile is laid into it now — or, if the focus moved, that
2953
+ * session's history, which is what switching sessions has always done.
2954
+ */
2955
+ leaveSplit() {
2956
+ if (!this.split) return this.note('advanced mode is not on — /advanced-mode starts it');
2957
+ const { screen, enteredWatching, mark, plannedTimer } = this.split;
2958
+ clearInterval(plannedTimer);
2959
+ this.split = null;
2960
+ screen.leave();
2961
+ const watched = this.watching;
2962
+ const lines = this.lines.get(watched) ?? [];
2963
+ if (watched && watched === enteredWatching) {
2964
+ for (const line of lines.slice(mark.get(watched) ?? lines.length)) {
2965
+ this.say(this.render(line));
2966
+ }
2967
+ } else if (watched) {
2968
+ this.printed.delete(watched);
2969
+ this.layHistoryIn(watched, lines);
2970
+ }
2971
+ this.note('back to the scrollback');
2972
+ return undefined;
2973
+ }
2974
+
2975
+ /** The window the prompt talks to. A live one becomes the session watched. */
2976
+ focusWindow(id) {
2977
+ this.split.focus = id;
2978
+ this.split.recent = [id, ...(this.split.recent ?? []).filter((each) => each !== id)];
2979
+ if (this.runs.some((run) => run.id === id)) {
2980
+ this.watching = id;
2981
+ }
2982
+ this.note('');
2983
+ }
2984
+
2985
+ cycleFocus(step) {
2986
+ const windows = this.windows();
2987
+ if (!windows.length) return this.note('no session windows yet — /new starts one');
2988
+ const at = windows.findIndex((meta) => meta.id === this.split.focus);
2989
+ return this.focusWindow(windows[(at + step + windows.length) % windows.length].id);
2990
+ }
2991
+
2992
+ /** The whole alternate screen, as rows — see split.mjs. */
2993
+ splitFrame() {
2994
+ const { width, height } = this.split.screen;
2995
+ const ink = this.ink;
2996
+ const windows = this.windows();
2997
+ const inner = Math.max(8, width - 2);
2998
+
2999
+ const overlay = this.overlayLines(inner);
3000
+ const current = this.current();
3001
+ const pending = this.pendingOn(current);
3002
+ const banner = this.select && this.select.kind !== 'runs' ? [] : pending
3003
+ ? permissionBanner(pending, inner, ink, this.runner ?? {})
3004
+ : questionBanner(this.askingOn(current), inner, ink, { entire: true });
3005
+ const input = this.mode === 'typing'
3006
+ ? `${this.input.label} ${this.input.line.render(
3007
+ Math.max(8, inner - visibleWidth(this.input.label) - 1), ink)}`
3008
+ : ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3009
+ + ' · / commands · tab next window');
3010
+ // A question or a request on screen says which window it came from.
3011
+ const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3012
+ const fixed = [...from, ...banner, ...overlay, input];
3013
+ const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
3014
+ // The roadmap across the top: as tall as its fullest column needs, never
3015
+ // more than a third of the screen, and never so tall that no window fits
3016
+ // under it — a short terminal drops it before it drops the sessions.
3017
+ const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
3018
+ const stripLines = stripRoom >= 3
3019
+ ? roadmapStrip(width, stripRoom, this.cards, ink, this.shownProjects())
3020
+ : [];
3021
+ const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
3022
+ // Four windows at most; the rest are named on one line above the prompt,
3023
+ // and Tab onto one of them brings it up in place of the least recent.
3024
+ const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus);
3025
+ const hidden = windows.filter((meta) => !shown.includes(meta));
3026
+ const tabs = hidden.length ? 1 : 0;
3027
+ const layout = splitLayout(width, height - strip - tabs, shown.length, promptRows);
3028
+ for (const rect of layout.zones) {
3029
+ rect.top += strip;
3030
+ }
3031
+ layout.prompt.top += strip + tabs;
3032
+
3033
+ const pieces = layout.zones.map((rect, i) => {
3034
+ const meta = shown[i];
3035
+ const n = windows.indexOf(meta) + 1;
3036
+ const asking = this.askingOn(this.runs.find((run) => run.id === meta.id));
3037
+ const waiting = this.pendingOn(this.runs.find((run) => run.id === meta.id))
3038
+ ? 'waiting for permission'
3039
+ : asking ? 'waiting for an answer' : null;
3040
+ const lines = (this.lines.get(meta.id) ?? []).map((line) => this.render(line));
3041
+ const body = lines.length ? lines : [ink.muted(meta.state === 'queued'
3042
+ ? ` waiting — ${meta.why ?? 'no reason recorded'}`
3043
+ : meta.ended ? ' nothing was said on this session here' : ' nothing said yet')];
3044
+ return {
3045
+ rect,
3046
+ lines: windowLines(rect, {
3047
+ title: `${n} ${titleFor(meta, waiting)}`,
3048
+ // The agent and its model first, bold and underlined — which CLI and
3049
+ // which model is what somebody comparing two windows looks for.
3050
+ subtitle: (() => {
3051
+ const who = agentModelFor(meta);
3052
+ const lead = who ? `${ink.strong(who)} ` : '';
3053
+ return lead + ink.muted(subtitleFor(meta, rect.width - 4 - (who ? who.length + 1 : 0)));
3054
+ })(),
3055
+ body,
3056
+ footer: actionsFor(meta),
3057
+ focused: meta.id === this.split.focus,
3058
+ dotted: meta.id !== this.split.focus,
3059
+ tone: toneFor(meta),
3060
+ }, ink),
3061
+ };
3062
+ });
3063
+
3064
+ if (tabs) {
3065
+ pieces.push({
3066
+ rect: { top: layout.prompt.top - 1, left: 0, width, height: 1 },
3067
+ lines: [tabsLine(hidden.map((meta) => ({ n: windows.indexOf(meta) + 1, meta })), width, ink)],
3068
+ });
3069
+ }
3070
+ const unseen = layout.hidden ? ` · ${layout.hidden} more not shown` : '';
3071
+ // A question's lines leave with the question — see `sayAbout`.
3072
+ const open = this.stillOpen();
3073
+ this.split.messages = this.split.messages
3074
+ .filter((line) => typeof line === 'string' || open.has(line.about));
3075
+ const messages = this.split.messages.flatMap((line) => {
3076
+ if (typeof line === 'string') return [line];
3077
+ if (line.body === undefined) return [line.text];
3078
+ return wordWrap(line.body, inner - 2 - line.prefix.length).map((part) => `${line.prefix}${line.paint(part)}`);
3079
+ });
3080
+ const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
3081
+ + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}${unseen}`;
3082
+ pieces.push({
3083
+ rect: layout.prompt,
3084
+ lines: windowLines(layout.prompt, {
3085
+ title: promptTitle,
3086
+ // The input line sits on the bottom row, the way a prompt does:
3087
+ // messages fill from above it, and blank rows pad a short log.
3088
+ body: [
3089
+ ...new Array(Math.max(0, layout.prompt.height - 3 - messages.length - fixed.length))
3090
+ .fill(''),
3091
+ ...messages,
3092
+ ...fixed,
3093
+ ],
3094
+ footer: this.status ? ink.warn(this.status) : keyList(this.keys(), '', inner, ink),
3095
+ }, ink),
3096
+ });
3097
+ if (strip) {
3098
+ pieces.push({
3099
+ rect: { top: 0, left: 0, width, height: strip },
3100
+ lines: stripLines,
3101
+ });
3102
+ }
3103
+ return composeFrame(width, height, pieces);
3104
+ }
3105
+
3106
+ /**
3107
+ * Every card of every project this machine serves, whatever its status —
3108
+ * the strip draws them all, and `d` offers the PLANNED ones. One request
3109
+ * per project, the board's own (`brief`, so no bodies). Needs a person's
3110
+ * session, like everything read off the roadmap here; a failure keeps the
3111
+ * last list rather than blanking the strip.
3112
+ */
3113
+ async refreshPlanned() {
3114
+ if (!this.session.signedIn) return;
3115
+ const all = [];
3116
+ for (const slug of this.runner?.projects ?? []) {
3117
+ try {
3118
+ const cards = await this.session.request(`/api/projects/${slug}/roadmap?brief=true`);
3119
+ for (const card of Array.isArray(cards) ? cards : []) {
3120
+ all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
3121
+ }
3122
+ } catch {
3123
+ return;
3124
+ }
3125
+ }
3126
+ this.cards = all;
3127
+ this.planned = all.filter((card) => card.status === 'PLANNED');
3128
+ this.dirty = true;
3129
+ }
3130
+
3131
+ /**
3132
+ * `d`, or `/new dev` with nothing after it: the planned cards as a list, and
3133
+ * the one picked is developed — `/new dev R3 @slug`, on this machine.
3134
+ */
3135
+ async pickPlanned(slug = null) {
3136
+ if (!this.requireSignIn('start a session')) return;
3137
+ await this.refreshPlanned();
3138
+ const cards = this.planned.filter((card) => !slug || card.slug === slug);
3139
+ if (!cards.length) {
3140
+ this.note(slug ? `nothing planned in ${slug}` : 'nothing planned — /new dev <the task in words> starts one anyway');
3141
+ return;
2452
3142
  }
3143
+ const several = new Set(cards.map((card) => card.slug)).size > 1;
3144
+ this.openPicker(new Select({
3145
+ kind: 'planned',
3146
+ title: 'develop which planned card?',
3147
+ rows: cards.map((card) => ({
3148
+ id: `${card.slug} ${card.ref}`,
3149
+ label: `${card.ref} ${card.title}`,
3150
+ ...(several ? { hint: card.slug } : {}),
3151
+ })),
3152
+ }));
3153
+ }
3154
+
3155
+ /** `/new plan|dev R3` · `/new plan|dev <the task in words>` · `/new ask …` — on this machine. */
3156
+ async startNew(words) {
3157
+ if (!this.requireSignIn('start a session')) return;
3158
+ // Advanced mode narrowed to ONE project: that is the project. Asking would
3159
+ // be asking somebody to repeat the choice they made on entering — this is
3160
+ // not the guess the question exists to prevent (a machine serving one
3161
+ // project, or the focused window), it is a choice already stated.
3162
+ const shown = this.split?.projects ? this.shownProjects() : [];
3163
+ if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
3164
+ words = [...words, `@${shown[0]}`];
3165
+ }
3166
+ const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway');
3167
+ if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
3168
+ const named = words.find((word) => word.startsWith('@'));
3169
+ await this.pickPlanned(named ? named.slice(1) : null);
3170
+ return;
3171
+ }
3172
+ const anyway = words.includes('--anyway');
3173
+ const parsed = parseNew(words.filter((word) => word !== '--anyway'), {
3174
+ // In advanced mode, among the projects it shows.
3175
+ projects: this.shownProjects(),
3176
+ });
3177
+ if (parsed.needsProject) {
3178
+ // Several projects here and nothing says which: ASK, from the list of
3179
+ // what this machine serves, and run the same command again with the
3180
+ // one picked. A sentence telling somebody to retype it with @slug is a
3181
+ // question the terminal could have asked itself.
3182
+ this.openPicker(new Select({
3183
+ kind: 'project',
3184
+ title: `which project is this ${words.find((word) => !word.startsWith('@')) ?? 'session'} for?`,
3185
+ rows: parsed.projects.map((slug) => ({ id: slug, label: slug })),
3186
+ }));
3187
+ this.newWaitingForProject = words;
3188
+ return;
3189
+ }
3190
+ if (parsed.error) {
3191
+ this.note(parsed.error);
3192
+ return;
3193
+ }
3194
+ if (parsed.kind === 'dev' && !parsed.ref) {
3195
+ // A dev from words writes its CARD first, then develops it exactly as
3196
+ // `/new dev R12` would. It used to be a manual session on a `task-…`
3197
+ // branch with no card: no number, nothing on the development board,
3198
+ // and a prompt that never said "commit" — so the work ended
3199
+ // uncommitted and `/pr` had nothing to propose.
3200
+ await this.developNewCard(parsed.slug, parsed.text, anyway);
3201
+ return;
3202
+ }
3203
+ const [path, body, what] = parsed.kind === 'ask'
3204
+ ? ['runs/ask', { prompt: parsed.text }, 'a question']
3205
+ : parsed.kind === 'plan'
3206
+ ? parsed.ref
3207
+ ? ['runs/plan', { entryNumber: parsed.ref }, `a plan of ${parsed.ref}`]
3208
+ // R150: the session writes the card first, then plans it.
3209
+ : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3210
+ : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
3211
+ await this.startSession(parsed.slug, path, body, what, anyway);
3212
+ }
3213
+
3214
+ /**
3215
+ * `/new dev <the task in words>`: the card, then its development. Two
3216
+ * requests, and the first is said on its own line — a card that was written
3217
+ * but whose run was refused (a usage window, say) is still on the board,
3218
+ * and the person should know which half happened.
3219
+ */
3220
+ async developNewCard(slug, text, anyway) {
3221
+ let card;
3222
+ try {
3223
+ card = await this.session.request(`/api/projects/${slug}/roadmap`, {
3224
+ method: 'POST',
3225
+ body: { title: cardTitleFor(text), body: text },
3226
+ });
3227
+ } catch (failure) {
3228
+ this.note(`could not write the card: ${failure.message}`);
3229
+ return;
3230
+ }
3231
+ const ref = card?.ref ?? (card?.number ? `R${card.number}` : null);
3232
+ if (!ref) {
3233
+ this.note('the card was written but the platform did not say its number — start it from the board');
3234
+ return;
3235
+ }
3236
+ this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
3237
+ await this.startSession(slug, 'runs', { entryNumber: ref }, `the development of ${ref}`, anyway);
3238
+ void this.refreshPlanned();
3239
+ }
3240
+
3241
+ /**
3242
+ * The sessions that stopped before this terminal saw them and can be picked
3243
+ * back up HERE — a failed run holding its checkout on this machine above
3244
+ * all. The daemon forgets a run as it ends, so after a restart nothing on
3245
+ * this side knew it existed and only the console could carry it on. Each
3246
+ * gets a window like any session that ended while watched, and the window
3247
+ * offers `/carry-on`.
3248
+ *
3249
+ * The first page of each served project's runs, which carries every run not
3250
+ * over plus the fifty newest that are — a failed run older than that is on
3251
+ * the console's Sessions page, and a terminal that paged through a
3252
+ * project's whole history on every attach would be slow for everybody to
3253
+ * find one that rarely exists. Once per attach and once per sign-in.
3254
+ */
3255
+ async findStopped() {
3256
+ if (!this.session.signedIn || !this.runner?.id) return;
3257
+ const found = [];
3258
+ for (const slug of this.runner.projects ?? []) {
3259
+ const page = await this.session.request(`/api/projects/${slug}/runs?limit=50`)
3260
+ .catch(() => []);
3261
+ for (const view of Array.isArray(page) ? page : []) {
3262
+ if (this.sessions.has(view.id) || this.closed.has(view.id)
3263
+ || this.runs.some((run) => run.id === view.id)
3264
+ || !carriesOnHere(view, this.runner.id, this.session.email)) {
3265
+ continue;
3266
+ }
3267
+ const meta = {
3268
+ id: view.id, projectSlug: slug, label: view.label, profile: view.profile,
3269
+ state: view.state, ended: true, view, prAsked: null,
3270
+ };
3271
+ this.sessions.set(view.id, meta);
3272
+ // Its window has no transcript to show — the daemon never streamed it
3273
+ // to this terminal — so it opens on why it stopped.
3274
+ if (!this.lines.has(view.id)) {
3275
+ const why = view.exitSummary ?? view.failureReason ?? `${String(view.state).toLowerCase()}`;
3276
+ // `seed`: it stands for a transcript this terminal never had, and
3277
+ // leaves when the session comes back (`trackSessions`) — above live
3278
+ // output it reads as the session failing again.
3279
+ this.lines.set(view.id, [{ kind: 'ERROR', body: `stopped: ${why}`, seed: true }]);
3280
+ }
3281
+ found.push(meta);
3282
+ }
3283
+ }
3284
+ if (!found.length) return;
3285
+ this.say(this.ink.warn(` ${found.length} session(s) stopped here and can be carried on:`));
3286
+ for (const meta of found) {
3287
+ this.say(` ${meta.projectSlug} · ${String(meta.state).toLowerCase()} · ${meta.label ?? meta.id}`);
3288
+ }
3289
+ this.say(this.ink.muted(this.split
3290
+ ? ' focus its window and type /continue'
3291
+ : ' /continue picks up the latest — /advanced-mode gives each one a window'));
2453
3292
  this.dirty = true;
3293
+ }
3294
+
3295
+ /**
3296
+ * `/carry-on` — the console's Carry on, for the focused (or last ended)
3297
+ * session, on THIS machine: a failed run's checkout is here, and it resumes
3298
+ * where its work is or nowhere. The platform re-queues the same run; its
3299
+ * window comes back to life when the daemon claims it.
3300
+ */
3301
+ async carryOn() {
3302
+ if (!this.requireSignIn('carry a session on')) return;
3303
+ const meta = this.commandTarget();
3304
+ const view = meta?.view;
3305
+ if (!meta || !view) {
3306
+ this.note('/continue needs a session that stopped — focus its window');
3307
+ return;
3308
+ }
3309
+ if (!meta.ended) {
3310
+ this.note(`${view.label ?? 'that session'} is still going — say something to it instead`);
3311
+ return;
3312
+ }
3313
+ if (!view.canCarryOn) {
3314
+ this.note(`that session is ${String(view.state ?? 'over').toLowerCase()} and cannot be carried on`);
3315
+ return;
3316
+ }
3317
+ if (!this.runner?.id) {
3318
+ this.note('this machine has not said which runner it is yet — try again in a moment');
3319
+ return;
3320
+ }
3321
+ try {
3322
+ meta.view = await this.session.request(
3323
+ `/api/projects/${meta.projectSlug}/runs/${meta.id}/carry-on`,
3324
+ { method: 'POST', body: { runnerId: this.runner.id } });
3325
+ meta.carryAsked = true;
3326
+ this.say(this.ink.success(` carrying on ${view.label ?? meta.id} — its window comes back when this machine takes it`));
3327
+ this.note('');
3328
+ } catch (failure) {
3329
+ this.note(failure.message);
3330
+ }
3331
+ }
3332
+
3333
+ /** `/develop` — the card a finished plan was about, on this machine. */
3334
+ async develop() {
3335
+ if (!this.requireSignIn('start a session')) return;
3336
+ const meta = this.commandTarget();
3337
+ const ref = meta?.view?.entryRef;
3338
+ if (!meta || !ref) {
3339
+ this.note('/develop needs a session on a card — focus a finished plan');
3340
+ return;
3341
+ }
3342
+ if (!meta.ended) {
3343
+ this.note(`${ref} is still being worked on — wait for it to end`);
3344
+ return;
3345
+ }
3346
+ await this.startSession(meta.projectSlug, 'runs', { entryNumber: ref }, `the development of ${ref}`, false);
3347
+ }
3348
+
3349
+ /**
3350
+ * One POST that starts a session, aimed at THIS machine so its window opens
3351
+ * here. A usage window over the machine's threshold is refused by the
3352
+ * platform in a sentence; `--anyway` is the same decision the console's
3353
+ * panel asks for.
3354
+ */
3355
+ async startSession(slug, path, body, what, anyway) {
3356
+ try {
3357
+ await this.session.request(`/api/projects/${slug}/${path}`, {
3358
+ method: 'POST',
3359
+ body: {
3360
+ ...body,
3361
+ ...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
3362
+ ...(anyway ? { acknowledgeUsage: true } : {}),
3363
+ },
3364
+ });
3365
+ this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
3366
+ this.note('');
3367
+ } catch (failure) {
3368
+ this.note(/usage|window/i.test(failure.message)
3369
+ ? `${failure.message} — add --anyway to start it regardless`
3370
+ : failure.message);
3371
+ }
3372
+ }
3373
+
3374
+ /**
3375
+ * `/pr` — the pull request of a finished session's branch. The machine pushes and runs `gh`, so the answer comes
3376
+ * back later: the window says it was asked for, and shows the URL when the
3377
+ * run carries one.
3378
+ */
3379
+ async openPullRequest() {
3380
+ if (!this.requireSignIn('open a pull request')) return;
3381
+ const meta = this.commandTarget();
3382
+ const view = meta?.view;
3383
+ if (!meta || !view?.branch) {
3384
+ this.note('/pr needs a session that wrote a branch — focus one');
3385
+ return;
3386
+ }
3387
+ if (!meta.ended) {
3388
+ this.note(`${view.branch} is still being written — wait for the session to end`);
3389
+ return;
3390
+ }
3391
+ if (isPullRequest(view.prUrl)) {
3392
+ this.note(`${view.branch} already has one: ${view.prUrl}`);
3393
+ return;
3394
+ }
3395
+ try {
3396
+ // Through the RUN: a card's run goes to its work item on the platform,
3397
+ // and a session started from a sentence — which has none — is proposed
3398
+ // from the run itself.
3399
+ const asked = await this.session.request(
3400
+ `/api/projects/${meta.projectSlug}/runs/${meta.id}/pull-request`, { method: 'POST' });
3401
+ meta.prAsked = true;
3402
+ this.say(this.ink.success(` asked this machine to open the pull request for ${view.branch}`));
3403
+ this.note('');
3404
+ // The answer is the REQUEST the machine was handed, and its result is
3405
+ // the URL or the reason there is none. A platform older than that
3406
+ // answers with the run, and only the run's prUrl can be watched.
3407
+ void (asked?.id && asked?.runnerId && asked?.kind === 'OPEN_PR'
3408
+ ? this.followPullRequest(meta, asked, 60)
3409
+ : this.awaitPullRequest(meta, 60));
3410
+ } catch (failure) {
3411
+ this.note(failure.message);
3412
+ }
3413
+ }
3414
+
3415
+ /**
3416
+ * Reads the machine's answer to `/pr` until there is one: the URL, which
3417
+ * the daemon checked the host for before saying so — or why not, in the
3418
+ * daemon's words (gh missing or signed out, a push refused). Before this the
3419
+ * terminal watched the run's prUrl, which a failure never writes, and said
3420
+ * "pull request asked for…" for as long as the window was open.
3421
+ */
3422
+ async followPullRequest(meta, request, tries) {
3423
+ const read = await this.session.request(
3424
+ `/api/runners/${request.runnerId}/workspace-requests/${request.id}`).catch(() => null);
3425
+ const state = read?.state;
3426
+ if (state === 'DONE') {
3427
+ const url = String(read.result ?? '').split('\n')[0].trim();
3428
+ if (isPullRequest(url)) {
3429
+ meta.prAsked = null;
3430
+ meta.view = { ...(meta.view ?? {}), prUrl: url };
3431
+ this.dirty = true;
3432
+ this.say(this.ink.success(` pull request: ${url}`));
3433
+ return;
3434
+ }
3435
+ }
3436
+ if (state === 'FAILED' || state === 'DONE') {
3437
+ const why = String(read.result ?? 'the machine did not say why').trim();
3438
+ meta.prAsked = `no pull request — ${why.split('\n')[0]}`;
3439
+ this.dirty = true;
3440
+ this.say(this.ink.danger(` no pull request for ${meta.view?.branch ?? 'this branch'}:`));
3441
+ for (const line of why.split('\n')) this.say(this.ink.danger(` ${line}`));
3442
+ return;
3443
+ }
3444
+ if (tries <= 0) {
3445
+ meta.prAsked = 'no answer from the machine yet — is its daemon running?';
3446
+ this.dirty = true;
3447
+ return;
3448
+ }
3449
+ const again = setTimeout(() => void this.followPullRequest(meta, request, tries - 1), 2000);
3450
+ again.unref?.();
3451
+ }
3452
+
3453
+ /** Reads the run until its pull request shows, or gives up and says so. */
3454
+ async awaitPullRequest(meta, tries) {
3455
+ await this.readView(meta);
3456
+ if (isPullRequest(meta.view?.prUrl)) {
3457
+ meta.prAsked = null;
3458
+ this.say(this.ink.success(` pull request: ${meta.view.prUrl}`));
3459
+ return;
3460
+ }
3461
+ if (tries <= 0) {
3462
+ meta.prAsked = 'no pull request yet — the board says why';
3463
+ this.dirty = true;
3464
+ return;
3465
+ }
3466
+ const again = setTimeout(() => void this.awaitPullRequest(meta, tries - 1), 3000);
3467
+ again.unref?.();
3468
+ }
3469
+
3470
+ /** `/open` — the session's pull request, in the browser. */
3471
+ openInBrowser() {
3472
+ const url = this.commandTarget()?.view?.prUrl;
3473
+ if (!isPullRequest(url)) {
3474
+ return this.note('no pull request on this session yet — /pr opens one');
3475
+ }
3476
+ const opener = this.options.openUrl ?? ((link) => {
3477
+ const command = process.platform === 'darwin' ? 'open'
3478
+ : process.platform === 'win32' ? 'explorer' : 'xdg-open';
3479
+ spawn(command, [link], { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
3480
+ });
3481
+ opener(url);
3482
+ return this.note(`opened ${url}`);
3483
+ }
3484
+
3485
+ /**
3486
+ * `/drop` — delete the checkout a finished session was cloned into.
3487
+ *
3488
+ * Only a checkout the daemon made on demand (`-auto-<n>`), so the project
3489
+ * always keeps the ones in the config. Refused while anything is in it: a
3490
+ * run on it, a run that failed and holds it (R80), uncommitted files, or a
3491
+ * commit origin does not have — deleting any of those is deleting work. It
3492
+ * asks first, naming the path. The daemon owns the list of checkouts and the
3493
+ * socket cannot change anything (R52), so the directory is removed here and
3494
+ * the daemon is sent SIGHUP, which makes it look again (auto-workspaces.mjs).
3495
+ */
3496
+ async dropCheckout() {
3497
+ const meta = this.commandTarget();
3498
+ const path = meta?.view?.workspace;
3499
+ if (!meta || !isAutoCheckout(path)) {
3500
+ return this.note('/drop deletes a checkout made on demand (…-auto-N) — this session was not in one');
3501
+ }
3502
+ if (meta.dropped) {
3503
+ return this.note(`${path} is already deleted`);
3504
+ }
3505
+ if (!meta.ended || this.runs.some((run) => run.workspace === path)) {
3506
+ return this.note(`a session is working in ${path} — wait for it to end`);
3507
+ }
3508
+ if (meta.view?.holdsACheckout) {
3509
+ return this.note(`the run keeps ${path} for its unfinished work — carry it on or discard it first`);
3510
+ }
3511
+ const unsafe = await (this.options.checkoutState ?? checkoutState)(path);
3512
+ if (unsafe) {
3513
+ return this.note(`not deleting ${path}: ${unsafe}`);
3514
+ }
3515
+ const noPr = isPullRequest(meta.view?.prUrl) ? '' : ' (no pull request yet — the branch is on origin)';
3516
+ this.dropping = { meta, path };
3517
+ this.openPicker(new Select({
3518
+ kind: 'drop',
3519
+ title: `delete ${path}?${noPr}`,
3520
+ rows: [
3521
+ { id: 'delete', label: `Delete ${path}` },
3522
+ { id: 'keep', label: 'Keep it' },
3523
+ ],
3524
+ }));
2454
3525
  return undefined;
2455
3526
  }
2456
3527
 
3528
+ /** The choice `/drop` asked for. */
3529
+ async droppedCheckout(row) {
3530
+ const { meta, path } = this.dropping ?? {};
3531
+ this.dropping = null;
3532
+ if (!meta || row.id !== 'delete') {
3533
+ return this.note('kept');
3534
+ }
3535
+ try {
3536
+ await (this.options.removeDir ?? ((dir) => rm(dir, { recursive: true, force: true })))(path);
3537
+ } catch (failure) {
3538
+ return this.note(`could not delete ${path}: ${failure.message}`);
3539
+ }
3540
+ meta.dropped = true;
3541
+ if (this.runner?.pid) {
3542
+ (this.options.signalDaemon ?? ((pid) => {
3543
+ try {
3544
+ process.kill(pid, 'SIGHUP');
3545
+ } catch {
3546
+ // Gone; the next one will not find the checkout either.
3547
+ }
3548
+ }))(this.runner.pid);
3549
+ }
3550
+ this.say(this.ink.success(` deleted ${path}`));
3551
+ return this.note('');
3552
+ }
3553
+
3554
+ /** `/close` — an ended session's window goes; a live one's stays. */
3555
+ closeWindow() {
3556
+ const meta = this.commandTarget();
3557
+ if (!meta) return this.note('no window to close');
3558
+ if (!meta.ended) return this.note('that session is still running — /cancel stops it');
3559
+ this.closed.add(meta.id);
3560
+ if (this.split) {
3561
+ this.split.focus = this.windows()[0]?.id ?? null;
3562
+ }
3563
+ return this.note('closed');
3564
+ }
3565
+
2457
3566
  // --- the things a person can do ---------------------------------------------
2458
3567
 
2459
3568
  async send(text) {
@@ -2633,6 +3742,13 @@ export class Attached {
2633
3742
  }
2634
3743
 
2635
3744
  this.stopped = true;
3745
+ // Off the alternate screen before anything is printed, or the goodbye
3746
+ // lands on a buffer that vanishes the moment the program exits.
3747
+ if (this.split) {
3748
+ clearInterval(this.split.plannedTimer);
3749
+ this.split.screen.release();
3750
+ this.split = null;
3751
+ }
2636
3752
  try {
2637
3753
  this.client?.destroy();
2638
3754
  } catch {
@@ -2673,6 +3789,131 @@ export class Attached {
2673
3789
  return process.exit(0);
2674
3790
  }
2675
3791
 
3792
+ /**
3793
+ * `/reload` — quitting and starting again, in one word, for when the client's
3794
+ * own code changed on disk.
3795
+ *
3796
+ * The same argv is run again in a fresh process, which is the only way a
3797
+ * module already loaded is read again; this one hands it the terminal and
3798
+ * waits, and leaves with its exit code. The daemon goes too — `q`'s SIGINT,
3799
+ * then waiting for it to be gone — when nothing is running here, so the
3800
+ * relaunch starts a daemon on the new code as well. When sessions are live
3801
+ * it is kept, and said: restarting it would end them, and a reload is not a
3802
+ * reason to lose somebody's work. `--attach` is refused: there the daemon IS
3803
+ * this process.
3804
+ */
3805
+ async reload() {
3806
+ if (this.options.onQuit) {
3807
+ return this.note('here the runner is this process (runner.mjs --attach) — quit and start it again');
3808
+ }
3809
+ const live = this.liveHere();
3810
+ const pid = this.runner?.pid ?? null;
3811
+ const restartDaemon = Boolean(pid) && live === 0;
3812
+ // Taken before anything is torn down: leaving advanced mode below would
3813
+ // otherwise be what gets saved.
3814
+ const state = this.reloadState();
3815
+
3816
+ this.stopped = true;
3817
+ if (this.split) {
3818
+ clearInterval(this.split.plannedTimer);
3819
+ this.split.screen.release();
3820
+ this.split = null;
3821
+ }
3822
+ try {
3823
+ this.client?.destroy();
3824
+ } catch {
3825
+ // Going anyway.
3826
+ }
3827
+ if (this.pending.length) {
3828
+ this.screen.update(this.pending, []);
3829
+ this.pending = [];
3830
+ }
3831
+ this.screen.close();
3832
+ if (!this.plain && process.stdin.isTTY) {
3833
+ process.stdout.write(`${ESC}[?2004l`);
3834
+ }
3835
+ process.stdin.setRawMode?.(false);
3836
+ process.stdin.removeAllListeners('data');
3837
+ process.stdin.pause();
3838
+
3839
+ if (restartDaemon) {
3840
+ this.stopDaemon(pid);
3841
+ const alive = this.options.alive ?? ((each) => {
3842
+ try {
3843
+ process.kill(each, 0);
3844
+ return true;
3845
+ } catch {
3846
+ return false;
3847
+ }
3848
+ });
3849
+ for (let waited = 0; waited < 100 && alive(pid); waited++) {
3850
+ await new Promise((done) => setTimeout(done, 100));
3851
+ }
3852
+ console.log(`\n ${this.ink.muted('reloading — the runner and this terminal, on the code on disk')}\n`);
3853
+ } else {
3854
+ console.log(`\n ${this.ink.muted(live
3855
+ ? `reloading this terminal — the runner keeps running, ${live} session${live === 1 ? ' is' : 's are'}`
3856
+ + ' live on it; /reload again once they end to reload it too'
3857
+ : 'reloading this terminal')}\n`);
3858
+ }
3859
+
3860
+ // `--setup` was a request to walk setup once, not every time.
3861
+ const argv = process.argv.slice(1).filter((arg) => arg !== '--setup');
3862
+ const relaunch = this.options.relaunch ?? ((args, env) => {
3863
+ const child = spawn(process.execPath, [...process.execArgv, ...args],
3864
+ { stdio: 'inherit', env: { ...process.env, ...env } });
3865
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
3866
+ return child;
3867
+ });
3868
+ relaunch(argv, { [RELOAD_STATE]: JSON.stringify(state) });
3869
+ return undefined;
3870
+ }
3871
+
3872
+ /**
3873
+ * What `/reload` carries across the restart — only what the person arranged,
3874
+ * never what the daemon will say again anyway. Ended sessions go with their
3875
+ * daemon-side facts, because the daemon forgot them and their windows (and
3876
+ * the /develop or /pr on them) would otherwise be gone.
3877
+ */
3878
+ reloadState() {
3879
+ return {
3880
+ advanced: Boolean(this.split),
3881
+ projects: this.split?.projects ? [...this.split.projects] : null,
3882
+ focus: this.split?.focus ?? null,
3883
+ watching: this.watching,
3884
+ showLog: this.showLog,
3885
+ closed: [...this.closed],
3886
+ ended: [...this.sessions.values()]
3887
+ .filter((meta) => meta.ended && !this.closed.has(meta.id))
3888
+ .map(({ id, projectSlug, label, profile, state }) => ({ id, projectSlug, label, profile, state })),
3889
+ };
3890
+ }
3891
+
3892
+ /** Puts back what `reloadState` saved. Anything missing is simply not restored. */
3893
+ restore(state) {
3894
+ if (!state || typeof state !== 'object') return;
3895
+ for (const meta of Array.isArray(state.ended) ? state.ended : []) {
3896
+ if (meta?.id && !this.sessions.has(meta.id)) {
3897
+ const restored = { ...meta, ended: true, view: null, prAsked: null };
3898
+ this.sessions.set(meta.id, restored);
3899
+ void this.readView(restored);
3900
+ }
3901
+ }
3902
+ for (const id of Array.isArray(state.closed) ? state.closed : []) {
3903
+ this.closed.add(id);
3904
+ }
3905
+ if (state.watching) {
3906
+ this.watching = state.watching;
3907
+ }
3908
+ this.showLog = state.showLog === true;
3909
+ if (state.advanced && !this.plain) {
3910
+ this.enterSplit(Array.isArray(state.projects) ? state.projects : null);
3911
+ if (state.focus && this.sessions.has(state.focus)) {
3912
+ this.split.focus = state.focus;
3913
+ }
3914
+ }
3915
+ }
3916
+
2676
3917
  /**
2677
3918
  * Whether leaving here ends the daemon — R123.
2678
3919
  *
@@ -2827,6 +4068,10 @@ export class Attached {
2827
4068
  parts.push(`${ink.warn(pending.approval?.suggestion ? 'y/s/Y/n' : 'y/s/n')} `
2828
4069
  + `${ink.muted('permission')}`);
2829
4070
  }
4071
+ if (this.split) {
4072
+ parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
4073
+ parts.push(`${ink.text('d')} ${ink.muted('develop a planned card')}`);
4074
+ }
2830
4075
  parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
2831
4076
  parts.push(`${ink.text('c')} ${ink.muted('config')}`);
2832
4077
  parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);