@ctrl-spc/cs 0.7.3 → 0.7.4

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.
@@ -132,6 +132,7 @@
132
132
  // and takes the leading comment with it, so a file whose first statement is
133
133
  // `import type` loses its v3 header in the published `dist/`.
134
134
  import { out, returned, signedInClient } from './client.js';
135
+ import { readableWriteError } from '../firewall.js';
135
136
  import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
136
137
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
137
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
@@ -140,12 +141,15 @@ import { designatedCoordinator } from './coordinator.js';
140
141
  import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
141
142
  import { harness, startAgent } from './spawn.js';
142
143
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
143
- import { startToolsServer } from './tools.js';
144
+ import { startToolsServer, PANEL3_IMAGES_BUCKET } from './tools.js';
144
145
  import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
145
146
  import { getMachineIdentity, scratchDir } from '../config.js';
146
147
  import { listCodebases } from '../codebases.js';
147
148
  import { killTree, processIsAlive } from '../win-shell.js';
148
149
  import { hostname, uptime } from 'node:os';
150
+ import { execFileSync } from 'node:child_process';
151
+ import { mkdir, writeFile } from 'node:fs/promises';
152
+ import { join } from 'node:path';
149
153
  const USAGE = 'usage: run [--once]';
150
154
  /** How long between takes. Short, because it is the whole delay between a user
151
155
  * sending and a card showing an agent on it, and the take is one small indexed
@@ -454,7 +458,14 @@ whereYouAre = [],
454
458
  the rules; rendering it underneath would put it below a block saying it
455
459
  wins. Already carries its own header and its own leading blank, which this
456
460
  join absorbs the same way it absorbs `whereYouAre`'s. */
457
- whatIsAttached = []) => {
461
+ whatIsAttached = [],
462
+ /* ═══ THE PICTURES ON THIS CARD, ALREADY ON DISK (33 Slice 6). ═══ Its own
463
+ parameter for `whatIsAttached`'s reason: these are neither a fact about the
464
+ machine nor something the person attached to the card, they are files this
465
+ spawn has just written, and the paths are only knowable after the cwd is
466
+ resolved. Below the rules and beside the codebase block, because a picture
467
+ is a fact about where this run is rather than an instruction. */
468
+ pictures = []) => {
458
469
  /* `whatWasAttached` begins its array with a blank, since its two other
459
470
  callers join it onto lines already above it. Here it is the first thing in
460
471
  the prompt, and a prompt that opens on an empty line is a prompt with a
@@ -467,6 +478,7 @@ whatIsAttached = []) => {
467
478
  ...(attached.length === 0 ? [] : [...attached, '']),
468
479
  ...standingRules(documents),
469
480
  ...(whereYouAre.length === 0 ? [] : ['', ...whereYouAre]),
481
+ ...(pictures.length === 0 ? [] : ['', ...pictures]),
470
482
  ];
471
483
  return block.length === 0 ? prompt : [...block, '', prompt].join('\n');
472
484
  };
@@ -587,7 +599,7 @@ async function writeAnswer(client, runId, cardId, text, processToken) {
587
599
  that has now exited, so recovery reaps it: at level 1 the turns are
588
600
  answered again, so the user gets an answer late instead of a card marked
589
601
  failed for a database hiccup. The caller prints the reason. */
590
- throw new Error(`could not write the answer to card ${cardId}: ${error.message}`);
602
+ throw new Error(`could not write the answer to card ${cardId}: ${readableWriteError(error.message)}`);
591
603
  }
