@ctrl-spc/cs 0.7.2 → 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.
- package/dist/companion.js +178 -18
- package/dist/daemon.js +103 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/login.js +9 -1
- package/dist/mcp.js +230 -308
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +22 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +802 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/skills.js +10 -1
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
package/dist/panel3/client.js
CHANGED
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
import { createClient } from '@supabase/supabase-js';
|
|
119
119
|
import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
|
|
120
120
|
import { readSession } from '../config.js';
|
|
121
|
+
import { readableWriteError } from '../firewall.js';
|
|
121
122
|
/**
|
|
122
123
|
* How a v3 client holds its session, for the two lifetimes v3 has.
|
|
123
124
|
*
|
|
@@ -202,7 +203,7 @@ export async function signedInClient(living = false) {
|
|
|
202
203
|
export async function returned(query, verb, subject) {
|
|
203
204
|
const { data, error } = await query;
|
|
204
205
|
if (error)
|
|
205
|
-
throw new Error(`could not ${verb} ${subject}: ${error.message}`);
|
|
206
|
+
throw new Error(`could not ${verb} ${subject}: ${readableWriteError(error.message)}`);
|
|
206
207
|
if (!data) {
|
|
207
208
|
throw new Error(`could not ${verb} ${subject}: neither data nor an error came back`);
|
|
208
209
|
}
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
* how to write it travels with `write_report`'s own description, where ux.md
|
|
131
131
|
* says the how belongs.
|
|
132
132
|
*/
|
|
133
|
+
import { FIREWALL_WRITING_RULE } from '../firewall.js';
|
|
133
134
|
/**
|
|
134
135
|
* ═══ WHAT EVERY LEVEL IS TOLD ABOUT THE THING IT SENDS BACK. ═══
|
|
135
136
|
*
|
|
@@ -429,9 +430,12 @@ const projectCodebases = (codebases) => {
|
|
|
429
430
|
* — which matters because the daemon writes it to `panel3_runs.brief`.
|
|
430
431
|
*
|
|
431
432
|
* ═══ `cardTitle` IS A NAME, NOT THE QUESTION, AND ITS LABEL SAYS SO. ═══ `say`
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
433
|
+
* writes it as the truncated first line of the FIRST message, and the launcher
|
|
434
|
+
* may replace it once, with `dispatch`'s `work_name`, so that it names the work
|
|
435
|
+
* rather than that sentence. Either way it names the CONVERSATION and not what
|
|
436
|
+
* is currently being asked: it is written before the first answer and nothing
|
|
437
|
+
* touches it after. Labelled as what is being asked, a second turn would tell
|
|
438
|
+
* the agent the current question is the opening line and then hand it the real
|
|
435
439
|
* ones underneath — so the label names the conversation, which is all the title
|
|
436
440
|
* has ever been.
|
|
437
441
|
*
|
|
@@ -494,6 +498,18 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
|
|
|
494
498
|
'',
|
|
495
499
|
'WHAT THIS CONVERSATION IS CALLED',
|
|
496
500
|
cardTitle,
|
|
501
|
+
/* ═══ THE ONE MOMENT ANYTHING CAN NAME THE WORK. ═══ Until the owner is
|
|
502
|
+
sent, this is the truncated first line of the person's first message, and
|
|
503
|
+
sending the owner cuts the branch for the work from it and stamps that
|
|
504
|
+
branch for good. The launcher is the only thing that runs in between, and
|
|
505
|
+
it has the messages in front of it, so it is the only thing that can say
|
|
506
|
+
what the work is before both are fixed. */
|
|
507
|
+
'This is the first line of what they typed, so it names their sentence rather than the work. If',
|
|
508
|
+
'the person pointed this conversation at a work item, that item is already its name and there is',
|
|
509
|
+
'nothing to do. Otherwise pass `work_name` to `dispatch`: a few words, six at most, naming the',
|
|
510
|
+
'work itself, such as "Fix the sign-out checklist bug". The conversation is called that from',
|
|
511
|
+
'then on, and the branch the work goes on is cut from it, both as you send the owner. This is',
|
|
512
|
+
'the only moment either can be named.',
|
|
497
513
|
'',
|
|
498
514
|
'WHAT HAS ALREADY BEEN MADE HERE',
|
|
499
515
|
'Everything this conversation has produced so far, whatever it was that made it, with the id to',
|
|
@@ -727,6 +743,9 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
|
|
|
727
743
|
'not what you were halfway through. Notes written at the end of the work are notes that were',
|
|
728
744
|
'never written.',
|
|
729
745
|
'',
|
|
746
|
+
'ANYTHING YOU SAVE GOES THROUGH A FIREWALL',
|
|
747
|
+
FIREWALL_WRITING_RULE,
|
|
748
|
+
'',
|
|
730
749
|
...WHILE_YOU_ARE_WORKING,
|
|
731
750
|
'',
|
|
732
751
|
...(level === 2
|
package/dist/panel3/run.js
CHANGED
|
@@ -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
|
|
2678
|
-
*
|
|
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
|
-
|
|
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(
|
|
2866
|
-
const child = await startChild(
|
|
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(
|
|
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(
|
|
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(
|
|
2927
|
-
await reconcileOwnerSessions(
|
|
2928
|
-
await recoverStranded(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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()`
|
|
3057
|
-
*
|
|
3058
|
-
*
|
|
3059
|
-
*
|
|
3060
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
3299
|
+
return { stop: () => stopListening(current(), machineId, machineHarness) };
|
|
3083
3300
|
}
|