@ctrl-spc/cs 0.6.0 → 0.7.0

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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: `cs3 answer`, the person settling a question. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * ═══ IT IS `cs3 say`'s SIBLING, AND IT IS A SEPARATE COMMAND ON PURPOSE. ═══
9
+ *
10
+ * Both are the person putting something on the record and returning, with no
11
+ * spawn to wait for. They differ in what the record does with it, and that
12
+ * difference is exactly why they are not one command: a message is new work, and
13
+ * an answer is the thing one particular piece of stopped work was waiting for.
14
+ * `cs3 say --card <id> "the answer"` would put the words on the card as a fresh
15
+ * message, leave the question open, and leave the run that asked it stopped
16
+ * forever — which is the fenced-off answer ux.md spends a section on, arrived at
17
+ * by the shortest route.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * ═══ THE CARD LEAVES needs-you IN THE SAME STATEMENT. ═══
21
+ *
22
+ * ux.md: "Every user input changes the screen immediately... a card still
23
+ * sitting in needs-you after the user answered it gets answered twice." So this
24
+ * command writes nothing itself: `panel3_answer_ask` settles the question and puts
25
+ * the card back in hand together, and there is no instant between them for a
26
+ * screen to be wrong in.
27
+ *
28
+ * ═══ EXCEPT WHEN THE QUESTION BELONGS TO WORK THE USER STOPPED, AND THEN THIS
29
+ * COMMAND SAYS SO. ═══ The same statement declines to put such a card in
30
+ * hand, because nobody will be started for the answer. What this command may not
31
+ * do is go on printing the sentence that is usually true: "in hand again" and
32
+ * "whoever was waiting on this is started again with it" would be the product
33
+ * announcing an effect it did not have, which is the same failure as a tool
34
+ * reporting success for work it did not do. So the card's state is read back and
35
+ * the outcome is what the record says, not what usually happens.
36
+ *
37
+ * ---------------------------------------------------------------------------
38
+ * ═══ AND IT DOES NOT SPAWN, WHICH IS THE WHOLE DESIGN RATHER THAN A GAP. ═══
39
+ *
40
+ * The agent that asked is gone: asking is terminal, and nothing is sitting there
41
+ * to be woken. What starts it again is the daemon's own poll, one round trip
42
+ * later, reading the same row this command wrote — so the answer is durable
43
+ * whether or not a machine is up, and this command is honest about the wait
44
+ * rather than pretending to have started something.
45
+ */
46
+ // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
47
+ // and takes the leading comment with it, so a file whose first statement is
48
+ // `import type` loses its v3 header in the published `dist/`.
49
+ import { at, out, returned, signedInClient } from './client.js';
50
+ import { ASK_CONTENT_COLUMNS, withAskContent } from './show.js';
51
+ const USAGE = 'usage: cs3 answer <question-id> "<text>"';
52
+ /**
53
+ * The two arguments, and a second bare word refused rather than joined: `cs3
54
+ * answer <id> yes it is` is an answer the shell already split, and gluing it back
55
+ * together would be this command guessing at what the person typed. The same rule
56
+ * `cs3 say` applies, for the same reason.
57
+ */
58
+ function parseArgs(args) {
59
+ const words = args.filter((a) => !a.startsWith('--'));
60
+ const flag = args.find((a) => a.startsWith('--'));
61
+ if (flag)
62
+ throw new Error(`unknown option ${flag}. ${USAGE}`);
63
+ if (words.length < 2)
64
+ throw new Error(`answer what, and with what? ${USAGE}`);
65
+ if (words.length > 2) {
66
+ throw new Error(`quote the answer as one argument, got ${words.length - 1}. ${USAGE}`);
67
+ }
68
+ if (words[1].trim() === '')
69
+ throw new Error(`the answer is empty. ${USAGE}`);
70
+ return { askId: words[0], answer: words[1] };
71
+ }
72
+ /**
73
+ * ═══ A REFUSAL SAYS WHICH REFUSAL IT IS, WHICH TAKES A SECOND READ. ═══
74
+ *
75
+ * `panel3_answer_ask` returns null for three different facts — already answered,
76
+ * not the person's to answer yet, no such question — because a caller inside the
77
+ * product cannot act on the difference. A person can: one of them means look at
78
+ * the card, one means wait, and one means the id is wrong. So this reads the row
79
+ * afterwards, purely to say which, and nothing about the record has changed in
80
+ * between that could make the sentence untrue.
81
+ *
82
+ * ═══ AND THE WAITING ONE IS ONLY SAID WHEN IT IS TRUE. ═══ It used to say the
83
+ * question "may yet be settled without troubling you" whatever the record said —
84
+ * including for a question `cs3 show` had already called stalled, because the
85
+ * agent it went to had ended without dealing with it. Two surfaces, opposite
86
+ * claims, and the one the person can act on was the one that was wrong. Those
87
+ * are now answerable, so the only refusal left here is the honest one: it has
88
+ * not been tried yet.
89
+ */
90
+ async function whyNot(client, askId) {
91
+ const asks = await withAskContent(client, await returned(client
92
+ .from('panel3_asks')
93
+ .select(`id, card_id, answered_at, pending_run_id, delivered_at, ${ASK_CONTENT_COLUMNS}`)
94
+ .eq('id', askId), 'read', `question ${askId}`));
95
+ const ask = asks[0];
96
+ if (!ask) {
97
+ return `there is no question with id ${askId}. \`cs3 show\` lists the cards and the questions on them.`;
98
+ }
99
+ if (ask.answered_at) {
100
+ return (`that question was already answered ${at(ask.answered_at)}, with:\n ${ask.answer ?? '(nothing)'}\n`
101
+ + 'Answering it again would rewrite a decision the work has already been started on. Send a '
102
+ + `message instead: cs3 say --card ${ask.card_id} "<text>"`);
103
+ }
104
+ return ('that question has not reached you: it is with the work it came from, which has not had its go '
105
+ + 'at it yet and may settle it without troubling you. `cs3 show` says who has it, and it becomes '
106
+ + 'yours to answer if they end without dealing with it.');
107
+ }
108
+ export async function answer(args) {
109
+ const { askId, answer: text } = parseArgs(args);
110
+ const client = await signedInClient();
111
+ /* ═══ STILL FREE TEXT, AND IT NEEDS NO CASE FOR THE SHAPE OF THE QUESTION.
112
+ ═══ questions-2/ux.md puts changing this command out of scope, and no
113
+ branch is needed to keep that promise: no selection and the text as the
114
+ note is the product's own "answered in their own words", which
115
+ `app.canonical_decision_answer` returns verbatim whatever mode the question
116
+ was asked in. So a shortlisted question answered from a terminal records
117
+ what was typed, exactly as it always did, and the panel reads it as an own
118
+ answer rather than as a choice nobody made. */
119
+ const { data: cardId, error } = await client
120
+ .rpc('panel3_answer_ask', { p_ask_id: askId, p_selected_options: [], p_answer_note: text });
121
+ if (error)
122
+ throw new Error(`could not answer question ${askId}: ${error.message}`);
123
+ if (cardId === null) {
124
+ /* NOTHING WAS WRITTEN, and this exits non-zero saying why. A command whose
125
+ whole job is settling something must never look like it settled it. */
126
+ throw new Error(await whyNot(client, askId));
127
+ }
128
+ /* WHAT THE RECORD DID, READ BACK, rather than what it usually does. One read,
129
+ and it is the card's own state: `panel3_answer_ask` puts a card in hand
130
+ exactly when somebody can be started for the answer, so the state is the
131
+ answer to "did anything start" without a second return value to keep in
132
+ step with it. */
133
+ const cards = await returned(client.from('panel3_cards').select('state').eq('id', cardId), 'read', `card ${cardId}`);
134
+ for (const said of answeredLines(askId, cardId, cards[0]?.state ?? null))
135
+ out(said);
136
+ }
137
+ /**
138
+ * What the command says once the answer is on the record, from the state the
139
+ * card is actually in.
140
+ *
141
+ * `working` is the ordinary outcome and the only one where anybody is started.
142
+ * Anything else means the card was not put back in hand, which today is a card
143
+ * the user stopped: the answer is kept, and nothing is waiting for it. A null
144
+ * state is a card that vanished between the write and the read, and it is said
145
+ * plainly rather than dressed as either outcome.
146
+ */
147
+ export function answeredLines(askId, cardId, state) {
148
+ if (state === 'working') {
149
+ return [
150
+ `answered ${askId}`,
151
+ `card ${cardId} in hand again`,
152
+ '',
153
+ ' Whoever was waiting on this is started again with it, wherever they had got to.',
154
+ ` cs3 show ${cardId}`,
155
+ ];
156
+ }
157
+ return [
158
+ `answered ${askId}`,
159
+ `card ${cardId} ${state === null ? 'no longer on the record' : `still ${state}`}`,
160
+ '',
161
+ ' NOBODY IS BEING STARTED FOR THIS. The answer is on the record and nothing is waiting for',
162
+ ' it, because the work that asked was stopped. Send a message if you want this card picked',
163
+ ' up again:',
164
+ ` cs3 say --card ${cardId} "<text>"`,
165
+ ];
166
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: THE EXACT CODEBASE A RUN WORKS IN. ═══
3
+ *
4
+ * A run names one registered codebase. The cloud keeps its path-free identity;
5
+ * this machine keeps the folder in `codebase-paths.json`. Nothing falls back to
6
+ * a project folder or a machine-wide working copy, because either could put an
7
+ * agent in a different repository while the record names this one.
8
+ */
9
+ import { statSync } from 'node:fs';
10
+ import { readCodebasePaths } from '../config.js';
11
+ function isDirectory(path) {
12
+ try {
13
+ return statSync(path).isDirectory();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function hasCheckoutForCodebase(codebase) {
20
+ const located = readCodebasePaths()[codebase.gitRemoteUrl];
21
+ return Boolean(located && isDirectory(located));
22
+ }
23
+ /** Resolve one registered codebase to this machine's local folder. */
24
+ export function checkoutForCodebase(codebase, machineName) {
25
+ const located = readCodebasePaths()[codebase.gitRemoteUrl];
26
+ if (located && hasCheckoutForCodebase(codebase))
27
+ return located;
28
+ throw new Error(`Open the companion on ${machineName} and locate ${codebase.name} before sending code work there.`);
29
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ═══ AGENT PANEL v3: the `cs3` bin. ═══
4
+ *
5
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
6
+ * import it.
7
+ *
8
+ * A SEPARATE BIN RATHER THAN A SUBCOMMAND OF `cs`, deliberately: v3 never
9
+ * touches v1's command router, so retiring either generation is deleting a
10
+ * directory and a line of `bin`, not unpicking a switch statement two
11
+ * generations share.
12
+ *
13
+ * ═══ AND SINCE recovery-1 SLICE 4, `run` IS NOT SOMETHING A PERSON TYPES. ═══
14
+ * Lane's ruling (2026-08-21): a person launches one CLI, `cs start`, and signs
15
+ * into it once. `cs start` now runs the panel's poll loop through `startPanel`,
16
+ * so that is the only launch the product ever names, and `cs3 run` is off the
17
+ * help text above.
18
+ *
19
+ * ═══ IT IS STILL HERE, AND IT IS NOT A SECOND OWNER OF THE LOOP. ═══ There is
20
+ * one `run()` in `run.ts` with two callers. This one signs in from
21
+ * `CTRL_SPC_V3_EMAIL` / `CTRL_SPC_V3_PASSWORD`, which is what lets
22
+ * `test/panel3-acceptance.mjs` and the three walk scripts stand a daemon up
23
+ * against their own isolated account and their own working copy without touching
24
+ * the installed CLI's `session.json` or its presence. Deleting it would mean
25
+ * rebuilding that isolation inside `cs start`, which is a launch path the tests
26
+ * would then own. DO NOT PUT IT BACK IN THE HELP TEXT.
27
+ *
28
+ * ═══ A FAILURE EXITS NON-ZERO AND SAYS WHY, ALWAYS. ═══ Every read in `show`
29
+ * and every write in `say` either returns its rows or throws, and this is the
30
+ * only handler. Nothing here converts a failure into an empty result, because a
31
+ * command whose whole job is observation is worthless the first time silence can
32
+ * mean either "nothing is there" or "the read failed", and a `say` that exited
33
+ * zero without writing would be an acknowledgement of nothing.
34
+ */
35
+ import { answer } from './answer.js';
36
+ import { run } from './run.js';
37
+ import { say } from './say.js';
38
+ import { show } from './show.js';
39
+ const HELP = `cs3 — CTRL+SPC agent panel v3
40
+
41
+ cs3 say "<text>" Start a card and put your message on it
42
+ cs3 say --project <p> "<text>" Start it filed under that project (id or name)
43
+ cs3 say --card <id> "<text>" Add a message to a card you already have
44
+ cs3 answer <id> "<text>" Answer a question a card is waiting on you for
45
+ cs3 show Every card
46
+ cs3 show <id> One card in full, or one run with its brief and report
47
+ cs3 help Show this help
48
+
49
+ This machine answers cards when \`cs start\` is running. These commands are the
50
+ terminal's view of the same record.
51
+
52
+ Acts as CTRL_SPC_V3_EMAIL / CTRL_SPC_V3_PASSWORD, against the hosted project
53
+ by default. Point it at a local stack with:
54
+ CTRL_SPC_SUPABASE_URL=http://127.0.0.1:54321
55
+ CTRL_SPC_SUPABASE_KEY=<the local publishable key from \`supabase status\`>
56
+ `;
57
+ async function main() {
58
+ const command = process.argv[2];
59
+ switch (command) {
60
+ case 'say':
61
+ return say(process.argv.slice(3));
62
+ case 'answer':
63
+ return answer(process.argv.slice(3));
64
+ case 'run':
65
+ return run(process.argv.slice(3));
66
+ case 'show':
67
+ return show(process.argv[3]);
68
+ case undefined:
69
+ case 'help':
70
+ case '--help':
71
+ case '-h':
72
+ console.log(HELP);
73
+ return;
74
+ default:
75
+ console.error(`Unknown command: ${command}\n`);
76
+ console.log(HELP);
77
+ process.exitCode = 1;
78
+ }
79
+ }
80
+ main().catch((err) => {
81
+ console.error(`cs3: ${err instanceof Error ? err.message : String(err)}`);
82
+ process.exit(1);
83
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: what every v3 command talks to the record through. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * WHY THE ERROR GUARD AND THE TWO PRINTERS LIVE HERE TOO.
9
+ *
10
+ * They were written twice, once in `show.ts` and once in `say.ts`, and a
11
+ * slightly different copy of something that already exists is a defect in this
12
+ * project rather than a delivery. The guard is the one that matters: it is where
13
+ * constraint 7 is actually enforced, and two copies of it means two places a
14
+ * later command could soften into returning null on failure. One owner, called
15
+ * by both, and every command added after this one inherits it rather than
16
+ * copying it a third time.
17
+ *
18
+ * ---------------------------------------------------------------------------
19
+ * WHY IT SIGNS IN EVERY TIME INSTEAD OF READING THE STORED SESSION.
20
+ *
21
+ * The installed CLI keeps one `session.json` on this machine and its long-lived
22
+ * daemon holds the access token that file names. A second process that reads it
23
+ * and lets the client refresh writes a ROTATED token back over the same file,
24
+ * and the daemon's token stops working. So v3 takes a fresh session of its own,
25
+ * in memory, and never touches the file: `persistSession: false`, and no v3
26
+ * client has any storage to write a rotated token into.
27
+ *
28
+ * ═══ THAT IS NOW TRUE OF THE COMMANDS AND NOT OF THE PANEL, AND THE DIFFERENCE
29
+ * IS THE WORD "PROCESS". ═══ recovery-1 Slice 4: `cs start` runs the poll
30
+ * loop itself, and it hands `run()` THE CLIENT IT ALREADY HAS. Read the hazard
31
+ * above again with that in mind — it is about a SECOND PROCESS refreshing
32
+ * against a file the first one is relying on. Inside one process there is one
33
+ * client, `getClient()`'s, with one refresh loop and one update-only
34
+ * `onAuthStateChange` writer, so a rotation is the daemon rotating its own
35
+ * token. `signedInClient` must never be called from that path, because taking a
36
+ * session of its own there is what would recreate the race indoors.
37
+ *
38
+ * So this function now serves `show`, `say`, `answer`, and `cs3 run` as the
39
+ * acceptance harness's isolated entry — every one of them a process that is NOT
40
+ * the installed daemon, which is exactly the set the paragraph above describes.
41
+ *
42
+ * ---------------------------------------------------------------------------
43
+ * ═══ AND THE DAEMON'S CLIENT REFRESHES, WHILE A ONE-SHOT COMMAND'S DOES NOT.
44
+ * ═══
45
+ *
46
+ * `autoRefreshToken` was off for every v3 client, which is right for `show`,
47
+ * `say` and `answer` — each signs in, does one thing and exits, long inside the
48
+ * access token's lifetime — and WRONG for `cs3 run`, which holds one client for
49
+ * as long as the daemon lives and hands that same client to every tool call an
50
+ * agent makes. Past the token's TTL every poll and every in-flight tool call
51
+ * would start failing, and the card would sit working with a daemon that could
52
+ * no longer read or write anything.
53
+ *
54
+ * ═══ THIS IS CLI v1's RECORDED ROOT CAUSE, AND IT WAS ON ITS WAY BACK IN. ═══
55
+ * MEMORY, "CLI v1 unreliability root cause": `autoRefreshToken: false` plus a
56
+ * long-lived connection plus swallowed 401s is why v1's presence went stale and
57
+ * nobody could say why. v3 does not swallow anything — `returned()` throws — so
58
+ * it would have failed loudly rather than silently, but it would still have
59
+ * failed, and no proof run here has ever been long enough to reach it.
60
+ *
61
+ * ═══ THE REASON THE FLAG WAS OFF STILL HOLDS, WHICH IS WHY ONLY THE FLAG
62
+ * CHANGES. ═══ What must never happen is a v3 process writing a rotated
63
+ * token over the installed CLI's `session.json`. That is prevented by
64
+ * `persistSession: false`, not by this flag: with no persistence the client has
65
+ * no storage, so a refresh replaces a token held in memory by THIS process and
66
+ * touches no file on the machine. The two flags answer different questions —
67
+ * one is where a session is kept, the other is whether it is kept alive — and
68
+ * only the second was ever wrong here.
69
+ *
70
+ * ---------------------------------------------------------------------------
71
+ * ═══ THE CREDENTIALS COME FROM THE ENVIRONMENT, WITH NO DEFAULT. ═══
72
+ *
73
+ * They had one, the seeded local pair, on the grounds that `supabase/seed.sql`
74
+ * already commits it. THE TWO PLACES ARE NOT THE SAME. This package is
75
+ * published (`"files": ["dist"]`, `"access": "public"`), and `dist/` is
76
+ * committed, so a literal password here is one `npm publish` away from the
77
+ * public registry — a harm about DISTRIBUTION that the loopback guard below,
78
+ * which is about where a request is SENT, does nothing to prevent. So there is
79
+ * no fallback: unset means refuse, and say where to get them.
80
+ *
81
+ * ---------------------------------------------------------------------------
82
+ * ═══ THE LOOPBACK-ONLY GUARD IS GONE, AND THIS IS THE EDIT IT ASKED FOR. ═══
83
+ *
84
+ * It read: "this build runs against the local stack alone and its migrations
85
+ * are not pushed until Lane says so", and it refused any host that was not
86
+ * loopback so an unset `CTRL_SPC_SUPABASE_URL` could not silently aim a v3
87
+ * command at production. It ended by naming its own removal: "the task that
88
+ * first points v3 at a hosted project removes it, deliberately and in one
89
+ * edit."
90
+ *
91
+ * That day is 2026-08-19. The 28 `panel3_` migrations are applied to the hosted
92
+ * project and recorded in its ledger, so the tables the guard was protecting
93
+ * the absence of now exist. Keeping it would mean v3 could never be run against
94
+ * the database it was just given.
95
+ *
96
+ * WHAT REPLACED IT IS NOT NOTHING. The two things the guard was standing in for
97
+ * are still enforced, and by the mechanisms that actually answer them:
98
+ *
99
+ * - A v3 process must never write a rotated token over the installed CLI's
100
+ * `session.json`. That is `persistSession: false`, below, and always was.
101
+ * - A password must never ship in this package. That is the refusal a few
102
+ * lines down: the credentials come from the environment with no fallback,
103
+ * because `dist/` is committed and published.
104
+ *
105
+ * What is left is that `CTRL_SPC_SUPABASE_URL` decides where a command goes,
106
+ * which is the same rule every other surface in this repository lives by.
107
+ */
108
+ import { createClient } from '@supabase/supabase-js';
109
+ import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
110
+ /**
111
+ * How a v3 client holds its session, for the two lifetimes v3 has.
112
+ *
113
+ * Exported so the rule can be checked without signing in, exactly as
114
+ * `agentArgs` and `toolNamesForLevel` are: a flag nobody can print is a flag
115
+ * nobody can check, and this one is invisible until a run outlives a token.
116
+ */
117
+ export const sessionOptions = (living) => ({
118
+ /* NEVER TRUE, AT EITHER LIFETIME. It is what keeps a v3 process from writing a
119
+ rotated token over the installed CLI's `session.json`. */
120
+ persistSession: false,
121
+ /* THE DAEMON'S CLIENT ONLY. See the header: a one-shot command exits inside
122
+ the token's lifetime, and the daemon does not. */
123
+ autoRefreshToken: living,
124
+ });
125
+ /**
126
+ * @param living whether this client outlives its access token. The daemon's
127
+ * does; every one-shot command's does not.
128
+ */
129
+ export async function signedInClient(living = false) {
130
+ const email = process.env.CTRL_SPC_V3_EMAIL;
131
+ const password = process.env.CTRL_SPC_V3_PASSWORD;
132
+ if (!email || !password) {
133
+ throw new Error('set CTRL_SPC_V3_EMAIL and CTRL_SPC_V3_PASSWORD to the account this command '
134
+ + `should act as at ${SUPABASE_URL}`);
135
+ }
136
+ const client = createClient(SUPABASE_URL, SUPABASE_KEY, { auth: sessionOptions(living) });
137
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
138
+ // Truthfully, and naming both halves of what was attempted: a wrong password
139
+ // and a stack that is not running are different problems with the same
140
+ // symptom at the call site, and the message has to tell them apart.
141
+ if (error) {
142
+ throw new Error(`could not sign in as ${email} at ${SUPABASE_URL}: ${error.message}`);
143
+ }
144
+ if (!data.session) {
145
+ throw new Error(`sign-in as ${email} at ${SUPABASE_URL} returned no session`);
146
+ }
147
+ return client;
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // ═══ WHAT A QUERY GAVE BACK, OR WHY IT COULD NOT. ═══
151
+ //
152
+ // The one place a v3 command turns a Supabase result into a value, and it either
153
+ // returns the data or throws. Constraint 7 lives here: NOTHING converts a
154
+ // failure into an empty array, a null or a success, because a `show` that
155
+ // printed "No cards." on a failed read and a `say` that exited zero without
156
+ // writing are the same lie told by two commands.
157
+ //
158
+ // The second guard is the one that is easy to leave out. No error AND no data is
159
+ // not "nothing is there" — it is a result this code does not understand, and
160
+ // passing it on as an empty value is how a failure becomes a silent success.
161
+ //
162
+ // The caller passes the VERB and the SUBJECT rather than a finished sentence, so
163
+ // one shared guard still says `could not read panel3_turns` and `could not create
164
+ // the card` rather than collapsing every failure into the same vague line.
165
+ export async function returned(query, verb, subject) {
166
+ const { data, error } = await query;
167
+ if (error)
168
+ throw new Error(`could not ${verb} ${subject}: ${error.message}`);
169
+ if (!data) {
170
+ throw new Error(`could not ${verb} ${subject}: neither data nor an error came back`);
171
+ }
172
+ return data;
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // PRINTING. Shared for the plainer reason: two copies of `at()` is two ways the
176
+ // same instant could come to be printed differently in two commands.
177
+ /** One line to stdout. */
178
+ export const out = (line = '') => console.log(line);
179
+ /** UTC, to the second. A local time would read differently on two machines
180
+ * comparing the same run. */
181
+ export const at = (iso) => `${new Date(iso).toISOString().slice(0, 19).replace('T', ' ')}Z`;
@@ -0,0 +1,18 @@
1
+ import { returned } from './client.js';
2
+ /**
3
+ * Read the designation. `cliv2_orchestrator_preference` carries `unique
4
+ * (user_id)`, so RLS alone (`auth.uid() = user_id`) can never return more than
5
+ * one row for the signed-in caller, and this is never an ambiguous read.
6
+ *
7
+ * ═══ A FAILED READ THROWS RATHER THAN RETURNING NULL. ═══ Constraint 4. A
8
+ * read that failed is not "nothing is designated" — that reading would let a
9
+ * transient error silently reopen the exact race the migration's shared lease
10
+ * closes, and would tell a person nobody is designated when the truth is
11
+ * unknown. `returned()` already throws on an error or an unreadable result;
12
+ * this function adds nothing on top of it.
13
+ */
14
+ export async function designatedCoordinator(client) {
15
+ const rows = await returned(client.from('cliv2_orchestrator_preference').select('machine_id, agent'), 'read', 'which machine and harness are designated to coordinate');
16
+ const row = rows[0];
17
+ return row ? { machineId: row.machine_id, agent: row.agent } : null;
18
+ }
@@ -0,0 +1,162 @@
1
+ import { returned } from './client.js';
2
+ /** How old a row may be and still read as listening.
3
+ *
4
+ * THE DAEMON REFRESHES IT ON EVERY POLL, so this is several polls' grace rather
5
+ * than one: a poll that spent a second on a slow query, or a machine whose
6
+ * clock is a little out, must not flip every card on screen to "waiting for a
7
+ * machine" and back. It is the same ratio v2 uses (`CLIV2_ONLINE_MS` is three
8
+ * heartbeats), at the shorter cadence a card deserves. */
9
+ export const LISTENING_WINDOW_MS = 15_000;
10
+ /** Is this machine still listening?
11
+ *
12
+ * THE ONE PREDICATE, shared by `cs3 show` and the panel, so the two screens
13
+ * cannot disagree about whether anything can pick a card up.
14
+ *
15
+ * ═══ IT TAKES THE ROW, NOT A TIMESTAMP, BECAUSE FRESHNESS IS NO LONGER THE
16
+ * WHOLE ANSWER. ═══ Since 20260907090000 a daemon that exits cleanly stamps
17
+ * `stopped_at` and KEEPS its row, so a row can be two seconds old and still
18
+ * belong to a machine that has gone. Filtering `stopped_at is null` in each
19
+ * query instead would put the rule in as many places as there are reads, and
20
+ * the panel needs one read that returns stopped rows TOO, in order to name a
21
+ * machine that is not listening. One rule, one spelling. */
22
+ export function isListening(machine, now = Date.now()) {
23
+ // A daemon that said goodbye is not listening, however fresh its last
24
+ // heartbeat was. Checked first, because it is the one case the window cannot
25
+ // see and the whole reason the column exists.
26
+ if (machine.stopped_at !== null)
27
+ return false;
28
+ const seen = new Date(machine.last_seen_at).getTime();
29
+ // An unparseable timestamp is not a machine that is listening. Returning true
30
+ // here would be the expensive lie ux.md names, told about a row we cannot read.
31
+ if (Number.isNaN(seen))
32
+ return false;
33
+ return now - seen < LISTENING_WINDOW_MS;
34
+ }
35
+ /**
36
+ * Say this machine is listening, or refresh what it already said.
37
+ *
38
+ * ═══ IT THROWS RATHER THAN SHRUGGING. ═══ Constraint 7. A heartbeat that failed
39
+ * quietly is a daemon that is polling while every card on screen says nothing
40
+ * will pick it up, which is the same lie in the other direction, and the caller
41
+ * decides what to do about it rather than never hearing.
42
+ *
43
+ * ═══ `name` AND `harness` ARE THE CALLER'S TO SAY, NEVER THIS FILE'S TO WORK
44
+ * OUT. ═══ `name` is `os.hostname()`, a label for the person reading the
45
+ * title bar and never an identifier (`machine_id` stays part of the key).
46
+ * `harness` is `harness()` from `spawn.ts`, REPORTED rather than chosen: the
47
+ * columns are `not null` with no default (20260902090000) precisely so a
48
+ * heartbeat that cannot say which harness it runs fails loudly instead of
49
+ * guessing.
50
+ *
51
+ * ═══ THE CONFLICT TARGET IS `(user_id, machine_id, harness)`, NOT
52
+ * `(user_id, machine_id)`. ═══ 20260903140000: one machine running two
53
+ * harnesses is two daemons, and a key that could not tell them apart had them
54
+ * overwrite the same row every poll, flipping a card between working and
55
+ * waiting for no reason a person watching could see. Two daemons for the same
56
+ * machine and the same harness — a restart racing its own predecessor — still
57
+ * collapse onto one row, which is the one collision this key is still meant
58
+ * to prevent.
59
+ */
60
+ export async function sayListening(client, machineId, name, harness) {
61
+ /* ═══ THE ERROR IS CHECKED DIRECTLY, RATHER THAN THROUGH `returned`. ═══ That
62
+ helper also refuses a null body, which is right for every read in v3 and
63
+ wrong for exactly this write: a write with no `select()` comes back 204 with
64
+ no content, so `returned` called a successful heartbeat a failure and the
65
+ daemon printed one every two seconds while the row it had just written sat
66
+ there. A write that says nothing is a write that worked. */
67
+ const { error } = await client
68
+ .from('panel3_machines')
69
+ /* `user_id` is left to the column default (`auth.uid()`), which is what the
70
+ RLS check compares against: a caller that named its own owner could only
71
+ ever name itself, so sending one buys nothing and invites a mismatch. */
72
+ .upsert({
73
+ machine_id: machineId,
74
+ name,
75
+ harness,
76
+ last_seen_at: new Date().toISOString(),
77
+ /* ═══ AND A HEARTBEAT CLEARS `stopped_at`, WHICH IS WHAT MAKES A RESTART
78
+ WORK AT ALL. ═══ The row survives a clean exit now (20260907090000),
79
+ so a daemon coming back would otherwise upsert onto a row still
80
+ stamped as gone and stay invisible for as long as it ran. Sent on
81
+ EVERY poll rather than only the first: nothing here knows which poll
82
+ is the first, and a daemon that is polling is listening whatever it
83
+ last did. */
84
+ stopped_at: null,
85
+ }, { onConflict: 'user_id,machine_id,harness' });
86
+ if (error)
87
+ throw new Error(`could not say this machine is listening: ${error.message}`);
88
+ }
89
+ /**
90
+ * Stop saying it, because this daemon is going away.
91
+ *
92
+ * ═══ IT MARKS THE ROW; IT DOES NOT DELETE IT. ═══ 20260907090000: `name` lives
93
+ * on this row and nowhere else, and recovery Slice 1's card has to NAME the
94
+ * machine whose work has been left behind. Deleting took that name away on the
95
+ * one path a person actually walks, Ctrl-C. Stamping `stopped_at` is the same
96
+ * single write at the same moment and reads as not-listening on the very next
97
+ * poll, so nothing about how fast a clean stop becomes true on screen changed.
98
+ * An update matching no row is a no-op, which is exactly right for a daemon
99
+ * that never got as far as writing one.
100
+ *
101
+ * ═══ SCOPED TO THIS DAEMON'S OWN ROW, BY MACHINE AND HARNESS. ═══ 20260903140000:
102
+ * a machine can run two daemons at once, one per harness, each its own row. A
103
+ * stop by `machine_id` alone would mark BOTH rows the moment either daemon
104
+ * exited, telling every card that nothing on that machine is listening while
105
+ * the sibling harness is still polling, which is exactly the lie this table
106
+ * exists to end, produced from the other side by exiting too thoroughly.
107
+ *
108
+ * Best-effort by design: a process that is exiting must not hang or fail on the
109
+ * way out, and the freshness window is what covers every way a machine can leave
110
+ * without getting here.
111
+ */
112
+ export async function stopListening(client, machineId, harness) {
113
+ try {
114
+ await client
115
+ .from('panel3_machines')
116
+ .update({ stopped_at: new Date().toISOString() })
117
+ .eq('machine_id', machineId)
118
+ .eq('harness', harness);
119
+ }
120
+ catch {
121
+ // The window covers it. See the header.
122
+ }
123
+ }
124
+ /** This account's machines that are listening right now, with what a screen
125
+ * needs to show a fleet rather than a count: the name and harness beside the
126
+ * id. Filtered through the one `isListening` predicate, so nothing here can
127
+ * disagree with `coordination` below about which rows count.
128
+ *
129
+ * ═══ THE ID-ONLY READ (`listeningMachines`) IS GONE, DELIBERATELY. ═══ `show.ts`
130
+ * was its only caller, wanting an id list and a length check, and Task 4 gave
131
+ * it both a coordinator to name and a harness to print beside each machine —
132
+ * which only this row shape carries. A second near-identical select earning
133
+ * its keep on a length check alone, once the one caller that wanted only that
134
+ * is gone, is the duplicate this project's guide says to remove rather than
135
+ * leave standing beside its replacement. */
136
+ export async function listeningFleet(client) {
137
+ const rows = await returned(client.from('panel3_machines').select('machine_id, name, harness, last_seen_at, stopped_at'), 'read', 'which machines are listening');
138
+ return rows.filter((r) => isListening(r));
139
+ }
140
+ export function coordination(designation, fleet) {
141
+ if (designation === null)
142
+ return fleet.length > 0 ? 'here' : 'nobody';
143
+ if (fleet.some((m) => m.machine_id === designation.machineId && m.harness === designation.agent)) {
144
+ return 'here';
145
+ }
146
+ return fleet.length > 0 ? 'elsewhere' : 'nobody';
147
+ }
148
+ export function elsewhereMismatch(designation, fleet) {
149
+ const machineListening = fleet.some((m) => m.machine_id === designation.machineId);
150
+ const harnessListening = fleet.some((m) => m.harness === designation.agent);
151
+ /* ═══ BOTH HALVES PRESENT MEANS TWO ROWS, NOT ONE, BECAUSE ONE ROW SUPPLYING
152
+ BOTH IS `coordination()`'s OWN `'here'`. ═══ So this is never a weaker
153
+ answer than `'harness'` or `'machine'` alone — it is a strictly more
154
+ specific one, and it is checked first. */
155
+ if (machineListening && harnessListening)
156
+ return 'split';
157
+ if (machineListening)
158
+ return 'harness';
159
+ if (harnessListening)
160
+ return 'machine';
161
+ return 'neither';
162
+ }