592
604
  /* ═══ INCLUDING WHEN THE RUN STOPPED TO ASK AND WILL BE STARTED AGAIN. ═══
593
605
  That reads like a mistake and is not: the process is gone, and the one that
@@ -964,6 +976,96 @@ async function refreshBaseProtection(client, codebase, rules, source, base) {
964
976
  said(`could not check whether ${base} is protected: ${error instanceof Error ? error.message : String(error)}`);
965
977
  }
966
978
  }
979
+ /**
980
+ * ═══ THE PICTURES ON THIS CARD, ON DISK WHERE THE AGENT CAN READ THEM, AND THE
981
+ * LINES THAT TELL IT SO (33 Slice 6). ═══
982
+ *
983
+ * ═══ WHY A FILE AND NOT A TOOL RESULT. ═══ `ToolDef['handler']` returns
984
+ * `Promise<string>` and `buildServer` wraps it in one text block, so a tool
985
+ * cannot hand back an image. But that is not the reason: `spawn.ts` already
986
+ * grants levels 2 and 3 the harness's own `Read`, which renders an image
987
+ * natively, works in both harnesses, and is how every other file reaches an agent
988
+ * here. A file on disk needs no new tool, no new allowlist entry, and — the part
989
+ * that decides it — no decision by the model to call anything.
990
+ *
991
+ * ═══ WHY THE RUN'S OWN CWD AND NOT `scratchDir()`. ═══ `workingDirectory` gives
992
+ * the scratch directory only to level 1 and to a level 2 owner with no codebase;
993
+ * every level 3 worker and every run with a codebase gets a WORKTREE. `agentArgs`
994
+ * passes no `--add-dir` and `--setting-sources ''` strips anything that might
995
+ * have supplied one, so a path outside the cwd is a path the child must ask
996
+ * permission to read — with nobody at the keyboard to answer. Writing into the
997
+ * directory the run already has needs no widening of what any worker may read.
998
+ *
999
+ * ═══ AND INSIDE `git rev-parse --git-dir`, WHICH IS THE PART THAT WAS MEASURED
1000
+ * RATHER THAN REASONED ABOUT. ═══ A folder in the working tree shows as `??`
1001
+ * in `git status`, and `settleCardWorktree` commits unconditionally — so a
1002
+ * picture left there is a picture committed onto the card's branch. And
1003
+ * `.git/info/exclude` cannot be used: in a linked worktree `.git` is a FILE
1004
+ * holding a `gitdir:` pointer, and `git rev-parse --git-path info/exclude`
1005
+ * resolves to the SHARED repository's exclude file, so writing it would alter
1006
+ * every other worktree and the person's own clone. The git dir resolves per
1007
+ * worktree, sits inside what the agent may read, and is invisible to
1008
+ * `git status`.
1009
+ *
1010
+ * For a scratch cwd, which is not a repository, the files go in the cwd itself.
1011
+ *
1012
+ * ═══ THE PATH IS HANDED TO A PROCESS, NEVER STORED. ═══ `spawn.ts`'s stated
1013
+ * rule: a path passed to a process is not a path stored in a row. Nothing here
1014
+ * writes one back.
1015
+ *
1016
+ * A failure to fetch one picture is NOT fatal and does not stop the others: an
1017
+ * agent starting without a picture it was never told about is a worse outcome
1018
+ * than one that starts and is told about the pictures that did arrive.
1019
+ */
1020
+ // Exported for `withStandingRules`' and `briefFor`'s reason: the seam this
1021
+ // feature crosses is a real file on disk and the lines naming it, and a test
1022
+ // over the spawn sites would prove nothing about either.
1023
+ export async function picturesOnDisk(client, cardId, where, level) {
1024
+ /* ═══ LEVEL 1 IS TOLD NOTHING, BECAUSE IT HAS NO `Read`. ═══ `builtIns` serves
1025
+ it no code tool at all, so naming a file it cannot open would be an
1026
+ instruction it must fail. */
1027
+ if (level === 1)
1028
+ return [];
1029
+ const images = await returned(client.from('panel3_images').select('id, storage_path, caption')
1030
+ .eq('card_id', cardId).order('created_at'), 'read', `the pictures on card ${cardId}`);
1031
+ if (images.length === 0)
1032
+ return [];
1033
+ const folder = join(gitDirOf(where.cwd) ?? where.cwd, 'ctrl-spc-images');
1034
+ await mkdir(folder, { recursive: true });
1035
+ const lines = [];
1036
+ for (const image of images) {
1037
+ const file = join(folder, `${image.id}.png`);
1038
+ const { data, error } = await client.storage.from(PANEL3_IMAGES_BUCKET).download(image.storage_path);
1039
+ if (error || !data) {
1040
+ out(`images could not fetch ${image.id}: ${error?.message ?? 'no data'}`);
1041
+ continue;
1042
+ }
1043
+ await writeFile(file, Buffer.from(await data.arrayBuffer()));
1044
+ /* THE PATH, NOT AN ID. `Read` takes a path directly, so there is nothing for
1045
+ the agent to resolve and no tool for it to remember. The caption is
1046
+ included when there is one, because a worker's picture carries its words
1047
+ on the row rather than in a turn the agent can read. */
1048
+ lines.push(`Image: ${file}. Read it before you answer about it.`
1049
+ + (image.caption === null ? '' : ` It was shown with: "${image.caption}"`));
1050
+ }
1051
+ if (lines.length === 0)
1052
+ return [];
1053
+ return ['PICTURES ON THIS CARD', ...lines];
1054
+ }
1055
+ /** The git directory for `folder`, or null when it is not a repository. Resolved
1056
+ * per worktree, which is the whole point: a linked worktree's git dir is its
1057
+ * own, and the shared repository's is not. */
1058
+ function gitDirOf(folder) {
1059
+ try {
1060
+ const found = execFileSync('git', ['-C', folder, 'rev-parse', '--absolute-git-dir'], {
1061
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1062
+ }).trim();
1063
+ return found === '' ? null : found;
1064
+ }
1065
+ catch {
1066
+ return null;
1067
+ }
1068
+ }
967
1069
  /**
968
1070
  * ═══ WHAT THE COORDINATOR IS TOLD ABOUT EACH CODEBASE IT MAY SEND WORK TO. ═══
969
1071
  *
@@ -1061,7 +1163,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1061
1163
  ...(parentProcessToken === undefined ? {} : { p_process_token: parentProcessToken }),
1062
1164
  });
1063
1165
  if (error)
1064
- throw new Error(`could not start an agent under run ${parentRunId}: ${error.message}`);
1166
+ throw new Error(`could not start an agent under run ${parentRunId}: ${readableWriteError(error.message)}`);
1065
1167
  const row = data?.[0];
1066
1168
  if (!row) {
1067
1169
  /* NOTHING WAS WRITTEN AND NOTHING IS RUNNING, and the two reasons are said
@@ -1084,6 +1186,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1084
1186
  const level = row.run_level;
1085
1187
  let where;
1086
1188
  let prompt;
1189
+ let pictures;
1087
1190
  /* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
1088
1191
  exists (`panel3_dispatch` wrote it above) and it carries this child's
1089
1192
  codebase, which is what decides which codebase-scoped rules it is under. */
