cawdev-cli 1.0.5-beta → 1.0.7-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runner/attach.mjs CHANGED
@@ -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';
@@ -481,7 +482,7 @@ export function elapsed(ms) {
481
482
  */
482
483
  export function footerLines(state, width, ink = painter(3)) {
483
484
  const {
484
- runner, runs = [], email, watching, connected = true,
485
+ runner, runs = [], email, expired = false, watching, connected = true,
485
486
  banner = [], overlay = [], input = null, keys = '', status = '',
486
487
  running = null, now = Date.now(), matches = [],
487
488
  } = state;
@@ -518,6 +519,16 @@ export function footerLines(state, width, ink = painter(3)) {
518
519
  ), width));
519
520
  }
520
521
 
522
+ // The platform refused the stored session (401). Pinned for the same reason
523
+ // as the row above: one line in the scrollback is gone with the next
524
+ // transcript line, and every action from here on would fail without it.
525
+ if (expired) {
526
+ lines.push(padVisible(clip(
527
+ ` ${ink.warn('!')} ${ink.muted('session expired —')} ${ink.text('/login')} ${ink.muted('to sign in again')}`,
528
+ width,
529
+ ), width));
530
+ }
531
+
521
532
  // Who and where. The runner's NAME is what the console shows and what
522
533
  // `--runner` takes, so it is the word somebody would type; the email is what
523
534
  // every action here is done as, and "watching only" is not a lesser state,
