cawdev-cli 1.0.4-beta → 1.0.6-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 +65 -0
- package/lib/ansi.mjs +2 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +219 -0
- package/lib/harness-prompt.mjs +14 -6
- package/lib/roadmap-format.mjs +52 -0
- package/lib/runner-config.mjs +3 -2
- package/mcp/server.mjs +53 -4
- package/package.json +1 -1
- package/runner/README.md +389 -11
- package/runner/attach.mjs +1838 -31
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +45 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +55 -5
- package/runner/input.mjs +21 -8
- package/runner/prefs.mjs +52 -0
- package/runner/runner.mjs +1256 -147
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +923 -0
package/runner/attach.mjs
CHANGED
|
@@ -46,18 +46,26 @@
|
|
|
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, setQuality, slugsOf,
|
|
55
|
+
removeProject, 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
|
+
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
|
+
carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
65
|
+
windowLines,
|
|
66
|
+
} from './split.mjs';
|
|
67
|
+
import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
|
|
68
|
+
import { loadPrefs, savePrefs } from './prefs.mjs';
|
|
61
69
|
import { oneLine } from './brand.mjs';
|
|
62
70
|
import { Scrollback } from './scrollback.mjs';
|
|
63
71
|
import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
@@ -127,7 +135,7 @@ export function questionState(questions, email) {
|
|
|
127
135
|
* let somebody type an answer and then refused it reads as cawdev being broken
|
|
128
136
|
* rather than as the question belonging to a colleague.
|
|
129
137
|
*/
|
|
130
|
-
export function questionBanner(asking, width, ink = painter(3)) {
|
|
138
|
+
export function questionBanner(asking, width, ink = painter(3), { entire = false } = {}) {
|
|
131
139
|
if (!asking) return [];
|
|
132
140
|
|
|
133
141
|
const options = asking.question.options?.length
|
|
@@ -135,10 +143,16 @@ export function questionBanner(asking, width, ink = painter(3)) {
|
|
|
135
143
|
: '';
|
|
136
144
|
// 12 is the label and its spaces: the same arithmetic the permission banner
|
|
137
145
|
// uses, and for the same reason — clip counts visible characters.
|
|
138
|
-
|
|
146
|
+
// `entire` (advanced mode, where the banner is the only place the question
|
|
147
|
+
// is shown): every word of it, wrapped between words under the label.
|
|
148
|
+
const label = ink.bold(ink.accent(' question '));
|
|
149
|
+
const head = entire
|
|
150
|
+
? wordWrap(asking.question.question, width - 12)
|
|
151
|
+
.map((line, i) => `${i === 0 ? label : ' '.repeat(10)} ${line}`)
|
|
152
|
+
: [`${label} ${clip(asking.question.question, width - 12)}`];
|
|
139
153
|
|
|
140
154
|
if (asking.yours) {
|
|
141
|
-
return [head, ` ${ink.success('a')} answer${options}`];
|
|
155
|
+
return [...head, ` ${ink.success('a')} answer${options}`];
|
|
142
156
|
}
|
|
143
157
|
// The NAME is the last thing to go, the way the session total is on R62's
|
|
144
158
|
// bar: "waiting on alice@…" is the whole answer to "why is that not moving",
|
|
@@ -147,7 +161,7 @@ export function questionBanner(asking, width, ink = painter(3)) {
|
|
|
147
161
|
// answer — which is what looking at this at 40 columns showed.
|
|
148
162
|
const who = ` waiting on ${asking.waitingOn ?? 'somebody else'}`;
|
|
149
163
|
const whole = `${who} — theirs to answer, or somebody they hand it to`;
|
|
150
|
-
return [head, ink.muted(clip(whole.length <= width ? whole : who, width))];
|
|
164
|
+
return [...head, ink.muted(clip(whole.length <= width ? whole : who, width))];
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
/**
|
|
@@ -572,11 +586,13 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
572
586
|
* @param argv the command line, for `--runner`, `--url`, `--watch-only`
|
|
573
587
|
* @param options set when the daemon is running IN THIS PROCESS (`--attach`):
|
|
574
588
|
* its socket is already known, quitting means stopping it, and it can say how
|
|
575
|
-
* much work would be lost if you did.
|
|
589
|
+
* much work would be lost if you did. `url` is where to sign in when the
|
|
590
|
+
* caller has read the config (`cawdev.mjs`'s `launchUrl`); without it,
|
|
591
|
+
* `urlFrom`, which cannot see a config.
|
|
576
592
|
*/
|
|
577
|
-
export async function attach(argv, options = {}) {
|
|
593
|
+
export async function attach(argv, { url, ...options } = {}) {
|
|
578
594
|
const socket = options.socketPath ?? (await chooseSocket(valueOf(argv, '--runner')));
|
|
579
|
-
const session = await openSession(argv);
|
|
595
|
+
const session = await openSession(argv, url ?? urlFrom(argv));
|
|
580
596
|
|
|
581
597
|
// Spread second so `--attach`'s own wiring wins: there the daemon IS this
|
|
582
598
|
// process and quitting has always stopped it.
|
|
@@ -590,6 +606,14 @@ export function valueOf(argv, flag) {
|
|
|
590
606
|
return at === -1 ? null : argv[at + 1];
|
|
591
607
|
}
|
|
592
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Whether a failed clone's output is ssh refusing to ask a person — BatchMode
|
|
611
|
+
* with a key the agent does not hold. Pure, for the test.
|
|
612
|
+
*/
|
|
613
|
+
export function sshNeedsAPerson(said) {
|
|
614
|
+
return /Permission denied \(publickey|passphrase|Host key verification failed/i.test(String(said ?? ''));
|
|
615
|
+
}
|
|
616
|
+
|
|
593
617
|
export function urlFrom(argv) {
|
|
594
618
|
return (valueOf(argv, '--url') ?? process.env.CAWDEV_URL ?? 'https://app.cawdev.com')
|
|
595
619
|
.replace(/\/+$/, '');
|
|
@@ -628,8 +652,7 @@ async function chooseSocket(wanted) {
|
|
|
628
652
|
* expired session looks like, and the answer is the same as having none — the
|
|
629
653
|
* browser, once, and then remembered again.
|
|
630
654
|
*/
|
|
631
|
-
async function openSession(argv) {
|
|
632
|
-
const url = urlFrom(argv);
|
|
655
|
+
async function openSession(argv, url) {
|
|
633
656
|
if (argv.includes('--watch-only')) {
|
|
634
657
|
return new Session(url);
|
|
635
658
|
}
|
|
@@ -694,8 +717,6 @@ export function splitPrompt(prompt, limit = 40) {
|
|
|
694
717
|
return { said: question, label: `${bracket ? `${bracket} ` : ''}▸` };
|
|
695
718
|
}
|
|
696
719
|
|
|
697
|
-
/** The agents the config screen offers a switch for, whether or not they are on. */
|
|
698
|
-
const KNOWN_AGENTS = ['claude', 'agy'];
|
|
699
720
|
|
|
700
721
|
/**
|
|
701
722
|
* The config screen's rows, with the state of each thing on the row — R288.
|
|
@@ -710,7 +731,7 @@ function qualityOf(entry) {
|
|
|
710
731
|
return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
|
|
711
732
|
}
|
|
712
733
|
|
|
713
|
-
export function configMenu(file, runner = {}) {
|
|
734
|
+
export function configMenu(file, runner = {}, gone = new Set()) {
|
|
714
735
|
const slugs = slugsOf(file);
|
|
715
736
|
const agents = agentsOf(file);
|
|
716
737
|
const rulesOn = file.acceptsRulesFromConsole === true;
|
|
@@ -747,11 +768,39 @@ export function configMenu(file, runner = {}) {
|
|
|
747
768
|
...slugs.map((slug) => (qualityOf(file.projects[slug])
|
|
748
769
|
? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
|
|
749
770
|
: { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
|
|
771
|
+
// One row per served project — first the ones cawdev no longer has,
|
|
772
|
+
// which are the reason anybody comes looking for this row.
|
|
773
|
+
...[...slugs].sort((a, b) => Number(gone.has(b)) - Number(gone.has(a))).map((slug) => ({
|
|
774
|
+
id: `remove:${slug}`,
|
|
775
|
+
label: `Stop serving ${slug}`,
|
|
776
|
+
hint: gone.has(slug) ? 'not on cawdev any more' : 'its checkouts stay on disk',
|
|
777
|
+
})),
|
|
750
778
|
{ id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
|
|
751
779
|
],
|
|
752
780
|
});
|
|
753
781
|
}
|
|
754
782
|
|
|
783
|
+
/** `/new`'s agent flags: `--claude`, `--codex`, `--agy`. */
|
|
784
|
+
const AGENT_FLAGS = new Set(KNOWN_AGENTS.map((agent) => `--${agent}`));
|
|
785
|
+
|
|
786
|
+
/** A start's body, naming the agent when one was chosen — and nothing when not. */
|
|
787
|
+
function withAgent(body, agent) {
|
|
788
|
+
return agent ? { ...body, agent } : body;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** What was started, saying with which agent when one was named. */
|
|
792
|
+
function withWho(what, agent) {
|
|
793
|
+
return agent ? `${what} with ${agent}` : what;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** How many times a finished dev session's zero commits is read again before it is believed — see countCommits. */
|
|
797
|
+
const COMMIT_REREADS = 6;
|
|
798
|
+
|
|
799
|
+
/** `r29` → `R29`, `I12` → `i12`, `29` as it is: how the API spells a card's ref (R221). */
|
|
800
|
+
function refOf(word) {
|
|
801
|
+
return word.replace(/^r/i, 'R').replace(/^i/i, 'i');
|
|
802
|
+
}
|
|
803
|
+
|
|
755
804
|
/** The slash commands, and the one place they are described. */
|
|
756
805
|
const COMMANDS = [
|
|
757
806
|
['/help', 'this list'],
|
|
@@ -761,6 +810,20 @@ const COMMANDS = [
|
|
|
761
810
|
['/config', "this machine's config — the same as c"],
|
|
762
811
|
['/cancel', 'cancel the session you are watching'],
|
|
763
812
|
['/log', "the daemon's own log, on or off"],
|
|
813
|
+
['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
|
|
814
|
+
['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
|
|
815
|
+
['/card', 'add an idea to Considering — no session: /card <the idea> (@project)'],
|
|
816
|
+
['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
|
|
817
|
+
['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
|
|
818
|
+
['/pr', "open the pull request of a finished session's branch"],
|
|
819
|
+
['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
|
|
820
|
+
['/projects', 'which projects advanced mode shows — tick one or several'],
|
|
821
|
+
['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
|
|
822
|
+
['/open', "open the session's pull request in your browser — or, on a finished plan, its card and plan on cawdev"],
|
|
823
|
+
['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone opens the session on cawdev"],
|
|
824
|
+
['/drop', 'delete the checkout a finished session was made on demand for'],
|
|
825
|
+
['/close', "close an ended session's window"],
|
|
826
|
+
['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
|
|
764
827
|
['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
|
|
765
828
|
];
|
|
766
829
|
|
|
@@ -774,6 +837,47 @@ const COMMANDS = [
|
|
|
774
837
|
* lesson about rules in a renderer is that they can only be tested by looking at
|
|
775
838
|
* them.
|
|
776
839
|
*/
|
|
840
|
+
/**
|
|
841
|
+
* Why a checkout must not be deleted, in words, or null when nothing in it
|
|
842
|
+
* would be lost: no uncommitted file, and no commit that is on no branch of
|
|
843
|
+
* origin.
|
|
844
|
+
*/
|
|
845
|
+
async function checkoutState(path) {
|
|
846
|
+
const git = (args) => new Promise((done) => {
|
|
847
|
+
const child = spawn('git', ['-C', path, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
848
|
+
let out = '';
|
|
849
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
850
|
+
child.on('error', () => done(null));
|
|
851
|
+
child.on('close', (code) => done(code === 0 ? out.trim() : null));
|
|
852
|
+
});
|
|
853
|
+
const status = await git(['status', '--porcelain']);
|
|
854
|
+
if (status === null) return 'it could not be read as a git checkout';
|
|
855
|
+
if (status) return `${status.split('\n').length} file(s) are not committed`;
|
|
856
|
+
const unpushed = await git(['rev-list', '--count', 'HEAD', '--not', '--remotes=origin']);
|
|
857
|
+
if (unpushed === null) return 'whether its commits are on origin could not be read';
|
|
858
|
+
if (Number(unpushed) > 0) return `${unpushed} commit(s) are not on origin — /pr pushes the branch`;
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The environment variable `/reload` hands its state over in. */
|
|
863
|
+
const RELOAD_STATE = 'CAWDEV_RELOAD_STATE';
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The state a `/reload` handed this process, read once and taken out of the
|
|
867
|
+
* environment — a session this terminal spawns later must not inherit it, and
|
|
868
|
+
* a second reload writes its own.
|
|
869
|
+
*/
|
|
870
|
+
function readReloadState() {
|
|
871
|
+
const raw = process.env[RELOAD_STATE];
|
|
872
|
+
delete process.env[RELOAD_STATE];
|
|
873
|
+
if (!raw) return null;
|
|
874
|
+
try {
|
|
875
|
+
return JSON.parse(raw);
|
|
876
|
+
} catch {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
777
881
|
export class Attached {
|
|
778
882
|
/**
|
|
779
883
|
* @param options `out` is where to draw — `process.stdout` in the real thing,
|
|
@@ -867,6 +971,35 @@ export class Attached {
|
|
|
867
971
|
* laptop.
|
|
868
972
|
*/
|
|
869
973
|
this.configuring = null;
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Advanced mode, when it is on: the alternate screen, the window the
|
|
977
|
+
* prompt talks to, and what was said while it was up — see split.mjs.
|
|
978
|
+
*/
|
|
979
|
+
this.split = null;
|
|
980
|
+
/**
|
|
981
|
+
* Every session this terminal has seen, ended ones included — the daemon
|
|
982
|
+
* forgets a run the moment it finishes, and "develop it now" or "open its
|
|
983
|
+
* pull request" is asked of exactly those. runId -> meta.
|
|
984
|
+
*/
|
|
985
|
+
this.sessions = new Map();
|
|
986
|
+
/** Ended sessions whose window somebody closed. */
|
|
987
|
+
this.closed = new Set();
|
|
988
|
+
/**
|
|
989
|
+
* Projects this machine serves that cawdev answered 404 for — deleted in
|
|
990
|
+
* the console, or the person's access went. The config still names them,
|
|
991
|
+
* so without this the terminal kept offering them everywhere it offers a
|
|
992
|
+
* project. Learnt from `refreshPlanned`, and forgotten when one answers.
|
|
993
|
+
*/
|
|
994
|
+
this.gone = new Set();
|
|
995
|
+
/** Of those, the ones not yet said — see `sayGone`. */
|
|
996
|
+
this.goneUnsaid = [];
|
|
997
|
+
/** A project whose next new CODE session takes the focus — see startNew. */
|
|
998
|
+
this.focusOnArrival = null;
|
|
999
|
+
/** The PLANNED cards of the projects this machine serves — advanced mode's top strip. */
|
|
1000
|
+
this.planned = [];
|
|
1001
|
+
// Every card of every served project, whatever its status — the strip.
|
|
1002
|
+
this.cards = [];
|
|
870
1003
|
}
|
|
871
1004
|
|
|
872
1005
|
/**
|
|
@@ -891,6 +1024,8 @@ export class Attached {
|
|
|
891
1024
|
async start() {
|
|
892
1025
|
this.screen.open();
|
|
893
1026
|
this.history = new History(await loadHistory(this.session.url).catch(() => []));
|
|
1027
|
+
// `/agent`'s choice, from last time — see prefs.mjs.
|
|
1028
|
+
this.agent = (await loadPrefs(this.session.url).catch(() => ({}))).agent ?? null;
|
|
894
1029
|
// Raw mode only where there is a terminal to put in it. Through a pipe
|
|
895
1030
|
// there are no keys, the transcript is the whole output, and that is the
|
|
896
1031
|
// honest degradation rather than a broken one.
|
|
@@ -928,6 +1063,7 @@ export class Attached {
|
|
|
928
1063
|
}
|
|
929
1064
|
process.stdout.on('resize', () => {
|
|
930
1065
|
this.screen.resize();
|
|
1066
|
+
this.split?.screen.resize();
|
|
931
1067
|
this.dirty = true;
|
|
932
1068
|
});
|
|
933
1069
|
|
|
@@ -936,6 +1072,10 @@ export class Attached {
|
|
|
936
1072
|
void this.watchInbox();
|
|
937
1073
|
void this.watchQuestions();
|
|
938
1074
|
}
|
|
1075
|
+
// `/reload`'s other half: the state the terminal was in before it
|
|
1076
|
+
// restarted — advanced mode, the focus, the windows of sessions that had
|
|
1077
|
+
// ended — so a reload changes the code and nothing else.
|
|
1078
|
+
this.restore(this.options.restore ?? readReloadState());
|
|
939
1079
|
|
|
940
1080
|
// One write per tick at most, carrying everything that happened in it. A
|
|
941
1081
|
// busy session emits hundreds of lines a second, and a write per line means
|
|
@@ -998,7 +1138,9 @@ export class Attached {
|
|
|
998
1138
|
if (event.type === 'hello') {
|
|
999
1139
|
this.runner = event.runner;
|
|
1000
1140
|
this.runs = event.runs;
|
|
1141
|
+
this.trackSessions();
|
|
1001
1142
|
this.chooseWatched();
|
|
1143
|
+
void this.findStopped();
|
|
1002
1144
|
} else if (event.type === 'runner') {
|
|
1003
1145
|
// R294: the daemon re-read its config — after a signal, or because the
|
|
1004
1146
|
// file's mtime moved — and says what it applied and what it could not.
|
|
@@ -1008,18 +1150,21 @@ export class Attached {
|
|
|
1008
1150
|
this.runner = event.runner;
|
|
1009
1151
|
} else if (event.type === 'runs') {
|
|
1010
1152
|
this.runs = event.runs;
|
|
1153
|
+
this.trackSessions();
|
|
1011
1154
|
this.chooseWatched();
|
|
1012
1155
|
} else if (event.type === 'output') {
|
|
1013
1156
|
const kept = this.lines.get(event.runId) ?? [];
|
|
1014
1157
|
kept.push(event.line);
|
|
1015
1158
|
if (kept.length > 5000) kept.splice(0, kept.length - 5000);
|
|
1016
1159
|
this.lines.set(event.runId, kept);
|
|
1017
|
-
|
|
1160
|
+
// In advanced mode the session's window is where its output goes; the
|
|
1161
|
+
// scrollback gets it when the mode is left.
|
|
1162
|
+
if (event.runId === this.watching && !this.showLog && !this.split) {
|
|
1018
1163
|
this.say(this.render(event.line));
|
|
1019
1164
|
}
|
|
1020
1165
|
} else if (event.type === 'backlog') {
|
|
1021
1166
|
this.lines.set(event.runId, event.lines);
|
|
1022
|
-
if (event.runId === this.watching && !this.printed.has(event.runId)) {
|
|
1167
|
+
if (event.runId === this.watching && !this.printed.has(event.runId) && !this.split) {
|
|
1023
1168
|
this.layHistoryIn(event.runId, event.lines);
|
|
1024
1169
|
}
|
|
1025
1170
|
} else if (event.type === 'log') {
|
|
@@ -1045,17 +1190,73 @@ export class Attached {
|
|
|
1045
1190
|
* character and an escape sequence.
|
|
1046
1191
|
*/
|
|
1047
1192
|
say(text) {
|
|
1193
|
+
// In advanced mode the prompt's window wraps what it shows, to its own
|
|
1194
|
+
// width; wrapping here first, to the whole terminal's, cut every long line
|
|
1195
|
+
// twice.
|
|
1196
|
+
if (this.split) {
|
|
1197
|
+
this.pending.push(...String(text).split('\n'));
|
|
1198
|
+
this.dirty = true;
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1048
1201
|
for (const line of wrap(text, this.screen.width)) {
|
|
1049
1202
|
this.pending.push(line);
|
|
1050
1203
|
}
|
|
1051
1204
|
this.dirty = true;
|
|
1052
1205
|
}
|
|
1053
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* A line that belongs to one question or one permission request. Outside
|
|
1209
|
+
* advanced mode it is printed like any other — the scrollback is a record.
|
|
1210
|
+
* In advanced mode it goes to the prompt window TAGGED, and the window drops
|
|
1211
|
+
* it as soon as what it is about is no longer open (`stillOpen`): answered
|
|
1212
|
+
* here, answered in the console, or its session gone. The prompt is where
|
|
1213
|
+
* the NEXT thing is read, and a question already answered sitting above it
|
|
1214
|
+
* is the text somebody reads by mistake.
|
|
1215
|
+
*/
|
|
1216
|
+
sayAbout(about, text) {
|
|
1217
|
+
// `{ prefix, body, paint }` is prose to be wrapped between words at the
|
|
1218
|
+
// width it is DRAWN at — the window's, which changes — rather than now.
|
|
1219
|
+
const prose = typeof text === 'object' && text !== null;
|
|
1220
|
+
if (!this.split) {
|
|
1221
|
+
this.say(prose ? `${text.prefix}${text.paint(text.body)}` : text);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
// Whatever was queued before it goes first, so the order on screen is the
|
|
1225
|
+
// order things happened in.
|
|
1226
|
+
this.split.messages.push(...this.pending);
|
|
1227
|
+
this.pending = [];
|
|
1228
|
+
if (prose) {
|
|
1229
|
+
this.split.messages.push({ ...text, about });
|
|
1230
|
+
} else {
|
|
1231
|
+
for (const line of String(text).split('\n')) {
|
|
1232
|
+
this.split.messages.push({ text: line, about });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
this.dirty = true;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** The ids of every question and permission request this terminal believes open. */
|
|
1239
|
+
stillOpen() {
|
|
1240
|
+
return new Set([
|
|
1241
|
+
...[...this.questions.values()].map((state) => state?.question?.id),
|
|
1242
|
+
...[...this.approvals.values()].map((pending) => pending?.approval?.id),
|
|
1243
|
+
].filter(Boolean));
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1054
1246
|
/** Everything queued, plus the footer as it is right now, in one write. */
|
|
1055
1247
|
flush() {
|
|
1056
1248
|
const lines = this.pending;
|
|
1057
1249
|
this.pending = [];
|
|
1058
1250
|
this.dirty = false;
|
|
1251
|
+
if (this.split) {
|
|
1252
|
+
// What would have been printed is the prompt window's to show.
|
|
1253
|
+
this.split.messages.push(...lines);
|
|
1254
|
+
if (this.split.messages.length > 500) {
|
|
1255
|
+
this.split.messages.splice(0, this.split.messages.length - 500);
|
|
1256
|
+
}
|
|
1257
|
+
this.split.screen.draw(this.splitFrame());
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1059
1260
|
this.screen.update(lines, this.footer());
|
|
1060
1261
|
}
|
|
1061
1262
|
|
|
@@ -1141,9 +1342,19 @@ export class Attached {
|
|
|
1141
1342
|
|
|
1142
1343
|
note(text) {
|
|
1143
1344
|
this.status = text;
|
|
1345
|
+
// In advanced mode, which window it was said about — the focused one at
|
|
1346
|
+
// the moment it was said. A note outlives the focus moving, and "a
|
|
1347
|
+
// session is running" read under another window is a note about nobody.
|
|
1348
|
+
this.statusWindow = this.split && text ? this.windowNumberOf(this.split.focus) : null;
|
|
1144
1349
|
this.dirty = true;
|
|
1145
1350
|
}
|
|
1146
1351
|
|
|
1352
|
+
/** A window's number, as its title shows it; null when it has none. */
|
|
1353
|
+
windowNumberOf(id) {
|
|
1354
|
+
const at = id ? this.windows().findIndex((meta) => meta.id === id) : -1;
|
|
1355
|
+
return at >= 0 ? at + 1 : null;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1147
1358
|
// --- what is waiting on a person -----------------------------------------
|
|
1148
1359
|
|
|
1149
1360
|
/**
|
|
@@ -1408,6 +1619,23 @@ export class Attached {
|
|
|
1408
1619
|
this.confirming = null;
|
|
1409
1620
|
}
|
|
1410
1621
|
|
|
1622
|
+
if (this.split && (key === '\t' || key === `${ESC}[Z`)) {
|
|
1623
|
+
return this.cycleFocus(key === '\t' ? 1 : -1);
|
|
1624
|
+
}
|
|
1625
|
+
if (this.split && key === 'd') {
|
|
1626
|
+
// On a finished plan's window, `d` is that plan's `/develop`: the card
|
|
1627
|
+
// it planned, by the agent that planned it, in the window it leaves.
|
|
1628
|
+
// Anywhere else, the list of planned cards.
|
|
1629
|
+
return void (this.finishedPlan(this.commandTarget()) ? this.develop() : this.pickPlanned());
|
|
1630
|
+
}
|
|
1631
|
+
// ↑ with no line open: the last thing typed, on a line ready to send — as
|
|
1632
|
+
// a shell does. Not in advanced mode, where the wheel arrives as arrows
|
|
1633
|
+
// (`?1007h`) and would pull the history up on every scroll; there ↑ works
|
|
1634
|
+
// on the line itself (`/` or enter, then ↑).
|
|
1635
|
+
if (!this.split && (key === `${ESC}[A` || key === `${ESC}OA`)) {
|
|
1636
|
+
return this.recallIntoALine();
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1411
1639
|
switch (key) {
|
|
1412
1640
|
case 'q':
|
|
1413
1641
|
return this.quit();
|
|
@@ -1470,6 +1698,10 @@ export class Attached {
|
|
|
1470
1698
|
default:
|
|
1471
1699
|
if (/^[1-9]$/.test(key)) {
|
|
1472
1700
|
const at = Number(key) - 1;
|
|
1701
|
+
if (this.split) {
|
|
1702
|
+
const windows = this.windows();
|
|
1703
|
+
return at < windows.length ? this.focusWindow(windows[at].id) : this.note('');
|
|
1704
|
+
}
|
|
1473
1705
|
if (at < this.runs.length) {
|
|
1474
1706
|
this.watch(this.runs[at].id);
|
|
1475
1707
|
}
|
|
@@ -1618,7 +1850,8 @@ export class Attached {
|
|
|
1618
1850
|
// With a completion list open, enter takes the highlighted command —
|
|
1619
1851
|
// which is what makes guessing a name stop being a step. With none, it
|
|
1620
1852
|
// sends what was typed.
|
|
1621
|
-
if (rows.length && this.input.showing !== false && this.input.kind === 'command'
|
|
1853
|
+
if (rows.length && this.input.showing !== false && this.input.kind === 'command'
|
|
1854
|
+
&& !this.input.recalling) {
|
|
1622
1855
|
line.set(rows[at][0]);
|
|
1623
1856
|
}
|
|
1624
1857
|
return this.submit();
|
|
@@ -1639,7 +1872,28 @@ export class Attached {
|
|
|
1639
1872
|
return undefined;
|
|
1640
1873
|
}
|
|
1641
1874
|
|
|
1642
|
-
|
|
1875
|
+
const up = key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10';
|
|
1876
|
+
const down = key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e';
|
|
1877
|
+
// ↑ on a line nothing has been typed on yet — empty, or the bare `/` a
|
|
1878
|
+
// command line opens with — is HISTORY, as in every shell; so is ↑/↓
|
|
1879
|
+
// while walking it. Only once something is typed do the arrows move in the
|
|
1880
|
+
// list of commands. Before this the `/` line showed every command and ↑
|
|
1881
|
+
// moved in that list, so a command line could never reach its history.
|
|
1882
|
+
const untouched = line.text === '' || line.text === '/';
|
|
1883
|
+
if ((up && (untouched || this.input.recalling)) || (down && this.input.recalling)) {
|
|
1884
|
+
const keep = this.input.kind === 'command' ? (entry) => entry.startsWith('/') : undefined;
|
|
1885
|
+
const recalled = up ? this.history.back(line.text, keep) : this.history.forward(keep);
|
|
1886
|
+
if (recalled !== null) {
|
|
1887
|
+
line.set(recalled);
|
|
1888
|
+
// Walking the history, not the completions: enter sends the line as
|
|
1889
|
+
// recalled rather than swapping it for the highlighted command.
|
|
1890
|
+
this.input.recalling = true;
|
|
1891
|
+
this.input.showing = false;
|
|
1892
|
+
this.dirty = true;
|
|
1893
|
+
}
|
|
1894
|
+
return undefined;
|
|
1895
|
+
}
|
|
1896
|
+
if (up) {
|
|
1643
1897
|
if (this.input.path && rows.length) {
|
|
1644
1898
|
this.input.path.at = (at - 1 + rows.length) % rows.length;
|
|
1645
1899
|
this.dirty = true;
|
|
@@ -1653,11 +1907,12 @@ export class Attached {
|
|
|
1653
1907
|
const older = this.history.back(line.text);
|
|
1654
1908
|
if (older !== null) {
|
|
1655
1909
|
line.set(older);
|
|
1910
|
+
this.input.recalling = true;
|
|
1656
1911
|
this.dirty = true;
|
|
1657
1912
|
}
|
|
1658
1913
|
return undefined;
|
|
1659
1914
|
}
|
|
1660
|
-
if (
|
|
1915
|
+
if (down) {
|
|
1661
1916
|
if (this.input.path && rows.length) {
|
|
1662
1917
|
this.input.path.at = (at + 1) % rows.length;
|
|
1663
1918
|
this.dirty = true;
|
|
@@ -1675,6 +1930,10 @@ export class Attached {
|
|
|
1675
1930
|
}
|
|
1676
1931
|
return undefined;
|
|
1677
1932
|
}
|
|
1933
|
+
// Anything else edits the line: what is on it is no longer a recollection.
|
|
1934
|
+
if (key !== '\r' && key !== '\n') {
|
|
1935
|
+
this.input.recalling = false;
|
|
1936
|
+
}
|
|
1678
1937
|
|
|
1679
1938
|
if (key === '\x7f' || key === '\b') {
|
|
1680
1939
|
line.backspace();
|
|
@@ -1739,7 +1998,10 @@ export class Attached {
|
|
|
1739
1998
|
if (kind === 'command' || (kind === 'prompt' && typed.startsWith('/'))) {
|
|
1740
1999
|
// After the write, not beside it: `/logout` forgets the history, and a
|
|
1741
2000
|
// write still in flight would put the command that cleared it back.
|
|
1742
|
-
|
|
2001
|
+
// The EXPANDED text: a command is as much a place to paste into as a
|
|
2002
|
+
// prompt, and running it on the placeholder sent "[pasted, 12 lines]"
|
|
2003
|
+
// as the task in place of the twelve lines.
|
|
2004
|
+
return void remembered.then(() => this.runCommand(text));
|
|
1743
2005
|
}
|
|
1744
2006
|
if (kind === 'prompt') {
|
|
1745
2007
|
return void this.send(text);
|
|
@@ -1818,9 +2080,24 @@ export class Attached {
|
|
|
1818
2080
|
// makes everywhere else.
|
|
1819
2081
|
return this.note('');
|
|
1820
2082
|
}
|
|
2083
|
+
if (select.many) {
|
|
2084
|
+
this.mode = 'keys';
|
|
2085
|
+
this.select = null;
|
|
2086
|
+
return this.choseMany(select, outcome.rows ?? []);
|
|
2087
|
+
}
|
|
1821
2088
|
return this.chose(select, outcome.row);
|
|
1822
2089
|
}
|
|
1823
2090
|
|
|
2091
|
+
/** Several rows were ticked and confirmed — a picker opened with `many`. */
|
|
2092
|
+
choseMany(select, rows) {
|
|
2093
|
+
if (select.kind === 'split-projects') {
|
|
2094
|
+
const slugs = rows.map((row) => row.id);
|
|
2095
|
+
if (!slugs.length) return this.note('');
|
|
2096
|
+
return this.split ? this.focusProjects(slugs) : this.enterSplit(slugs);
|
|
2097
|
+
}
|
|
2098
|
+
return this.note('');
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1824
2101
|
/**
|
|
1825
2102
|
* A row was chosen, whichever way it was chosen.
|
|
1826
2103
|
*
|
|
@@ -1855,6 +2132,29 @@ export class Attached {
|
|
|
1855
2132
|
}
|
|
1856
2133
|
return void this.allow(row.id);
|
|
1857
2134
|
}
|
|
2135
|
+
if (select.kind === 'drop') {
|
|
2136
|
+
return void this.droppedCheckout(row);
|
|
2137
|
+
}
|
|
2138
|
+
if (select.kind === 'view-card') {
|
|
2139
|
+
const [slug, ref] = String(row.id).split(' ');
|
|
2140
|
+
return void this.showCard(slug, ref);
|
|
2141
|
+
}
|
|
2142
|
+
if (select.kind === 'planned') {
|
|
2143
|
+
const [slug, ref] = String(row.id).split(' ');
|
|
2144
|
+
return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
|
|
2145
|
+
}
|
|
2146
|
+
if (select.kind === 'card-project') {
|
|
2147
|
+
// `/card` asked which project; the same card, with it named.
|
|
2148
|
+
const words = this.cardWaitingForProject ?? [];
|
|
2149
|
+
this.cardWaitingForProject = null;
|
|
2150
|
+
return void this.addCard([...words, `@${row.id}`]);
|
|
2151
|
+
}
|
|
2152
|
+
if (select.kind === 'project') {
|
|
2153
|
+
// `/new` asked which project; the same command again, with it named.
|
|
2154
|
+
const words = this.newWaitingForProject ?? [];
|
|
2155
|
+
this.newWaitingForProject = null;
|
|
2156
|
+
return void this.startNew([...words, `@${row.id}`]);
|
|
2157
|
+
}
|
|
1858
2158
|
if (select.kind === 'config') {
|
|
1859
2159
|
// R288: the walk asked, the walk gets the row. Nothing is decided here.
|
|
1860
2160
|
this.configuring?.answer?.(row);
|
|
@@ -1961,7 +2261,8 @@ export class Attached {
|
|
|
1961
2261
|
}
|
|
1962
2262
|
return this.openPicker(new Select({
|
|
1963
2263
|
kind: 'question',
|
|
1964
|
-
title: clip(
|
|
2264
|
+
title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
|
|
2265
|
+
Math.max(20, this.screen.width - 2)),
|
|
1965
2266
|
rows: [
|
|
1966
2267
|
...options.map((option) => ({ id: option, label: option })),
|
|
1967
2268
|
// Always last, and always there.
|
|
@@ -1989,7 +2290,7 @@ export class Attached {
|
|
|
1989
2290
|
kind: 'permission',
|
|
1990
2291
|
// The call, on the title, as well as printed in full above: the picker
|
|
1991
2292
|
// may still be on screen when the transcript under it has moved on.
|
|
1992
|
-
title: `${approval.toolName} · ${approval.summary}`,
|
|
2293
|
+
title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${approval.summary}`,
|
|
1993
2294
|
rows: [
|
|
1994
2295
|
{ id: 'y', label: 'Allow once', hint: 'this call and no more' },
|
|
1995
2296
|
// R78: the middle grant NAMES what it covers, and it says so in the
|
|
@@ -2036,11 +2337,13 @@ export class Attached {
|
|
|
2036
2337
|
if (pending && !this.announced.has(pending.approval.id)) {
|
|
2037
2338
|
this.announced.add(pending.approval.id);
|
|
2038
2339
|
const ink = this.ink;
|
|
2039
|
-
|
|
2040
|
-
this.
|
|
2340
|
+
const about = pending.approval.id;
|
|
2341
|
+
this.sayAbout(about, '');
|
|
2342
|
+
this.sayAbout(about, `${ink.bold(ink.warn(' permission '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
|
|
2343
|
+
this.sayAbout(about, ` ${ink.text(pending.approval.toolName)}`);
|
|
2041
2344
|
// Not clipped: this is the thing being decided about, and a command cut
|
|
2042
2345
|
// at the width is a command you have not read.
|
|
2043
|
-
this.
|
|
2346
|
+
this.sayAbout(about, { prefix: ' ', body: pending.approval.summary, paint: ink.text });
|
|
2044
2347
|
if (this.mode === 'keys') {
|
|
2045
2348
|
this.askPermission(pending);
|
|
2046
2349
|
}
|
|
@@ -2053,13 +2356,20 @@ export class Attached {
|
|
|
2053
2356
|
}
|
|
2054
2357
|
this.announced.add(asking.question.id);
|
|
2055
2358
|
const ink = this.ink;
|
|
2056
|
-
|
|
2057
|
-
|
|
2359
|
+
// In advanced mode the question is the prompt's BANNER alone, whole and
|
|
2360
|
+
// wrapped, and it goes when the question does: written into the messages
|
|
2361
|
+
// as well, it was on screen twice — once whole, once cut — and stayed
|
|
2362
|
+
// after it was answered.
|
|
2363
|
+
if (!this.split) {
|
|
2364
|
+
this.say('');
|
|
2365
|
+
this.say(`${ink.bold(ink.accent(' question '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
|
|
2366
|
+
this.say(` ${ink.text(asking.question.question)}`);
|
|
2367
|
+
}
|
|
2058
2368
|
// R58: a watcher who does not own the question is told whose it is and is
|
|
2059
2369
|
// offered nothing. A picker here would take an answer the platform then
|
|
2060
2370
|
// refuses, which reads as cawdev being broken.
|
|
2061
2371
|
if (!asking.yours) {
|
|
2062
|
-
this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
|
|
2372
|
+
if (!this.split) this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
|
|
2063
2373
|
return;
|
|
2064
2374
|
}
|
|
2065
2375
|
if (this.mode === 'keys' && this.session.signedIn && asking.question.options?.length) {
|
|
@@ -2152,7 +2462,7 @@ export class Attached {
|
|
|
2152
2462
|
const ask = { line: (prompt) => this.askLine(prompt) };
|
|
2153
2463
|
const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
|
|
2154
2464
|
|
|
2155
|
-
const chosen = await this.askPick(configMenu(file, this.runner));
|
|
2465
|
+
const chosen = await this.askPick(configMenu(file, this.runner, this.gone));
|
|
2156
2466
|
this.say('');
|
|
2157
2467
|
this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
|
|
2158
2468
|
|
|
@@ -2204,6 +2514,15 @@ export class Attached {
|
|
|
2204
2514
|
});
|
|
2205
2515
|
return this.reloadDaemon(file, next);
|
|
2206
2516
|
}
|
|
2517
|
+
if (chosen.id.startsWith('remove:')) {
|
|
2518
|
+
const slug = chosen.id.slice('remove:'.length);
|
|
2519
|
+
const next = await removeProject({ file, configPath, ask, say, ink, slug });
|
|
2520
|
+
// Off the screen now, though the daemon serves it until a restart.
|
|
2521
|
+
this.gone.add(slug);
|
|
2522
|
+
this.cards = this.cards.filter((card) => card.slug !== slug);
|
|
2523
|
+
this.planned = this.planned.filter((card) => card.slug !== slug);
|
|
2524
|
+
return this.reloadDaemon(file, next);
|
|
2525
|
+
}
|
|
2207
2526
|
if (chosen.id === 'rules') {
|
|
2208
2527
|
const next = await setAcceptsRulesFromConsole({
|
|
2209
2528
|
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
|
@@ -2377,14 +2696,27 @@ export class Attached {
|
|
|
2377
2696
|
* a terminal in raw mode, and `GIT_TERMINAL_PROMPT=0` because a clone that
|
|
2378
2697
|
* stops to ask for a password here would hang the screen with no prompt on
|
|
2379
2698
|
* it: the credential helper may answer, a person cannot.
|
|
2699
|
+
*
|
|
2700
|
+
* ssh does not read that variable: a key with a passphrase is asked for on
|
|
2701
|
+
* `/dev/tty` directly, over this screen, while the screen's own raw-mode
|
|
2702
|
+
* reader takes half the keys. So ssh runs in BatchMode — a key the agent
|
|
2703
|
+
* holds (or one with no passphrase) still works, and one that would have to
|
|
2704
|
+
* ask fails at once and says how to make it not ask. A GIT_SSH_COMMAND the
|
|
2705
|
+
* person set themselves is theirs and left alone.
|
|
2380
2706
|
*/
|
|
2381
2707
|
cloneInto(gitUrl, path) {
|
|
2382
2708
|
return new Promise((done, fail) => {
|
|
2383
2709
|
const child = spawn('git', ['clone', '--progress', gitUrl, path], {
|
|
2384
2710
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2385
|
-
env: {
|
|
2711
|
+
env: {
|
|
2712
|
+
...process.env,
|
|
2713
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
2714
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes',
|
|
2715
|
+
},
|
|
2386
2716
|
});
|
|
2717
|
+
let said = '';
|
|
2387
2718
|
const print = (chunk) => {
|
|
2719
|
+
said += chunk;
|
|
2388
2720
|
for (const line of String(chunk).split(/\r?\n|\r/)) {
|
|
2389
2721
|
if (line.trim()) this.say(` ${this.ink.muted(line)}`);
|
|
2390
2722
|
}
|
|
@@ -2392,14 +2724,25 @@ export class Attached {
|
|
|
2392
2724
|
child.stdout.on('data', print);
|
|
2393
2725
|
child.stderr.on('data', print);
|
|
2394
2726
|
child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
|
|
2395
|
-
child.on('close', (code) =>
|
|
2727
|
+
child.on('close', (code) => {
|
|
2728
|
+
if (code === 0) {
|
|
2729
|
+
done();
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
fail(new Error(`git clone exited ${code}.${sshNeedsAPerson(said)
|
|
2733
|
+
? ' ssh needed to ask you something — a key\'s passphrase, or whether to trust the host '
|
|
2734
|
+
+ '— and this screen cannot ask. Add the key to your agent once (macOS: `ssh-add '
|
|
2735
|
+
+ '--apple-use-keychain ~/.ssh/id_ed25519`) and try again, or run `cawdev config '
|
|
2736
|
+
+ 'add-project` from a shell, which can ask.'
|
|
2737
|
+
: ''}`));
|
|
2738
|
+
});
|
|
2396
2739
|
});
|
|
2397
2740
|
}
|
|
2398
2741
|
|
|
2399
2742
|
// --- slash commands ---------------------------------------------------------
|
|
2400
2743
|
|
|
2401
2744
|
async runCommand(text) {
|
|
2402
|
-
const [word, ...rest] = text.slice(1)
|
|
2745
|
+
const [word, ...rest] = commandWords(text.slice(1));
|
|
2403
2746
|
switch (word) {
|
|
2404
2747
|
case 'help':
|
|
2405
2748
|
this.say('');
|
|
@@ -2435,6 +2778,40 @@ export class Attached {
|
|
|
2435
2778
|
case 'log':
|
|
2436
2779
|
this.showLog = !this.showLog;
|
|
2437
2780
|
return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
|
|
2781
|
+
case 'advanced-mode':
|
|
2782
|
+
case 'advanced': {
|
|
2783
|
+
const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : !this.split;
|
|
2784
|
+
if (!want) return this.leaveSplit();
|
|
2785
|
+
// `@slug` names the projects outright; otherwise they are asked.
|
|
2786
|
+
const named = rest.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
|
|
2787
|
+
return named.length ? this.enterSplit(named) : this.askSplitProjects();
|
|
2788
|
+
}
|
|
2789
|
+
case 'projects':
|
|
2790
|
+
return this.askSplitProjects();
|
|
2791
|
+
case 'refresh':
|
|
2792
|
+
return void this.refreshCards();
|
|
2793
|
+
case 'new':
|
|
2794
|
+
return void this.startNew(rest);
|
|
2795
|
+
case 'card':
|
|
2796
|
+
return void this.addCard(rest);
|
|
2797
|
+
case 'agent':
|
|
2798
|
+
return void this.chooseAgent(rest[0]);
|
|
2799
|
+
case 'develop':
|
|
2800
|
+
return void this.develop(rest);
|
|
2801
|
+
case 'pr':
|
|
2802
|
+
return void this.openPullRequest();
|
|
2803
|
+
case 'continue':
|
|
2804
|
+
return void this.carryOn();
|
|
2805
|
+
case 'open':
|
|
2806
|
+
return void this.openInBrowser();
|
|
2807
|
+
case 'view':
|
|
2808
|
+
return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
|
|
2809
|
+
case 'drop':
|
|
2810
|
+
return void this.dropCheckout();
|
|
2811
|
+
case 'close':
|
|
2812
|
+
return this.closeWindow();
|
|
2813
|
+
case 'reload':
|
|
2814
|
+
return void this.reload();
|
|
2438
2815
|
case 'quit':
|
|
2439
2816
|
case 'exit':
|
|
2440
2817
|
return this.quit();
|
|
@@ -2452,6 +2829,9 @@ export class Attached {
|
|
|
2452
2829
|
* the moment it is decided.
|
|
2453
2830
|
*/
|
|
2454
2831
|
async signIn() {
|
|
2832
|
+
if (this.split) {
|
|
2833
|
+
this.leaveSplit();
|
|
2834
|
+
}
|
|
2455
2835
|
// Everything queued goes out first and the footer comes down, so what
|
|
2456
2836
|
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2457
2837
|
// than through the middle of a live region nothing is going to erase.
|
|
@@ -2466,11 +2846,1301 @@ export class Attached {
|
|
|
2466
2846
|
if (this.session.signedIn) {
|
|
2467
2847
|
void this.watchInbox();
|
|
2468
2848
|
void this.watchQuestions();
|
|
2849
|
+
void this.findStopped();
|
|
2850
|
+
}
|
|
2851
|
+
this.dirty = true;
|
|
2852
|
+
return undefined;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// --- advanced mode — split.mjs ----------------------------------------------
|
|
2856
|
+
|
|
2857
|
+
/**
|
|
2858
|
+
* What this terminal knows of every session it has seen, after the daemon's
|
|
2859
|
+
* list changed. A session that left the list has ENDED — the daemon forgets
|
|
2860
|
+
* it there and then — and the platform is asked how, because what can be done
|
|
2861
|
+
* next (develop the plan, open the pull request) is decided by that.
|
|
2862
|
+
*/
|
|
2863
|
+
trackSessions() {
|
|
2864
|
+
const live = new Set();
|
|
2865
|
+
for (const run of this.runs) {
|
|
2866
|
+
live.add(run.id);
|
|
2867
|
+
const known = this.sessions.get(run.id);
|
|
2868
|
+
if (known?.ended && this.lines.has(run.id)) {
|
|
2869
|
+
// Back to life (a carry-on): what was said about how it stopped is
|
|
2870
|
+
// over, and the new output is what the window is for.
|
|
2871
|
+
this.lines.set(run.id, this.lines.get(run.id).filter((line) => !line.seed));
|
|
2872
|
+
}
|
|
2873
|
+
const meta = known ?? { id: run.id, view: null, prAsked: null };
|
|
2874
|
+
Object.assign(meta, {
|
|
2875
|
+
projectSlug: run.projectSlug,
|
|
2876
|
+
label: run.label,
|
|
2877
|
+
profile: run.profile ?? meta.profile ?? null,
|
|
2878
|
+
state: run.state,
|
|
2879
|
+
why: run.why ?? null,
|
|
2880
|
+
// Where a live session is working — the daemon's word, and the one a
|
|
2881
|
+
// window names (`subtitleFor`); kept once it ends, with the view's.
|
|
2882
|
+
workspace: run.workspace ?? meta.workspace ?? null,
|
|
2883
|
+
// Which CLI and model it runs on — per stage, so kept up to date.
|
|
2884
|
+
// A daemon that sends the field is believed even when it is null (a
|
|
2885
|
+
// stage asking for no model); an older one that sends nothing keeps
|
|
2886
|
+
// what was known.
|
|
2887
|
+
agent: run.agent !== undefined ? run.agent : meta.agent ?? null,
|
|
2888
|
+
model: run.model !== undefined ? run.model : meta.model ?? null,
|
|
2889
|
+
ended: false,
|
|
2890
|
+
// A carried-on run the daemon has taken: the ask is answered.
|
|
2891
|
+
carryAsked: false,
|
|
2892
|
+
});
|
|
2893
|
+
if (!known) {
|
|
2894
|
+
this.sessions.set(run.id, meta);
|
|
2895
|
+
void this.readView(meta);
|
|
2896
|
+
// The development `d` started in place of a plan's window takes the
|
|
2897
|
+
// focus that window had, so it reads as the plan's successor.
|
|
2898
|
+
if (this.split && this.focusOnArrival === run.projectSlug && (run.profile ?? 'CODE') === 'CODE') {
|
|
2899
|
+
this.focusOnArrival = null;
|
|
2900
|
+
this.split.focus = run.id;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (this.split) {
|
|
2904
|
+
this.requestBacklogFor(run.id);
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
for (const meta of this.sessions.values()) {
|
|
2908
|
+
if (!meta.ended && !live.has(meta.id)) {
|
|
2909
|
+
meta.ended = true;
|
|
2910
|
+
void this.readView(meta, 5);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (this.split && !this.windows().some((meta) => meta.id === this.split.focus)) {
|
|
2914
|
+
this.split.focus = this.windows()[0]?.id ?? null;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* The platform's view of a session — its card, its kind, how it ended, its
|
|
2920
|
+
* pull request. Needs a person's session; without one a window says what the
|
|
2921
|
+
* daemon knows and no more.
|
|
2922
|
+
*
|
|
2923
|
+
* @param retries how many times to read again while the platform still says
|
|
2924
|
+
* the run is live: the daemon drops a run a moment before its last
|
|
2925
|
+
* transition lands, and an ended window saying "running" offers nothing.
|
|
2926
|
+
*/
|
|
2927
|
+
async readView(meta, retries = 0) {
|
|
2928
|
+
if (!this.session.signedIn) return;
|
|
2929
|
+
try {
|
|
2930
|
+
meta.view = await this.session.request(`/api/projects/${meta.projectSlug}/runs/${meta.id}`);
|
|
2931
|
+
this.dirty = true;
|
|
2932
|
+
await this.countCommits(meta);
|
|
2933
|
+
this.sayWhyItFailed(meta);
|
|
2934
|
+
if (meta.ended && meta.view?.live && retries > 0) {
|
|
2935
|
+
const again = setTimeout(() => void this.readView(meta, retries - 1), 2000);
|
|
2936
|
+
again.unref?.();
|
|
2937
|
+
}
|
|
2938
|
+
} catch {
|
|
2939
|
+
// A window with the daemon's title is still a window.
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* A session that ended FAILED says why, in its own window — once. The
|
|
2945
|
+
* reason is the run's summary on the platform (a refusal before anything
|
|
2946
|
+
* was spawned — Codex on a machine that has not allowed everything — never
|
|
2947
|
+
* reaches the daemon's live transcript), and a window reading "failed" and
|
|
2948
|
+
* nothing else sent people to the console to find out what to do.
|
|
2949
|
+
*/
|
|
2950
|
+
sayWhyItFailed(meta) {
|
|
2951
|
+
const view = meta.view;
|
|
2952
|
+
const failed = view?.state === 'FAILED';
|
|
2953
|
+
// A dev session that finished having built nothing says why too — its
|
|
2954
|
+
// summary is where the session explained stopping (R24: "the plan is
|
|
2955
|
+
// incomplete"), and the window otherwise read as work done.
|
|
2956
|
+
if (!meta.ended || meta.failureSaid || !(failed || builtNothing(meta)) || !view.exitSummary) return;
|
|
2957
|
+
meta.failureSaid = true;
|
|
2958
|
+
const kept = this.lines.get(meta.id) ?? [];
|
|
2959
|
+
const said = kept.some((line) => line.body === view.exitSummary.split('\n')[0]);
|
|
2960
|
+
if (said) return;
|
|
2961
|
+
const lines = String(view.exitSummary).split('\n').map((body) => ({ kind: 'ERROR', body }));
|
|
2962
|
+
this.lines.set(meta.id, [...kept, { kind: 'ERROR', body: failed ? 'failed:' : 'nothing built:' }, ...lines]);
|
|
2963
|
+
this.dirty = true;
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/**
|
|
2967
|
+
* How many commits a finished dev session made — read once it has finished,
|
|
2968
|
+
* so `builtNothing` can tell "finished" from "finished having built nothing".
|
|
2969
|
+
* A read that fails leaves it unknown, and unknown claims nothing: the
|
|
2970
|
+
* window reads "finished" as it always did.
|
|
2971
|
+
*
|
|
2972
|
+
* A ZERO is not believed at once. The runner's last reading of the commits
|
|
2973
|
+
* lands AFTER the run is FINISHED — the agent ends its own run, and the
|
|
2974
|
+
* runner pushes, opens the pull request and only then reports — so the
|
|
2975
|
+
* first read of a run that committed twice and pushed can answer none. That
|
|
2976
|
+
* zero, cached, told a Codex session with two commits on origin that
|
|
2977
|
+
* "nothing was committed". A zero is read again `COMMIT_REREADS` times,
|
|
2978
|
+
* `commitRereadMs` apart, and claimed only if it is still a zero after that.
|
|
2979
|
+
*/
|
|
2980
|
+
async countCommits(meta, rereads = COMMIT_REREADS) {
|
|
2981
|
+
const view = meta.view;
|
|
2982
|
+
if (!meta.ended || meta.commits !== undefined || view?.state !== 'FINISHED'
|
|
2983
|
+
|| (view.profile ?? 'CODE') !== 'CODE') return;
|
|
2984
|
+
if (rereads === COMMIT_REREADS && meta.countingCommits) return;
|
|
2985
|
+
meta.countingCommits = true;
|
|
2986
|
+
let count;
|
|
2987
|
+
try {
|
|
2988
|
+
const commits = await this.session.request(
|
|
2989
|
+
`/api/projects/${meta.projectSlug}/runs/${meta.id}/commits`);
|
|
2990
|
+
count = Array.isArray(commits) ? commits.length : undefined;
|
|
2991
|
+
} catch {
|
|
2992
|
+
// Unknown: nothing claimed.
|
|
2993
|
+
}
|
|
2994
|
+
if (count === 0 && rereads > 0) {
|
|
2995
|
+
const again = setTimeout(async () => {
|
|
2996
|
+
await this.countCommits(meta, rereads - 1);
|
|
2997
|
+
this.sayWhyItFailed(meta);
|
|
2998
|
+
}, this.options.commitRereadMs ?? 5_000);
|
|
2999
|
+
again.unref?.();
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
meta.countingCommits = false;
|
|
3003
|
+
meta.commits = count;
|
|
3004
|
+
this.dirty = true;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* Which session a question or a request in the prompt comes from, by the
|
|
3009
|
+
* number its window carries (the position in the run list outside
|
|
3010
|
+
* advanced mode) — see split.mjs's `sessionTag`.
|
|
3011
|
+
*/
|
|
3012
|
+
tagOf(run) {
|
|
3013
|
+
if (!run) return '';
|
|
3014
|
+
const meta = this.sessions.get(run.id) ?? run;
|
|
3015
|
+
const n = this.split
|
|
3016
|
+
? this.windows().findIndex((each) => each.id === run.id) + 1
|
|
3017
|
+
: this.runs.findIndex((each) => each.id === run.id) + 1;
|
|
3018
|
+
return sessionTag(n, meta);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/** The sessions that get a window: every one seen, less those closed. */
|
|
3022
|
+
windows() {
|
|
3023
|
+
const only = this.split?.projects;
|
|
3024
|
+
return [...this.sessions.values()].filter((meta) => !this.closed.has(meta.id)
|
|
3025
|
+
&& (!only || only.has(meta.projectSlug)));
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
/** The projects this machine serves that cawdev still has — see `gone`. */
|
|
3029
|
+
servedProjects() {
|
|
3030
|
+
return (this.runner?.projects ?? []).filter((slug) => !this.gone.has(slug));
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
/** The projects on screen: advanced mode's pick, else every one served here. */
|
|
3034
|
+
shownProjects() {
|
|
3035
|
+
const served = this.servedProjects();
|
|
3036
|
+
return this.split?.projects ? served.filter((slug) => this.split.projects.has(slug)) : served;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
/** The session a command like /pr is about: the focused window, else the one watched. */
|
|
3040
|
+
commandTarget() {
|
|
3041
|
+
if (this.split) {
|
|
3042
|
+
return this.sessions.get(this.split.focus) ?? null;
|
|
3043
|
+
}
|
|
3044
|
+
return this.sessions.get(this.watching)
|
|
3045
|
+
?? [...this.sessions.values()].reverse().find((meta) => meta.ended) ?? null;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
/** The backlog of any live session, once — a window needs its history too. */
|
|
3049
|
+
requestBacklogFor(runId) {
|
|
3050
|
+
if (!runId || this.asked.has(runId) || !this.connected
|
|
3051
|
+
|| !this.runs.some((run) => run.id === runId)) {
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
this.asked.add(runId);
|
|
3055
|
+
try {
|
|
3056
|
+
this.client.write(`${JSON.stringify({ type: 'backlog', runId })}\n`);
|
|
3057
|
+
} catch {
|
|
3058
|
+
this.asked.delete(runId);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
/**
|
|
3063
|
+
* `/advanced-mode` on a machine serving several projects: which of them to
|
|
3064
|
+
* show, ticked in a picker (all ticked to start with, so enter alone keeps
|
|
3065
|
+
* today's behaviour). One project served: nothing to ask. `/projects` asks
|
|
3066
|
+
* again from inside the mode.
|
|
3067
|
+
*/
|
|
3068
|
+
async askSplitProjects() {
|
|
3069
|
+
if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
|
|
3070
|
+
// Read first, so a project deleted in the console is not offered — and
|
|
3071
|
+
// say so AFTER the mode is entered, or the sentence lands on the normal
|
|
3072
|
+
// screen the alternate one is about to cover.
|
|
3073
|
+
await this.refreshPlanned({ hold: true });
|
|
3074
|
+
const served = this.servedProjects();
|
|
3075
|
+
if (served.length <= 1) {
|
|
3076
|
+
const entered = this.split ? this.note('this machine serves one project') : this.enterSplit(served);
|
|
3077
|
+
this.sayGone();
|
|
3078
|
+
return entered;
|
|
3079
|
+
}
|
|
3080
|
+
this.sayGone();
|
|
3081
|
+
const current = this.split?.projects ? [...this.split.projects] : served;
|
|
3082
|
+
return this.openPicker(new Select({
|
|
3083
|
+
kind: 'split-projects',
|
|
3084
|
+
title: 'which projects should advanced mode show?',
|
|
3085
|
+
rows: served.map((slug) => ({ id: slug, label: slug })),
|
|
3086
|
+
many: true,
|
|
3087
|
+
picked: current,
|
|
3088
|
+
// On what is shown now, so space alone takes it off.
|
|
3089
|
+
at: Math.max(0, served.findIndex((slug) => current.includes(slug))),
|
|
3090
|
+
}));
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
/**
|
|
3094
|
+
* Never a prompt to a session of a project advanced mode is not showing: a
|
|
3095
|
+
* watched session KNOWN to be in a hidden project hands over to the focused
|
|
3096
|
+
* window. One not known yet (a reload, before the daemon has said hello) is
|
|
3097
|
+
* left alone — it may well be in a project that is shown.
|
|
3098
|
+
*/
|
|
3099
|
+
leaveHiddenWatch() {
|
|
3100
|
+
const watched = this.sessions.get(this.watching);
|
|
3101
|
+
const only = this.split?.projects;
|
|
3102
|
+
if (watched && only && !only.has(watched.projectSlug) && this.split.focus) {
|
|
3103
|
+
this.watching = this.split.focus;
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/** Advanced mode, narrowed to `slugs` — its windows, its roadmap, its `/new`. */
|
|
3108
|
+
focusProjects(slugs) {
|
|
3109
|
+
if (!this.split) return this.enterSplit(slugs);
|
|
3110
|
+
this.split.projects = new Set(slugs);
|
|
3111
|
+
if (!this.windows().some((meta) => meta.id === this.split.focus)) {
|
|
3112
|
+
this.split.focus = this.windows()[0]?.id ?? null;
|
|
3113
|
+
}
|
|
3114
|
+
this.leaveHiddenWatch();
|
|
3115
|
+
this.say(this.ink.muted(`showing ${slugs.join(', ')} — /projects changes it`));
|
|
3116
|
+
this.dirty = true;
|
|
3117
|
+
return undefined;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
enterSplit(projects = null) {
|
|
3121
|
+
if (this.split) return this.note('already in advanced mode — /advanced-mode off to leave');
|
|
3122
|
+
if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
|
|
3123
|
+
const shown = projects?.length ? new Set(projects) : null;
|
|
3124
|
+
// The footer comes down first, so the normal screen is left as a clean
|
|
3125
|
+
// scrollback for when this mode is left.
|
|
3126
|
+
this.screen.update(this.pending, []);
|
|
3127
|
+
this.pending = [];
|
|
3128
|
+
const screen = new SplitScreen(this.options.out ?? process.stdout);
|
|
3129
|
+
this.split = {
|
|
3130
|
+
screen,
|
|
3131
|
+
// The projects this mode shows; null is every one this machine serves.
|
|
3132
|
+
projects: shown,
|
|
3133
|
+
focus: null,
|
|
3134
|
+
messages: [],
|
|
3135
|
+
enteredWatching: this.watching,
|
|
3136
|
+
mark: new Map([...this.lines].map(([id, lines]) => [id, lines.length])),
|
|
3137
|
+
};
|
|
3138
|
+
const watched = this.windows().find((meta) => meta.id === this.watching);
|
|
3139
|
+
this.split.focus = watched?.id ?? this.windows()[0]?.id ?? null;
|
|
3140
|
+
this.leaveHiddenWatch();
|
|
3141
|
+
screen.enter();
|
|
3142
|
+
for (const meta of this.windows()) {
|
|
3143
|
+
this.requestBacklogFor(meta.id);
|
|
3144
|
+
}
|
|
3145
|
+
void this.refreshPlanned();
|
|
3146
|
+
this.split.plannedTimer = setInterval(() => void this.refreshPlanned(), 30_000);
|
|
3147
|
+
this.split.plannedTimer.unref?.();
|
|
3148
|
+
this.say(this.ink.muted('advanced mode — tab moves between windows · /new starts a session'
|
|
3149
|
+
+ ' · /projects picks what is shown · /advanced-mode leaves'));
|
|
3150
|
+
if (shown) {
|
|
3151
|
+
this.say(this.ink.muted(`showing ${[...shown].join(', ')}`));
|
|
3152
|
+
}
|
|
3153
|
+
this.dirty = true;
|
|
3154
|
+
return undefined;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/**
|
|
3158
|
+
* Back to the scrollback, which is as it was left. What the watched session
|
|
3159
|
+
* said meanwhile is laid into it now — or, if the focus moved, that
|
|
3160
|
+
* session's history, which is what switching sessions has always done.
|
|
3161
|
+
*/
|
|
3162
|
+
leaveSplit() {
|
|
3163
|
+
if (!this.split) return this.note('advanced mode is not on — /advanced-mode starts it');
|
|
3164
|
+
const { screen, enteredWatching, mark, plannedTimer } = this.split;
|
|
3165
|
+
clearInterval(plannedTimer);
|
|
3166
|
+
this.split = null;
|
|
3167
|
+
screen.leave();
|
|
3168
|
+
const watched = this.watching;
|
|
3169
|
+
const lines = this.lines.get(watched) ?? [];
|
|
3170
|
+
if (watched && watched === enteredWatching) {
|
|
3171
|
+
for (const line of lines.slice(mark.get(watched) ?? lines.length)) {
|
|
3172
|
+
this.say(this.render(line));
|
|
3173
|
+
}
|
|
3174
|
+
} else if (watched) {
|
|
3175
|
+
this.printed.delete(watched);
|
|
3176
|
+
this.layHistoryIn(watched, lines);
|
|
3177
|
+
}
|
|
3178
|
+
this.note('back to the scrollback');
|
|
3179
|
+
return undefined;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
/** The window the prompt talks to. A live one becomes the session watched. */
|
|
3183
|
+
focusWindow(id) {
|
|
3184
|
+
this.split.focus = id;
|
|
3185
|
+
this.split.recent = [id, ...(this.split.recent ?? []).filter((each) => each !== id)];
|
|
3186
|
+
if (this.runs.some((run) => run.id === id)) {
|
|
3187
|
+
this.watching = id;
|
|
3188
|
+
}
|
|
3189
|
+
this.note('');
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
cycleFocus(step) {
|
|
3193
|
+
const windows = this.windows();
|
|
3194
|
+
if (!windows.length) return this.note('no session windows yet — /new starts one');
|
|
3195
|
+
const at = windows.findIndex((meta) => meta.id === this.split.focus);
|
|
3196
|
+
return this.focusWindow(windows[(at + step + windows.length) % windows.length].id);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
/** The whole alternate screen, as rows — see split.mjs. */
|
|
3200
|
+
splitFrame() {
|
|
3201
|
+
const { width, height } = this.split.screen;
|
|
3202
|
+
const ink = this.ink;
|
|
3203
|
+
const windows = this.windows();
|
|
3204
|
+
const inner = Math.max(8, width - 2);
|
|
3205
|
+
|
|
3206
|
+
const overlay = this.overlayLines(inner);
|
|
3207
|
+
const current = this.current();
|
|
3208
|
+
const pending = this.pendingOn(current);
|
|
3209
|
+
const banner = this.select && this.select.kind !== 'runs' ? [] : pending
|
|
3210
|
+
? permissionBanner(pending, inner, ink, this.runner ?? {})
|
|
3211
|
+
: questionBanner(this.askingOn(current), inner, ink, { entire: true });
|
|
3212
|
+
// Which window a command or a prompt will act on, before it is sent.
|
|
3213
|
+
const target = this.windowNumberOf(this.split.focus);
|
|
3214
|
+
const tag = target ? ink.bold(ink.accent(`[${target}] `)) : '';
|
|
3215
|
+
const input = this.mode === 'typing'
|
|
3216
|
+
? `${tag}${this.input.label} ${this.input.line.render(
|
|
3217
|
+
Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
|
|
3218
|
+
: tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
|
|
3219
|
+
+ ' · / commands · tab next window');
|
|
3220
|
+
// A question or a request on screen says which window it came from.
|
|
3221
|
+
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3222
|
+
const fixed = [...from, ...banner, ...overlay, input];
|
|
3223
|
+
const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
|
|
3224
|
+
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3225
|
+
// more than a third of the screen, and never so tall that no window fits
|
|
3226
|
+
// under it — a short terminal drops it before it drops the sessions.
|
|
3227
|
+
const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
|
|
3228
|
+
// A rule under it, from the strip's own room: the roadmap is the board and
|
|
3229
|
+
// the windows are the sessions, and without it the strip's last card line
|
|
3230
|
+
// ran straight into the first window's border.
|
|
3231
|
+
const stripLines = stripRoom >= 4
|
|
3232
|
+
? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
|
|
3233
|
+
ink.muted('─'.repeat(width))]
|
|
3234
|
+
: [];
|
|
3235
|
+
const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
|
|
3236
|
+
// Four windows at most; the rest are named on one line above the prompt,
|
|
3237
|
+
// and Tab onto one of them brings it up in place of the least recent.
|
|
3238
|
+
const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus);
|
|
3239
|
+
const hidden = windows.filter((meta) => !shown.includes(meta));
|
|
3240
|
+
const tabs = hidden.length ? 1 : 0;
|
|
3241
|
+
const layout = splitLayout(width, height - strip - tabs, shown.length, promptRows);
|
|
3242
|
+
for (const rect of layout.zones) {
|
|
3243
|
+
rect.top += strip;
|
|
3244
|
+
}
|
|
3245
|
+
layout.prompt.top += strip + tabs;
|
|
3246
|
+
|
|
3247
|
+
const pieces = layout.zones.map((rect, i) => {
|
|
3248
|
+
const meta = shown[i];
|
|
3249
|
+
const n = windows.indexOf(meta) + 1;
|
|
3250
|
+
const asking = this.askingOn(this.runs.find((run) => run.id === meta.id));
|
|
3251
|
+
const waiting = this.pendingOn(this.runs.find((run) => run.id === meta.id))
|
|
3252
|
+
? 'waiting for permission'
|
|
3253
|
+
: asking ? 'waiting for an answer' : null;
|
|
3254
|
+
const lines = (this.lines.get(meta.id) ?? []).map((line) => this.render(line));
|
|
3255
|
+
const body = lines.length ? lines : [ink.muted(meta.state === 'queued'
|
|
3256
|
+
? ` waiting — ${meta.why ?? 'no reason recorded'}`
|
|
3257
|
+
: meta.ended ? ' nothing was said on this session here' : ' nothing said yet')];
|
|
3258
|
+
return {
|
|
3259
|
+
rect,
|
|
3260
|
+
lines: windowLines(rect, {
|
|
3261
|
+
title: `${n} ${titleFor(meta, waiting)}`,
|
|
3262
|
+
// The agent and its model first, bold and underlined — which CLI and
|
|
3263
|
+
// which model is what somebody comparing two windows looks for.
|
|
3264
|
+
subtitle: (() => {
|
|
3265
|
+
const who = agentModelFor(meta);
|
|
3266
|
+
const lead = who ? `${ink.strong(who)} ` : '';
|
|
3267
|
+
return lead + ink.muted(subtitleFor(meta, rect.width - 4 - (who ? who.length + 1 : 0)));
|
|
3268
|
+
})(),
|
|
3269
|
+
body,
|
|
3270
|
+
footer: actionsFor(meta),
|
|
3271
|
+
focused: meta.id === this.split.focus,
|
|
3272
|
+
dotted: meta.id !== this.split.focus,
|
|
3273
|
+
tone: toneFor(meta),
|
|
3274
|
+
}, ink),
|
|
3275
|
+
};
|
|
3276
|
+
});
|
|
3277
|
+
|
|
3278
|
+
if (tabs) {
|
|
3279
|
+
pieces.push({
|
|
3280
|
+
rect: { top: layout.prompt.top - 1, left: 0, width, height: 1 },
|
|
3281
|
+
lines: [tabsLine(hidden.map((meta) => ({ n: windows.indexOf(meta) + 1, meta })), width, ink)],
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
const unseen = layout.hidden ? ` · ${layout.hidden} more not shown` : '';
|
|
3285
|
+
// A question's lines leave with the question — see `sayAbout`.
|
|
3286
|
+
const open = this.stillOpen();
|
|
3287
|
+
this.split.messages = this.split.messages
|
|
3288
|
+
.filter((line) => typeof line === 'string' || open.has(line.about));
|
|
3289
|
+
const messages = this.split.messages.flatMap((line) => {
|
|
3290
|
+
if (typeof line === 'string') return [line];
|
|
3291
|
+
if (line.body === undefined) return [line.text];
|
|
3292
|
+
return wordWrap(line.body, inner - 2 - line.prefix.length).map((part) => `${line.prefix}${line.paint(part)}`);
|
|
3293
|
+
});
|
|
3294
|
+
const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
|
|
3295
|
+
+ `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
|
|
3296
|
+
+ `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
|
|
3297
|
+
pieces.push({
|
|
3298
|
+
rect: layout.prompt,
|
|
3299
|
+
lines: windowLines(layout.prompt, {
|
|
3300
|
+
title: promptTitle,
|
|
3301
|
+
// The input line sits on the bottom row, the way a prompt does:
|
|
3302
|
+
// messages fill from above it, and blank rows pad a short log.
|
|
3303
|
+
body: [
|
|
3304
|
+
...new Array(Math.max(0, layout.prompt.height - 3 - messages.length - fixed.length))
|
|
3305
|
+
.fill(''),
|
|
3306
|
+
...messages,
|
|
3307
|
+
...fixed,
|
|
3308
|
+
],
|
|
3309
|
+
footer: this.status
|
|
3310
|
+
? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
|
|
3311
|
+
: keyList(this.keys(), '', inner, ink),
|
|
3312
|
+
}, ink),
|
|
3313
|
+
});
|
|
3314
|
+
if (strip) {
|
|
3315
|
+
pieces.push({
|
|
3316
|
+
rect: { top: 0, left: 0, width, height: strip },
|
|
3317
|
+
lines: stripLines,
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
return composeFrame(width, height, pieces);
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
/**
|
|
3324
|
+
* Every card of every project this machine serves, whatever its status —
|
|
3325
|
+
* the strip draws them all, and `d` offers the PLANNED ones. One request
|
|
3326
|
+
* per project, the board's own (`brief`, so no bodies). Needs a person's
|
|
3327
|
+
* session, like everything read off the roadmap here.
|
|
3328
|
+
*
|
|
3329
|
+
* A project that cannot be read keeps ITS last cards and no other's: this
|
|
3330
|
+
* used to give up on the first failure and keep the whole last list, so one
|
|
3331
|
+
* served slug the session could not reach left every column at 0 — a strip
|
|
3332
|
+
* saying "nothing considered" about a board full of ideas, and saying it
|
|
3333
|
+
* silently. What failed is returned so `/refresh` can say it.
|
|
3334
|
+
*/
|
|
3335
|
+
async refreshPlanned({ hold = false } = {}) {
|
|
3336
|
+
if (!this.session.signedIn) return { signedIn: false, read: [], failed: [] };
|
|
3337
|
+
const read = [];
|
|
3338
|
+
const failed = [];
|
|
3339
|
+
const all = [];
|
|
3340
|
+
for (const slug of this.runner?.projects ?? []) {
|
|
3341
|
+
try {
|
|
3342
|
+
const cards = await this.session.request(`/api/projects/${slug}/roadmap?brief=true`);
|
|
3343
|
+
for (const card of Array.isArray(cards) ? cards : []) {
|
|
3344
|
+
all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
|
|
3345
|
+
}
|
|
3346
|
+
read.push(slug);
|
|
3347
|
+
this.gone.delete(slug);
|
|
3348
|
+
} catch (failure) {
|
|
3349
|
+
if (failure?.status === 404) {
|
|
3350
|
+
this.forgetGone(slug);
|
|
3351
|
+
continue;
|
|
3352
|
+
}
|
|
3353
|
+
failed.push({ slug, message: failure?.message ?? String(failure) });
|
|
3354
|
+
all.push(...this.cards.filter((card) => card.slug === slug));
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
this.cards = all;
|
|
3358
|
+
this.planned = all.filter((card) => card.status === 'PLANNED');
|
|
3359
|
+
this.dirty = true;
|
|
3360
|
+
if (!hold) this.sayGone();
|
|
3361
|
+
return { signedIn: true, read, failed };
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
/** A served project cawdev answered 404 for: hidden, and said once. */
|
|
3365
|
+
forgetGone(slug) {
|
|
3366
|
+
if (this.gone.has(slug)) return;
|
|
3367
|
+
this.gone.add(slug);
|
|
3368
|
+
this.goneUnsaid.push(slug);
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/** What `forgetGone` learnt, said where it will be read. */
|
|
3372
|
+
sayGone() {
|
|
3373
|
+
for (const slug of this.goneUnsaid.splice(0)) {
|
|
3374
|
+
this.say(this.ink.warn(` this machine serves ${slug}, but cawdev has no such project for you any more`
|
|
3375
|
+
+ ' — deleted, or your access went. It is hidden here;'
|
|
3376
|
+
+ ` c → Stop serving ${slug} takes it off this machine.`));
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/**
|
|
3381
|
+
* `/refresh`: the cards and their statuses read again now, rather than on
|
|
3382
|
+
* the strip's thirty-second timer — somebody who just moved a card in the
|
|
3383
|
+
* console should not have to wait for the terminal to believe it. Says what
|
|
3384
|
+
* it read per project shown, and which could not be read and why.
|
|
3385
|
+
*/
|
|
3386
|
+
async refreshCards() {
|
|
3387
|
+
if (!this.requireSignIn('read the roadmap')) return;
|
|
3388
|
+
const { read, failed } = await this.refreshPlanned();
|
|
3389
|
+
const shown = new Set(this.shownProjects());
|
|
3390
|
+
const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
|
|
3391
|
+
const mine = this.cards.filter((card) => card.slug === slug);
|
|
3392
|
+
const tally = BOARD_COLUMNS
|
|
3393
|
+
.map(([status, label]) => `${mine.filter((card) => card.status === status).length} ${label.toLowerCase()}`)
|
|
3394
|
+
.join(' · ');
|
|
3395
|
+
return `${slug}: ${tally}`;
|
|
3396
|
+
});
|
|
3397
|
+
for (const { slug, message } of failed) {
|
|
3398
|
+
this.say(this.ink.danger(` could not read the roadmap of ${slug}: ${message}`));
|
|
3399
|
+
}
|
|
3400
|
+
if (!read.length && !failed.length) {
|
|
3401
|
+
return this.note('refreshed — this machine serves no project yet');
|
|
3402
|
+
}
|
|
3403
|
+
this.note(failed.length && !counts.length
|
|
3404
|
+
? `refresh failed for ${failed.map((each) => each.slug).join(', ')}`
|
|
3405
|
+
: `refreshed — ${counts.join('; ')}${failed.length ? ` (${failed.length} could not be read)` : ''}`);
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
/**
|
|
3409
|
+
* `d`, or `/new dev` with nothing after it: the planned cards as a list, and
|
|
3410
|
+
* the one picked is developed — `/new dev R3 @slug`, on this machine.
|
|
3411
|
+
*/
|
|
3412
|
+
async pickPlanned(slug = null) {
|
|
3413
|
+
if (!this.requireSignIn('start a session')) return;
|
|
3414
|
+
await this.refreshPlanned();
|
|
3415
|
+
const cards = this.planned.filter((card) => !slug || card.slug === slug);
|
|
3416
|
+
if (!cards.length) {
|
|
3417
|
+
this.note(slug ? `nothing planned in ${slug}` : 'nothing planned — /new dev <the task in words> starts one anyway');
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
const several = new Set(cards.map((card) => card.slug)).size > 1;
|
|
3421
|
+
this.openPicker(new Select({
|
|
3422
|
+
kind: 'planned',
|
|
3423
|
+
title: 'develop which planned card?',
|
|
3424
|
+
rows: cards.map((card) => ({
|
|
3425
|
+
id: `${card.slug} ${card.ref}`,
|
|
3426
|
+
label: `${card.ref} ${card.title}`,
|
|
3427
|
+
...(several ? { hint: card.slug } : {}),
|
|
3428
|
+
})),
|
|
3429
|
+
}));
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
/** `/new plan|dev R3` · `/new plan|dev <the task in words>` · `/new ask …` — on this machine. */
|
|
3433
|
+
async startNew(words) {
|
|
3434
|
+
if (!this.requireSignIn('start a session')) return;
|
|
3435
|
+
// Advanced mode narrowed to ONE project: that is the project. Asking would
|
|
3436
|
+
// be asking somebody to repeat the choice they made on entering — this is
|
|
3437
|
+
// not the guess the question exists to prevent (a machine serving one
|
|
3438
|
+
// project, or the focused window), it is a choice already stated.
|
|
3439
|
+
const shown = this.split?.projects ? this.shownProjects() : [];
|
|
3440
|
+
if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
|
|
3441
|
+
words = [...words, `@${shown[0]}`];
|
|
3442
|
+
}
|
|
3443
|
+
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway');
|
|
3444
|
+
if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
|
|
3445
|
+
const named = words.find((word) => word.startsWith('@'));
|
|
3446
|
+
await this.pickPlanned(named ? named.slice(1) : null);
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
const anyway = words.includes('--anyway');
|
|
3450
|
+
// `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
|
|
3451
|
+
const flagged = words.find((word) => AGENT_FLAGS.has(word));
|
|
3452
|
+
let agent = flagged ? flagged.slice(2) : this.agent;
|
|
3453
|
+
const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
|
|
3454
|
+
// In advanced mode, among the projects it shows.
|
|
3455
|
+
projects: this.shownProjects(),
|
|
3456
|
+
});
|
|
3457
|
+
if (parsed.needsProject) {
|
|
3458
|
+
// Several projects here and nothing says which: ASK, from the list of
|
|
3459
|
+
// what this machine serves, and run the same command again with the
|
|
3460
|
+
// one picked. A sentence telling somebody to retype it with @slug is a
|
|
3461
|
+
// question the terminal could have asked itself.
|
|
3462
|
+
this.openPicker(new Select({
|
|
3463
|
+
kind: 'project',
|
|
3464
|
+
title: `which project is this ${words.find((word) => !word.startsWith('@')) ?? 'session'} for?`,
|
|
3465
|
+
rows: parsed.projects.map((slug) => ({ id: slug, label: slug })),
|
|
3466
|
+
}));
|
|
3467
|
+
this.newWaitingForProject = words;
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
if (parsed.error) {
|
|
3471
|
+
this.note(parsed.error);
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
if (parsed.kind === 'dev' && !parsed.ref) {
|
|
3475
|
+
// A dev from words writes its CARD first, then develops it exactly as
|
|
3476
|
+
// `/new dev R12` would. It used to be a manual session on a `task-…`
|
|
3477
|
+
// branch with no card: no number, nothing on the development board,
|
|
3478
|
+
// and a prompt that never said "commit" — so the work ended
|
|
3479
|
+
// uncommitted and `/pr` had nothing to propose.
|
|
3480
|
+
await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
// Developing a card whose finished plan has a window here: the agent that
|
|
3484
|
+
// planned it develops it unless one was named on the line — it has read
|
|
3485
|
+
// the code once already, and swapping CLIs between the two halves of one
|
|
3486
|
+
// card was a choice nobody made — and the plan's window gives way to the
|
|
3487
|
+
// development's once it has started.
|
|
3488
|
+
const plan = parsed.kind === 'dev' && parsed.ref ? this.planWindowFor(parsed.slug, parsed.ref) : null;
|
|
3489
|
+
const planAgent = plan ? plan.agent ?? plan.view?.agent ?? null : null;
|
|
3490
|
+
if (!flagged && planAgent) {
|
|
3491
|
+
agent = planAgent;
|
|
3492
|
+
}
|
|
3493
|
+
const [path, body, what] = parsed.kind === 'ask'
|
|
3494
|
+
? ['runs/ask', { prompt: parsed.text }, 'a question']
|
|
3495
|
+
: parsed.kind === 'plan'
|
|
3496
|
+
? parsed.ref
|
|
3497
|
+
? ['runs/plan', { entryNumber: parsed.ref }, `a plan of ${parsed.ref}`]
|
|
3498
|
+
// R150: the session writes the card first, then plans it.
|
|
3499
|
+
: ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
|
|
3500
|
+
: ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
|
|
3501
|
+
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
|
|
3502
|
+
if (started && plan) {
|
|
3503
|
+
this.closeWindowOf(plan);
|
|
3504
|
+
this.focusOnArrival = plan.projectSlug;
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
|
|
3509
|
+
finishedPlan(meta) {
|
|
3510
|
+
return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
|
|
3511
|
+
&& meta.view.profile === 'PLAN' && meta.view.entryRef);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
/** The open window of a finished plan of `ref` in `slug`, if there is one. */
|
|
3515
|
+
planWindowFor(slug, ref) {
|
|
3516
|
+
return [...this.sessions.values()].reverse().find((meta) => !this.closed.has(meta.id)
|
|
3517
|
+
&& meta.projectSlug === slug && this.finishedPlan(meta)
|
|
3518
|
+
&& meta.view.entryRef.toLowerCase() === String(ref).toLowerCase()) ?? null;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/**
|
|
3522
|
+
* `/agent` — which CLI `/new` asks for when a command names none. Kept
|
|
3523
|
+
* across launches (prefs.mjs). `default` goes back to the project's own
|
|
3524
|
+
* choice. A `--codex` on one `/new` still wins over it.
|
|
3525
|
+
*/
|
|
3526
|
+
async chooseAgent(name) {
|
|
3527
|
+
if (!name) {
|
|
3528
|
+
return this.note(this.agent
|
|
3529
|
+
? `/new uses ${this.agent} — /agent default goes back to the project's choice`
|
|
3530
|
+
: "/new uses the project's default agent — /agent claude|codex|agy picks one here");
|
|
3531
|
+
}
|
|
3532
|
+
const wanted = name.toLowerCase();
|
|
3533
|
+
if (['default', 'auto', 'none', 'project'].includes(wanted)) {
|
|
3534
|
+
this.agent = null;
|
|
3535
|
+
await savePrefs(this.session.url, { agent: null }).catch(() => undefined);
|
|
3536
|
+
this.dirty = true;
|
|
3537
|
+
return this.note("/new uses the project's default agent again");
|
|
3538
|
+
}
|
|
3539
|
+
if (!KNOWN_AGENTS.includes(wanted)) {
|
|
3540
|
+
return this.note(`no agent called ${name} — ${KNOWN_AGENTS.join(', ')}`);
|
|
2469
3541
|
}
|
|
3542
|
+
this.agent = wanted;
|
|
3543
|
+
await savePrefs(this.session.url, { agent: wanted }).catch(() => undefined);
|
|
2470
3544
|
this.dirty = true;
|
|
3545
|
+
// Said, not refused: the platform may send the run to another machine,
|
|
3546
|
+
// and this one can be told to spawn it (`c` → Enable) in a second.
|
|
3547
|
+
const here = (this.runner?.agents ?? []).map((command) => agentOfCommand(command));
|
|
3548
|
+
return this.note(this.runner?.agents && !here.includes(wanted)
|
|
3549
|
+
? `/new uses ${wanted} — but this machine does not spawn it: c → Enable ${wanted}`
|
|
3550
|
+
: `/new uses ${wanted}`);
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
/**
|
|
3554
|
+
* `/card <the idea in words>` — a card in CONSIDERING, and nothing else: no
|
|
3555
|
+
* session, no branch, nothing spent. The roadmap's first column is where an
|
|
3556
|
+
* idea waits to be decided on, and writing one down should not need a model
|
|
3557
|
+
* to do it. The project is chosen as `/new` chooses it: `@slug`, else
|
|
3558
|
+
* advanced mode narrowed to one, else asked — never guessed.
|
|
3559
|
+
*/
|
|
3560
|
+
async addCard(words) {
|
|
3561
|
+
if (!this.requireSignIn('write a card')) return;
|
|
3562
|
+
const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
|
|
3563
|
+
const text = wordsText(words.filter((word) => word && !word.startsWith('@')));
|
|
3564
|
+
if (!text) {
|
|
3565
|
+
this.note('/card what? — /card <the idea in words> (@project)');
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const served = this.servedProjects();
|
|
3569
|
+
const shown = this.shownProjects();
|
|
3570
|
+
const slug = named ?? (this.split?.projects && shown.length === 1 ? shown[0] : null);
|
|
3571
|
+
if (named && !served.includes(named)) {
|
|
3572
|
+
this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
if (!slug) {
|
|
3576
|
+
if (!shown.length) {
|
|
3577
|
+
this.note('this machine serves no project to write a card in');
|
|
3578
|
+
return;
|
|
3579
|
+
}
|
|
3580
|
+
this.openPicker(new Select({
|
|
3581
|
+
kind: 'card-project',
|
|
3582
|
+
title: 'which project is this card for?',
|
|
3583
|
+
rows: shown.map((each) => ({ id: each, label: each })),
|
|
3584
|
+
}));
|
|
3585
|
+
this.cardWaitingForProject = words;
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
let card;
|
|
3589
|
+
try {
|
|
3590
|
+
card = await this.session.request(`/api/projects/${slug}/roadmap`, {
|
|
3591
|
+
method: 'POST',
|
|
3592
|
+
body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
|
|
3593
|
+
});
|
|
3594
|
+
} catch (failure) {
|
|
3595
|
+
this.note(`could not write the card: ${failure.message}`);
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
const ref = card?.ref ?? (card?.number ? `R${card.number}` : 'the card');
|
|
3599
|
+
this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}`));
|
|
3600
|
+
this.note('');
|
|
3601
|
+
void this.refreshPlanned();
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
/**
|
|
3605
|
+
* `/new dev <the task in words>`: the card, then its development. Two
|
|
3606
|
+
* requests, and the first is said on its own line — a card that was written
|
|
3607
|
+
* but whose run was refused (a usage window, say) is still on the board,
|
|
3608
|
+
* and the person should know which half happened.
|
|
3609
|
+
*/
|
|
3610
|
+
async developNewCard(slug, text, anyway, agent = this.agent) {
|
|
3611
|
+
let card;
|
|
3612
|
+
try {
|
|
3613
|
+
card = await this.session.request(`/api/projects/${slug}/roadmap`, {
|
|
3614
|
+
method: 'POST',
|
|
3615
|
+
body: { title: cardTitleFor(text), body: text },
|
|
3616
|
+
});
|
|
3617
|
+
} catch (failure) {
|
|
3618
|
+
this.note(`could not write the card: ${failure.message}`);
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
const ref = card?.ref ?? (card?.number ? `R${card.number}` : null);
|
|
3622
|
+
if (!ref) {
|
|
3623
|
+
this.note('the card was written but the platform did not say its number — start it from the board');
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
|
|
3627
|
+
await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
|
|
3628
|
+
withWho(`the development of ${ref}`, agent), anyway);
|
|
3629
|
+
void this.refreshPlanned();
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* The sessions that stopped before this terminal saw them and can be picked
|
|
3634
|
+
* back up HERE — a failed run holding its checkout on this machine above
|
|
3635
|
+
* all. The daemon forgets a run as it ends, so after a restart nothing on
|
|
3636
|
+
* this side knew it existed and only the console could carry it on. Each
|
|
3637
|
+
* gets a window like any session that ended while watched, and the window
|
|
3638
|
+
* offers `/carry-on`.
|
|
3639
|
+
*
|
|
3640
|
+
* The first page of each served project's runs, which carries every run not
|
|
3641
|
+
* over plus the fifty newest that are — a failed run older than that is on
|
|
3642
|
+
* the console's Sessions page, and a terminal that paged through a
|
|
3643
|
+
* project's whole history on every attach would be slow for everybody to
|
|
3644
|
+
* find one that rarely exists. Once per attach and once per sign-in.
|
|
3645
|
+
*/
|
|
3646
|
+
async findStopped() {
|
|
3647
|
+
if (!this.session.signedIn || !this.runner?.id) return;
|
|
3648
|
+
const found = [];
|
|
3649
|
+
for (const slug of this.runner.projects ?? []) {
|
|
3650
|
+
const page = await this.session.request(`/api/projects/${slug}/runs?limit=50`)
|
|
3651
|
+
.catch(() => []);
|
|
3652
|
+
for (const view of Array.isArray(page) ? page : []) {
|
|
3653
|
+
if (this.sessions.has(view.id) || this.closed.has(view.id)
|
|
3654
|
+
|| this.runs.some((run) => run.id === view.id)
|
|
3655
|
+
|| !carriesOnHere(view, this.runner.id, this.session.email)) {
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3658
|
+
const meta = {
|
|
3659
|
+
id: view.id, projectSlug: slug, label: view.label, profile: view.profile,
|
|
3660
|
+
state: view.state, ended: true, view, prAsked: null,
|
|
3661
|
+
};
|
|
3662
|
+
this.sessions.set(view.id, meta);
|
|
3663
|
+
// Its window has no transcript to show — the daemon never streamed it
|
|
3664
|
+
// to this terminal — so it opens on why it stopped.
|
|
3665
|
+
if (!this.lines.has(view.id)) {
|
|
3666
|
+
const why = view.exitSummary ?? view.failureReason ?? `${String(view.state).toLowerCase()}`;
|
|
3667
|
+
// `seed`: it stands for a transcript this terminal never had, and
|
|
3668
|
+
// leaves when the session comes back (`trackSessions`) — above live
|
|
3669
|
+
// output it reads as the session failing again.
|
|
3670
|
+
this.lines.set(view.id, [{ kind: 'ERROR', body: `stopped: ${why}`, seed: true }]);
|
|
3671
|
+
}
|
|
3672
|
+
found.push(meta);
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
if (!found.length) return;
|
|
3676
|
+
this.say(this.ink.warn(` ${found.length} session(s) stopped here and can be carried on:`));
|
|
3677
|
+
for (const meta of found) {
|
|
3678
|
+
this.say(` ${meta.projectSlug} · ${String(meta.state).toLowerCase()} · ${meta.label ?? meta.id}`);
|
|
3679
|
+
}
|
|
3680
|
+
this.say(this.ink.muted(this.split
|
|
3681
|
+
? ' focus its window and type /continue'
|
|
3682
|
+
: ' /continue picks up the latest — /advanced-mode gives each one a window'));
|
|
3683
|
+
this.dirty = true;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/**
|
|
3687
|
+
* `/carry-on` — the console's Carry on, for the focused (or last ended)
|
|
3688
|
+
* session, on THIS machine: a failed run's checkout is here, and it resumes
|
|
3689
|
+
* where its work is or nowhere. The platform re-queues the same run; its
|
|
3690
|
+
* window comes back to life when the daemon claims it.
|
|
3691
|
+
*/
|
|
3692
|
+
async carryOn() {
|
|
3693
|
+
if (!this.requireSignIn('carry a session on')) return;
|
|
3694
|
+
const meta = this.commandTarget();
|
|
3695
|
+
const view = meta?.view;
|
|
3696
|
+
if (!meta || !view) {
|
|
3697
|
+
this.note('/continue needs a session that stopped — focus its window');
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
if (!meta.ended) {
|
|
3701
|
+
this.note(`${view.label ?? 'that session'} is still going — say something to it instead`);
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
if (!view.canCarryOn) {
|
|
3705
|
+
this.note(`that session is ${String(view.state ?? 'over').toLowerCase()} and cannot be carried on`);
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
if (!this.runner?.id) {
|
|
3709
|
+
this.note('this machine has not said which runner it is yet — try again in a moment');
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
try {
|
|
3713
|
+
meta.view = await this.session.request(
|
|
3714
|
+
`/api/projects/${meta.projectSlug}/runs/${meta.id}/carry-on`,
|
|
3715
|
+
{ method: 'POST', body: { runnerId: this.runner.id } });
|
|
3716
|
+
meta.carryAsked = true;
|
|
3717
|
+
this.say(this.ink.success(` carrying on ${view.label ?? meta.id} — its window comes back when this machine takes it`));
|
|
3718
|
+
this.note('');
|
|
3719
|
+
} catch (failure) {
|
|
3720
|
+
this.note(failure.message);
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
/**
|
|
3725
|
+
* `/develop` — start the development of a card on this machine: the card a
|
|
3726
|
+
* finished plan was about (the focused session's), or the one named —
|
|
3727
|
+
* `/develop R30`, `@slug` for its project. It is `/new dev R30` said from a
|
|
3728
|
+
* plan's window, and it goes THROUGH `startNew` so the agent is chosen the
|
|
3729
|
+
* same way: `--codex`/`--claude`/`--agy`, else `/agent`'s. It used to ignore
|
|
3730
|
+
* every word after it, so `/develop R30 --codex` started the focused plan's
|
|
3731
|
+
* card with the runner's default agent — Claude — and said nothing.
|
|
3732
|
+
* Words that are none of those (`starts`, from the window's own hint) are
|
|
3733
|
+
* passed over.
|
|
3734
|
+
*/
|
|
3735
|
+
async develop(words = []) {
|
|
3736
|
+
if (!this.requireSignIn('start a session')) return;
|
|
3737
|
+
const flags = words.filter((word) => word.startsWith('--'));
|
|
3738
|
+
const named = words.find((word) => word.startsWith('@'));
|
|
3739
|
+
const typed = words.find((word) => /^r?\d+$/i.test(word));
|
|
3740
|
+
if (typed) {
|
|
3741
|
+
const ref = typed.replace(/^r/i, 'R').replace(/^(\d)/, 'R$1');
|
|
3742
|
+
const slug = named?.slice(1) ?? this.commandTarget()?.projectSlug;
|
|
3743
|
+
await this.startNew(['dev', ref, ...(slug ? [`@${slug}`] : []), ...flags]);
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
const meta = this.commandTarget();
|
|
3747
|
+
const ref = meta?.view?.entryRef;
|
|
3748
|
+
if (!meta || !ref) {
|
|
3749
|
+
this.note('/develop needs a session on a card — focus a finished plan, or name one: /develop R30');
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
if (!meta.ended) {
|
|
3753
|
+
this.note(`${ref} is still being worked on — wait for it to end`);
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
await this.startNew(['dev', ref, `@${meta.projectSlug}`, ...flags]);
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
/**
|
|
3760
|
+
* One POST that starts a session, aimed at THIS machine so its window opens
|
|
3761
|
+
* here. A usage window over the machine's threshold is refused by the
|
|
3762
|
+
* platform in a sentence; `--anyway` is the same decision the console's
|
|
3763
|
+
* panel asks for.
|
|
3764
|
+
*/
|
|
3765
|
+
async startSession(slug, path, body, what, anyway) {
|
|
3766
|
+
try {
|
|
3767
|
+
await this.session.request(`/api/projects/${slug}/${path}`, {
|
|
3768
|
+
method: 'POST',
|
|
3769
|
+
body: {
|
|
3770
|
+
...body,
|
|
3771
|
+
...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
|
|
3772
|
+
...(anyway ? { acknowledgeUsage: true } : {}),
|
|
3773
|
+
},
|
|
3774
|
+
});
|
|
3775
|
+
this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
|
|
3776
|
+
this.note('');
|
|
3777
|
+
return true;
|
|
3778
|
+
} catch (failure) {
|
|
3779
|
+
this.note(/usage|window/i.test(failure.message)
|
|
3780
|
+
? `${failure.message} — add --anyway to start it regardless`
|
|
3781
|
+
: failure.message);
|
|
3782
|
+
return false;
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
/**
|
|
3787
|
+
* `/pr` — the pull request of a finished session's branch. The machine pushes and runs `gh`, so the answer comes
|
|
3788
|
+
* back later: the window says it was asked for, and shows the URL when the
|
|
3789
|
+
* run carries one.
|
|
3790
|
+
*/
|
|
3791
|
+
async openPullRequest() {
|
|
3792
|
+
if (!this.requireSignIn('open a pull request')) return;
|
|
3793
|
+
const meta = this.commandTarget();
|
|
3794
|
+
const view = meta?.view;
|
|
3795
|
+
if (!meta || !view?.branch) {
|
|
3796
|
+
this.note('/pr needs a session that wrote a branch — focus one');
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
if (!meta.ended) {
|
|
3800
|
+
this.note(`${view.branch} is still being written — wait for the session to end`);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
if (isPullRequest(view.prUrl)) {
|
|
3804
|
+
this.note(`${view.branch} already has one: ${view.prUrl}`);
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
try {
|
|
3808
|
+
// Through the RUN: a card's run goes to its work item on the platform,
|
|
3809
|
+
// and a session started from a sentence — which has none — is proposed
|
|
3810
|
+
// from the run itself.
|
|
3811
|
+
const asked = await this.session.request(
|
|
3812
|
+
`/api/projects/${meta.projectSlug}/runs/${meta.id}/pull-request`, { method: 'POST' });
|
|
3813
|
+
meta.prAsked = true;
|
|
3814
|
+
this.say(this.ink.success(` asked this machine to open the pull request for ${view.branch}`));
|
|
3815
|
+
this.note('');
|
|
3816
|
+
// The answer is the REQUEST the machine was handed, and its result is
|
|
3817
|
+
// the URL or the reason there is none. A platform older than that
|
|
3818
|
+
// answers with the run, and only the run's prUrl can be watched.
|
|
3819
|
+
void (asked?.id && asked?.runnerId && asked?.kind === 'OPEN_PR'
|
|
3820
|
+
? this.followPullRequest(meta, asked, 60)
|
|
3821
|
+
: this.awaitPullRequest(meta, 60));
|
|
3822
|
+
} catch (failure) {
|
|
3823
|
+
this.note(failure.message);
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
/**
|
|
3828
|
+
* Reads the machine's answer to `/pr` until there is one: the URL, which
|
|
3829
|
+
* the daemon checked the host for before saying so — or why not, in the
|
|
3830
|
+
* daemon's words (gh missing or signed out, a push refused). Before this the
|
|
3831
|
+
* terminal watched the run's prUrl, which a failure never writes, and said
|
|
3832
|
+
* "pull request asked for…" for as long as the window was open.
|
|
3833
|
+
*/
|
|
3834
|
+
async followPullRequest(meta, request, tries) {
|
|
3835
|
+
const read = await this.session.request(
|
|
3836
|
+
`/api/runners/${request.runnerId}/workspace-requests/${request.id}`).catch(() => null);
|
|
3837
|
+
const state = read?.state;
|
|
3838
|
+
if (state === 'DONE') {
|
|
3839
|
+
const url = String(read.result ?? '').split('\n')[0].trim();
|
|
3840
|
+
if (isPullRequest(url)) {
|
|
3841
|
+
meta.prAsked = null;
|
|
3842
|
+
meta.view = { ...(meta.view ?? {}), prUrl: url };
|
|
3843
|
+
this.dirty = true;
|
|
3844
|
+
this.say(this.ink.success(` pull request: ${url}`));
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
if (state === 'FAILED' || state === 'DONE') {
|
|
3849
|
+
const why = String(read.result ?? 'the machine did not say why').trim();
|
|
3850
|
+
meta.prAsked = `no pull request — ${why.split('\n')[0]}`;
|
|
3851
|
+
this.dirty = true;
|
|
3852
|
+
this.say(this.ink.danger(` no pull request for ${meta.view?.branch ?? 'this branch'}:`));
|
|
3853
|
+
for (const line of why.split('\n')) this.say(this.ink.danger(` ${line}`));
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
if (tries <= 0) {
|
|
3857
|
+
meta.prAsked = 'no answer from the machine yet — is its daemon running?';
|
|
3858
|
+
this.dirty = true;
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
const again = setTimeout(() => void this.followPullRequest(meta, request, tries - 1), 2000);
|
|
3862
|
+
again.unref?.();
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
/** Reads the run until its pull request shows, or gives up and says so. */
|
|
3866
|
+
async awaitPullRequest(meta, tries) {
|
|
3867
|
+
await this.readView(meta);
|
|
3868
|
+
if (isPullRequest(meta.view?.prUrl)) {
|
|
3869
|
+
meta.prAsked = null;
|
|
3870
|
+
this.say(this.ink.success(` pull request: ${meta.view.prUrl}`));
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
if (tries <= 0) {
|
|
3874
|
+
meta.prAsked = 'no pull request yet — the board says why';
|
|
3875
|
+
this.dirty = true;
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
const again = setTimeout(() => void this.awaitPullRequest(meta, tries - 1), 3000);
|
|
3879
|
+
again.unref?.();
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
/**
|
|
3883
|
+
* `/open` — the session's pull request, in the browser. A finished plan has
|
|
3884
|
+
* none: what it produced is the card's plan of record, so `/open` there is
|
|
3885
|
+
* the card on cawdev, opened on its plan (`?show=plan`).
|
|
3886
|
+
*/
|
|
3887
|
+
async openInBrowser() {
|
|
3888
|
+
const meta = this.commandTarget();
|
|
3889
|
+
if (!meta) {
|
|
3890
|
+
return this.note('/open needs a session — focus one');
|
|
3891
|
+
}
|
|
3892
|
+
if (this.finishedPlan(meta)) {
|
|
3893
|
+
const ref = meta.view.entryRef;
|
|
3894
|
+
const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
|
|
3895
|
+
const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `${board}/${ref}?show=plan`);
|
|
3896
|
+
if (!url) {
|
|
3897
|
+
return this.note('cannot tell where this card is on cawdev');
|
|
3898
|
+
}
|
|
3899
|
+
this.openLink(url);
|
|
3900
|
+
return this.note(`opened ${url}`);
|
|
3901
|
+
}
|
|
3902
|
+
// Read again, not the view taken when the session ended: the runner opens
|
|
3903
|
+
// the pull request and records its link AFTER the run is FINISHED, so the
|
|
3904
|
+
// copy read at that moment had none — and /open said "no pull request"
|
|
3905
|
+
// about a branch with one open on GitHub.
|
|
3906
|
+
await this.readView(meta);
|
|
3907
|
+
let url = meta.view?.prUrl;
|
|
3908
|
+
// A staged run's pull request can be the card's, through its work item
|
|
3909
|
+
// (i168) — the branch's, whichever run opened it.
|
|
3910
|
+
const ref = meta.view?.entryRef;
|
|
3911
|
+
if (!isPullRequest(url) && ref && this.session.signedIn) {
|
|
3912
|
+
const card = await this.session.request(`/api/projects/${meta.projectSlug}/roadmap/${ref}`)
|
|
3913
|
+
.catch(() => null);
|
|
3914
|
+
url = card?.workItem?.prUrl ?? card?.review?.prUrl ?? url;
|
|
3915
|
+
}
|
|
3916
|
+
if (!isPullRequest(url)) {
|
|
3917
|
+
return this.note('no pull request on this session yet — /pr opens one');
|
|
3918
|
+
}
|
|
3919
|
+
this.openLink(url);
|
|
3920
|
+
return this.note(`opened ${url}`);
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
/** A link, in this machine's browser. */
|
|
3924
|
+
openLink(url) {
|
|
3925
|
+
const opener = this.options.openUrl ?? ((link) => {
|
|
3926
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
3927
|
+
: process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
3928
|
+
spawn(command, [link], { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
|
|
3929
|
+
});
|
|
3930
|
+
opener(url);
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
/**
|
|
3934
|
+
* `/view` alone — the focused session's page on cawdev (its transcript,
|
|
3935
|
+
* stages, card and checkout). On the console this terminal signed in to:
|
|
3936
|
+
* the sign-in URL is the console's, where the daemon's may be the API
|
|
3937
|
+
* behind it.
|
|
3938
|
+
*/
|
|
3939
|
+
viewOnCawdev() {
|
|
3940
|
+
const meta = this.commandTarget();
|
|
3941
|
+
if (!meta) {
|
|
3942
|
+
return this.note('/view needs a session — focus one, or name a card: /view R29');
|
|
3943
|
+
}
|
|
3944
|
+
const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
|
|
3945
|
+
if (!url) {
|
|
3946
|
+
return this.note('cannot tell where this session is on cawdev');
|
|
3947
|
+
}
|
|
3948
|
+
this.openLink(url);
|
|
3949
|
+
return this.note(`opened ${url}`);
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
/** A page of a project on the console this terminal signed in to, or null. */
|
|
3953
|
+
consoleUrl(slug, path) {
|
|
3954
|
+
const base = String(this.session.url ?? '').replace(/\/+$/, '');
|
|
3955
|
+
return base && slug ? `${base}/projects/${slug}/${path}` : null;
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
/**
|
|
3959
|
+
* `/view R29` · `/view i12` · `/view <words of its title>` · `/view card`
|
|
3960
|
+
* — a card printed here, rather than a browser opened on it: somebody at
|
|
3961
|
+
* this terminal wants to read what a card says without leaving it. `card`
|
|
3962
|
+
* is the focused session's own. `@slug` names the project; otherwise every
|
|
3963
|
+
* project on screen is looked in, and a ref or words matching cards in
|
|
3964
|
+
* several are offered as a list rather than guessed between.
|
|
3965
|
+
*/
|
|
3966
|
+
async viewCard(words) {
|
|
3967
|
+
if (!this.requireSignIn('read a card')) return;
|
|
3968
|
+
const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
|
|
3969
|
+
const bare = words.filter((word) => word && !word.startsWith('@'));
|
|
3970
|
+
if (bare.length === 1 && bare[0].toLowerCase() === 'card') {
|
|
3971
|
+
const meta = this.commandTarget();
|
|
3972
|
+
const ref = meta?.view?.entryRef;
|
|
3973
|
+
if (!ref) {
|
|
3974
|
+
return this.note(meta ? 'this session has no card — /view R29 names one' : '/view card needs a session — focus one');
|
|
3975
|
+
}
|
|
3976
|
+
return this.showCard(meta.projectSlug, ref);
|
|
3977
|
+
}
|
|
3978
|
+
const projects = named.length ? named
|
|
3979
|
+
: this.shownProjects().length ? this.shownProjects() : this.servedProjects();
|
|
3980
|
+
if (!projects.length) {
|
|
3981
|
+
return this.note('no project here to look in — name one: /view R29 @slug');
|
|
3982
|
+
}
|
|
3983
|
+
const asRef = bare.length === 1 && /^[ri]?\d+$/i.test(bare[0]) ? refOf(bare[0]) : null;
|
|
3984
|
+
let found;
|
|
3985
|
+
if (asRef) {
|
|
3986
|
+
const reads = await Promise.all(projects.map((slug) => this.session
|
|
3987
|
+
.request(`/api/projects/${slug}/roadmap/${asRef}`)
|
|
3988
|
+
.then((card) => (card ? { slug, ref: card.ref ?? asRef, title: card.title } : null))
|
|
3989
|
+
.catch(() => null)));
|
|
3990
|
+
found = reads.filter(Boolean);
|
|
3991
|
+
if (!found.length) {
|
|
3992
|
+
return this.note(`no ${asRef} in ${projects.join(', ')}`);
|
|
3993
|
+
}
|
|
3994
|
+
} else {
|
|
3995
|
+
const wanted = bare.map((word) => word.toLowerCase());
|
|
3996
|
+
if (!wanted.length) {
|
|
3997
|
+
return this.note('/view which card? /view R29, or words of its title');
|
|
3998
|
+
}
|
|
3999
|
+
await this.refreshPlanned();
|
|
4000
|
+
found = this.cards.filter((card) => projects.includes(card.slug)
|
|
4001
|
+
&& wanted.every((word) => String(card.title ?? '').toLowerCase().includes(word)));
|
|
4002
|
+
if (!found.length) {
|
|
4003
|
+
return this.note(`no card titled like "${bare.join(' ')}" in ${projects.join(', ')}`);
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
if (found.length === 1) {
|
|
4007
|
+
return this.showCard(found[0].slug, found[0].ref);
|
|
4008
|
+
}
|
|
4009
|
+
const several = new Set(found.map((card) => card.slug)).size > 1;
|
|
4010
|
+
this.openPicker(new Select({
|
|
4011
|
+
kind: 'view-card',
|
|
4012
|
+
title: 'view which card?',
|
|
4013
|
+
rows: found.map((card) => ({
|
|
4014
|
+
id: `${card.slug} ${card.ref}`,
|
|
4015
|
+
label: `${card.ref} ${card.title ?? ''}`.trim(),
|
|
4016
|
+
...(several ? { hint: card.slug } : {}),
|
|
4017
|
+
})),
|
|
4018
|
+
}));
|
|
2471
4019
|
return undefined;
|
|
2472
4020
|
}
|
|
2473
4021
|
|
|
4022
|
+
/** One card, read fresh off the platform and printed into the transcript. */
|
|
4023
|
+
async showCard(slug, ref) {
|
|
4024
|
+
let card;
|
|
4025
|
+
try {
|
|
4026
|
+
card = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
|
|
4027
|
+
} catch (failure) {
|
|
4028
|
+
return this.note(`could not read ${ref} on ${slug}: ${failure.message}`);
|
|
4029
|
+
}
|
|
4030
|
+
if (!card) {
|
|
4031
|
+
return this.note(`no ${ref} on ${slug}`);
|
|
4032
|
+
}
|
|
4033
|
+
const url = this.consoleUrl(slug, `roadmap/${card.ref ?? ref}`);
|
|
4034
|
+
this.say('');
|
|
4035
|
+
for (const line of cardLines(card, this.ink, url)) this.say(line);
|
|
4036
|
+
this.say('');
|
|
4037
|
+
return this.note(`${card.ref ?? ref} on ${slug}`);
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
/** ↑ with no line open: the most recent entry, on the line it belongs to. */
|
|
4041
|
+
recallIntoALine() {
|
|
4042
|
+
const last = this.history.entries.at(-1);
|
|
4043
|
+
if (last === undefined) {
|
|
4044
|
+
return this.note('nothing typed yet to go back to');
|
|
4045
|
+
}
|
|
4046
|
+
const command = last.startsWith('/');
|
|
4047
|
+
// The line first — opening one resets the history — then one step back,
|
|
4048
|
+
// so the next ↑ goes further rather than to the same entry again.
|
|
4049
|
+
this.type(command ? 'command' : 'prompt', command ? 'cawdev ▸' : 'prompt ▸', '');
|
|
4050
|
+
this.input.line.set(this.history.back(''));
|
|
4051
|
+
this.input.recalling = true;
|
|
4052
|
+
this.input.showing = false;
|
|
4053
|
+
this.dirty = true;
|
|
4054
|
+
return undefined;
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
/**
|
|
4058
|
+
* `/drop` — delete the checkout a finished session was cloned into.
|
|
4059
|
+
*
|
|
4060
|
+
* Only a checkout the daemon made on demand (`-auto-<n>`), so the project
|
|
4061
|
+
* always keeps the ones in the config. Refused while anything is in it: a
|
|
4062
|
+
* run on it, a run that failed and holds it (R80), uncommitted files, or a
|
|
4063
|
+
* commit origin does not have — deleting any of those is deleting work. It
|
|
4064
|
+
* asks first, naming the path. The daemon owns the list of checkouts and the
|
|
4065
|
+
* socket cannot change anything (R52), so the directory is removed here and
|
|
4066
|
+
* the daemon is sent SIGHUP, which makes it look again (auto-workspaces.mjs).
|
|
4067
|
+
*/
|
|
4068
|
+
async dropCheckout() {
|
|
4069
|
+
const meta = this.commandTarget();
|
|
4070
|
+
const path = meta?.view?.workspace;
|
|
4071
|
+
if (!meta || !isAutoCheckout(path)) {
|
|
4072
|
+
return this.note('/drop deletes a checkout made on demand (…-auto-N) — this session was not in one');
|
|
4073
|
+
}
|
|
4074
|
+
if (meta.dropped) {
|
|
4075
|
+
return this.note(`${path} is already deleted`);
|
|
4076
|
+
}
|
|
4077
|
+
if (!meta.ended || this.runs.some((run) => run.workspace === path)) {
|
|
4078
|
+
return this.note(`a session is working in ${path} — wait for it to end`);
|
|
4079
|
+
}
|
|
4080
|
+
if (meta.view?.holdsACheckout) {
|
|
4081
|
+
return this.note(`the run keeps ${path} for its unfinished work — carry it on or discard it first`);
|
|
4082
|
+
}
|
|
4083
|
+
const unsafe = await (this.options.checkoutState ?? checkoutState)(path);
|
|
4084
|
+
if (unsafe) {
|
|
4085
|
+
return this.note(`not deleting ${path}: ${unsafe}`);
|
|
4086
|
+
}
|
|
4087
|
+
const noPr = isPullRequest(meta.view?.prUrl) ? '' : ' (no pull request yet — the branch is on origin)';
|
|
4088
|
+
this.dropping = { meta, path };
|
|
4089
|
+
this.openPicker(new Select({
|
|
4090
|
+
kind: 'drop',
|
|
4091
|
+
title: `delete ${path}?${noPr}`,
|
|
4092
|
+
rows: [
|
|
4093
|
+
{ id: 'delete', label: `Delete ${path}` },
|
|
4094
|
+
{ id: 'keep', label: 'Keep it' },
|
|
4095
|
+
],
|
|
4096
|
+
}));
|
|
4097
|
+
return undefined;
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
/** The choice `/drop` asked for. */
|
|
4101
|
+
async droppedCheckout(row) {
|
|
4102
|
+
const { meta, path } = this.dropping ?? {};
|
|
4103
|
+
this.dropping = null;
|
|
4104
|
+
if (!meta || row.id !== 'delete') {
|
|
4105
|
+
return this.note('kept');
|
|
4106
|
+
}
|
|
4107
|
+
try {
|
|
4108
|
+
await (this.options.removeDir ?? ((dir) => rm(dir, { recursive: true, force: true })))(path);
|
|
4109
|
+
} catch (failure) {
|
|
4110
|
+
return this.note(`could not delete ${path}: ${failure.message}`);
|
|
4111
|
+
}
|
|
4112
|
+
meta.dropped = true;
|
|
4113
|
+
if (this.runner?.pid) {
|
|
4114
|
+
(this.options.signalDaemon ?? ((pid) => {
|
|
4115
|
+
try {
|
|
4116
|
+
process.kill(pid, 'SIGHUP');
|
|
4117
|
+
} catch {
|
|
4118
|
+
// Gone; the next one will not find the checkout either.
|
|
4119
|
+
}
|
|
4120
|
+
}))(this.runner.pid);
|
|
4121
|
+
}
|
|
4122
|
+
this.say(this.ink.success(` deleted ${path}`));
|
|
4123
|
+
return this.note('');
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
/** `/close` — an ended session's window goes; a live one's stays. */
|
|
4127
|
+
closeWindow() {
|
|
4128
|
+
const meta = this.commandTarget();
|
|
4129
|
+
if (!meta) return this.note('no window to close');
|
|
4130
|
+
if (!meta.ended) return this.note('that session is still running — /cancel stops it');
|
|
4131
|
+
this.closeWindowOf(meta);
|
|
4132
|
+
return this.note('closed');
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
/** An ended session's window goes; focus moves to the first one left. */
|
|
4136
|
+
closeWindowOf(meta) {
|
|
4137
|
+
this.closed.add(meta.id);
|
|
4138
|
+
if (this.split && (this.split.focus === meta.id || !this.sessions.has(this.split.focus))) {
|
|
4139
|
+
this.split.focus = this.windows()[0]?.id ?? null;
|
|
4140
|
+
}
|
|
4141
|
+
this.dirty = true;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
2474
4144
|
// --- the things a person can do ---------------------------------------------
|
|
2475
4145
|
|
|
2476
4146
|
async send(text) {
|
|
@@ -2650,6 +4320,13 @@ export class Attached {
|
|
|
2650
4320
|
}
|
|
2651
4321
|
|
|
2652
4322
|
this.stopped = true;
|
|
4323
|
+
// Off the alternate screen before anything is printed, or the goodbye
|
|
4324
|
+
// lands on a buffer that vanishes the moment the program exits.
|
|
4325
|
+
if (this.split) {
|
|
4326
|
+
clearInterval(this.split.plannedTimer);
|
|
4327
|
+
this.split.screen.release();
|
|
4328
|
+
this.split = null;
|
|
4329
|
+
}
|
|
2653
4330
|
try {
|
|
2654
4331
|
this.client?.destroy();
|
|
2655
4332
|
} catch {
|
|
@@ -2690,6 +4367,131 @@ export class Attached {
|
|
|
2690
4367
|
return process.exit(0);
|
|
2691
4368
|
}
|
|
2692
4369
|
|
|
4370
|
+
/**
|
|
4371
|
+
* `/reload` — quitting and starting again, in one word, for when the client's
|
|
4372
|
+
* own code changed on disk.
|
|
4373
|
+
*
|
|
4374
|
+
* The same argv is run again in a fresh process, which is the only way a
|
|
4375
|
+
* module already loaded is read again; this one hands it the terminal and
|
|
4376
|
+
* waits, and leaves with its exit code. The daemon goes too — `q`'s SIGINT,
|
|
4377
|
+
* then waiting for it to be gone — when nothing is running here, so the
|
|
4378
|
+
* relaunch starts a daemon on the new code as well. When sessions are live
|
|
4379
|
+
* it is kept, and said: restarting it would end them, and a reload is not a
|
|
4380
|
+
* reason to lose somebody's work. `--attach` is refused: there the daemon IS
|
|
4381
|
+
* this process.
|
|
4382
|
+
*/
|
|
4383
|
+
async reload() {
|
|
4384
|
+
if (this.options.onQuit) {
|
|
4385
|
+
return this.note('here the runner is this process (runner.mjs --attach) — quit and start it again');
|
|
4386
|
+
}
|
|
4387
|
+
const live = this.liveHere();
|
|
4388
|
+
const pid = this.runner?.pid ?? null;
|
|
4389
|
+
const restartDaemon = Boolean(pid) && live === 0;
|
|
4390
|
+
// Taken before anything is torn down: leaving advanced mode below would
|
|
4391
|
+
// otherwise be what gets saved.
|
|
4392
|
+
const state = this.reloadState();
|
|
4393
|
+
|
|
4394
|
+
this.stopped = true;
|
|
4395
|
+
if (this.split) {
|
|
4396
|
+
clearInterval(this.split.plannedTimer);
|
|
4397
|
+
this.split.screen.release();
|
|
4398
|
+
this.split = null;
|
|
4399
|
+
}
|
|
4400
|
+
try {
|
|
4401
|
+
this.client?.destroy();
|
|
4402
|
+
} catch {
|
|
4403
|
+
// Going anyway.
|
|
4404
|
+
}
|
|
4405
|
+
if (this.pending.length) {
|
|
4406
|
+
this.screen.update(this.pending, []);
|
|
4407
|
+
this.pending = [];
|
|
4408
|
+
}
|
|
4409
|
+
this.screen.close();
|
|
4410
|
+
if (!this.plain && process.stdin.isTTY) {
|
|
4411
|
+
process.stdout.write(`${ESC}[?2004l`);
|
|
4412
|
+
}
|
|
4413
|
+
process.stdin.setRawMode?.(false);
|
|
4414
|
+
process.stdin.removeAllListeners('data');
|
|
4415
|
+
process.stdin.pause();
|
|
4416
|
+
|
|
4417
|
+
if (restartDaemon) {
|
|
4418
|
+
this.stopDaemon(pid);
|
|
4419
|
+
const alive = this.options.alive ?? ((each) => {
|
|
4420
|
+
try {
|
|
4421
|
+
process.kill(each, 0);
|
|
4422
|
+
return true;
|
|
4423
|
+
} catch {
|
|
4424
|
+
return false;
|
|
4425
|
+
}
|
|
4426
|
+
});
|
|
4427
|
+
for (let waited = 0; waited < 100 && alive(pid); waited++) {
|
|
4428
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
4429
|
+
}
|
|
4430
|
+
console.log(`\n ${this.ink.muted('reloading — the runner and this terminal, on the code on disk')}\n`);
|
|
4431
|
+
} else {
|
|
4432
|
+
console.log(`\n ${this.ink.muted(live
|
|
4433
|
+
? `reloading this terminal — the runner keeps running, ${live} session${live === 1 ? ' is' : 's are'}`
|
|
4434
|
+
+ ' live on it; /reload again once they end to reload it too'
|
|
4435
|
+
: 'reloading this terminal')}\n`);
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
// `--setup` was a request to walk setup once, not every time.
|
|
4439
|
+
const argv = process.argv.slice(1).filter((arg) => arg !== '--setup');
|
|
4440
|
+
const relaunch = this.options.relaunch ?? ((args, env) => {
|
|
4441
|
+
const child = spawn(process.execPath, [...process.execArgv, ...args],
|
|
4442
|
+
{ stdio: 'inherit', env: { ...process.env, ...env } });
|
|
4443
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
4444
|
+
return child;
|
|
4445
|
+
});
|
|
4446
|
+
relaunch(argv, { [RELOAD_STATE]: JSON.stringify(state) });
|
|
4447
|
+
return undefined;
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
/**
|
|
4451
|
+
* What `/reload` carries across the restart — only what the person arranged,
|
|
4452
|
+
* never what the daemon will say again anyway. Ended sessions go with their
|
|
4453
|
+
* daemon-side facts, because the daemon forgot them and their windows (and
|
|
4454
|
+
* the /develop or /pr on them) would otherwise be gone.
|
|
4455
|
+
*/
|
|
4456
|
+
reloadState() {
|
|
4457
|
+
return {
|
|
4458
|
+
advanced: Boolean(this.split),
|
|
4459
|
+
projects: this.split?.projects ? [...this.split.projects] : null,
|
|
4460
|
+
focus: this.split?.focus ?? null,
|
|
4461
|
+
watching: this.watching,
|
|
4462
|
+
showLog: this.showLog,
|
|
4463
|
+
closed: [...this.closed],
|
|
4464
|
+
ended: [...this.sessions.values()]
|
|
4465
|
+
.filter((meta) => meta.ended && !this.closed.has(meta.id))
|
|
4466
|
+
.map(({ id, projectSlug, label, profile, state }) => ({ id, projectSlug, label, profile, state })),
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
/** Puts back what `reloadState` saved. Anything missing is simply not restored. */
|
|
4471
|
+
restore(state) {
|
|
4472
|
+
if (!state || typeof state !== 'object') return;
|
|
4473
|
+
for (const meta of Array.isArray(state.ended) ? state.ended : []) {
|
|
4474
|
+
if (meta?.id && !this.sessions.has(meta.id)) {
|
|
4475
|
+
const restored = { ...meta, ended: true, view: null, prAsked: null };
|
|
4476
|
+
this.sessions.set(meta.id, restored);
|
|
4477
|
+
void this.readView(restored);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
for (const id of Array.isArray(state.closed) ? state.closed : []) {
|
|
4481
|
+
this.closed.add(id);
|
|
4482
|
+
}
|
|
4483
|
+
if (state.watching) {
|
|
4484
|
+
this.watching = state.watching;
|
|
4485
|
+
}
|
|
4486
|
+
this.showLog = state.showLog === true;
|
|
4487
|
+
if (state.advanced && !this.plain) {
|
|
4488
|
+
this.enterSplit(Array.isArray(state.projects) ? state.projects : null);
|
|
4489
|
+
if (state.focus && this.sessions.has(state.focus)) {
|
|
4490
|
+
this.split.focus = state.focus;
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
|
|
2693
4495
|
/**
|
|
2694
4496
|
* Whether leaving here ends the daemon — R123.
|
|
2695
4497
|
*
|
|
@@ -2844,6 +4646,11 @@ export class Attached {
|
|
|
2844
4646
|
parts.push(`${ink.warn(pending.approval?.suggestion ? 'y/s/Y/n' : 'y/s/n')} `
|
|
2845
4647
|
+ `${ink.muted('permission')}`);
|
|
2846
4648
|
}
|
|
4649
|
+
if (this.split) {
|
|
4650
|
+
parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
|
|
4651
|
+
parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
|
|
4652
|
+
? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
|
|
4653
|
+
}
|
|
2847
4654
|
parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
|
|
2848
4655
|
parts.push(`${ink.text('c')} ${ink.muted('config')}`);
|
|
2849
4656
|
parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);
|