cawdev-cli 1.0.5-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 +38 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +5 -0
- package/lib/roadmap-format.mjs +52 -0
- package/mcp/server.mjs +50 -4
- package/package.json +1 -1
- package/runner/README.md +142 -19
- package/runner/attach.mjs +627 -48
- package/runner/cawdev.mjs +17 -2
- package/runner/configure.mjs +34 -0
- package/runner/input.mjs +21 -8
- package/runner/prefs.mjs +52 -0
- package/runner/runner.mjs +152 -21
- package/runner/split.mjs +109 -16
package/runner/attach.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import { basename, dirname } from 'node:path';
|
|
|
52
52
|
import { listSockets, socketPathFor } from './control.mjs';
|
|
53
53
|
import {
|
|
54
54
|
RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
55
|
-
setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
|
|
55
|
+
removeProject, setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
|
|
56
56
|
} from './configure.mjs';
|
|
57
57
|
import {
|
|
58
58
|
TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
|
|
@@ -60,11 +60,12 @@ import {
|
|
|
60
60
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
61
61
|
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
62
62
|
import {
|
|
63
|
-
MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
|
-
carriesOnHere, cardTitleFor, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
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
65
|
windowLines,
|
|
66
66
|
} from './split.mjs';
|
|
67
|
-
import { KNOWN_AGENTS } from '../lib/agents.mjs';
|
|
67
|
+
import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
|
|
68
|
+
import { loadPrefs, savePrefs } from './prefs.mjs';
|
|
68
69
|
import { oneLine } from './brand.mjs';
|
|
69
70
|
import { Scrollback } from './scrollback.mjs';
|
|
70
71
|
import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
@@ -730,7 +731,7 @@ function qualityOf(entry) {
|
|
|
730
731
|
return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
|
|
731
732
|
}
|
|
732
733
|
|
|
733
|
-
export function configMenu(file, runner = {}) {
|
|
734
|
+
export function configMenu(file, runner = {}, gone = new Set()) {
|
|
734
735
|
const slugs = slugsOf(file);
|
|
735
736
|
const agents = agentsOf(file);
|
|
736
737
|
const rulesOn = file.acceptsRulesFromConsole === true;
|
|
@@ -767,11 +768,39 @@ export function configMenu(file, runner = {}) {
|
|
|
767
768
|
...slugs.map((slug) => (qualityOf(file.projects[slug])
|
|
768
769
|
? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
|
|
769
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
|
+
})),
|
|
770
778
|
{ id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
|
|
771
779
|
],
|
|
772
780
|
});
|
|
773
781
|
}
|
|
774
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
|
+
|
|
775
804
|
/** The slash commands, and the one place they are described. */
|
|
776
805
|
const COMMANDS = [
|
|
777
806
|
['/help', 'this list'],
|
|
@@ -783,11 +812,15 @@ const COMMANDS = [
|
|
|
783
812
|
['/log', "the daemon's own log, on or off"],
|
|
784
813
|
['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
|
|
785
814
|
['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
|
|
786
|
-
['/
|
|
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)'],
|
|
787
818
|
['/pr', "open the pull request of a finished session's branch"],
|
|
788
819
|
['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
|
|
789
820
|
['/projects', 'which projects advanced mode shows — tick one or several'],
|
|
790
|
-
['/
|
|
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"],
|
|
791
824
|
['/drop', 'delete the checkout a finished session was made on demand for'],
|
|
792
825
|
['/close', "close an ended session's window"],
|
|
793
826
|
['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
|
|
@@ -952,6 +985,17 @@ export class Attached {
|
|
|
952
985
|
this.sessions = new Map();
|
|
953
986
|
/** Ended sessions whose window somebody closed. */
|
|
954
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;
|
|
955
999
|
/** The PLANNED cards of the projects this machine serves — advanced mode's top strip. */
|
|
956
1000
|
this.planned = [];
|
|
957
1001
|
// Every card of every served project, whatever its status — the strip.
|
|
@@ -980,6 +1024,8 @@ export class Attached {
|
|
|
980
1024
|
async start() {
|
|
981
1025
|
this.screen.open();
|
|
982
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;
|
|
983
1029
|
// Raw mode only where there is a terminal to put in it. Through a pipe
|
|
984
1030
|
// there are no keys, the transcript is the whole output, and that is the
|
|
985
1031
|
// honest degradation rather than a broken one.
|
|
@@ -1296,9 +1342,19 @@ export class Attached {
|
|
|
1296
1342
|
|
|
1297
1343
|
note(text) {
|
|
1298
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;
|
|
1299
1349
|
this.dirty = true;
|
|
1300
1350
|
}
|
|
1301
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
|
+
|
|
1302
1358
|
// --- what is waiting on a person -----------------------------------------
|
|
1303
1359
|
|
|
1304
1360
|
/**
|
|
@@ -1567,7 +1623,17 @@ export class Attached {
|
|
|
1567
1623
|
return this.cycleFocus(key === '\t' ? 1 : -1);
|
|
1568
1624
|
}
|
|
1569
1625
|
if (this.split && key === 'd') {
|
|
1570
|
-
|
|
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();
|
|
1571
1637
|
}
|
|
1572
1638
|
|
|
1573
1639
|
switch (key) {
|
|
@@ -1784,7 +1850,8 @@ export class Attached {
|
|
|
1784
1850
|
// With a completion list open, enter takes the highlighted command —
|
|
1785
1851
|
// which is what makes guessing a name stop being a step. With none, it
|
|
1786
1852
|
// sends what was typed.
|
|
1787
|
-
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) {
|
|
1788
1855
|
line.set(rows[at][0]);
|
|
1789
1856
|
}
|
|
1790
1857
|
return this.submit();
|
|
@@ -1805,7 +1872,28 @@ export class Attached {
|
|
|
1805
1872
|
return undefined;
|
|
1806
1873
|
}
|
|
1807
1874
|
|
|
1808
|
-
|
|
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) {
|
|
1809
1897
|
if (this.input.path && rows.length) {
|
|
1810
1898
|
this.input.path.at = (at - 1 + rows.length) % rows.length;
|
|
1811
1899
|
this.dirty = true;
|
|
@@ -1819,11 +1907,12 @@ export class Attached {
|
|
|
1819
1907
|
const older = this.history.back(line.text);
|
|
1820
1908
|
if (older !== null) {
|
|
1821
1909
|
line.set(older);
|
|
1910
|
+
this.input.recalling = true;
|
|
1822
1911
|
this.dirty = true;
|
|
1823
1912
|
}
|
|
1824
1913
|
return undefined;
|
|
1825
1914
|
}
|
|
1826
|
-
if (
|
|
1915
|
+
if (down) {
|
|
1827
1916
|
if (this.input.path && rows.length) {
|
|
1828
1917
|
this.input.path.at = (at + 1) % rows.length;
|
|
1829
1918
|
this.dirty = true;
|
|
@@ -1841,6 +1930,10 @@ export class Attached {
|
|
|
1841
1930
|
}
|
|
1842
1931
|
return undefined;
|
|
1843
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
|
+
}
|
|
1844
1937
|
|
|
1845
1938
|
if (key === '\x7f' || key === '\b') {
|
|
1846
1939
|
line.backspace();
|
|
@@ -1905,7 +1998,10 @@ export class Attached {
|
|
|
1905
1998
|
if (kind === 'command' || (kind === 'prompt' && typed.startsWith('/'))) {
|
|
1906
1999
|
// After the write, not beside it: `/logout` forgets the history, and a
|
|
1907
2000
|
// write still in flight would put the command that cleared it back.
|
|
1908
|
-
|
|
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));
|
|
1909
2005
|
}
|
|
1910
2006
|
if (kind === 'prompt') {
|
|
1911
2007
|
return void this.send(text);
|
|
@@ -2039,10 +2135,20 @@ export class Attached {
|
|
|
2039
2135
|
if (select.kind === 'drop') {
|
|
2040
2136
|
return void this.droppedCheckout(row);
|
|
2041
2137
|
}
|
|
2138
|
+
if (select.kind === 'view-card') {
|
|
2139
|
+
const [slug, ref] = String(row.id).split(' ');
|
|
2140
|
+
return void this.showCard(slug, ref);
|
|
2141
|
+
}
|
|
2042
2142
|
if (select.kind === 'planned') {
|
|
2043
2143
|
const [slug, ref] = String(row.id).split(' ');
|
|
2044
2144
|
return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
|
|
2045
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
|
+
}
|
|
2046
2152
|
if (select.kind === 'project') {
|
|
2047
2153
|
// `/new` asked which project; the same command again, with it named.
|
|
2048
2154
|
const words = this.newWaitingForProject ?? [];
|
|
@@ -2356,7 +2462,7 @@ export class Attached {
|
|
|
2356
2462
|
const ask = { line: (prompt) => this.askLine(prompt) };
|
|
2357
2463
|
const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
|
|
2358
2464
|
|
|
2359
|
-
const chosen = await this.askPick(configMenu(file, this.runner));
|
|
2465
|
+
const chosen = await this.askPick(configMenu(file, this.runner, this.gone));
|
|
2360
2466
|
this.say('');
|
|
2361
2467
|
this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
|
|
2362
2468
|
|
|
@@ -2408,6 +2514,15 @@ export class Attached {
|
|
|
2408
2514
|
});
|
|
2409
2515
|
return this.reloadDaemon(file, next);
|
|
2410
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
|
+
}
|
|
2411
2526
|
if (chosen.id === 'rules') {
|
|
2412
2527
|
const next = await setAcceptsRulesFromConsole({
|
|
2413
2528
|
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
|
@@ -2627,7 +2742,7 @@ export class Attached {
|
|
|
2627
2742
|
// --- slash commands ---------------------------------------------------------
|
|
2628
2743
|
|
|
2629
2744
|
async runCommand(text) {
|
|
2630
|
-
const [word, ...rest] = text.slice(1)
|
|
2745
|
+
const [word, ...rest] = commandWords(text.slice(1));
|
|
2631
2746
|
switch (word) {
|
|
2632
2747
|
case 'help':
|
|
2633
2748
|
this.say('');
|
|
@@ -2673,16 +2788,24 @@ export class Attached {
|
|
|
2673
2788
|
}
|
|
2674
2789
|
case 'projects':
|
|
2675
2790
|
return this.askSplitProjects();
|
|
2791
|
+
case 'refresh':
|
|
2792
|
+
return void this.refreshCards();
|
|
2676
2793
|
case 'new':
|
|
2677
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]);
|
|
2678
2799
|
case 'develop':
|
|
2679
|
-
return void this.develop();
|
|
2800
|
+
return void this.develop(rest);
|
|
2680
2801
|
case 'pr':
|
|
2681
2802
|
return void this.openPullRequest();
|
|
2682
2803
|
case 'continue':
|
|
2683
2804
|
return void this.carryOn();
|
|
2684
2805
|
case 'open':
|
|
2685
|
-
return this.openInBrowser();
|
|
2806
|
+
return void this.openInBrowser();
|
|
2807
|
+
case 'view':
|
|
2808
|
+
return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
|
|
2686
2809
|
case 'drop':
|
|
2687
2810
|
return void this.dropCheckout();
|
|
2688
2811
|
case 'close':
|
|
@@ -2770,6 +2893,12 @@ export class Attached {
|
|
|
2770
2893
|
if (!known) {
|
|
2771
2894
|
this.sessions.set(run.id, meta);
|
|
2772
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
|
+
}
|
|
2773
2902
|
}
|
|
2774
2903
|
if (this.split) {
|
|
2775
2904
|
this.requestBacklogFor(run.id);
|
|
@@ -2800,6 +2929,8 @@ export class Attached {
|
|
|
2800
2929
|
try {
|
|
2801
2930
|
meta.view = await this.session.request(`/api/projects/${meta.projectSlug}/runs/${meta.id}`);
|
|
2802
2931
|
this.dirty = true;
|
|
2932
|
+
await this.countCommits(meta);
|
|
2933
|
+
this.sayWhyItFailed(meta);
|
|
2803
2934
|
if (meta.ended && meta.view?.live && retries > 0) {
|
|
2804
2935
|
const again = setTimeout(() => void this.readView(meta, retries - 1), 2000);
|
|
2805
2936
|
again.unref?.();
|
|
@@ -2809,6 +2940,70 @@ export class Attached {
|
|
|
2809
2940
|
}
|
|
2810
2941
|
}
|
|
2811
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
|
+
|
|
2812
3007
|
/**
|
|
2813
3008
|
* Which session a question or a request in the prompt comes from, by the
|
|
2814
3009
|
* number its window carries (the position in the run list outside
|
|
@@ -2830,9 +3025,14 @@ export class Attached {
|
|
|
2830
3025
|
&& (!only || only.has(meta.projectSlug)));
|
|
2831
3026
|
}
|
|
2832
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
|
+
|
|
2833
3033
|
/** The projects on screen: advanced mode's pick, else every one served here. */
|
|
2834
3034
|
shownProjects() {
|
|
2835
|
-
const served = this.
|
|
3035
|
+
const served = this.servedProjects();
|
|
2836
3036
|
return this.split?.projects ? served.filter((slug) => this.split.projects.has(slug)) : served;
|
|
2837
3037
|
}
|
|
2838
3038
|
|
|
@@ -2865,12 +3065,19 @@ export class Attached {
|
|
|
2865
3065
|
* today's behaviour). One project served: nothing to ask. `/projects` asks
|
|
2866
3066
|
* again from inside the mode.
|
|
2867
3067
|
*/
|
|
2868
|
-
askSplitProjects() {
|
|
3068
|
+
async askSplitProjects() {
|
|
2869
3069
|
if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
|
|
2870
|
-
|
|
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();
|
|
2871
3075
|
if (served.length <= 1) {
|
|
2872
|
-
|
|
3076
|
+
const entered = this.split ? this.note('this machine serves one project') : this.enterSplit(served);
|
|
3077
|
+
this.sayGone();
|
|
3078
|
+
return entered;
|
|
2873
3079
|
}
|
|
3080
|
+
this.sayGone();
|
|
2874
3081
|
const current = this.split?.projects ? [...this.split.projects] : served;
|
|
2875
3082
|
return this.openPicker(new Select({
|
|
2876
3083
|
kind: 'split-projects',
|
|
@@ -3002,10 +3209,13 @@ export class Attached {
|
|
|
3002
3209
|
const banner = this.select && this.select.kind !== 'runs' ? [] : pending
|
|
3003
3210
|
? permissionBanner(pending, inner, ink, this.runner ?? {})
|
|
3004
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}] `)) : '';
|
|
3005
3215
|
const input = this.mode === 'typing'
|
|
3006
|
-
? `${this.input.label} ${this.input.line.render(
|
|
3007
|
-
Math.max(8, inner - visibleWidth(this.input.label) - 1), ink)}`
|
|
3008
|
-
: ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
|
|
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'}`
|
|
3009
3219
|
+ ' · / commands · tab next window');
|
|
3010
3220
|
// A question or a request on screen says which window it came from.
|
|
3011
3221
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
@@ -3015,8 +3225,12 @@ export class Attached {
|
|
|
3015
3225
|
// more than a third of the screen, and never so tall that no window fits
|
|
3016
3226
|
// under it — a short terminal drops it before it drops the sessions.
|
|
3017
3227
|
const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
|
|
3018
|
-
|
|
3019
|
-
|
|
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))]
|
|
3020
3234
|
: [];
|
|
3021
3235
|
const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
|
|
3022
3236
|
// Four windows at most; the rest are named on one line above the prompt,
|
|
@@ -3078,7 +3292,8 @@ export class Attached {
|
|
|
3078
3292
|
return wordWrap(line.body, inner - 2 - line.prefix.length).map((part) => `${line.prefix}${line.paint(part)}`);
|
|
3079
3293
|
});
|
|
3080
3294
|
const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
|
|
3081
|
-
+ `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}
|
|
3295
|
+
+ `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
|
|
3296
|
+
+ `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
|
|
3082
3297
|
pieces.push({
|
|
3083
3298
|
rect: layout.prompt,
|
|
3084
3299
|
lines: windowLines(layout.prompt, {
|
|
@@ -3091,7 +3306,9 @@ export class Attached {
|
|
|
3091
3306
|
...messages,
|
|
3092
3307
|
...fixed,
|
|
3093
3308
|
],
|
|
3094
|
-
footer: this.status
|
|
3309
|
+
footer: this.status
|
|
3310
|
+
? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
|
|
3311
|
+
: keyList(this.keys(), '', inner, ink),
|
|
3095
3312
|
}, ink),
|
|
3096
3313
|
});
|
|
3097
3314
|
if (strip) {
|
|
@@ -3107,11 +3324,18 @@ export class Attached {
|
|
|
3107
3324
|
* Every card of every project this machine serves, whatever its status —
|
|
3108
3325
|
* the strip draws them all, and `d` offers the PLANNED ones. One request
|
|
3109
3326
|
* per project, the board's own (`brief`, so no bodies). Needs a person's
|
|
3110
|
-
* session, like everything read off the roadmap here
|
|
3111
|
-
*
|
|
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.
|
|
3112
3334
|
*/
|
|
3113
|
-
async refreshPlanned() {
|
|
3114
|
-
if (!this.session.signedIn) return;
|
|
3335
|
+
async refreshPlanned({ hold = false } = {}) {
|
|
3336
|
+
if (!this.session.signedIn) return { signedIn: false, read: [], failed: [] };
|
|
3337
|
+
const read = [];
|
|
3338
|
+
const failed = [];
|
|
3115
3339
|
const all = [];
|
|
3116
3340
|
for (const slug of this.runner?.projects ?? []) {
|
|
3117
3341
|
try {
|
|
@@ -3119,13 +3343,66 @@ export class Attached {
|
|
|
3119
3343
|
for (const card of Array.isArray(cards) ? cards : []) {
|
|
3120
3344
|
all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
|
|
3121
3345
|
}
|
|
3122
|
-
|
|
3123
|
-
|
|
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));
|
|
3124
3355
|
}
|
|
3125
3356
|
}
|
|
3126
3357
|
this.cards = all;
|
|
3127
3358
|
this.planned = all.filter((card) => card.status === 'PLANNED');
|
|
3128
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)` : ''}`);
|
|
3129
3406
|
}
|
|
3130
3407
|
|
|
3131
3408
|
/**
|
|
@@ -3170,7 +3447,10 @@ export class Attached {
|
|
|
3170
3447
|
return;
|
|
3171
3448
|
}
|
|
3172
3449
|
const anyway = words.includes('--anyway');
|
|
3173
|
-
|
|
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)), {
|
|
3174
3454
|
// In advanced mode, among the projects it shows.
|
|
3175
3455
|
projects: this.shownProjects(),
|
|
3176
3456
|
});
|
|
@@ -3197,9 +3477,19 @@ export class Attached {
|
|
|
3197
3477
|
// branch with no card: no number, nothing on the development board,
|
|
3198
3478
|
// and a prompt that never said "commit" — so the work ended
|
|
3199
3479
|
// uncommitted and `/pr` had nothing to propose.
|
|
3200
|
-
await this.developNewCard(parsed.slug, parsed.text, anyway);
|
|
3480
|
+
await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
|
|
3201
3481
|
return;
|
|
3202
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
|
+
}
|
|
3203
3493
|
const [path, body, what] = parsed.kind === 'ask'
|
|
3204
3494
|
? ['runs/ask', { prompt: parsed.text }, 'a question']
|
|
3205
3495
|
: parsed.kind === 'plan'
|
|
@@ -3208,7 +3498,107 @@ export class Attached {
|
|
|
3208
3498
|
// R150: the session writes the card first, then plans it.
|
|
3209
3499
|
: ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
|
|
3210
3500
|
: ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
|
|
3211
|
-
await this.startSession(parsed.slug, path, body, what, anyway);
|
|
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(', ')}`);
|
|
3541
|
+
}
|
|
3542
|
+
this.agent = wanted;
|
|
3543
|
+
await savePrefs(this.session.url, { agent: wanted }).catch(() => undefined);
|
|
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();
|
|
3212
3602
|
}
|
|
3213
3603
|
|
|
3214
3604
|
/**
|
|
@@ -3217,7 +3607,7 @@ export class Attached {
|
|
|
3217
3607
|
* but whose run was refused (a usage window, say) is still on the board,
|
|
3218
3608
|
* and the person should know which half happened.
|
|
3219
3609
|
*/
|
|
3220
|
-
async developNewCard(slug, text, anyway) {
|
|
3610
|
+
async developNewCard(slug, text, anyway, agent = this.agent) {
|
|
3221
3611
|
let card;
|
|
3222
3612
|
try {
|
|
3223
3613
|
card = await this.session.request(`/api/projects/${slug}/roadmap`, {
|
|
@@ -3234,7 +3624,8 @@ export class Attached {
|
|
|
3234
3624
|
return;
|
|
3235
3625
|
}
|
|
3236
3626
|
this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
|
|
3237
|
-
await this.startSession(slug, 'runs', { entryNumber: ref },
|
|
3627
|
+
await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
|
|
3628
|
+
withWho(`the development of ${ref}`, agent), anyway);
|
|
3238
3629
|
void this.refreshPlanned();
|
|
3239
3630
|
}
|
|
3240
3631
|
|
|
@@ -3330,20 +3721,39 @@ export class Attached {
|
|
|
3330
3721
|
}
|
|
3331
3722
|
}
|
|
3332
3723
|
|
|
3333
|
-
/**
|
|
3334
|
-
|
|
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 = []) {
|
|
3335
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
|
+
}
|
|
3336
3746
|
const meta = this.commandTarget();
|
|
3337
3747
|
const ref = meta?.view?.entryRef;
|
|
3338
3748
|
if (!meta || !ref) {
|
|
3339
|
-
this.note('/develop needs a session on a card — focus a finished plan');
|
|
3749
|
+
this.note('/develop needs a session on a card — focus a finished plan, or name one: /develop R30');
|
|
3340
3750
|
return;
|
|
3341
3751
|
}
|
|
3342
3752
|
if (!meta.ended) {
|
|
3343
3753
|
this.note(`${ref} is still being worked on — wait for it to end`);
|
|
3344
3754
|
return;
|
|
3345
3755
|
}
|
|
3346
|
-
await this.
|
|
3756
|
+
await this.startNew(['dev', ref, `@${meta.projectSlug}`, ...flags]);
|
|
3347
3757
|
}
|
|
3348
3758
|
|
|
3349
3759
|
/**
|
|
@@ -3364,10 +3774,12 @@ export class Attached {
|
|
|
3364
3774
|
});
|
|
3365
3775
|
this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
|
|
3366
3776
|
this.note('');
|
|
3777
|
+
return true;
|
|
3367
3778
|
} catch (failure) {
|
|
3368
3779
|
this.note(/usage|window/i.test(failure.message)
|
|
3369
3780
|
? `${failure.message} — add --anyway to start it regardless`
|
|
3370
3781
|
: failure.message);
|
|
3782
|
+
return false;
|
|
3371
3783
|
}
|
|
3372
3784
|
}
|
|
3373
3785
|
|
|
@@ -3467,21 +3879,181 @@ export class Attached {
|
|
|
3467
3879
|
again.unref?.();
|
|
3468
3880
|
}
|
|
3469
3881
|
|
|
3470
|
-
/**
|
|
3471
|
-
|
|
3472
|
-
|
|
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
|
+
}
|
|
3473
3916
|
if (!isPullRequest(url)) {
|
|
3474
3917
|
return this.note('no pull request on this session yet — /pr opens one');
|
|
3475
3918
|
}
|
|
3919
|
+
this.openLink(url);
|
|
3920
|
+
return this.note(`opened ${url}`);
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
/** A link, in this machine's browser. */
|
|
3924
|
+
openLink(url) {
|
|
3476
3925
|
const opener = this.options.openUrl ?? ((link) => {
|
|
3477
3926
|
const command = process.platform === 'darwin' ? 'open'
|
|
3478
3927
|
: process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
3479
3928
|
spawn(command, [link], { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
|
|
3480
3929
|
});
|
|
3481
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);
|
|
3482
3949
|
return this.note(`opened ${url}`);
|
|
3483
3950
|
}
|
|
3484
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
|
+
}));
|
|
4019
|
+
return undefined;
|
|
4020
|
+
}
|
|
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
|
+
|
|
3485
4057
|
/**
|
|
3486
4058
|
* `/drop` — delete the checkout a finished session was cloned into.
|
|
3487
4059
|
*
|
|
@@ -3556,11 +4128,17 @@ export class Attached {
|
|
|
3556
4128
|
const meta = this.commandTarget();
|
|
3557
4129
|
if (!meta) return this.note('no window to close');
|
|
3558
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) {
|
|
3559
4137
|
this.closed.add(meta.id);
|
|
3560
|
-
if (this.split) {
|
|
4138
|
+
if (this.split && (this.split.focus === meta.id || !this.sessions.has(this.split.focus))) {
|
|
3561
4139
|
this.split.focus = this.windows()[0]?.id ?? null;
|
|
3562
4140
|
}
|
|
3563
|
-
|
|
4141
|
+
this.dirty = true;
|
|
3564
4142
|
}
|
|
3565
4143
|
|
|
3566
4144
|
// --- the things a person can do ---------------------------------------------
|
|
@@ -4070,7 +4648,8 @@ export class Attached {
|
|
|
4070
4648
|
}
|
|
4071
4649
|
if (this.split) {
|
|
4072
4650
|
parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
|
|
4073
|
-
parts.push(`${ink.text('d')} ${ink.muted(
|
|
4651
|
+
parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
|
|
4652
|
+
? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
|
|
4074
4653
|
}
|
|
4075
4654
|
parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
|
|
4076
4655
|
parts.push(`${ink.text('c')} ${ink.muted('config')}`);
|