@@ -678,19 +689,19 @@ async function openSession(argv, url) {
678
689
  * too is not belt and braces: a machine over ssh has no browser to open, and
679
690
  * this is the only thing that makes it work at all.
680
691
  */
681
- async function runSignIn(session, ink = painter()) {
692
+ async function runSignIn(session, ink = painter(), say = (line) => console.log(line)) {
682
693
  const outcome = await signInThroughBrowser(session, ({ url, code }) => {
683
- console.log(` ${ink.muted('If your browser did not open, go to')}`);
684
- console.log(` ${ink.accent(url)}`);
685
- console.log(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
686
- console.log(` ${ink.muted('Waiting…')}`);
694
+ say(` ${ink.muted('If your browser did not open, go to')}`);
695
+ say(` ${ink.accent(url)}`);
696
+ say(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
697
+ say(` ${ink.muted('Waiting…')}`);
687
698
  });
688
699
  if (outcome.signedIn) {
689
- console.log(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
700
+ say(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
690
701
  } else if (outcome.refused) {
691
- console.log(` ${ink.danger('Refused')} in the browser.\n`);
702
+ say(` ${ink.danger('Refused')} in the browser.\n`);
692
703
  } else {
693
- console.log(` ${ink.warn('The code expired.')}\n`);
704
+ say(` ${ink.warn('The code expired.')}\n`);
694
705
  }
695
706
  return outcome;
696
707
  }
@@ -730,7 +741,7 @@ function qualityOf(entry) {
730
741
  return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
731
742
  }
732
743
 
733
- export function configMenu(file, runner = {}) {
744
+ export function configMenu(file, runner = {}, gone = new Set()) {
734
745
  const slugs = slugsOf(file);
735
746
  const agents = agentsOf(file);
736
747
  const rulesOn = file.acceptsRulesFromConsole === true;
@@ -767,11 +778,42 @@ export function configMenu(file, runner = {}) {
767
778
  ...slugs.map((slug) => (qualityOf(file.projects[slug])
768
779
  ? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
769
780
  : { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
781
+ // One row per served project — first the ones cawdev no longer has,
782
+ // which are the reason anybody comes looking for this row.
783
+ ...[...slugs].sort((a, b) => Number(gone.has(b)) - Number(gone.has(a))).map((slug) => ({
784
+ id: `remove:${slug}`,
785
+ label: `Stop serving ${slug}`,
786
+ hint: gone.has(slug) ? 'not on cawdev any more' : 'its checkouts stay on disk',
787
+ })),
770
788
  { id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
771
789
  ],
772
790
  });
773
791
  }
774
792
 
793
+ /** `/new`'s agent flags: `--claude`, `--codex`, `--agy`. */
794
+ const AGENT_FLAGS = new Set(KNOWN_AGENTS.map((agent) => `--${agent}`));
795
+
796
+ /** A start's body, naming the agent when one was chosen — and nothing when not. */
797
+ function withAgent(body, agent) {
798
+ return agent ? { ...body, agent } : body;
799
+ }
800
+
801
+ /** What was started, saying with which agent when one was named. */
802
+ function withWho(what, agent) {
803
+ return agent ? `${what} with ${agent}` : what;
804
+ }
805
+
806
+ /** How many times a finished dev session's zero commits is read again before it is believed — see countCommits. */
807
+ const COMMIT_REREADS = 6;
808
+
809
+ /** `r29` → `R29`, `I12` → `i12`, `29` as it is: how the API spells a card's ref (R221). */
810
+ function refOf(word) {
811
+ return word.replace(/^r/i, 'R').replace(/^i/i, 'i');
812
+ }
813
+
814
+ /** How many matching commands advanced mode's prompt shows at once. */
815
+ const SPLIT_COMPLETIONS = 6;
816
+
775
817
  /** The slash commands, and the one place they are described. */
776
818
  const COMMANDS = [
777
819
  ['/help', 'this list'],
@@ -783,11 +825,15 @@ const COMMANDS = [
783
825
  ['/log', "the daemon's own log, on or off"],
784
826
  ['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
785
827
  ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
786
- ['/develop', 'start the development of the card a finished plan was about'],
828
+ ['/card', 'add an idea to Considering — no session: /card <the idea> (@project)'],
829
+ ['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
830
+ ['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
787
831
  ['/pr', "open the pull request of a finished session's branch"],
788
832
  ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
789
833
  ['/projects', 'which projects advanced mode shows — tick one or several'],
790
- ['/open', "open the session's pull request in your browser"],
834
+ ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
835
+ ['/open', "open the session's pull request in your browser — or, on a finished plan, its card and plan on cawdev"],
836
+ ['/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
837
  ['/drop', 'delete the checkout a finished session was made on demand for'],
792
838
  ['/close', "close an ended session's window"],
793
839
  ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
@@ -854,6 +900,11 @@ export class Attached {
854
900
  constructor(socketPath, session, options = {}) {
855
901
  this.socketPath = socketPath;
856
902
  this.session = session;
903
+ if (session) {
904
+ session.onExpired = () => this.sessionExpired();
905
+ }
906
+ /** True from a 401 on a session that was signed in until `/login` works. */
907
+ this.expired = false;
857
908
  this.options = options;
858
909
  this.ink = painter();
859
910
  this.screen = new Scrollback(options.out ?? process.stdout);
@@ -952,6 +1003,17 @@ export class Attached {
952
1003
  this.sessions = new Map();
953
1004
  /** Ended sessions whose window somebody closed. */
954
1005
  this.closed = new Set();
1006
+ /**
1007
+ * Projects this machine serves that cawdev answered 404 for — deleted in
1008
+ * the console, or the person's access went. The config still names them,
1009
+ * so without this the terminal kept offering them everywhere it offers a
1010
+ * project. Learnt from `refreshPlanned`, and forgotten when one answers.
1011
+ */
1012
+ this.gone = new Set();
1013
+ /** Of those, the ones not yet said — see `sayGone`. */
1014
+ this.goneUnsaid = [];
1015
+ /** A project whose next new CODE session takes the focus — see startNew. */
1016
+ this.focusOnArrival = null;
955
1017
  /** The PLANNED cards of the projects this machine serves — advanced mode's top strip. */
956
1018
  this.planned = [];
957
1019
  // Every card of every served project, whatever its status — the strip.
@@ -980,6 +1042,8 @@ export class Attached {
980
1042
  async start() {
981
1043
  this.screen.open();
982
1044
  this.history = new History(await loadHistory(this.session.url).catch(() => []));
1045
+ // `/agent`'s choice, from last time — see prefs.mjs.
1046
+ this.agent = (await loadPrefs(this.session.url).catch(() => ({}))).agent ?? null;
983
1047
  // Raw mode only where there is a terminal to put in it. Through a pipe
984
1048
  // there are no keys, the transcript is the whole output, and that is the
985
1049
  // honest degradation rather than a broken one.
@@ -1296,9 +1360,19 @@ export class Attached {
1296
1360
 
1297
1361
  note(text) {
1298
1362
  this.status = text;
1363
+ // In advanced mode, which window it was said about — the focused one at
1364
+ // the moment it was said. A note outlives the focus moving, and "a
1365
+ // session is running" read under another window is a note about nobody.
1366
+ this.statusWindow = this.split && text ? this.windowNumberOf(this.split.focus) : null;
1299
1367
  this.dirty = true;
1300
1368
  }
1301
1369
 
1370
+ /** A window's number, as its title shows it; null when it has none. */
1371
+ windowNumberOf(id) {
1372
+ const at = id ? this.windows().findIndex((meta) => meta.id === id) : -1;
1373
+ return at >= 0 ? at + 1 : null;
1374
+ }
1375
+
1302
1376
  // --- what is waiting on a person -----------------------------------------
1303
1377
 
1304
1378
  /**
@@ -1567,7 +1641,17 @@ export class Attached {
1567
1641
  return this.cycleFocus(key === '\t' ? 1 : -1);
1568
1642
  }
1569
1643
  if (this.split && key === 'd') {
1570
- return void this.pickPlanned();
1644
+ // On a finished plan's window, `d` is that plan's `/develop`: the card
1645
+ // it planned, by the agent that planned it, in the window it leaves.
1646
+ // Anywhere else, the list of planned cards.
1647
+ return void (this.finishedPlan(this.commandTarget()) ? this.develop() : this.pickPlanned());
1648
+ }
1649
+ // ↑ with no line open: the last thing typed, on a line ready to send — as
1650
+ // a shell does. Not in advanced mode, where the wheel arrives as arrows
1651
+ // (`?1007h`) and would pull the history up on every scroll; there ↑ works
1652
+ // on the line itself (`/` or enter, then ↑).
1653
+ if (!this.split && (key === `${ESC}[A` || key === `${ESC}OA`)) {
1654
+ return this.recallIntoALine();
1571
1655
  }
1572
1656
 
1573
1657
  switch (key) {
@@ -1784,7 +1868,8 @@ export class Attached {
1784
1868
  // With a completion list open, enter takes the highlighted command —
1785
1869
  // which is what makes guessing a name stop being a step. With none, it
1786
1870
  // sends what was typed.
1787
- if (rows.length && this.input.showing !== false && this.input.kind === 'command') {
1871
+ if (rows.length && this.input.showing !== false && this.input.kind === 'command'
1872
+ && !this.input.recalling) {
1788
1873
  line.set(rows[at][0]);
1789
1874
  }
1790
1875
  return this.submit();
@@ -1797,15 +1882,44 @@ export class Attached {
1797
1882
  }
1798
1883
  if (rows.length) {
1799
1884
  // As far as they agree, which is what every shell does and nobody has
1800
- // to be taught. One match completes it whole.
1801
- line.set(commonPrefix(rows.map(([name]) => name)));
1885
+ // to be taught. One match completes it whole. Once the line is already
1886
+ // that far, Tab moves down the list instead — `/log` is a command AND
1887
+ // the prefix of `/login` and `/logout`, and Tab stopping there for
1888
+ // good left those two reachable only by the arrows.
1889
+ const prefix = commonPrefix(rows.map(([name]) => name));
1890
+ if (rows.length > 1 && line.text.toLowerCase() === prefix.toLowerCase() && this.input.showing !== false) {
1891
+ this.input.at = (at + 1) % rows.length;
1892
+ } else {
1893
+ line.set(prefix);
1894
+ }
1802
1895
  this.input.showing = true;
1803
1896
  this.dirty = true;
1804
1897
  }
1805
1898
  return undefined;
1806
1899
  }
1807
1900
 
1808
- if (key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10') {
1901
+ const up = key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10';
1902
+ const down = key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e';
1903
+ // ↑ on a line nothing has been typed on yet — empty, or the bare `/` a
1904
+ // command line opens with — is HISTORY, as in every shell; so is ↑/↓
1905
+ // while walking it. Only once something is typed do the arrows move in the
1906
+ // list of commands. Before this the `/` line showed every command and ↑
1907
+ // moved in that list, so a command line could never reach its history.
1908
+ const untouched = line.text === '' || line.text === '/';
1909
+ if ((up && (untouched || this.input.recalling)) || (down && this.input.recalling)) {
1910
+ const keep = this.input.kind === 'command' ? (entry) => entry.startsWith('/') : undefined;
1911
+ const recalled = up ? this.history.back(line.text, keep) : this.history.forward(keep);
1912
+ if (recalled !== null) {
1913
+ line.set(recalled);
1914
+ // Walking the history, not the completions: enter sends the line as
1915
+ // recalled rather than swapping it for the highlighted command.
1916
+ this.input.recalling = true;
1917
+ this.input.showing = false;
1918
+ this.dirty = true;
1919
+ }
1920
+ return undefined;
1921
+ }
1922
+ if (up) {
1809
1923
  if (this.input.path && rows.length) {
1810
1924
  this.input.path.at = (at - 1 + rows.length) % rows.length;
1811
1925
  this.dirty = true;
@@ -1819,11 +1933,12 @@ export class Attached {
1819
1933
  const older = this.history.back(line.text);
1820
1934
  if (older !== null) {
1821
1935
  line.set(older);
1936
+ this.input.recalling = true;
1822
1937
  this.dirty = true;
1823
1938
  }
1824
1939
  return undefined;
1825
1940
  }
1826
- if (key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e') {
1941
+ if (down) {
1827
1942
  if (this.input.path && rows.length) {
1828
1943
  this.input.path.at = (at + 1) % rows.length;
1829
1944
  this.dirty = true;
@@ -1841,6 +1956,10 @@ export class Attached {
1841
1956
  }
1842
1957
  return undefined;
1843
1958
  }
1959
+ // Anything else edits the line: what is on it is no longer a recollection.
1960
+ if (key !== '\r' && key !== '\n') {
1961
+ this.input.recalling = false;
1962
+ }
1844
1963
 
1845
1964
  if (key === '\x7f' || key === '\b') {
1846
1965
  line.backspace();
@@ -1905,7 +2024,10 @@ export class Attached {
1905
2024
  if (kind === 'command' || (kind === 'prompt' && typed.startsWith('/'))) {
1906
2025
  // After the write, not beside it: `/logout` forgets the history, and a
1907
2026
  // write still in flight would put the command that cleared it back.
1908
- return void remembered.then(() => this.runCommand(typed));
2027
+ // The EXPANDED text: a command is as much a place to paste into as a
2028
+ // prompt, and running it on the placeholder sent "[pasted, 12 lines]"
2029
+ // as the task in place of the twelve lines.
2030
+ return void remembered.then(() => this.runCommand(text));
1909
2031
  }
1910
2032
  if (kind === 'prompt') {
1911
2033
  return void this.send(text);
@@ -2039,10 +2161,20 @@ export class Attached {
2039
2161
  if (select.kind === 'drop') {
2040
2162
  return void this.droppedCheckout(row);
2041
2163
  }
2164
+ if (select.kind === 'view-card') {
2165
+ const [slug, ref] = String(row.id).split(' ');
2166
+ return void this.showCard(slug, ref);
2167
+ }
2042
2168
  if (select.kind === 'planned') {
2043
2169
  const [slug, ref] = String(row.id).split(' ');
2044
2170
  return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
2045
2171
  }
2172
+ if (select.kind === 'card-project') {
2173
+ // `/card` asked which project; the same card, with it named.
2174
+ const words = this.cardWaitingForProject ?? [];
2175
+ this.cardWaitingForProject = null;
2176
+ return void this.addCard([...words, `@${row.id}`]);
2177
+ }
2046
2178
  if (select.kind === 'project') {
2047
2179
  // `/new` asked which project; the same command again, with it named.
2048
2180
  const words = this.newWaitingForProject ?? [];
@@ -2356,7 +2488,7 @@ export class Attached {
2356
2488
  const ask = { line: (prompt) => this.askLine(prompt) };
2357
2489
  const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
2358
2490
 
2359
- const chosen = await this.askPick(configMenu(file, this.runner));
2491
+ const chosen = await this.askPick(configMenu(file, this.runner, this.gone));
2360
2492
  this.say('');
2361
2493
  this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
2362
2494
 
@@ -2408,6 +2540,15 @@ export class Attached {
2408
2540
  });
2409
2541
  return this.reloadDaemon(file, next);
2410
2542
  }
2543
+ if (chosen.id.startsWith('remove:')) {
2544
+ const slug = chosen.id.slice('remove:'.length);
2545
+ const next = await removeProject({ file, configPath, ask, say, ink, slug });
2546
+ // Off the screen now, though the daemon serves it until a restart.
2547
+ this.gone.add(slug);
2548
+ this.cards = this.cards.filter((card) => card.slug !== slug);
2549
+ this.planned = this.planned.filter((card) => card.slug !== slug);
2550
+ return this.reloadDaemon(file, next);
2551
+ }
2411
2552
  if (chosen.id === 'rules') {
2412
2553
  const next = await setAcceptsRulesFromConsole({
2413
2554
  file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
@@ -2627,7 +2768,7 @@ export class Attached {
2627
2768
  // --- slash commands ---------------------------------------------------------
2628
2769
 
2629
2770
  async runCommand(text) {
2630
- const [word, ...rest] = text.slice(1).split(/\s+/);
2771
+ const [word, ...rest] = commandWords(text.slice(1));
2631
2772
  switch (word) {
2632
2773
  case 'help':
2633
2774
  this.say('');
@@ -2673,16 +2814,24 @@ export class Attached {
2673
2814
  }
2674
2815
  case 'projects':
2675
2816
  return this.askSplitProjects();
2817
+ case 'refresh':
2818
+ return void this.refreshCards();
2676
2819
  case 'new':
2677
2820
  return void this.startNew(rest);
2821
+ case 'card':
2822
+ return void this.addCard(rest);
2823
+ case 'agent':
2824
+ return void this.chooseAgent(rest[0]);
2678
2825
  case 'develop':
2679
- return void this.develop();
2826
+ return void this.develop(rest);
2680
2827
  case 'pr':
2681
2828
  return void this.openPullRequest();
2682
2829
  case 'continue':
2683
2830
  return void this.carryOn();
2684
2831
  case 'open':
2685
- return this.openInBrowser();
2832
+ return void this.openInBrowser();
2833
+ case 'view':
2834
+ return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
2686
2835
  case 'drop':
2687
2836
  return void this.dropCheckout();
2688
2837
  case 'close':
@@ -2706,21 +2855,31 @@ export class Attached {
2706
2855
  * the moment it is decided.
2707
2856
  */
2708
2857
  async signIn() {
2858
+ let say;
2709
2859
  if (this.split) {
2710
- this.leaveSplit();
2860
+ // Advanced mode stays: the URL and the code go to the prompt window,
2861
+ // where everything said there is read, and the browser opens anyway.
2862
+ say = (line) => this.say(line);
2863
+ } else {
2864
+ // Everything queued goes out first and the footer comes down, so what
2865
+ // `runSignIn` prints with `console.log` lands under the transcript rather
2866
+ // than through the middle of a live region nothing is going to erase.
2867
+ this.say('');
2868
+ this.screen.update(this.pending, []);
2869
+ this.pending = [];
2711
2870
  }
2712
- // Everything queued goes out first and the footer comes down, so what
2713
- // `runSignIn` prints with `console.log` lands under the transcript rather
2714
- // than through the middle of a live region nothing is going to erase.
2715
- this.say('');
2716
- this.screen.update(this.pending, []);
2717
- this.pending = [];
2718
- const outcome = await runSignIn(this.session, this.ink)
2871
+ const outcome = await runSignIn(this.session, this.ink, say)
2719
2872
  .catch((failure) => ({ signedIn: false, message: failure.message }));
2720
2873
  if (outcome.message) {
2721
2874
  this.say(this.ink.danger(` could not sign in: ${outcome.message}`));
2722
2875
  }
2723
- if (this.session.signedIn) {
2876
+ if (this.session.signedIn && this.expired) {
2877
+ // After an expiry the watchers never stopped — they have been failing
2878
+ // quietly and pick up on their own — so a second set would poll twice.
2879
+ this.expired = false;
2880
+ this.note('signed in again');
2881
+ void this.findStopped();
2882
+ } else if (this.session.signedIn) {
2724
2883
  void this.watchInbox();
2725
2884
  void this.watchQuestions();
2726
2885
  void this.findStopped();
@@ -2729,6 +2888,20 @@ export class Attached {
2729
2888
  return undefined;
2730
2889
  }
2731
2890
 
2891
+ /**
2892
+ * The platform answered 401 to a session that was signed in: it expired, was
2893
+ * signed out from the console, or the account went. Said once, where it is
2894
+ * read — the prompt window in advanced mode, the scrollback otherwise — and
2895
+ * the footer keeps a row until `/login` succeeds. The watchers keep polling
2896
+ * and fail quietly; they pick up again on their own after the sign-in.
2897
+ */
2898
+ sessionExpired() {
2899
+ if (this.expired) return;
2900
+ this.expired = true;
2901
+ this.say(this.ink.warn(' your cawdev session has expired or is no longer valid — type /login to sign in again'));
2902
+ this.note('session expired — /login to sign in again');
2903
+ }
2904
+
2732
2905
  // --- advanced mode — split.mjs ----------------------------------------------
2733
2906
 
2734
2907
  /**
@@ -2770,6 +2943,12 @@ export class Attached {
2770
2943
  if (!known) {
2771
2944
  this.sessions.set(run.id, meta);
2772
2945
  void this.readView(meta);
2946
+ // The development `d` started in place of a plan's window takes the
2947
+ // focus that window had, so it reads as the plan's successor.
2948
+ if (this.split && this.focusOnArrival === run.projectSlug && (run.profile ?? 'CODE') === 'CODE') {
2949
+ this.focusOnArrival = null;
2950
+ this.split.focus = run.id;
2951
+ }
2773
2952
  }
2774
2953
  if (this.split) {
2775
2954
  this.requestBacklogFor(run.id);
@@ -2800,6 +2979,8 @@ export class Attached {
2800
2979
  try {
2801
2980
  meta.view = await this.session.request(`/api/projects/${meta.projectSlug}/runs/${meta.id}`);
2802
2981
  this.dirty = true;
2982
+ await this.countCommits(meta);
2983
+ this.sayWhyItFailed(meta);
2803
2984
  if (meta.ended && meta.view?.live && retries > 0) {
2804
2985
  const again = setTimeout(() => void this.readView(meta, retries - 1), 2000);
2805
2986
  again.unref?.();
@@ -2809,6 +2990,70 @@ export class Attached {
2809
2990
  }
2810
2991
  }
2811
2992
 
2993
+ /**
2994
+ * A session that ended FAILED says why, in its own window — once. The
2995
+ * reason is the run's summary on the platform (a refusal before anything
2996
+ * was spawned — Codex on a machine that has not allowed everything — never
2997
+ * reaches the daemon's live transcript), and a window reading "failed" and
2998
+ * nothing else sent people to the console to find out what to do.
2999
+ */
3000
+ sayWhyItFailed(meta) {
3001
+ const view = meta.view;
3002
+ const failed = view?.state === 'FAILED';
3003
+ // A dev session that finished having built nothing says why too — its
3004
+ // summary is where the session explained stopping (R24: "the plan is
3005
+ // incomplete"), and the window otherwise read as work done.
3006
+ if (!meta.ended || meta.failureSaid || !(failed || builtNothing(meta)) || !view.exitSummary) return;
3007
+ meta.failureSaid = true;
3008
+ const kept = this.lines.get(meta.id) ?? [];
3009
+ const said = kept.some((line) => line.body === view.exitSummary.split('\n')[0]);
3010
+ if (said) return;
3011
+ const lines = String(view.exitSummary).split('\n').map((body) => ({ kind: 'ERROR', body }));
3012
+ this.lines.set(meta.id, [...kept, { kind: 'ERROR', body: failed ? 'failed:' : 'nothing built:' }, ...lines]);
3013
+ this.dirty = true;
3014
+ }
3015
+
3016
+ /**
3017
+ * How many commits a finished dev session made — read once it has finished,
3018
+ * so `builtNothing` can tell "finished" from "finished having built nothing".
3019
+ * A read that fails leaves it unknown, and unknown claims nothing: the
3020
+ * window reads "finished" as it always did.
3021
+ *
3022
+ * A ZERO is not believed at once. The runner's last reading of the commits
3023
+ * lands AFTER the run is FINISHED — the agent ends its own run, and the
3024
+ * runner pushes, opens the pull request and only then reports — so the
3025
+ * first read of a run that committed twice and pushed can answer none. That
3026
+ * zero, cached, told a Codex session with two commits on origin that
3027
+ * "nothing was committed". A zero is read again `COMMIT_REREADS` times,
3028
+ * `commitRereadMs` apart, and claimed only if it is still a zero after that.
3029
+ */
3030
+ async countCommits(meta, rereads = COMMIT_REREADS) {
3031
+ const view = meta.view;
3032
+ if (!meta.ended || meta.commits !== undefined || view?.state !== 'FINISHED'
3033
+ || (view.profile ?? 'CODE') !== 'CODE') return;
3034
+ if (rereads === COMMIT_REREADS && meta.countingCommits) return;
3035
+ meta.countingCommits = true;
3036
+ let count;
3037
+ try {
3038
+ const commits = await this.session.request(
3039
+ `/api/projects/${meta.projectSlug}/runs/${meta.id}/commits`);
3040
+ count = Array.isArray(commits) ? commits.length : undefined;
3041
+ } catch {
3042
+ // Unknown: nothing claimed.
3043
+ }
3044
+ if (count === 0 && rereads > 0) {
3045
+ const again = setTimeout(async () => {
3046
+ await this.countCommits(meta, rereads - 1);
3047
+ this.sayWhyItFailed(meta);
3048
+ }, this.options.commitRereadMs ?? 5_000);
3049
+ again.unref?.();
3050
+ return;
3051
+ }
3052
+ meta.countingCommits = false;
3053
+ meta.commits = count;
3054
+ this.dirty = true;
3055
+ }
3056
+
2812
3057
  /**
2813
3058
  * Which session a question or a request in the prompt comes from, by the
2814
3059
  * number its window carries (the position in the run list outside
@@ -2830,9 +3075,14 @@ export class Attached {
2830
3075
  && (!only || only.has(meta.projectSlug)));
2831
3076
  }
2832
3077
 
3078
+ /** The projects this machine serves that cawdev still has — see `gone`. */
3079
+ servedProjects() {
3080
+ return (this.runner?.projects ?? []).filter((slug) => !this.gone.has(slug));
3081
+ }
3082
+
2833
3083
  /** The projects on screen: advanced mode's pick, else every one served here. */
2834
3084
  shownProjects() {
2835
- const served = this.runner?.projects ?? [];
3085
+ const served = this.servedProjects();
2836
3086
  return this.split?.projects ? served.filter((slug) => this.split.projects.has(slug)) : served;
2837
3087
  }
2838
3088
 
@@ -2865,12 +3115,19 @@ export class Attached {
2865
3115
  * today's behaviour). One project served: nothing to ask. `/projects` asks
2866
3116
  * again from inside the mode.
2867
3117
  */
2868
- askSplitProjects() {
3118
+ async askSplitProjects() {
2869
3119
  if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
2870
- const served = this.runner?.projects ?? [];
3120
+ // Read first, so a project deleted in the console is not offered — and
3121
+ // say so AFTER the mode is entered, or the sentence lands on the normal
3122
+ // screen the alternate one is about to cover.
3123
+ await this.refreshPlanned({ hold: true });
3124
+ const served = this.servedProjects();
2871
3125
  if (served.length <= 1) {
2872
- return this.split ? this.note('this machine serves one project') : this.enterSplit(served);
3126
+ const entered = this.split ? this.note('this machine serves one project') : this.enterSplit(served);
3127
+ this.sayGone();
3128
+ return entered;
2873
3129
  }
3130
+ this.sayGone();
2874
3131
  const current = this.split?.projects ? [...this.split.projects] : served;
2875
3132
  return this.openPicker(new Select({
2876
3133
  kind: 'split-projects',
@@ -3002,21 +3259,37 @@ export class Attached {
3002
3259
  const banner = this.select && this.select.kind !== 'runs' ? [] : pending
3003
3260
  ? permissionBanner(pending, inner, ink, this.runner ?? {})
3004
3261
  : questionBanner(this.askingOn(current), inner, ink, { entire: true });
3262
+ // Which window a command or a prompt will act on, before it is sent.
3263
+ const target = this.windowNumberOf(this.split.focus);
3264
+ const tag = target ? ink.bold(ink.accent(`[${target}] `)) : '';
3005
3265
  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'}`
3266
+ ? `${tag}${this.input.label} ${this.input.line.render(
3267
+ Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
3268
+ : tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3009
3269
  + ' · / commands · tab next window');
3010
3270
  // A question or a request on screen says which window it came from.
3011
3271
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3012
- const fixed = [...from, ...banner, ...overlay, input];
3272
+ // The commands matching what is typed, above the line as in the footer —
3273
+ // advanced mode drew none, so enter took a highlighted command nobody saw.
3274
+ const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
3275
+ // Six rows at most, scrolled to the highlighted one: a bare `/` matches
3276
+ // every command, and the prompt grows with what it holds.
3277
+ const first = Math.max(0, Math.min(matches.at - 2, matches.rows.length - SPLIT_COMPLETIONS));
3278
+ const completing = completionLines(
3279
+ matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
3280
+ );
3281
+ const fixed = [...from, ...banner, ...overlay, ...completing, input];
3013
3282
  const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
3014
3283
  // The roadmap across the top: as tall as its fullest column needs, never
3015
3284
  // more than a third of the screen, and never so tall that no window fits
3016
3285
  // under it — a short terminal drops it before it drops the sessions.
3017
3286
  const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
3018
- const stripLines = stripRoom >= 3
3019
- ? roadmapStrip(width, stripRoom, this.cards, ink, this.shownProjects())
3287
+ // A rule under it, from the strip's own room: the roadmap is the board and
3288
+ // the windows are the sessions, and without it the strip's last card line
3289
+ // ran straight into the first window's border.
3290
+ const stripLines = stripRoom >= 4
3291
+ ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
3292
+ ink.muted('─'.repeat(width))]
3020
3293
  : [];
3021
3294
  const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
3022
3295
  // Four windows at most; the rest are named on one line above the prompt,
@@ -3078,7 +3351,8 @@ export class Attached {
3078
3351
  return wordWrap(line.body, inner - 2 - line.prefix.length).map((part) => `${line.prefix}${line.paint(part)}`);
3079
3352
  });
3080
3353
  const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
3081
- + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}${unseen}`;
3354
+ + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
3355
+ + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
3082
3356
  pieces.push({
3083
3357
  rect: layout.prompt,
3084
3358
  lines: windowLines(layout.prompt, {
@@ -3091,7 +3365,9 @@ export class Attached {
3091
3365
  ...messages,
3092
3366
  ...fixed,
3093
3367
  ],
3094
- footer: this.status ? ink.warn(this.status) : keyList(this.keys(), '', inner, ink),
3368
+ footer: this.status
3369
+ ? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
3370
+ : keyList(this.keys(), '', inner, ink),
3095
3371
  }, ink),
3096
3372
  });
3097
3373
  if (strip) {
@@ -3107,11 +3383,18 @@ export class Attached {
3107
3383
  * Every card of every project this machine serves, whatever its status —
3108
3384
  * the strip draws them all, and `d` offers the PLANNED ones. One request
3109
3385
  * per project, the board's own (`brief`, so no bodies). Needs a person's
3110
- * session, like everything read off the roadmap here; a failure keeps the
3111
- * last list rather than blanking the strip.
3386
+ * session, like everything read off the roadmap here.
3387
+ *
3388
+ * A project that cannot be read keeps ITS last cards and no other's: this
3389
+ * used to give up on the first failure and keep the whole last list, so one
3390
+ * served slug the session could not reach left every column at 0 — a strip
3391
+ * saying "nothing considered" about a board full of ideas, and saying it
3392
+ * silently. What failed is returned so `/refresh` can say it.
3112
3393
  */
3113
- async refreshPlanned() {
3114
- if (!this.session.signedIn) return;
3394
+ async refreshPlanned({ hold = false } = {}) {
3395
+ if (!this.session.signedIn) return { signedIn: false, read: [], failed: [] };
3396
+ const read = [];
3397
+ const failed = [];
3115
3398
  const all = [];
3116
3399
  for (const slug of this.runner?.projects ?? []) {
3117
3400
  try {
@@ -3119,13 +3402,66 @@ export class Attached {
3119
3402
  for (const card of Array.isArray(cards) ? cards : []) {
3120
3403
  all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
3121
3404
  }
3122
- } catch {
3123
- return;
3405
+ read.push(slug);
3406
+ this.gone.delete(slug);
3407
+ } catch (failure) {
3408
+ if (failure?.status === 404) {
3409
+ this.forgetGone(slug);
3410
+ continue;
3411
+ }
3412
+ failed.push({ slug, message: failure?.message ?? String(failure) });
3413
+ all.push(...this.cards.filter((card) => card.slug === slug));
3124
3414
  }
3125
3415
  }
3126
3416
  this.cards = all;
3127
3417
  this.planned = all.filter((card) => card.status === 'PLANNED');
3128
3418
  this.dirty = true;
3419
+ if (!hold) this.sayGone();
3420
+ return { signedIn: true, read, failed };
3421
+ }
3422
+
3423
+ /** A served project cawdev answered 404 for: hidden, and said once. */
3424
+ forgetGone(slug) {
3425
+ if (this.gone.has(slug)) return;
3426
+ this.gone.add(slug);
3427
+ this.goneUnsaid.push(slug);
3428
+ }
3429
+
3430
+ /** What `forgetGone` learnt, said where it will be read. */
3431
+ sayGone() {
3432
+ for (const slug of this.goneUnsaid.splice(0)) {
3433
+ this.say(this.ink.warn(` this machine serves ${slug}, but cawdev has no such project for you any more`
3434
+ + ' — deleted, or your access went. It is hidden here;'
3435
+ + ` c → Stop serving ${slug} takes it off this machine.`));
3436
+ }
3437
+ }
3438
+
3439
+ /**
3440
+ * `/refresh`: the cards and their statuses read again now, rather than on
3441
+ * the strip's thirty-second timer — somebody who just moved a card in the
3442
+ * console should not have to wait for the terminal to believe it. Says what
3443
+ * it read per project shown, and which could not be read and why.
3444
+ */
3445
+ async refreshCards() {
3446
+ if (!this.requireSignIn('read the roadmap')) return;
3447
+ const { read, failed } = await this.refreshPlanned();
3448
+ const shown = new Set(this.shownProjects());
3449
+ const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
3450
+ const mine = this.cards.filter((card) => card.slug === slug);
3451
+ const tally = BOARD_COLUMNS
3452
+ .map(([status, label]) => `${mine.filter((card) => card.status === status).length} ${label.toLowerCase()}`)
3453
+ .join(' · ');
3454
+ return `${slug}: ${tally}`;
3455
+ });
3456
+ for (const { slug, message } of failed) {
3457
+ this.say(this.ink.danger(` could not read the roadmap of ${slug}: ${message}`));
3458
+ }
3459
+ if (!read.length && !failed.length) {
3460
+ return this.note('refreshed — this machine serves no project yet');
3461
+ }
3462
+ this.note(failed.length && !counts.length
3463
+ ? `refresh failed for ${failed.map((each) => each.slug).join(', ')}`
3464
+ : `refreshed — ${counts.join('; ')}${failed.length ? ` (${failed.length} could not be read)` : ''}`);
3129
3465
  }
3130
3466
 
3131
3467
  /**
@@ -3170,7 +3506,10 @@ export class Attached {
3170
3506
  return;
3171
3507
  }
3172
3508
  const anyway = words.includes('--anyway');
3173
- const parsed = parseNew(words.filter((word) => word !== '--anyway'), {
3509
+ // `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
3510
+ const flagged = words.find((word) => AGENT_FLAGS.has(word));
3511
+ let agent = flagged ? flagged.slice(2) : this.agent;
3512
+ const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
3174
3513
  // In advanced mode, among the projects it shows.
3175
3514
  projects: this.shownProjects(),
3176
3515
  });
@@ -3197,9 +3536,19 @@ export class Attached {
3197
3536
  // branch with no card: no number, nothing on the development board,
3198
3537
  // and a prompt that never said "commit" — so the work ended
3199
3538
  // uncommitted and `/pr` had nothing to propose.
3200
- await this.developNewCard(parsed.slug, parsed.text, anyway);
3539
+ await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
3201
3540
  return;
3202
3541
  }
3542
+ // Developing a card whose finished plan has a window here: the agent that
3543
+ // planned it develops it unless one was named on the line — it has read
3544
+ // the code once already, and swapping CLIs between the two halves of one
3545
+ // card was a choice nobody made — and the plan's window gives way to the
3546
+ // development's once it has started.
3547
+ const plan = parsed.kind === 'dev' && parsed.ref ? this.planWindowFor(parsed.slug, parsed.ref) : null;
3548
+ const planAgent = plan ? plan.agent ?? plan.view?.agent ?? null : null;
3549
+ if (!flagged && planAgent) {
3550
+ agent = planAgent;
3551
+ }
3203
3552
  const [path, body, what] = parsed.kind === 'ask'
3204
3553
  ? ['runs/ask', { prompt: parsed.text }, 'a question']
3205
3554
  : parsed.kind === 'plan'
@@ -3208,7 +3557,107 @@ export class Attached {
3208
3557
  // R150: the session writes the card first, then plans it.
3209
3558
  : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3210
3559
  : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
3211
- await this.startSession(parsed.slug, path, body, what, anyway);
3560
+ const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
3561
+ if (started && plan) {
3562
+ this.closeWindowOf(plan);
3563
+ this.focusOnArrival = plan.projectSlug;
3564
+ }
3565
+ }
3566
+
3567
+ /** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
3568
+ finishedPlan(meta) {
3569
+ return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
3570
+ && meta.view.profile === 'PLAN' && meta.view.entryRef);
3571
+ }
3572
+
3573
+ /** The open window of a finished plan of `ref` in `slug`, if there is one. */
3574
+ planWindowFor(slug, ref) {
3575
+ return [...this.sessions.values()].reverse().find((meta) => !this.closed.has(meta.id)
3576
+ && meta.projectSlug === slug && this.finishedPlan(meta)
3577
+ && meta.view.entryRef.toLowerCase() === String(ref).toLowerCase()) ?? null;
3578
+ }
3579
+
3580
+ /**
3581
+ * `/agent` — which CLI `/new` asks for when a command names none. Kept
3582
+ * across launches (prefs.mjs). `default` goes back to the project's own
3583
+ * choice. A `--codex` on one `/new` still wins over it.
3584
+ */
3585
+ async chooseAgent(name) {
3586
+ if (!name) {
3587
+ return this.note(this.agent
3588
+ ? `/new uses ${this.agent} — /agent default goes back to the project's choice`
3589
+ : "/new uses the project's default agent — /agent claude|codex|agy picks one here");
3590
+ }
3591
+ const wanted = name.toLowerCase();
3592
+ if (['default', 'auto', 'none', 'project'].includes(wanted)) {
3593
+ this.agent = null;
3594
+ await savePrefs(this.session.url, { agent: null }).catch(() => undefined);
3595
+ this.dirty = true;
3596
+ return this.note("/new uses the project's default agent again");
3597
+ }
3598
+ if (!KNOWN_AGENTS.includes(wanted)) {
3599
+ return this.note(`no agent called ${name} — ${KNOWN_AGENTS.join(', ')}`);
3600
+ }
3601
+ this.agent = wanted;
3602
+ await savePrefs(this.session.url, { agent: wanted }).catch(() => undefined);
3603
+ this.dirty = true;
3604
+ // Said, not refused: the platform may send the run to another machine,
3605
+ // and this one can be told to spawn it (`c` → Enable) in a second.
3606
+ const here = (this.runner?.agents ?? []).map((command) => agentOfCommand(command));
3607
+ return this.note(this.runner?.agents && !here.includes(wanted)
3608
+ ? `/new uses ${wanted} — but this machine does not spawn it: c → Enable ${wanted}`
3609
+ : `/new uses ${wanted}`);
3610
+ }
3611
+
3612
+ /**
3613
+ * `/card <the idea in words>` — a card in CONSIDERING, and nothing else: no
3614
+ * session, no branch, nothing spent. The roadmap's first column is where an
3615
+ * idea waits to be decided on, and writing one down should not need a model
3616
+ * to do it. The project is chosen as `/new` chooses it: `@slug`, else
3617
+ * advanced mode narrowed to one, else asked — never guessed.
3618
+ */
3619
+ async addCard(words) {
3620
+ if (!this.requireSignIn('write a card')) return;
3621
+ const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
3622
+ const text = wordsText(words.filter((word) => word && !word.startsWith('@')));
3623
+ if (!text) {
3624
+ this.note('/card what? — /card <the idea in words> (@project)');
3625
+ return;
3626
+ }
3627
+ const served = this.servedProjects();
3628
+ const shown = this.shownProjects();
3629
+ const slug = named ?? (this.split?.projects && shown.length === 1 ? shown[0] : null);
3630
+ if (named && !served.includes(named)) {
3631
+ this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
3632
+ return;
3633
+ }
3634
+ if (!slug) {
3635
+ if (!shown.length) {
3636
+ this.note('this machine serves no project to write a card in');
3637
+ return;
3638
+ }
3639
+ this.openPicker(new Select({
3640
+ kind: 'card-project',
3641
+ title: 'which project is this card for?',
3642
+ rows: shown.map((each) => ({ id: each, label: each })),
3643
+ }));
3644
+ this.cardWaitingForProject = words;
3645
+ return;
3646
+ }
3647
+ let card;
3648
+ try {
3649
+ card = await this.session.request(`/api/projects/${slug}/roadmap`, {
3650
+ method: 'POST',
3651
+ body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
3652
+ });
3653
+ } catch (failure) {
3654
+ this.note(`could not write the card: ${failure.message}`);
3655
+ return;
3656
+ }
3657
+ const ref = card?.ref ?? (card?.number ? `R${card.number}` : 'the card');
3658
+ this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}`));
3659
+ this.note('');
3660
+ void this.refreshPlanned();
3212
3661
  }
3213
3662
 
3214
3663
  /**
@@ -3217,7 +3666,7 @@ export class Attached {
3217
3666
  * but whose run was refused (a usage window, say) is still on the board,
3218
3667
  * and the person should know which half happened.
3219
3668
  */
3220
- async developNewCard(slug, text, anyway) {
3669
+ async developNewCard(slug, text, anyway, agent = this.agent) {
3221
3670
  let card;
3222
3671
  try {
3223
3672
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
@@ -3234,7 +3683,8 @@ export class Attached {
3234
3683
  return;
3235
3684
  }
3236
3685
  this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
3237
- await this.startSession(slug, 'runs', { entryNumber: ref }, `the development of ${ref}`, anyway);
3686
+ await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
3687
+ withWho(`the development of ${ref}`, agent), anyway);
3238
3688
  void this.refreshPlanned();
3239
3689
  }
3240
3690
 
@@ -3330,20 +3780,39 @@ export class Attached {
3330
3780
  }
3331
3781
  }
3332
3782
 
3333
- /** `/develop` — the card a finished plan was about, on this machine. */
3334
- async develop() {
3783
+ /**
3784
+ * `/develop` — start the development of a card on this machine: the card a
3785
+ * finished plan was about (the focused session's), or the one named —
3786
+ * `/develop R30`, `@slug` for its project. It is `/new dev R30` said from a
3787
+ * plan's window, and it goes THROUGH `startNew` so the agent is chosen the
3788
+ * same way: `--codex`/`--claude`/`--agy`, else `/agent`'s. It used to ignore
3789
+ * every word after it, so `/develop R30 --codex` started the focused plan's
3790
+ * card with the runner's default agent — Claude — and said nothing.
3791
+ * Words that are none of those (`starts`, from the window's own hint) are
3792
+ * passed over.
3793
+ */
3794
+ async develop(words = []) {
3335
3795
  if (!this.requireSignIn('start a session')) return;
3796
+ const flags = words.filter((word) => word.startsWith('--'));
3797
+ const named = words.find((word) => word.startsWith('@'));
3798
+ const typed = words.find((word) => /^r?\d+$/i.test(word));
3799
+ if (typed) {
3800
+ const ref = typed.replace(/^r/i, 'R').replace(/^(\d)/, 'R$1');
3801
+ const slug = named?.slice(1) ?? this.commandTarget()?.projectSlug;
3802
+ await this.startNew(['dev', ref, ...(slug ? [`@${slug}`] : []), ...flags]);
3803
+ return;
3804
+ }
3336
3805
  const meta = this.commandTarget();
3337
3806
  const ref = meta?.view?.entryRef;
3338
3807
  if (!meta || !ref) {
3339
- this.note('/develop needs a session on a card — focus a finished plan');
3808
+ this.note('/develop needs a session on a card — focus a finished plan, or name one: /develop R30');
3340
3809
  return;
3341
3810
  }
3342
3811
  if (!meta.ended) {
3343
3812
  this.note(`${ref} is still being worked on — wait for it to end`);
3344
3813
  return;
3345
3814
  }
3346
- await this.startSession(meta.projectSlug, 'runs', { entryNumber: ref }, `the development of ${ref}`, false);
3815
+ await this.startNew(['dev', ref, `@${meta.projectSlug}`, ...flags]);
3347
3816
  }
3348
3817
 
3349
3818
  /**
@@ -3364,10 +3833,12 @@ export class Attached {
3364
3833
  });
3365
3834
  this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
3366
3835
  this.note('');
3836
+ return true;
3367
3837
  } catch (failure) {
3368
3838
  this.note(/usage|window/i.test(failure.message)
3369
3839
  ? `${failure.message} — add --anyway to start it regardless`
3370
3840
  : failure.message);
3841
+ return false;
3371
3842
  }
3372
3843
  }
3373
3844
 
@@ -3467,21 +3938,181 @@ export class Attached {
3467
3938
  again.unref?.();
3468
3939
  }
3469
3940
 
3470
- /** `/open` — the session's pull request, in the browser. */
3471
- openInBrowser() {
3472
- const url = this.commandTarget()?.view?.prUrl;
3941
+ /**
3942
+ * `/open` — the session's pull request, in the browser. A finished plan has
3943
+ * none: what it produced is the card's plan of record, so `/open` there is
3944
+ * the card on cawdev, opened on its plan (`?show=plan`).
3945
+ */
3946
+ async openInBrowser() {
3947
+ const meta = this.commandTarget();
3948
+ if (!meta) {
3949
+ return this.note('/open needs a session — focus one');
3950
+ }
3951
+ if (this.finishedPlan(meta)) {
3952
+ const ref = meta.view.entryRef;
3953
+ const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
3954
+ const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `${board}/${ref}?show=plan`);
3955
+ if (!url) {
3956
+ return this.note('cannot tell where this card is on cawdev');
3957
+ }
3958
+ this.openLink(url);
3959
+ return this.note(`opened ${url}`);
3960
+ }
3961
+ // Read again, not the view taken when the session ended: the runner opens
3962
+ // the pull request and records its link AFTER the run is FINISHED, so the
3963
+ // copy read at that moment had none — and /open said "no pull request"
3964
+ // about a branch with one open on GitHub.
3965
+ await this.readView(meta);
3966
+ let url = meta.view?.prUrl;
3967
+ // A staged run's pull request can be the card's, through its work item
3968
+ // (i168) — the branch's, whichever run opened it.
3969
+ const ref = meta.view?.entryRef;
3970
+ if (!isPullRequest(url) && ref && this.session.signedIn) {
3971
+ const card = await this.session.request(`/api/projects/${meta.projectSlug}/roadmap/${ref}`)
3972
+ .catch(() => null);
3973
+ url = card?.workItem?.prUrl ?? card?.review?.prUrl ?? url;
3974
+ }
3473
3975
  if (!isPullRequest(url)) {
3474
3976
  return this.note('no pull request on this session yet — /pr opens one');
3475
3977
  }
3978
+ this.openLink(url);
3979
+ return this.note(`opened ${url}`);
3980
+ }
3981
+
3982
+ /** A link, in this machine's browser. */
3983
+ openLink(url) {
3476
3984
  const opener = this.options.openUrl ?? ((link) => {
3477
3985
  const command = process.platform === 'darwin' ? 'open'
3478
3986
  : process.platform === 'win32' ? 'explorer' : 'xdg-open';
3479
3987
  spawn(command, [link], { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
3480
3988
  });
3481
3989
  opener(url);
3990
+ }
3991
+
3992
+ /**
3993
+ * `/view` alone — the focused session's page on cawdev (its transcript,
3994
+ * stages, card and checkout). On the console this terminal signed in to:
3995
+ * the sign-in URL is the console's, where the daemon's may be the API
3996
+ * behind it.
3997
+ */
3998
+ viewOnCawdev() {
3999
+ const meta = this.commandTarget();
4000
+ if (!meta) {
4001
+ return this.note('/view needs a session — focus one, or name a card: /view R29');
4002
+ }
4003
+ const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
4004
+ if (!url) {
4005
+ return this.note('cannot tell where this session is on cawdev');
4006
+ }
4007
+ this.openLink(url);
3482
4008
  return this.note(`opened ${url}`);
3483
4009
  }
3484
4010
 
4011
+ /** A page of a project on the console this terminal signed in to, or null. */
4012
+ consoleUrl(slug, path) {
4013
+ const base = String(this.session.url ?? '').replace(/\/+$/, '');
4014
+ return base && slug ? `${base}/projects/${slug}/${path}` : null;
4015
+ }
4016
+
4017
+ /**
4018
+ * `/view R29` · `/view i12` · `/view <words of its title>` · `/view card`
4019
+ * — a card printed here, rather than a browser opened on it: somebody at
4020
+ * this terminal wants to read what a card says without leaving it. `card`
4021
+ * is the focused session's own. `@slug` names the project; otherwise every
4022
+ * project on screen is looked in, and a ref or words matching cards in
4023
+ * several are offered as a list rather than guessed between.
4024
+ */
4025
+ async viewCard(words) {
4026
+ if (!this.requireSignIn('read a card')) return;
4027
+ const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
4028
+ const bare = words.filter((word) => word && !word.startsWith('@'));
4029
+ if (bare.length === 1 && bare[0].toLowerCase() === 'card') {
4030
+ const meta = this.commandTarget();
4031
+ const ref = meta?.view?.entryRef;
4032
+ if (!ref) {
4033
+ return this.note(meta ? 'this session has no card — /view R29 names one' : '/view card needs a session — focus one');
4034
+ }
4035
+ return this.showCard(meta.projectSlug, ref);
4036
+ }
4037
+ const projects = named.length ? named
4038
+ : this.shownProjects().length ? this.shownProjects() : this.servedProjects();
4039
+ if (!projects.length) {
4040
+ return this.note('no project here to look in — name one: /view R29 @slug');
4041
+ }
4042
+ const asRef = bare.length === 1 && /^[ri]?\d+$/i.test(bare[0]) ? refOf(bare[0]) : null;
4043
+ let found;
4044
+ if (asRef) {
4045
+ const reads = await Promise.all(projects.map((slug) => this.session
4046
+ .request(`/api/projects/${slug}/roadmap/${asRef}`)
4047
+ .then((card) => (card ? { slug, ref: card.ref ?? asRef, title: card.title } : null))
4048
+ .catch(() => null)));
4049
+ found = reads.filter(Boolean);
4050
+ if (!found.length) {
4051
+ return this.note(`no ${asRef} in ${projects.join(', ')}`);
4052
+ }
4053
+ } else {
4054
+ const wanted = bare.map((word) => word.toLowerCase());
4055
+ if (!wanted.length) {
4056
+ return this.note('/view which card? /view R29, or words of its title');
4057
+ }
4058
+ await this.refreshPlanned();
4059
+ found = this.cards.filter((card) => projects.includes(card.slug)
4060
+ && wanted.every((word) => String(card.title ?? '').toLowerCase().includes(word)));
4061
+ if (!found.length) {
4062
+ return this.note(`no card titled like "${bare.join(' ')}" in ${projects.join(', ')}`);
4063
+ }
4064
+ }
4065
+ if (found.length === 1) {
4066
+ return this.showCard(found[0].slug, found[0].ref);
4067
+ }
4068
+ const several = new Set(found.map((card) => card.slug)).size > 1;
4069
+ this.openPicker(new Select({
4070
+ kind: 'view-card',
4071
+ title: 'view which card?',
4072
+ rows: found.map((card) => ({
4073
+ id: `${card.slug} ${card.ref}`,
4074
+ label: `${card.ref} ${card.title ?? ''}`.trim(),
4075
+ ...(several ? { hint: card.slug } : {}),
4076
+ })),
4077
+ }));
4078
+ return undefined;
4079
+ }
4080
+
4081
+ /** One card, read fresh off the platform and printed into the transcript. */
4082
+ async showCard(slug, ref) {
4083
+ let card;
4084
+ try {
4085
+ card = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
4086
+ } catch (failure) {
4087
+ return this.note(`could not read ${ref} on ${slug}: ${failure.message}`);
4088
+ }
4089
+ if (!card) {
4090
+ return this.note(`no ${ref} on ${slug}`);
4091
+ }
4092
+ const url = this.consoleUrl(slug, `roadmap/${card.ref ?? ref}`);
4093
+ this.say('');
4094
+ for (const line of cardLines(card, this.ink, url)) this.say(line);
4095
+ this.say('');
4096
+ return this.note(`${card.ref ?? ref} on ${slug}`);
4097
+ }
4098
+
4099
+ /** ↑ with no line open: the most recent entry, on the line it belongs to. */
4100
+ recallIntoALine() {
4101
+ const last = this.history.entries.at(-1);
4102
+ if (last === undefined) {
4103
+ return this.note('nothing typed yet to go back to');
4104
+ }
4105
+ const command = last.startsWith('/');
4106
+ // The line first — opening one resets the history — then one step back,
4107
+ // so the next ↑ goes further rather than to the same entry again.
4108
+ this.type(command ? 'command' : 'prompt', command ? 'cawdev ▸' : 'prompt ▸', '');
4109
+ this.input.line.set(this.history.back(''));
4110
+ this.input.recalling = true;
4111
+ this.input.showing = false;
4112
+ this.dirty = true;
4113
+ return undefined;
4114
+ }
4115
+
3485
4116
  /**
3486
4117
  * `/drop` — delete the checkout a finished session was cloned into.
3487
4118
  *
@@ -3556,11 +4187,17 @@ export class Attached {
3556
4187
  const meta = this.commandTarget();
3557
4188
  if (!meta) return this.note('no window to close');
3558
4189
  if (!meta.ended) return this.note('that session is still running — /cancel stops it');
4190
+ this.closeWindowOf(meta);
4191
+ return this.note('closed');
4192
+ }
4193
+
4194
+ /** An ended session's window goes; focus moves to the first one left. */
4195
+ closeWindowOf(meta) {
3559
4196
  this.closed.add(meta.id);
3560
- if (this.split) {
4197
+ if (this.split && (this.split.focus === meta.id || !this.sessions.has(this.split.focus))) {
3561
4198
  this.split.focus = this.windows()[0]?.id ?? null;
3562
4199
  }
3563
- return this.note('closed');
4200
+ this.dirty = true;
3564
4201
  }
3565
4202
 
3566
4203
  // --- the things a person can do ---------------------------------------------
@@ -3983,6 +4620,7 @@ export class Attached {
3983
4620
  runner: this.runner,
3984
4621
  runs: this.runs,
3985
4622
  email: this.session.email,
4623
+ expired: this.expired,
3986
4624
  watching: run,
3987
4625
  connected: this.connected,
3988
4626
  // R83's status line is about what this machine is DRIVING, which is not
@@ -4070,7 +4708,8 @@ export class Attached {
4070
4708
  }
4071
4709
  if (this.split) {
4072
4710
  parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
4073
- parts.push(`${ink.text('d')} ${ink.muted('develop a planned card')}`);
4711
+ parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
4712
+ ? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
4074
4713
  }
4075
4714
  parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
4076
4715
  parts.push(`${ink.text('c')} ${ink.muted('config')}`);