cawdev-cli 1.0.4-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/lib/agents.mjs +27 -0
- package/lib/ansi.mjs +2 -0
- package/lib/codex.mjs +214 -0
- package/lib/harness-prompt.mjs +14 -6
- package/lib/runner-config.mjs +3 -2
- package/mcp/server.mjs +3 -0
- package/package.json +1 -1
- package/runner/README.md +264 -9
- package/runner/attach.mjs +1251 -23
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +28 -11
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +21 -5
- package/runner/runner.mjs +1111 -133
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +830 -0
package/runner/attach.mjs
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
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';
|
|
@@ -58,6 +59,12 @@ import {
|
|
|
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
|
-
|
|
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.
|
|
@@ -761,6 +781,16 @@ const COMMANDS = [
|
|
|
761
781
|
['/config', "this machine's config — the same as c"],
|
|
762
782
|
['/cancel', 'cancel the session you are watching'],
|
|
763
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'],
|
|
764
794
|
['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
|
|
765
795
|
];
|
|
766
796
|
|
|
@@ -774,6 +804,47 @@ const COMMANDS = [
|
|
|
774
804
|
* lesson about rules in a renderer is that they can only be tested by looking at
|
|
775
805
|
* them.
|
|
776
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
|
+
|
|
777
848
|
export class Attached {
|
|
778
849
|
/**
|
|
779
850
|
* @param options `out` is where to draw — `process.stdout` in the real thing,
|
|
@@ -867,6 +938,24 @@ export class Attached {
|
|
|
867
938
|
* laptop.
|
|
868
939
|
*/
|
|
869
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 = [];
|
|
870
959
|
}
|
|
871
960
|
|
|
872
961
|
/**
|
|
@@ -928,6 +1017,7 @@ export class Attached {
|
|
|
928
1017
|
}
|
|
929
1018
|
process.stdout.on('resize', () => {
|
|
930
1019
|
this.screen.resize();
|
|
1020
|
+
this.split?.screen.resize();
|
|
931
1021
|
this.dirty = true;
|
|
932
1022
|
});
|
|
933
1023
|
|
|
@@ -936,6 +1026,10 @@ export class Attached {
|
|
|
936
1026
|
void this.watchInbox();
|
|
937
1027
|
void this.watchQuestions();
|
|
938
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());
|
|
939
1033
|
|
|
940
1034
|
// One write per tick at most, carrying everything that happened in it. A
|
|
941
1035
|
// busy session emits hundreds of lines a second, and a write per line means
|
|
@@ -998,7 +1092,9 @@ export class Attached {
|
|
|
998
1092
|
if (event.type === 'hello') {
|
|
999
1093
|
this.runner = event.runner;
|
|
1000
1094
|
this.runs = event.runs;
|
|
1095
|
+
this.trackSessions();
|
|
1001
1096
|
this.chooseWatched();
|
|
1097
|
+
void this.findStopped();
|
|
1002
1098
|
} else if (event.type === 'runner') {
|
|
1003
1099
|
// R294: the daemon re-read its config — after a signal, or because the
|
|
1004
1100
|
// file's mtime moved — and says what it applied and what it could not.
|
|
@@ -1008,18 +1104,21 @@ export class Attached {
|
|
|
1008
1104
|
this.runner = event.runner;
|
|
1009
1105
|
} else if (event.type === 'runs') {
|
|
1010
1106
|
this.runs = event.runs;
|
|
1107
|
+
this.trackSessions();
|
|
1011
1108
|
this.chooseWatched();
|
|
1012
1109
|
} else if (event.type === 'output') {
|
|
1013
1110
|
const kept = this.lines.get(event.runId) ?? [];
|
|
1014
1111
|
kept.push(event.line);
|
|
1015
1112
|
if (kept.length > 5000) kept.splice(0, kept.length - 5000);
|
|
1016
1113
|
this.lines.set(event.runId, kept);
|
|
1017
|
-
|
|
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) {
|
|
1018
1117
|
this.say(this.render(event.line));
|
|
1019
1118
|
}
|
|
1020
1119
|
} else if (event.type === 'backlog') {
|
|
1021
1120
|
this.lines.set(event.runId, event.lines);
|
|
1022
|
-
if (event.runId === this.watching && !this.printed.has(event.runId)) {
|
|
1121
|
+
if (event.runId === this.watching && !this.printed.has(event.runId) && !this.split) {
|
|
1023
1122
|
this.layHistoryIn(event.runId, event.lines);
|
|
1024
1123
|
}
|
|
1025
1124
|
} else if (event.type === 'log') {
|
|
@@ -1045,17 +1144,73 @@ export class Attached {
|
|
|
1045
1144
|
* character and an escape sequence.
|
|
1046
1145
|
*/
|
|
1047
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
|
+
}
|
|
1048
1155
|
for (const line of wrap(text, this.screen.width)) {
|
|
1049
1156
|
this.pending.push(line);
|
|
1050
1157
|
}
|
|
1051
1158
|
this.dirty = true;
|
|
1052
1159
|
}
|
|
1053
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
|
+
|
|
1054
1200
|
/** Everything queued, plus the footer as it is right now, in one write. */
|
|
1055
1201
|
flush() {
|
|
1056
1202
|
const lines = this.pending;
|
|
1057
1203
|
this.pending = [];
|
|
1058
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
|
+
}
|
|
1059
1214
|
this.screen.update(lines, this.footer());
|
|
1060
1215
|
}
|
|
1061
1216
|
|
|
@@ -1408,6 +1563,13 @@ export class Attached {
|
|
|
1408
1563
|
this.confirming = null;
|
|
1409
1564
|
}
|
|
1410
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
|
+
|
|
1411
1573
|
switch (key) {
|
|
1412
1574
|
case 'q':
|
|
1413
1575
|
return this.quit();
|
|
@@ -1470,6 +1632,10 @@ export class Attached {
|
|
|
1470
1632
|
default:
|
|
1471
1633
|
if (/^[1-9]$/.test(key)) {
|
|
1472
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
|
+
}
|
|
1473
1639
|
if (at < this.runs.length) {
|
|
1474
1640
|
this.watch(this.runs[at].id);
|
|
1475
1641
|
}
|
|
@@ -1818,9 +1984,24 @@ export class Attached {
|
|
|
1818
1984
|
// makes everywhere else.
|
|
1819
1985
|
return this.note('');
|
|
1820
1986
|
}
|
|
1987
|
+
if (select.many) {
|
|
1988
|
+
this.mode = 'keys';
|
|
1989
|
+
this.select = null;
|
|
1990
|
+
return this.choseMany(select, outcome.rows ?? []);
|
|
1991
|
+
}
|
|
1821
1992
|
return this.chose(select, outcome.row);
|
|
1822
1993
|
}
|
|
1823
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
|
+
|
|
1824
2005
|
/**
|
|
1825
2006
|
* A row was chosen, whichever way it was chosen.
|
|
1826
2007
|
*
|
|
@@ -1855,6 +2036,19 @@ export class Attached {
|
|
|
1855
2036
|
}
|
|
1856
2037
|
return void this.allow(row.id);
|
|
1857
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
|
+
}
|
|
1858
2052
|
if (select.kind === 'config') {
|
|
1859
2053
|
// R288: the walk asked, the walk gets the row. Nothing is decided here.
|
|
1860
2054
|
this.configuring?.answer?.(row);
|
|
@@ -1961,7 +2155,8 @@ export class Attached {
|
|
|
1961
2155
|
}
|
|
1962
2156
|
return this.openPicker(new Select({
|
|
1963
2157
|
kind: 'question',
|
|
1964
|
-
title: clip(
|
|
2158
|
+
title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
|
|
2159
|
+
Math.max(20, this.screen.width - 2)),
|
|
1965
2160
|
rows: [
|
|
1966
2161
|
...options.map((option) => ({ id: option, label: option })),
|
|
1967
2162
|
// Always last, and always there.
|
|
@@ -1989,7 +2184,7 @@ export class Attached {
|
|
|
1989
2184
|
kind: 'permission',
|
|
1990
2185
|
// The call, on the title, as well as printed in full above: the picker
|
|
1991
2186
|
// may still be on screen when the transcript under it has moved on.
|
|
1992
|
-
title: `${approval.toolName} · ${approval.summary}`,
|
|
2187
|
+
title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${approval.summary}`,
|
|
1993
2188
|
rows: [
|
|
1994
2189
|
{ id: 'y', label: 'Allow once', hint: 'this call and no more' },
|
|
1995
2190
|
// R78: the middle grant NAMES what it covers, and it says so in the
|
|
@@ -2036,11 +2231,13 @@ export class Attached {
|
|
|
2036
2231
|
if (pending && !this.announced.has(pending.approval.id)) {
|
|
2037
2232
|
this.announced.add(pending.approval.id);
|
|
2038
2233
|
const ink = this.ink;
|
|
2039
|
-
|
|
2040
|
-
this.
|
|
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)}`);
|
|
2041
2238
|
// Not clipped: this is the thing being decided about, and a command cut
|
|
2042
2239
|
// at the width is a command you have not read.
|
|
2043
|
-
this.
|
|
2240
|
+
this.sayAbout(about, { prefix: ' ', body: pending.approval.summary, paint: ink.text });
|
|
2044
2241
|
if (this.mode === 'keys') {
|
|
2045
2242
|
this.askPermission(pending);
|
|
2046
2243
|
}
|
|
@@ -2053,13 +2250,20 @@ export class Attached {
|
|
|
2053
2250
|
}
|
|
2054
2251
|
this.announced.add(asking.question.id);
|
|
2055
2252
|
const ink = this.ink;
|
|
2056
|
-
|
|
2057
|
-
|
|
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
|
+
}
|
|
2058
2262
|
// R58: a watcher who does not own the question is told whose it is and is
|
|
2059
2263
|
// offered nothing. A picker here would take an answer the platform then
|
|
2060
2264
|
// refuses, which reads as cawdev being broken.
|
|
2061
2265
|
if (!asking.yours) {
|
|
2062
|
-
this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
|
|
2266
|
+
if (!this.split) this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
|
|
2063
2267
|
return;
|
|
2064
2268
|
}
|
|
2065
2269
|
if (this.mode === 'keys' && this.session.signedIn && asking.question.options?.length) {
|
|
@@ -2377,14 +2581,27 @@ export class Attached {
|
|
|
2377
2581
|
* a terminal in raw mode, and `GIT_TERMINAL_PROMPT=0` because a clone that
|
|
2378
2582
|
* stops to ask for a password here would hang the screen with no prompt on
|
|
2379
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.
|
|
2380
2591
|
*/
|
|
2381
2592
|
cloneInto(gitUrl, path) {
|
|
2382
2593
|
return new Promise((done, fail) => {
|
|
2383
2594
|
const child = spawn('git', ['clone', '--progress', gitUrl, path], {
|
|
2384
2595
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2385
|
-
env: {
|
|
2596
|
+
env: {
|
|
2597
|
+
...process.env,
|
|
2598
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
2599
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes',
|
|
2600
|
+
},
|
|
2386
2601
|
});
|
|
2602
|
+
let said = '';
|
|
2387
2603
|
const print = (chunk) => {
|
|
2604
|
+
said += chunk;
|
|
2388
2605
|
for (const line of String(chunk).split(/\r?\n|\r/)) {
|
|
2389
2606
|
if (line.trim()) this.say(` ${this.ink.muted(line)}`);
|
|
2390
2607
|
}
|
|
@@ -2392,7 +2609,18 @@ export class Attached {
|
|
|
2392
2609
|
child.stdout.on('data', print);
|
|
2393
2610
|
child.stderr.on('data', print);
|
|
2394
2611
|
child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
|
|
2395
|
-
child.on('close', (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
|
+
});
|
|
2396
2624
|
});
|
|
2397
2625
|
}
|
|
2398
2626
|
|
|
@@ -2435,6 +2663,32 @@ export class Attached {
|
|
|
2435
2663
|
case 'log':
|
|
2436
2664
|
this.showLog = !this.showLog;
|
|
2437
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();
|
|
2438
2692
|
case 'quit':
|
|
2439
2693
|
case 'exit':
|
|
2440
2694
|
return this.quit();
|
|
@@ -2452,6 +2706,9 @@ export class Attached {
|
|
|
2452
2706
|
* the moment it is decided.
|
|
2453
2707
|
*/
|
|
2454
2708
|
async signIn() {
|
|
2709
|
+
if (this.split) {
|
|
2710
|
+
this.leaveSplit();
|
|
2711
|
+
}
|
|
2455
2712
|
// Everything queued goes out first and the footer comes down, so what
|
|
2456
2713
|
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2457
2714
|
// than through the middle of a live region nothing is going to erase.
|
|
@@ -2466,11 +2723,846 @@ export class Attached {
|
|
|
2466
2723
|
if (this.session.signedIn) {
|
|
2467
2724
|
void this.watchInbox();
|
|
2468
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;
|
|
2469
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`));
|
|
2470
2909
|
this.dirty = true;
|
|
2471
2910
|
return undefined;
|
|
2472
2911
|
}
|
|
2473
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;
|
|
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'));
|
|
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
|
+
}));
|
|
3525
|
+
return undefined;
|
|
3526
|
+
}
|
|
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
|
+
|
|
2474
3566
|
// --- the things a person can do ---------------------------------------------
|
|
2475
3567
|
|
|
2476
3568
|
async send(text) {
|
|
@@ -2650,6 +3742,13 @@ export class Attached {
|
|
|
2650
3742
|
}
|
|
2651
3743
|
|
|
2652
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
|
+
}
|
|
2653
3752
|
try {
|
|
2654
3753
|
this.client?.destroy();
|
|
2655
3754
|
} catch {
|
|
@@ -2690,6 +3789,131 @@ export class Attached {
|
|
|
2690
3789
|
return process.exit(0);
|
|
2691
3790
|
}
|
|
2692
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
|
+
|
|
2693
3917
|
/**
|
|
2694
3918
|
* Whether leaving here ends the daemon — R123.
|
|
2695
3919
|
*
|
|
@@ -2844,6 +4068,10 @@ export class Attached {
|
|
|
2844
4068
|
parts.push(`${ink.warn(pending.approval?.suggestion ? 'y/s/Y/n' : 'y/s/n')} `
|
|
2845
4069
|
+ `${ink.muted('permission')}`);
|
|
2846
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
|
+
}
|
|
2847
4075
|
parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
|
|
2848
4076
|
parts.push(`${ink.text('c')} ${ink.muted('config')}`);
|
|
2849
4077
|
parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);
|