@@ -1094,6 +1197,10 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1094
1197
  ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
1095
1198
  : brief;
1096
1199
  rules = await standingRulesFor(client, row.run_id);
1200
+ /* Inside this site's own try, for `standingRulesFor`'s reason: a failure to
1201
+ assemble what the agent needs ends the run the way this path already ends
1202
+ runs, rather than starting a process that is missing it. */
1203
+ pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1097
1204
  }
1098
1205
  catch (error) {
1099
1206
  const why = error instanceof Error ? error.message : String(error);
@@ -1102,7 +1209,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
1102
1209
  }
1103
1210
  const processToken = row.process_token ?? undefined;
1104
1211
  const isOwner = level === 2;
1105
- const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
1212
+ const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
1106
1213
  if (started.pid === null) {
1107
1214
  /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
1108
1215
  must never be left in quietly. The answer is already settled — nothing ran
@@ -1572,8 +1679,14 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1572
1679
  is a `returned()` message naming tables and columns, which carries no local
1573
1680
  path and is therefore shareable. */
1574
1681
  let rules;
1682
+ let pictures;
1575
1683
  try {
1576
1684
  rules = await standingRulesFor(client, runId);
1685
+ /* ═══ WRITTEN AGAIN ON EVERY START, LIKE THE RULES. ═══ A resumed process is
1686
+ a NEW process with a new copy of the working directory, so the files a
1687
+ previous one was handed are not there any more, and the stored brief this
1688
+ path replays cannot carry a path that was not known when it was written. */
1689
+ pictures = await picturesOnDisk(client, claimed.run_card_id, where, level);
1577
1690
  }
1578
1691
  catch (error) {
1579
1692
  const why = error instanceof Error ? error.message : String(error);
@@ -1587,7 +1700,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1587
1700
  still running. See `retryPrompt`. */
1588
1701
  withStandingRules(rules, afterPid === null
1589
1702
  ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1590
- : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block), level, tools.urlFor(runId), where.cwd);
1703
+ : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd);
1591
1704
  if (started.pid === null) {
1592
1705
  /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1593
1706
  must never be left in quietly. Same handling as a dispatch that could not
@@ -1694,15 +1807,17 @@ async function startRearmed(client, tools, machineId, row) {
1694
1807
  same way: the re-arm's claim has already happened, so a failure here ends
1695
1808
  the run with its reason rather than leaving it claimed with no process. */
1696
1809
  let rules;
1810
+ let pictures;
1697
1811
  try {
1698
1812
  rules = await standingRulesFor(client, row.run_id);
1813
+ pictures = await picturesOnDisk(client, row.run_card_id, where, level);
1699
1814
  }
1700
1815
  catch (error) {
1701
1816
  const why = error instanceof Error ? error.message : String(error);
1702
1817
  await endRun(client, level, row.run_id, row.run_card_id, why);
1703
1818
  throw new Error(`NO AGENT IS RUNNING: ${why}`);
1704
1819
  }
1705
- const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id), where.cwd);
1820
+ const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd);
1706
1821
  if (started.pid === null) {
1707
1822
  const answer = await started.answered;
1708
1823
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -1950,11 +2065,17 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1950
2065
  activation does not. This block carries the PERSON's attachments; where the
1951
2066
  owner is working is `where.block`'s answer. */
1952
2067
  let attached;
2068
+ let pictures;
1953
2069
  try {
1954
2070
  where = await ownerDirectory(client, candidate);
1955
2071
  rules = await standingRulesFor(client, runId);
1956
2072
  attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
1957
2073
  .filter((line) => !line.startsWith('codebase ')));
2074
+ /* ═══ THE OWNER'S OWN ACTIVATION, WHICH IS WHERE MOST PICTURES ARRIVE. ═══
2075
+ The person sends one and this is the run that reads it. Written on every
2076
+ activation rather than once, so an owner resumed into an existing native
2077
+ conversation is told again about a directory a previous process wrote. */
2078
+ pictures = await picturesOnDisk(client, claimed.run_card_id, where, 2);
1958
2079
  }
1959
2080
  catch (error) {
1960
2081
  const why = error instanceof Error ? error.message : String(error);
@@ -1980,7 +2101,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1980
2101
  are not the same event and `prompt.ts` exists to stop an agent being
1981
2102
  told an untrue reason for its own restart. */
1982
2103
  afterPid !== null, landing);
1983
- const started = startAgent(withStandingRules(rules, prompt, where.block, attached), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
2104
+ const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
1984
2105
  if (started.pid === null) {
1985
2106
  const answer = await started.answered;
1986
2107
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -1997,7 +2118,38 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1997
2118
  settled: settle(client, tools, machineId, 2, runId, claimed.run_card_id, started, true, claimed.process_token, ownerSessionLifecycle(started, runId, machineHarness, claimed.process_token, resumeSessionId)),
1998
2119
  };
1999
2120
  }
2000
- /** The only owner rows the normal poll may try, including an explicit hand-back move. */
2121
+ /** The only owner rows the normal poll may try, including an explicit hand-back move.
2122
+ *
2123
+ * ═══ A CARD THAT IS OVER HAS NO ACTIVATION TO TAKE, AND THE POLL MUST KNOW
2124
+ * THAT WITHOUT ASKING. ═══ `panel3_take_owner_activation` already refuses
2125
+ * every one of these, so the rows were never started; what the refusal cost was
2126
+ * the ASKING. A finished owner stays `finished` forever and its card stays
2127
+ * `done` forever, so each one was re-offered on every poll for the life of the
2128
+ * account: measured here, 35 owners, ~340ms per round trip, ~12s added to a
2129
+ * single poll. The heartbeat at the top of that poll is written once per
2130
+ * iteration, and `isListening`'s window is 15s, so a long enough tail of
2131
+ * finished cards pushes the gap past the window and every card on screen reads
2132
+ * `Nothing is listening` off a daemon that is polling perfectly.
2133
+ *
2134
+ * The card's state is the right gate rather than the run's, because it is what
2135
+ * says the conversation is over: a `finished` owner on a `working` card is
2136
+ * exactly the row a person's next message re-activates, and it must still be
2137
+ * offered. It is read here off the row the poll already has (`card:` is
2138
+ * embedded in the candidates select), so the check costs nothing.
2139
+ *
2140
+ * ═══ THE GATE NAMES THE LIVE STATES, NOT THE TERMINAL ONES. ═══ There are three
2141
+ * ways a card ends — `done`, `failed` and `stopped` (20260828140000) — and only
2142
+ * two ways it is live. Enumerating the terminals is the spelling that rots: it
2143
+ * was written that way first and let `stopped` straight through, because
2144
+ * `stopped` was added to the check constraint by a later migration than the one
2145
+ * that introduced the other three. A state this file has never heard of is far
2146
+ * likelier to be a new ending than a new kind of alive, and the safe direction
2147
+ * for an unknown is to keep asking rather than to go silent, which is what
2148
+ * naming the live pair gives.
2149
+ *
2150
+ * A hand-back is deliberately NOT gated on it: `handed_back_at` is a person
2151
+ * asking for the work to move, and the RPC owns whether that is still possible.
2152
+ */
2001
2153
  export function ownerPollClaim(candidate) {
2002
2154
  if (candidate.state === 'running') {
2003
2155
  if (candidate.handed_back_at === null || candidate.process_token === null)
@@ -2005,6 +2157,12 @@ export function ownerPollClaim(candidate) {
2005
2157
  return { afterProcessToken: candidate.process_token, afterPid: null };
2006
2158
  }
2007
2159
  if (candidate.state === 'finished' || candidate.state === 'asked') {
2160
+ /* Undefined means a caller that does not carry the card, which keeps the
2161
+ old behaviour rather than silently refusing a row it cannot judge. */
2162
+ if (candidate.card_state !== undefined
2163
+ && candidate.card_state !== 'working'
2164
+ && candidate.card_state !== 'needs-you')
2165
+ return null;
2008
2166
  return { afterProcessToken: null, afterPid: null };
2009
2167
  }
2010
2168
  return null;
@@ -2130,7 +2288,7 @@ async function takeOwnerActivations(client, tools, machineId, mine, hold) {
2130
2288
  || (candidate.pid === null
2131
2289
  && Date.now() - new Date(candidate.resumed_at ?? candidate.started_at).getTime() < PID_GRACE_MS)))
2132
2290
  continue;
2133
- const claim = ownerPollClaim(candidate);
2291
+ const claim = ownerPollClaim({ ...candidate, card_state: candidate.card?.state ?? null });
2134
2292
  if (!claim)
2135
2293
  continue;
2136
2294
  try {
@@ -2674,8 +2832,20 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2674
2832
  * turns with two ideas of what recovery may reap.
2675
2833
  *
2676
2834
  * @param injected the client to poll and serve tools through, when the caller
2677
- * already has one. Its presence is also what says THIS PROCESS IS NOT v3's, so
2678
- * the signals belong to somebody else see the handler block below.
2835
+ * already has one, OR a function returning whichever client the caller is
2836
+ * holding NOW. Its presence is also what says THIS PROCESS IS NOT v3's, so the
2837
+ * signals belong to somebody else — see the handler block below.
2838
+ *
2839
+ * ═══ A FUNCTION, BECAUSE THE CALLER'S CLIENT CAN BE REPLACED UNDER THIS LOOP.
2840
+ * ═══ Under `cs start` the injected client is v2 presence's, and presence
2841
+ * REBUILDS it when `session.json` comes to hold a different refresh token. A
2842
+ * value captured once at startup is then a client the database refuses and
2843
+ * nobody watches for `SIGNED_OUT`, while this loop goes on writing
2844
+ * `panel3_machines` every two seconds against it: 37,051 writes in one window,
2845
+ * which was the single largest contributor to the incident this parameter was
2846
+ * widened for. The acceptance harness still passes a plain client, which is
2847
+ * normalised into a constant getter below, so there is one shape inside `run`
2848
+ * and no branch anywhere near the poll.
2679
2849
  */
2680
2850
  /**
2681
2851
  * ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
@@ -2806,6 +2976,31 @@ async function reportHarnessMismatch(client, machineId, machineHarness) {
2806
2976
  + `this daemon is restarted with CTRL_SPC_V3_AGENT=${designation.agent}, or ${machineHarness} `
2807
2977
  + 'is designated in the app.');
2808
2978
  }
2979
+ /**
2980
+ * ═══ THE ONE PLACE THE TWO SHAPES OF CALLER BECOME ONE SHAPE. ═══
2981
+ *
2982
+ * `run` is entered two ways. The acceptance harness (`panel3/cli.js run`) signs
2983
+ * in once and holds one client for the life of a `--once` poll, so a value is
2984
+ * the honest thing for it to pass. `cs start` hands in v2 presence's client,
2985
+ * which presence REPLACES when `session.json` comes to hold a different refresh
2986
+ * token, so the honest thing for it to pass is a way to ask.
2987
+ *
2988
+ * ═══ IT IS A FUNCTION RATHER THAN A BRANCH AT EVERY USE SITE, AND THAT IS THE
2989
+ * WHOLE PRODUCT RULE. ═══ The defect being closed is a client read in one
2990
+ * place and captured in another: the poll body could be made to re-read and the
2991
+ * shutdown write, the signal handler and the dispatch callback would still hold
2992
+ * a client the database refuses. Collapsing both callers into one getter at the
2993
+ * top means there is no second shape left for a use site to capture, so
2994
+ * "everything reads the live client" is true by construction rather than by
2995
+ * every call site remembering.
2996
+ *
2997
+ * Exported because that rule is what a test should hold: a client swapped after
2998
+ * `run` was entered is the one subsequently used, which is a claim about this
2999
+ * function and nothing else.
3000
+ */
3001
+ export function clientReader(injected) {
3002
+ return typeof injected === 'function' ? injected : () => injected;
3003
+ }
2809
3004
  export async function run(args, injected) {
2810
3005
  let once = false;
2811
3006
  for (const arg of args) {
@@ -2827,7 +3022,13 @@ export async function run(args, injected) {
2827
3022
  token has exactly one writer. Signing in again there would be the second
2828
3023
  refresh loop `client.ts`'s header exists to prevent, this time inside one
2829
3024
  process rather than across two. */
2830
- const client = injected ?? await signedInClient(true);
3025
+ /* ═══ NORMALISED ONCE, HERE, SO EVERYTHING BELOW READS THE SAME WAY. ═══ A
3026
+ plain client becomes a getter that always answers with it, and a getter is
3027
+ used as it is. Every use site inside this loop then calls `current()`, and
3028
+ which of the two the caller passed stops being visible past this line. That
3029
+ is the property that matters, because the failure being closed is a value
3030
+ captured in one place and not another. */
3031
+ const current = clientReader(injected ?? await signedInClient(true));
2831
3032
  const machineId = getMachineIdentity().id;
2832
3033
  /* THE NAME AND THE HARNESS, RESOLVED ONCE, AT STARTUP, RATHER THAN PER POLL.
2833
3034
  Both are properties of this machine for the life of this process: the
@@ -2862,8 +3063,8 @@ export async function run(args, injected) {
2862
3063
  `tools` is referenced inside the callback it is being given, which is safe
2863
3064
  for the plain reason that the callback can only run once a request has
2864
3065
  arrived at a server that by then exists. */
2865
- const tools = await startToolsServer(client, async (parentRunId, brief, codebase, processToken) => {
2866
- const child = await startChild(client, tools, machineId, parentRunId, brief, codebase, processToken);
3066
+ const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken) => {
3067
+ const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken);
2867
3068
  hold(child.runId, child.settled);
2868
3069
  return { runId: child.runId };
2869
3070
  });
@@ -2890,7 +3091,7 @@ export async function run(args, injected) {
2890
3091
  if (!once && !injected) {
2891
3092
  for (const signal of ['SIGINT', 'SIGTERM']) {
2892
3093
  process.once(signal, () => {
2893
- void stopListening(client, machineId, machineHarness).finally(() => {
3094
+ void stopListening(current(), machineId, machineHarness).finally(() => {
2894
3095
  /* The exit code a signal is supposed to produce, and the reason it is
2895
3096
  said explicitly: `process.once` REPLACES node's default handler, so
2896
3097
  without this a Ctrl-C would leave the daemon polling forever. */
@@ -2917,20 +3118,20 @@ export async function run(args, injected) {
2917
3118
  is two seconds away or nobody has one running; this row is the only place
2918
3119
  the difference exists. It is written before the takes rather than after
2919
3120
  so that a machine which is up but busy still reads as up. */
2920
- await sayListening(client, machineId, machineName, machineHarness);
3121
+ await sayListening(current(), machineId, machineName, machineHarness);
2921
3122
  /* ═══ THE USER'S STOP IS HONOURED BEFORE ANYTHING ELSE ON THE POLL. ═══ It
2922
3123
  is the only thing here that a person is waiting on, and the two takes
2923
3124
  below can spend the rest of the poll starting agents. Nothing else needs
2924
3125
  to run first: `panel3_stop_card` has already ended the runs, so recovery
2925
3126
  cannot see them and neither take can start them. */
2926
- await killStopped(client, machineId);
2927
- await reconcileOwnerSessions(client, machineId, machineHarness, new Set(inFlight.keys()));
2928
- await recoverStranded(client, tools, machineId, new Set(inFlight.keys()), hold);
3127
+ await killStopped(current(), machineId);
3128
+ await reconcileOwnerSessions(current(), machineId, machineHarness, new Set(inFlight.keys()));
3129
+ await recoverStranded(current(), tools, machineId, new Set(inFlight.keys()), hold);
2929
3130
  /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
2930
3131
  deliberately: a run this machine is about to resume is one whose card is
2931
3132
  not finished, and the sweep asks the record after recovery has had its
2932
3133
  say about what is really still running. */
2933
- await sweepFinishedWorktrees(client);
3134
+ await sweepFinishedWorktrees(current());
2934
3135
  /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
2935
3136
  IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
2936
3137
  runs on local, pid-accurate evidence before it looks at anybody's, and a
@@ -2941,10 +3142,10 @@ export async function run(args, injected) {
2941
3142
  BEFORE both takes, for the reason the sweep is: work that already exists,
2942
3143
  with a brief and a report behind it, comes before work that has not
2943
3144
  started. */
2944
- await takeHandedBack(client, tools, machineId, new Set(inFlight.keys()), hold);
3145
+ await takeHandedBack(current(), tools, machineId, new Set(inFlight.keys()), hold);
2945
3146
  /* Every reason a conversational owner may continue is claimed together.
2946
3147
  The legacy turn and re-arm takes exclude named owners in the database. */
2947
- await takeOwnerActivations(client, tools, machineId, new Set(inFlight.keys()), hold);
3148
+ await takeOwnerActivations(current(), tools, machineId, new Set(inFlight.keys()), hold);
2948
3149
  /* ═══ AND IF THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, IT SAYS
2949
3150
  SO BEFORE TAKING NOTHING. ═══
2950
3151
  `panel3_take_turns` and `panel3_take_rearms` compare `p_agent` against
@@ -2962,11 +3163,11 @@ export async function run(args, injected) {
2962
3163
  ux.md's forbidden state — "nothing will act on it and nothing on screen
2963
3164
  says so" — is reached the moment they do. This is the daemon's own
2964
3165
  screen saying so. */
2965
- await reportHarnessMismatch(client, machineId, machineHarness);
3166
+ await reportHarnessMismatch(current(), machineId, machineHarness);
2966
3167
  /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
2967
3168
  to say where it is running: the exclusion is cross-machine and recovery is
2968
3169
  per-machine, so a row with nobody's machine on it could be neither. */
2969
- const taken = await returned(client.rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
3170
+ const taken = await returned(current().rpc('panel3_take_turns', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'turns');
2970
3171
  /* ═══ THE OTHER KIND OF TAKEABLE WORK. ═══ ux.md's re-arm: an answered
2971
3172
  question makes the branch that asked it takeable again, and a question
2972
3173
  still walking up makes the run it reached takeable so that level gets its
@@ -2980,14 +3181,14 @@ export async function run(args, injected) {
2980
3181
  to it on a later poll rather than putting two of them on one card. The
2981
3182
  other order would decide the same question from a snapshot taken before
2982
3183
  the run existed. */
2983
- const rearmed = await returned(client.rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
3184
+ const rearmed = await returned(current().rpc('panel3_take_rearms', { p_machine_id: machineId, p_agent: machineHarness }), 'take', 'runs that can carry on');
2984
3185
  for (const row of rearmed) {
2985
3186
  /* NOT AWAITED PAST THE SPAWN, exactly as a taken card is not: the work is a
2986
3187
  real agent and holding the poll open for it would put every other card
2987
3188
  behind it. A throw here is caught rather than taking the loop down,
2988
3189
  because one run that could not be started must not stop the others. */
2989
3190
  try {
2990
- hold(row.run_id, (await startRearmed(client, tools, machineId, row)).settled);
3191
+ hold(row.run_id, (await startRearmed(current(), tools, machineId, row)).settled);
2991
3192
  }
2992
3193
  catch (error) {
2993
3194
  said(`could not start run ${row.run_id} again: ${error instanceof Error ? error.message : String(error)}`);
@@ -3006,7 +3207,7 @@ export async function run(args, injected) {
3006
3207
  // throw is not lost: its process has exited, so the next sweep reaps it
3007
3208
  // and the turns are answered again. It is there so the reason is printed
3008
3209
  // rather than becoming an unhandled rejection.
3009
- hold(runId, answerCard(client, tools, machineId, cardId, turns));
3210
+ hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3010
3211
  }
3011
3212
  if (once) {
3012
3213
  /* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
@@ -3021,7 +3222,7 @@ export async function run(args, injected) {
3021
3222
  await tools.close();
3022
3223
  // This machine is not listening any more, and it knows that here rather
3023
3224
  // than fifteen seconds from now. Same reason as the signal handlers above.
3024
- await stopListening(client, machineId, machineHarness);
3225
+ await stopListening(current(), machineId, machineHarness);
3025
3226
  return;
3026
3227
  }
3027
3228
  }
@@ -3053,11 +3254,21 @@ export async function run(args, injected) {
3053
3254
  * suite, which is the point: the rule is enumerated, never weakened.
3054
3255
  *
3055
3256
  * ═══ THE CLIENT IS THE CALLER'S, AND THAT IS THE WHOLE OF "ONE SIGN-IN". ═══
3056
- * `startPresence()` hands over the client it built from the session `cs login`
3057
- * stored. This must never take one of its own: two clients in one process is two
3058
- * refresh loops on one rotating refresh token, both writing `session.json`
3059
- * through `getClient`'s `onAuthStateChange`, which is the hazard `client.ts`
3060
- * describes moved indoors.
3257
+ * `startPresence()` owns the client it built from the session `cs login` stored.
3258
+ * This must never take one of its own: two clients in one process is two refresh
3259
+ * loops on one rotating refresh token, both writing `session.json` through
3260
+ * `getClient`'s `onAuthStateChange`, which is the hazard `client.ts` describes
3261
+ * moved indoors.
3262
+ *
3263
+ * ═══ AND IT IS READ RATHER THAN HANDED OVER, BECAUSE PRESENCE REPLACES IT. ═══
3264
+ * A `SupabaseClient` is still accepted, for the harness and for any caller
3265
+ * holding exactly one. `cs start` passes a function instead, because presence's
3266
+ * heartbeat rebuilds its client when the account on disk changes, and the panel
3267
+ * holding the pre-rebuild one is the largest single share of the poll storm this
3268
+ * widening closes. `stop` reads it through the same getter for the same reason:
3269
+ * it is the last write this process makes, minutes or days after the getter was
3270
+ * handed over, and a goodbye written with a refused token leaves the card saying
3271
+ * an agent is working.
3061
3272
  *
3062
3273
  * ═══ IT DOES NOT BLOCK, AND IT DOES NOT TAKE THE DAEMON DOWN. ═══ The loop
3063
3274
  * never returns, so awaiting it here would hang `cs start` before it printed a
@@ -3065,7 +3276,8 @@ export async function run(args, injected) {
3065
3276
  * which is said on the one stream a person reads and leaves v2 presence
3066
3277
  * heartbeating rather than killing the process around it.
3067
3278
  */
3068
- export function startPanel(client) {
3279
+ export function startPanel(injected) {
3280
+ const current = clientReader(injected);
3069
3281
  /* Both resolved HERE as well as inside the loop, deliberately: `stop` has to
3070
3282
  name the same row the loop's heartbeat writes, and it must be able to do
3071
3283
  that after the loop has thrown. They are the two facts about this machine
@@ -3073,11 +3285,16 @@ export function startPanel(client) {
3073
3285
  `CTRL_SPC_V3_AGENT` — so reading them twice cannot disagree. */
3074
3286
  const machineId = getMachineIdentity().id;
3075
3287
  const machineHarness = harness();
3076
- void run([], client).catch((error) => {
3288
+ /* THE GETTER, NOT `current()`. Passing the read itself is what keeps ONE
3289
+ normalisation in this process: `run` calls `clientReader` on whatever it is
3290
+ given, a getter passes through unchanged, and the loop reads the same live
3291
+ client `stop` does. Passing `current()` here would resolve it once and hand
3292
+ the loop the very snapshot this parameter was widened to stop producing. */
3293
+ void run([], current).catch((error) => {
3077
3294
  said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3078
3295
  });
3079
3296
  /* `stopListening` is already best-effort and swallows its own failure: a
3080
3297
  process on its way out must not hang or fail, and the freshness window
3081
3298
  covers every way a machine can leave without getting here. */
3082
- return { stop: () => stopListening(client, machineId, machineHarness) };
3299
+ return { stop: () => stopListening(current(), machineId, machineHarness) };
3083
3300
  }