@ctrl-spc/cs 0.7.0 → 0.7.2
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/README.md +1 -1
- package/dist/codex-home.js +72 -7
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/mcp.js +15 -6
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +473 -62
- package/dist/panel3/run.js +1206 -111
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +197 -23
- package/dist/panel3/spawn.js +85 -20
- package/dist/panel3/tools.js +677 -51
- package/dist/presence.js +8 -0
- package/dist/skills.js +165 -0
- package/dist/workflows.js +68 -0
- package/package.json +2 -3
package/dist/panel3/tools.js
CHANGED
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
* agent naming something out of its reach stops nothing and is told so.
|
|
106
106
|
*
|
|
107
107
|
* ═══ AND THE KILL IS STILL THE DAEMON'S. ═══ The record is the intent, exactly
|
|
108
|
-
* as it is for the person's own Stop: `
|
|
108
|
+
* as it is for the person's own Stop: `panel3/cli.js run` kills the processes of stopped
|
|
109
109
|
* runs on its own machine, on the poll it already has. Nothing here waits for
|
|
110
110
|
* that, and nothing here claims it has happened.
|
|
111
111
|
*/
|
|
@@ -121,7 +121,8 @@ import { returned } from './client.js';
|
|
|
121
121
|
import { rememberSecret, redactArgs } from './secrets.js';
|
|
122
122
|
import { workBrief } from './prompt.js';
|
|
123
123
|
import { listCodebases } from '../codebases.js';
|
|
124
|
-
import {
|
|
124
|
+
import { readWorkflow } from '../workflows.js';
|
|
125
|
+
import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, setMainBranch, withAskContent, } from './show.js';
|
|
125
126
|
// ---------------------------------------------------------------------------
|
|
126
127
|
// READING AND WRITING. Every query goes through the same guard every other v3
|
|
127
128
|
// command uses, so a failed read can never reach an agent as an empty list.
|
|
@@ -176,6 +177,7 @@ const ended = (runId, verb) => `could not ${verb} run ${runId}: THIS RUN HAS END
|
|
|
176
177
|
/** The two states a run may still be written to in, as PostgREST takes them.
|
|
177
178
|
* See `whileRunning`: `asked` is a process winding up, not a run that is gone. */
|
|
178
179
|
const STILL_WRITING = ['running', 'asked'];
|
|
180
|
+
const secretScope = ({ runId, processToken }) => processToken === undefined ? runId : `${runId}:${processToken}`;
|
|
179
181
|
/**
|
|
180
182
|
* ═══ ASKING AND PASSING A QUESTION ON ARE ONE ACT, SO THEY ARE ONE FUNCTION.
|
|
181
183
|
* ═══
|
|
@@ -192,8 +194,61 @@ const STILL_WRITING = ['running', 'asked'];
|
|
|
192
194
|
* on after it asked, holding a question nobody can answer while it works on top
|
|
193
195
|
* of the assumption it could not make.
|
|
194
196
|
*/
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* ═══ HOW MUCH A PERSON IS ASKED TO READ ON ONE CARD, AND WHY IT IS 700. ═══
|
|
199
|
+
*
|
|
200
|
+
* Issue 7. Measured, not chosen: every person-facing question this product has
|
|
201
|
+
* ever asked splits cleanly at this line. The five Lane read and called
|
|
202
|
+
* unreadable ran 959 to 1373 characters; every question nobody objected to was
|
|
203
|
+
* 594 or less. 700 is above the largest accepted and below the smallest
|
|
204
|
+
* rejected, so no question anybody has been happy with is refused by it.
|
|
205
|
+
*
|
|
206
|
+
* IT COUNTS EVERYTHING THE PERSON READS, not one field: the card renders the
|
|
207
|
+
* question, the context and every option together, so a bound on `context`
|
|
208
|
+
* alone is satisfied by moving the paragraph into `question` and the card is
|
|
209
|
+
* exactly as unreadable.
|
|
210
|
+
*
|
|
211
|
+
* AND IT LIVES IN THE HANDLERS, NOT IN A ZOD `.max()`. A schema rejection
|
|
212
|
+
* happens before the callback runs, so it never reaches `buildServer`'s
|
|
213
|
+
* try/catch and the agent gets a protocol error rather than a correctable tool
|
|
214
|
+
* failure. A throw from here comes back as `isError`, which is a refusal the
|
|
215
|
+
* caller can fix in the same turn. It is also why the bound reaches
|
|
216
|
+
* `ask_question` and `say` and NOT `escalate` or the shared `DECIDING` schema:
|
|
217
|
+
* an escalation is rewritten whole by whoever passes it on, so no unbounded
|
|
218
|
+
* child text is ever inherited by what the person reads.
|
|
219
|
+
*/
|
|
220
|
+
const PERSON_READS_LIMIT = 700;
|
|
221
|
+
async function asked(caller, args, askPerson) {
|
|
222
|
+
const { client, runId, processToken } = caller;
|
|
223
|
+
const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
|
|
224
|
+
if (askPerson) {
|
|
225
|
+
/* ═══ REFUSED BEFORE ANYTHING IS WRITTEN, AND TOLD WHICH HALF TO CUT. ═══
|
|
226
|
+
Issue 6 made a question say what answering it causes, which is the half
|
|
227
|
+
worth keeping; what makes a question unreadable is the findings that got
|
|
228
|
+
carried in with it. So the refusal does not say "be brief" — it names the
|
|
229
|
+
total, the limit, and where the findings belong instead. */
|
|
230
|
+
const total = question.length + context.length
|
|
231
|
+
+ (options ?? []).reduce((sum, option) => sum + option.length, 0);
|
|
232
|
+
if (total > PERSON_READS_LIMIT) {
|
|
233
|
+
throw new Error(`NOTHING WAS WRITTEN and nobody was asked: that question is ${total} characters and they `
|
|
234
|
+
+ `read it on a card three inches wide. The limit is ${PERSON_READS_LIMIT}, counting the `
|
|
235
|
+
+ 'question, the context and every option together. Cut what you found, not what each '
|
|
236
|
+
+ 'answer causes; findings belong in the artifact or your report.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (related_artifact_id !== undefined
|
|
240
|
+
&& (!askPerson || caller.level !== 2 || !caller.isOwner || processToken === undefined)) {
|
|
241
|
+
throw new Error('NOTHING WAS WRITTEN. An artifact approval question can be opened only by the current '
|
|
242
|
+
+ 'Level 2 conversation owner with its current process token.');
|
|
243
|
+
}
|
|
244
|
+
if (related_artifact_id !== undefined && (!work_item_id?.trim()
|
|
245
|
+
|| answer_mode !== 'single_select'
|
|
246
|
+
|| options?.length !== 2
|
|
247
|
+
|| options[0] !== 'approve'
|
|
248
|
+
|| options[1] !== 'request changes')) {
|
|
249
|
+
throw new Error('NOTHING WAS WRITTEN. An artifact approval question requires its Work Item, '
|
|
250
|
+
+ 'answer_mode single_select, and exactly these options in order: approve, request changes.');
|
|
251
|
+
}
|
|
197
252
|
const [ask] = await rows(client.rpc('panel3_ask', {
|
|
198
253
|
p_run_id: runId,
|
|
199
254
|
p_question: question,
|
|
@@ -209,6 +264,9 @@ async function asked(client, runId, args, askPerson) {
|
|
|
209
264
|
ESCALATION ANYWAY. ═══ `escalate` shares this function and passes
|
|
210
265
|
nothing, so the argument is absent rather than null-and-ignored there. */
|
|
211
266
|
p_work_item_id: work_item_id ?? null,
|
|
267
|
+
...(related_artifact_id === undefined
|
|
268
|
+
? (processToken === undefined ? {} : { p_process_token: processToken })
|
|
269
|
+
: { p_process_token: processToken, p_related_artifact_id: related_artifact_id }),
|
|
212
270
|
}), 'put', 'your question where it can be answered');
|
|
213
271
|
if (!ask) {
|
|
214
272
|
/* NOTHING WAS WRITTEN, and the reasons are said together because the agent
|
|
@@ -284,6 +342,48 @@ const DECIDING = {
|
|
|
284
342
|
+ 'for free_text. A SUGGESTION, NOT A BALLOT — the person may always answer in their own words '
|
|
285
343
|
+ 'instead, so offer what you would act on rather than every possibility.'),
|
|
286
344
|
};
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// PROJECT CONTEXT (19/project-context-7b). `public.project_documents` — the
|
|
347
|
+
// reference a team writes in the web app: architecture, design notes, how a
|
|
348
|
+
// subsystem came to be the way it is. Read when the work needs it, at every
|
|
349
|
+
// level, and ADDED TO by the levels that do the work.
|
|
350
|
+
//
|
|
351
|
+
// ═══ IT IS NOT THE SAME TABLE AS THE STANDING RULES, AND THAT IS THE POINT.
|
|
352
|
+
// ═══ `public.agent_instructions` is an OBLIGATION and arrives in the prompt,
|
|
353
|
+
// whole, before an agent acts; no tool here reads or writes it, at any level. A
|
|
354
|
+
// reference an agent chooses to read and a mandate it is handed are two
|
|
355
|
+
// different things, and the schema keeps them apart so a tool cannot confuse
|
|
356
|
+
// them (see 20260919090000_agent_instructions.sql).
|
|
357
|
+
/**
|
|
358
|
+
* ═══ THE READ IS BOUNDED, AND IT SAYS WHEN IT BOUNDED SOMETHING. ═══
|
|
359
|
+
*
|
|
360
|
+
* `project_documents.content` is unbounded `text` and a project may hold any
|
|
361
|
+
* number of documents, so an unbounded read would put a team's whole corpus into
|
|
362
|
+
* the same window the mandatory standing-rules block needs — and would do it
|
|
363
|
+
* silently. There is no existing row or byte limit anywhere in this file to
|
|
364
|
+
* inherit: `mcp.ts` has one of the right shape, and conventions.md rule 2 forbids
|
|
365
|
+
* `panel3/` to import it.
|
|
366
|
+
*
|
|
367
|
+
* TWO BOUNDS RATHER THAN ONE, because they catch different shapes: many small
|
|
368
|
+
* documents, and one enormous one. Whichever bites, the response ends by saying
|
|
369
|
+
* how many documents it did not show and how to ask for fewer — a truncation
|
|
370
|
+
* nobody is told about reads as a project with nothing else written down, which
|
|
371
|
+
* is worse than the read failing.
|
|
372
|
+
*/
|
|
373
|
+
const CONTEXT_DOCUMENT_ROWS = 50;
|
|
374
|
+
const CONTEXT_DOCUMENT_CHARACTERS = 60000;
|
|
375
|
+
/**
|
|
376
|
+
* Which project the caller's card is filed under, or null when it has none.
|
|
377
|
+
*
|
|
378
|
+
* `panel3_cards.project_id` IS NULLABLE and a record-only card genuinely has no
|
|
379
|
+
* project, so null is a fact rather than a failure and each caller words its own
|
|
380
|
+
* refusal. A failed READ throws through `rows`, which is a different thing again
|
|
381
|
+
* and must never be flattened into "no project".
|
|
382
|
+
*/
|
|
383
|
+
async function projectOfCard(caller) {
|
|
384
|
+
const cards = await rows(caller.client.from('panel3_cards').select('project_id').eq('id', caller.cardId), 'read', `the project for card ${caller.cardId}`);
|
|
385
|
+
return cards[0]?.project_id ?? null;
|
|
386
|
+
}
|
|
287
387
|
const ALL = [1, 2, 3];
|
|
288
388
|
const line = (...parts) => parts.filter(Boolean).join(' ');
|
|
289
389
|
const listed = (items, empty) => (items.length === 0 ? empty : items.join('\n'));
|
|
@@ -651,6 +751,92 @@ const TOOLS = [
|
|
|
651
751
|
].join('\n');
|
|
652
752
|
},
|
|
653
753
|
},
|
|
754
|
+
{
|
|
755
|
+
name: 'get_workflow',
|
|
756
|
+
/* ═══ LEVEL 2 ALONE, AND THAT IS A DELIBERATE DIVERGENCE FROM `get_skill`'s
|
|
757
|
+
`ALL`. ═══ `get_skill`'s own comment argues level 1 needs it because "a
|
|
758
|
+
coordinator that cannot read the skill cannot write a responsibility that
|
|
759
|
+
respects it". That argument does not carry across, and the difference is
|
|
760
|
+
what a launcher is writing. A skill is a METHOD that applies to whatever
|
|
761
|
+
single responsibility level 1 writes, so a launcher ignorant of it writes
|
|
762
|
+
a responsibility that contradicts it. A workflow is not applied to the
|
|
763
|
+
launcher's responsibility: it is a SEQUENCE the OWNER decomposes, and the
|
|
764
|
+
launcher writes one line — see this conversation through — for the agent
|
|
765
|
+
that reads the process itself. `attachmentLine` already prints the
|
|
766
|
+
workflow's NAME in the launcher's prompt, so it can name the process in
|
|
767
|
+
what it writes without reading a stage of it.
|
|
768
|
+
|
|
769
|
+
AND NOT LEVEL 3 EITHER. A worker owns one piece and nothing else; handing
|
|
770
|
+
it the whole recipe invites it to run stages that are not its. What it
|
|
771
|
+
does need — its own stage's document — reaches it in its brief, because
|
|
772
|
+
`workBrief` tells the owner to put it there. Same narrowing, same reason,
|
|
773
|
+
as `get_credential` below.
|
|
774
|
+
|
|
775
|
+
AND THERE IS NO `list_workflows` BESIDE IT, for `get_skill`'s reason: the
|
|
776
|
+
id arrived in the brief, off the card's own attachment row, put there by
|
|
777
|
+
the person. A v3 agent never goes looking for a workflow. */
|
|
778
|
+
levels: [2],
|
|
779
|
+
description: 'Read the workflow attached to this conversation. A workflow is the PROCESS this work '
|
|
780
|
+
+ 'follows: named stages in an order, each with a document to work to. Call this with the id '
|
|
781
|
+
+ 'printed beside the workflow in what was attached, and read all of it before you decide how '
|
|
782
|
+
+ 'this work is split.',
|
|
783
|
+
input: { workflow_id: z.string() },
|
|
784
|
+
handler: async ({ client }, args) => {
|
|
785
|
+
const { workflow_id } = args;
|
|
786
|
+
const workflow = await readWorkflow(client, workflow_id);
|
|
787
|
+
/* ═══ EXITS RESOLVE TO A NUMBERED STAGE, NEVER TO A RAW UUID. ═══
|
|
788
|
+
`to_stage_id` is a uuid and no v3 tool at any level takes one, so a raw
|
|
789
|
+
id would be a reference the reader cannot follow. This matters
|
|
790
|
+
concretely: exits are the only surviving representation of a REPEAT, and
|
|
791
|
+
every org is seeded with a feature loop whose exits are mostly backward.
|
|
792
|
+
A rendering that dropped them would present the one workflow every org
|
|
793
|
+
actually has as a straight line, which is a different process. */
|
|
794
|
+
const numberOf = new Map(workflow.stages.map((stage, i) => [stage.id, i + 1]));
|
|
795
|
+
const exitsOf = (stageId) => workflow.exits
|
|
796
|
+
.filter((exit) => exit.stageId === stageId)
|
|
797
|
+
.map((exit) => {
|
|
798
|
+
const number = numberOf.get(exit.toStageId);
|
|
799
|
+
const stage = workflow.stages.find((candidate) => candidate.id === exit.toStageId);
|
|
800
|
+
return number && stage
|
|
801
|
+
? `if ${exit.condition}, go to stage ${number} ${stage.name}`
|
|
802
|
+
: `if ${exit.condition}, go back to an earlier stage`;
|
|
803
|
+
});
|
|
804
|
+
return [
|
|
805
|
+
workflow.name,
|
|
806
|
+
`id ${workflow.id}`,
|
|
807
|
+
`about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
|
|
808
|
+
'',
|
|
809
|
+
`STAGES ${workflow.stages.length}`,
|
|
810
|
+
...workflow.stages.flatMap((stage, i) => [
|
|
811
|
+
'',
|
|
812
|
+
line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
|
|
813
|
+
'',
|
|
814
|
+
/* THE WHOLE BODY. The body IS the stage document, so truncating it is
|
|
815
|
+
truncating the process. An empty one is a stage nobody has written
|
|
816
|
+
yet, which is a real state and is said rather than hidden. */
|
|
817
|
+
stage.body.trim() === '' ? '(this stage has not been written yet)' : stage.body,
|
|
818
|
+
...exitsOf(stage.id),
|
|
819
|
+
]),
|
|
820
|
+
'',
|
|
821
|
+
/* ═══ THE PERSON'S ENDING CHOICE, SAID RATHER THAN OBEYED OR DISCARDED.
|
|
822
|
+
═══ They picked it in the web app's own picker. v3 has no backlog read
|
|
823
|
+
at any level — `list_work_items` is newest-first with no rank — so
|
|
824
|
+
this cannot be done here. Rendering the raw value would invite
|
|
825
|
+
improvisation and rendering nothing would silently discard their
|
|
826
|
+
choice, which is the untruthful failure the guide forbids. The slice
|
|
827
|
+
that gives v3 a backlog read replaces this sentence. */
|
|
828
|
+
...(workflow.ending === 'next-in-backlog'
|
|
829
|
+
? [
|
|
830
|
+
'This workflow is set to start again on the next backlog item. That is not available '
|
|
831
|
+
+ 'here: stop when the last stage is done, and say so.',
|
|
832
|
+
'',
|
|
833
|
+
]
|
|
834
|
+
: []),
|
|
835
|
+
'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
|
|
836
|
+
+ 'above. It is not a document to summarise, quote back or file away.',
|
|
837
|
+
].join('\n');
|
|
838
|
+
},
|
|
839
|
+
},
|
|
654
840
|
{
|
|
655
841
|
name: 'get_credential',
|
|
656
842
|
/* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
|
|
@@ -675,7 +861,7 @@ const TOOLS = [
|
|
|
675
861
|
+ 'beside the credential in what was attached. The value is yours to USE in the work you were '
|
|
676
862
|
+ 'sent to do; what you must not do is publish it.',
|
|
677
863
|
input: { credential_id: z.string() },
|
|
678
|
-
handler: async ({ client, runId }, args) => {
|
|
864
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
679
865
|
const { credential_id } = args;
|
|
680
866
|
const found = await rows(client.from('credentials').select('id, name, kind, username').eq('id', credential_id), 'read', `credential ${credential_id}`);
|
|
681
867
|
/* ═══ ONE MESSAGE FOR THREE DIFFERENT FACTS, AND THAT IS DELIBERATE. ═══
|
|
@@ -711,7 +897,7 @@ const TOOLS = [
|
|
|
711
897
|
BEFORE the agent can write anything containing it. ═══ See secrets.ts:
|
|
712
898
|
from here on, this value cannot reach a panel3_ row through any tool
|
|
713
899
|
this run calls or through the answer it ends with. */
|
|
714
|
-
rememberSecret(runId, secret, row.name);
|
|
900
|
+
rememberSecret(secretScope({ runId, processToken }), secret, row.name);
|
|
715
901
|
const username = typeof value.username === 'string' ? value.username : null;
|
|
716
902
|
return [
|
|
717
903
|
row.name,
|
|
@@ -746,13 +932,14 @@ const TOOLS = [
|
|
|
746
932
|
handler: async ({ client }, args) => {
|
|
747
933
|
const { artifact_id } = args;
|
|
748
934
|
const artifact = await only(client.from('artifacts')
|
|
749
|
-
.select('id, task_id, type, format, title, content, storage_path, created_at')
|
|
935
|
+
.select('id, task_id, type, format, title, content, storage_path, revision, created_at')
|
|
750
936
|
.eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
|
|
751
937
|
return [
|
|
752
938
|
`${artifact.title ?? '(untitled)'}`,
|
|
753
939
|
`id ${artifact.id}`,
|
|
754
940
|
`work item ${artifact.task_id}`,
|
|
755
941
|
`type ${artifact.type} (${artifact.format})`,
|
|
942
|
+
`revision ${artifact.revision}`,
|
|
756
943
|
'',
|
|
757
944
|
/* A stored file and an empty body are different facts and are said
|
|
758
945
|
differently. Returning '' for a PNG would read as an artifact with
|
|
@@ -761,6 +948,85 @@ const TOOLS = [
|
|
|
761
948
|
].join('\n');
|
|
762
949
|
},
|
|
763
950
|
},
|
|
951
|
+
{
|
|
952
|
+
name: 'get_project_context',
|
|
953
|
+
/* ALL THREE, straight off ux.md's "Who gets what" table, where
|
|
954
|
+
`get_project_context` has always sat in the "Read the record" row. Reading
|
|
955
|
+
what a team has written down is not a privilege any level is denied. */
|
|
956
|
+
levels: ALL,
|
|
957
|
+
description: 'The documentation this project\'s team has written: architecture, design notes, conventions, '
|
|
958
|
+
+ 'how a subsystem came to be the way it is. Read it when the work needs it — before you '
|
|
959
|
+
+ 'design something, before you follow a convention you are guessing at, and before you tell '
|
|
960
|
+
+ 'the person how this project does something. Pass a codebase id to get that codebase\'s own '
|
|
961
|
+
+ 'documents as well as the project\'s.',
|
|
962
|
+
input: {
|
|
963
|
+
codebase_id: z.string().uuid().optional().describe('A registered project codebase, to get its OWN documents alongside the project-wide ones. '
|
|
964
|
+
+ 'Leave it out for the project-wide documents alone.'),
|
|
965
|
+
},
|
|
966
|
+
handler: async (caller, args) => {
|
|
967
|
+
const { codebase_id } = args;
|
|
968
|
+
const projectId = await projectOfCard(caller);
|
|
969
|
+
if (!projectId) {
|
|
970
|
+
return 'This conversation is not filed under a project, so there is no project context to read.';
|
|
971
|
+
}
|
|
972
|
+
const scoped = caller.client
|
|
973
|
+
.from('project_documents')
|
|
974
|
+
.select('id, title, type, codebase_id, content')
|
|
975
|
+
.eq('project_id', projectId);
|
|
976
|
+
const documents = await rows((codebase_id === undefined
|
|
977
|
+
? scoped.is('codebase_id', null)
|
|
978
|
+
: scoped.or(`codebase_id.is.null,codebase_id.eq.${codebase_id}`))
|
|
979
|
+
/* MOST SPECIFIC FIRST, the order ux.md gives for the two scopes: a
|
|
980
|
+
codebase's documents beat the project's for work in that codebase,
|
|
981
|
+
so they are read first and are the ones that survive the bound. */
|
|
982
|
+
.order('codebase_id', { nullsFirst: false })
|
|
983
|
+
.order('created_at')
|
|
984
|
+
/* ONE MORE THAN THE CAP, so "there are more" is something this read
|
|
985
|
+
OBSERVED rather than something it assumed from a full page. The
|
|
986
|
+
extra row is never rendered; it only makes the closing line honest,
|
|
987
|
+
and it is why the count below is "at least". */
|
|
988
|
+
.limit(CONTEXT_DOCUMENT_ROWS + 1), 'read', `the project context on project ${projectId}`);
|
|
989
|
+
const withinRows = documents.slice(0, CONTEXT_DOCUMENT_ROWS);
|
|
990
|
+
const pastRowCap = documents.length > CONTEXT_DOCUMENT_ROWS;
|
|
991
|
+
/* THE CHARACTER WALK. The first document is always included whatever its
|
|
992
|
+
size: a project whose one document is larger than the whole budget must
|
|
993
|
+
still get an answer, and truncating a document's BODY would hand an
|
|
994
|
+
agent half a convention with no sign of where it stopped. */
|
|
995
|
+
const shown = [];
|
|
996
|
+
let used = 0;
|
|
997
|
+
for (const document of withinRows) {
|
|
998
|
+
if (used + document.content.length > CONTEXT_DOCUMENT_CHARACTERS && shown.length > 0)
|
|
999
|
+
break;
|
|
1000
|
+
shown.push(document);
|
|
1001
|
+
used += document.content.length;
|
|
1002
|
+
}
|
|
1003
|
+
if (shown.length === 0) {
|
|
1004
|
+
return codebase_id === undefined
|
|
1005
|
+
? 'This project has no context documents yet.'
|
|
1006
|
+
: 'This project and that codebase have no context documents yet.';
|
|
1007
|
+
}
|
|
1008
|
+
const cut = withinRows.length - shown.length;
|
|
1009
|
+
return [
|
|
1010
|
+
...shown.flatMap((document) => [
|
|
1011
|
+
`--- ${document.title} (${document.type}${document.codebase_id === null ? ', project' : ', this codebase'}) id ${document.id} ---`,
|
|
1012
|
+
document.content,
|
|
1013
|
+
'',
|
|
1014
|
+
]),
|
|
1015
|
+
/* ═══ SAID WHENEVER SOMETHING WAS LEFT OUT, AND NEVER OTHERWISE. ═══ A
|
|
1016
|
+
complete answer that ends by discussing its own limits teaches an
|
|
1017
|
+
agent to distrust a read that was fine. */
|
|
1018
|
+
...(cut > 0 || pastRowCap
|
|
1019
|
+
? [
|
|
1020
|
+
`NOT EVERYTHING IS ABOVE: at least ${cut + (pastRowCap ? 1 : 0)} more `
|
|
1021
|
+
+ `${cut + (pastRowCap ? 1 : 0) === 1 ? 'document was' : 'documents were'} not shown. `
|
|
1022
|
+
+ `This read is capped at ${CONTEXT_DOCUMENT_ROWS} documents and `
|
|
1023
|
+
+ `${CONTEXT_DOCUMENT_CHARACTERS} characters, and the most specific documents come `
|
|
1024
|
+
+ 'first. Ask again without a codebase, or for one codebase at a time, to narrow it.',
|
|
1025
|
+
]
|
|
1026
|
+
: []),
|
|
1027
|
+
].join('\n');
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
764
1030
|
{
|
|
765
1031
|
name: 'list_cards',
|
|
766
1032
|
/* ═══ LEVEL 1 ONLY, AND THIS OVERRIDES plan.md's TABLE. ═══ The plan grants
|
|
@@ -887,6 +1153,136 @@ const TOOLS = [
|
|
|
887
1153
|
// the placement tools. Editing an item as the work moves, and producing an
|
|
888
1154
|
// object, are two other rows of that same table and they are NOT level 1's:
|
|
889
1155
|
// see the next section, which is where they now live.
|
|
1156
|
+
/**
|
|
1157
|
+
* ═══ THE ANSWER TO THE ONE GIT QUESTION A PERSON IS EVER ASKED. ═══
|
|
1158
|
+
*
|
|
1159
|
+
* worktrees-8, story 3: a codebase whose base branch is genuinely ambiguous is
|
|
1160
|
+
* settled by asking the person ONCE, and the answer is stored on the CODEBASE
|
|
1161
|
+
* rather than on the card that prompted it, so nobody is asked twice.
|
|
1162
|
+
*
|
|
1163
|
+
* A TOOL RATHER THAN A SIDE EFFECT OF THE ANSWER, for two reasons. The answer
|
|
1164
|
+
* has to land on the codebase and an ask lands on a card. And the only other
|
|
1165
|
+
* route is teaching the questions surface to write a settings table, which
|
|
1166
|
+
* would couple two things that have no reason to know each other.
|
|
1167
|
+
*
|
|
1168
|
+
* LEVEL 1 ONLY. It is the coordinator that sees across cards, that holds
|
|
1169
|
+
* `ask_question`, and that needs the fact before it dispatches. A level 2 or 3
|
|
1170
|
+
* agent asking would be asking about a card, mid-work, having already started
|
|
1171
|
+
* in the wrong place.
|
|
1172
|
+
*/
|
|
1173
|
+
{
|
|
1174
|
+
name: 'set_main_branch',
|
|
1175
|
+
levels: [1],
|
|
1176
|
+
description: 'Record which branch finished work in a codebase should go back to. Use this ONLY after asking '
|
|
1177
|
+
+ 'the person and getting an answer: it is the organization\'s setting, it applies to every '
|
|
1178
|
+
+ 'member and every future card in that codebase, and nobody is asked again. Never guess it, '
|
|
1179
|
+
+ 'and never call this to change a branch somebody already set.',
|
|
1180
|
+
input: {
|
|
1181
|
+
codebase_id: z.string().describe('The codebase id, as the codebase list gave it to you.'),
|
|
1182
|
+
branch: z.string().min(1).describe('The branch name exactly as the person chose it.'),
|
|
1183
|
+
},
|
|
1184
|
+
handler: async (caller, args) => {
|
|
1185
|
+
const { codebase_id, branch } = args;
|
|
1186
|
+
/* THROUGH `show.ts`, WHICH IS THE ONE FILE UNDER panel3/ THAT MAY NAME
|
|
1187
|
+
THIS TABLE. conventions.md records why, and
|
|
1188
|
+
panel3-isolation.contract.test.mjs pins it: reads and writes alike go
|
|
1189
|
+
through one file, so a grep for the table finds every caller. */
|
|
1190
|
+
await setMainBranch(caller.client, codebase_id, branch);
|
|
1191
|
+
return `Work in that codebase now starts from ${branch} and goes back to it. `
|
|
1192
|
+
+ 'Nobody will be asked again.';
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
/**
|
|
1196
|
+
* ═══ THE AGENT ASKS FOR THE OFFER; THE PRODUCT WRITES IT. ═══
|
|
1197
|
+
*
|
|
1198
|
+
* worktrees-8 C1. `ask_question`'s options are typed by a model out of prose,
|
|
1199
|
+
* so any product rule that reads them back is a rule about transcription. This
|
|
1200
|
+
* tool carries ONE agent-typed string, `what_was_done`, and nothing is ever
|
|
1201
|
+
* compared against it: the question, both answers and the merge itself are the
|
|
1202
|
+
* product's, exactly as an artifact approval already is.
|
|
1203
|
+
*
|
|
1204
|
+
* ═══ IT READS STAMPS, IT DOES NOT RESOLVE ANYTHING. ═══ `branch` and `base`
|
|
1205
|
+
* were written by `cardWorktree` precisely so a second reader would not
|
|
1206
|
+
* re-implement the derivation. No git runs here.
|
|
1207
|
+
*/
|
|
1208
|
+
{
|
|
1209
|
+
name: 'offer_ending',
|
|
1210
|
+
/* ═══ LEVEL 2 ONLY, AND THE DATABASE RECHECKS THAT IT IS THE OWNER. ═══
|
|
1211
|
+
Level 1 never holds a card's branch, and level 3 has no ending and does
|
|
1212
|
+
not speak to the person. */
|
|
1213
|
+
levels: [2],
|
|
1214
|
+
description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
|
|
1215
|
+
+ 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
|
|
1216
|
+
+ 'the main branch. You do not write the question or the answers and you never merge anything: '
|
|
1217
|
+
+ 'the product composes both, and it performs the merge itself if they choose to put the work '
|
|
1218
|
+
+ 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
|
|
1219
|
+
+ 'you are started again with what the product did with their answer.',
|
|
1220
|
+
input: {
|
|
1221
|
+
what_was_done: z.string().min(1).describe('One sentence, in the person\'s words, saying what this card\'s work actually changed. It is '
|
|
1222
|
+
+ 'the only part of the offer you write.'),
|
|
1223
|
+
},
|
|
1224
|
+
handler: async (caller, args) => {
|
|
1225
|
+
const { what_was_done } = args;
|
|
1226
|
+
const { client, runId, processToken } = caller;
|
|
1227
|
+
if (!caller.isOwner || processToken === undefined) {
|
|
1228
|
+
throw new Error('NOTHING WAS WRITTEN. Only this card\'s current conversation owner can offer the ending.');
|
|
1229
|
+
}
|
|
1230
|
+
if (what_was_done.length > PERSON_READS_LIMIT) {
|
|
1231
|
+
throw new Error(`NOTHING WAS WRITTEN and nobody was asked: that is ${what_was_done.length} characters and `
|
|
1232
|
+
+ `they read it on a card three inches wide. The limit is ${PERSON_READS_LIMIT}. One `
|
|
1233
|
+
+ 'sentence saying what changed.');
|
|
1234
|
+
}
|
|
1235
|
+
const run = await only(client.from('panel3_runs')
|
|
1236
|
+
.select('codebase_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
|
|
1237
|
+
.eq('id', runId), 'read', 'which branch this card\'s work is on');
|
|
1238
|
+
if (!run.codebase_id || !run.branch || !run.base || !run.card?.project_id) {
|
|
1239
|
+
throw new Error('NOTHING WAS WRITTEN. This card has no codebase, branch and base recorded, so there is no '
|
|
1240
|
+
+ 'ending to offer. Say what you did and finish the card.');
|
|
1241
|
+
}
|
|
1242
|
+
const rules = await gitRulesFor(client, run.codebase_id, run.card.project_id);
|
|
1243
|
+
if (rules.landing !== 'main') {
|
|
1244
|
+
throw new Error('NOTHING WAS WRITTEN. Finished work in this codebase does not go onto the main branch, so '
|
|
1245
|
+
+ 'there is no ending to offer. Say where the work is and finish the card.');
|
|
1246
|
+
}
|
|
1247
|
+
const [ask] = await rows(client.rpc('panel3_ask', {
|
|
1248
|
+
p_run_id: runId,
|
|
1249
|
+
/* THE QUESTION AND THE CONTEXT ARE COMPOSED HERE, from the two stamps,
|
|
1250
|
+
which is why story 4's scenario can promise the branch and the
|
|
1251
|
+
separation are in it. The agent's sentence is appended to the
|
|
1252
|
+
context and decides nothing. */
|
|
1253
|
+
p_question: `Put this work onto ${run.base}?`,
|
|
1254
|
+
p_category: 'Finished work',
|
|
1255
|
+
p_context: `The work on this card is finished and is on branch ${run.branch}, in a copy of its own, `
|
|
1256
|
+
+ 'apart from anything else working in this codebase on this computer. '
|
|
1257
|
+
+ what_was_done,
|
|
1258
|
+
/* ═══ PASSED AND THEN IGNORED, AND BOTH HALVES ARE DELIBERATE. ═══ The
|
|
1259
|
+
statement overwrites the mode and the options with the product's own
|
|
1260
|
+
when `p_offers_landing` is true, which is where they are enforced.
|
|
1261
|
+
They are named here only because the twelve-argument core carries no
|
|
1262
|
+
defaults, so PostgREST resolves the overload by the exact argument
|
|
1263
|
+
set it is given. */
|
|
1264
|
+
p_answer_mode: 'single_select',
|
|
1265
|
+
p_options: [],
|
|
1266
|
+
p_question_id: null,
|
|
1267
|
+
p_work_item_id: null,
|
|
1268
|
+
p_ask_person: true,
|
|
1269
|
+
p_process_token: processToken,
|
|
1270
|
+
p_related_artifact_id: null,
|
|
1271
|
+
p_offers_landing: true,
|
|
1272
|
+
}), 'put', 'the ending where it can be answered');
|
|
1273
|
+
if (!ask || ask.ask_id === null) {
|
|
1274
|
+
throw new Error(`NOTHING WAS WRITTEN and nobody was asked: run ${runId} has already ended, or a question `
|
|
1275
|
+
+ `is already waiting on you${ask?.held_question ? ` (${ask.held_question})` : ''}. Deal `
|
|
1276
|
+
+ 'with that first.');
|
|
1277
|
+
}
|
|
1278
|
+
return [
|
|
1279
|
+
`The ending is with the person now, id ${ask.ask_id}. They were asked whether this work `
|
|
1280
|
+
+ `should go onto ${run.base}, with the product's own two answers.`,
|
|
1281
|
+
'YOU HAVE STOPPED. Do not merge anything and do not finish the card: when they answer, you',
|
|
1282
|
+
'are started again and told what the product did.',
|
|
1283
|
+
].join('\n');
|
|
1284
|
+
},
|
|
1285
|
+
},
|
|
890
1286
|
{
|
|
891
1287
|
name: 'create_epic',
|
|
892
1288
|
levels: [1],
|
|
@@ -1053,15 +1449,152 @@ const TOOLS = [
|
|
|
1053
1449
|
return `Updated artifact ${artifact.title ?? artifact.id}.`;
|
|
1054
1450
|
},
|
|
1055
1451
|
},
|
|
1452
|
+
{
|
|
1453
|
+
name: 'add_project_context',
|
|
1454
|
+
/* ═══ THE LEVELS THAT DO THE WORK, AND ux.md IS AMENDED RATHER THAN ARGUED
|
|
1455
|
+
PAST. ═══ A context document is a PRODUCED OBJECT, not board structure, so
|
|
1456
|
+
it does not fall under "product structure is level 1's alone"; and the
|
|
1457
|
+
levels that do the work are the levels that learn something worth keeping,
|
|
1458
|
+
while level 1 launches and exits. ux.md's "Who gets what" table carries
|
|
1459
|
+
this ruling, and `panel3-tools.contract.test.mjs` pins all three lists to
|
|
1460
|
+
it exhaustively. */
|
|
1461
|
+
levels: [2, 3],
|
|
1462
|
+
/* ═══ ADD, AND NEVER CHANGE OR REMOVE. ═══ `project_documents` has no
|
|
1463
|
+
history, no version UI and no DELETE policy — hard delete is unreachable
|
|
1464
|
+
rather than merely un-surfaced. That was safe while no agent could write
|
|
1465
|
+
these rows at all. An agent UPDATE would permanently overwrite a person's
|
|
1466
|
+
own text with nothing to restore from, policed only by a sentence in a
|
|
1467
|
+
description. So the tool adds and never changes, and the name says so
|
|
1468
|
+
rather than the description having to. */
|
|
1469
|
+
description: 'Write one new document into this project\'s context — the reference the team keeps for work '
|
|
1470
|
+
+ 'like this. Use it when the conversation SETTLES something the project should keep: a '
|
|
1471
|
+
+ 'convention agreed, how a subsystem actually fits together, why an approach was rejected. '
|
|
1472
|
+
+ 'It adds a document and never changes or removes one, so do not use it to correct something '
|
|
1473
|
+
+ 'already written; say what is wrong in your report instead. Not for the work you were sent '
|
|
1474
|
+
+ 'to do — that goes in an artifact on the work item.',
|
|
1475
|
+
input: {
|
|
1476
|
+
title: z.string().min(1).max(100).describe('What this document is, in a few words a teammate would search for. 100 characters at most.'),
|
|
1477
|
+
type: z.enum(['instructions', 'architecture', 'design', 'conventions', 'other']).describe('Which kind of reference this is, matching the five the web app offers.'),
|
|
1478
|
+
content: z.string().min(1).describe('The document itself, in markdown, complete. It is read by people and by agents on later '
|
|
1479
|
+
+ 'work, neither of whom has seen this conversation.'),
|
|
1480
|
+
scope: z.enum(['project', 'codebase']).optional().describe('`codebase` files it under the codebase you are working in, for something true of that repo '
|
|
1481
|
+
+ 'and no other. Defaults to `project`, which every codebase inherits.'),
|
|
1482
|
+
},
|
|
1483
|
+
handler: async (caller, args) => {
|
|
1484
|
+
const a = args;
|
|
1485
|
+
/* ═══ THE PROJECT COMES OFF THE RUN'S OWN CARD, NEVER OFF AN ARGUMENT.
|
|
1486
|
+
═══ There is no `project_id` input, deliberately: RLS is org-scoped, so
|
|
1487
|
+
a card in one project could otherwise write a document into a sibling
|
|
1488
|
+
project in the same organization, and nothing about that would look like
|
|
1489
|
+
a mistake afterwards. */
|
|
1490
|
+
const projectId = await projectOfCard(caller);
|
|
1491
|
+
if (!projectId) {
|
|
1492
|
+
throw new Error('NOTHING WAS WRITTEN: this conversation is not filed under a project, so there is no '
|
|
1493
|
+
+ 'project context to add to. Put what you learned in your report instead.');
|
|
1494
|
+
}
|
|
1495
|
+
let codebaseId = null;
|
|
1496
|
+
if (a.scope === 'codebase') {
|
|
1497
|
+
/* THE RUN'S OWN CODEBASE, AND NO OTHER, for the same reason the project
|
|
1498
|
+
is not an argument. A level 2 owner on a record-only card has none,
|
|
1499
|
+
and that is a refusal rather than a silent fall back to project
|
|
1500
|
+
scope: filing a codebase-specific note project-wide puts it in front
|
|
1501
|
+
of work it does not apply to. */
|
|
1502
|
+
const runs = await rows(caller.client.from('panel3_runs').select('codebase_id').eq('id', caller.runId), 'read', `the codebase run ${caller.runId} belongs to`);
|
|
1503
|
+
codebaseId = runs[0]?.codebase_id ?? null;
|
|
1504
|
+
if (!codebaseId) {
|
|
1505
|
+
throw new Error('NOTHING WAS WRITTEN: this work is not attached to a codebase, so it has no codebase '
|
|
1506
|
+
+ 'to file a document under. Call this again with scope `project` if it holds for the '
|
|
1507
|
+
+ 'whole project.');
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const document = await only(caller.client.from('project_documents').insert({
|
|
1511
|
+
project_id: projectId,
|
|
1512
|
+
codebase_id: codebaseId,
|
|
1513
|
+
title: a.title.trim(),
|
|
1514
|
+
type: a.type,
|
|
1515
|
+
content: a.content,
|
|
1516
|
+
}).select('id, title'), 'write', 'a project context document');
|
|
1517
|
+
return (`Written. "${document.title}" is in this project's context now, id ${document.id}`
|
|
1518
|
+
+ `${codebaseId === null ? '' : ', filed under this codebase'}. `
|
|
1519
|
+
+ 'The person will find it under Project settings. You cannot change or remove it, so if it '
|
|
1520
|
+
+ 'needs correcting, say so rather than writing a second one.');
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
// ── Say something now ────────────────────────────────────────────────────
|
|
1524
|
+
//
|
|
1525
|
+
// ═══ LEVEL 2 ONLY, AND THE DATABASE IS WHAT ENFORCES IT. ═══ `panel3_say`
|
|
1526
|
+
// refuses any run that is not the one `panel3_cards.conversation_run_id`
|
|
1527
|
+
// names, and only a level 2 run is ever appointed, so a worker cannot reach
|
|
1528
|
+
// the person through this however the list below is edited. The tool is absent
|
|
1529
|
+
// at 1 and 3 as well, so an agent that has no business calling it is not
|
|
1530
|
+
// offered it and then refused.
|
|
1531
|
+
//
|
|
1532
|
+
// ═══ WHY IT EXISTS AT ALL, GIVEN `panel3_answer`. ═══ Nothing reached the
|
|
1533
|
+
// person until the owner's process exited, and a turn that ends by asking
|
|
1534
|
+
// writes no message at all. Measured 2026-08-24: "Write a plan for this item"
|
|
1535
|
+
// was silent for two and a half minutes and then said a question. An
|
|
1536
|
+
// acknowledgement cannot be an exit, because an exit is the end of the turn.
|
|
1537
|
+
{
|
|
1538
|
+
name: 'say',
|
|
1539
|
+
levels: [2],
|
|
1540
|
+
description: 'Say something to the person NOW, without stopping. Use it the moment you know what you are '
|
|
1541
|
+
+ 'going to do, before you start doing it: they have seen only their own message, and until '
|
|
1542
|
+
+ 'you say something the card shows them a pulse. One or two sentences in their words, saying '
|
|
1543
|
+
+ 'what you are about to do and what they will get. Use it again if what you are doing changes '
|
|
1544
|
+
+ 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
|
|
1545
|
+
+ 'and does not replace the reply or the question you finish with: keep working after it. Do '
|
|
1546
|
+
+ 'not use it for a running commentary, and do not use it to ask anything.',
|
|
1547
|
+
input: {
|
|
1548
|
+
message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
|
|
1549
|
+
+ 'the plan and bring it to you."'),
|
|
1550
|
+
},
|
|
1551
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1552
|
+
const { message } = args;
|
|
1553
|
+
/* ═══ THE SAME BOUND AS A QUESTION, BECAUSE IT IS THE SAME CARD. ═══
|
|
1554
|
+
Issue 7. Without this the bound on `ask_question` is theatre: an owner
|
|
1555
|
+
refused on a question can put the same paragraph in front of the same
|
|
1556
|
+
person through this door, and it does not even end its turn to do it. */
|
|
1557
|
+
if (message.length > PERSON_READS_LIMIT) {
|
|
1558
|
+
throw new Error(`NOTHING WAS WRITTEN: that is ${message.length} characters and they read it on a card `
|
|
1559
|
+
+ `three inches wide. The limit is ${PERSON_READS_LIMIT}. One or two sentences saying `
|
|
1560
|
+
+ 'what you are about to do; what you found goes in the artifact or your report.');
|
|
1561
|
+
}
|
|
1562
|
+
const { data, error } = await client.rpc('panel3_say', {
|
|
1563
|
+
p_run_id: runId,
|
|
1564
|
+
p_body: message,
|
|
1565
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1566
|
+
});
|
|
1567
|
+
if (error)
|
|
1568
|
+
throw new Error(`could not say that on this card: ${error.message}`);
|
|
1569
|
+
/* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
|
|
1570
|
+
SPOKE. ═══ Three reasons collapse to one sentence because the caller
|
|
1571
|
+
cannot tell them apart and all three mean the same thing to it: this
|
|
1572
|
+
conversation is not yours to speak on any more. An owner that thinks it
|
|
1573
|
+
acknowledged and did not is the failure this tool exists to remove,
|
|
1574
|
+
arrived at from the other side. */
|
|
1575
|
+
if (data === null) {
|
|
1576
|
+
throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
|
|
1577
|
+
+ 'or somebody else owns it now. Do not tell anybody you said anything.');
|
|
1578
|
+
}
|
|
1579
|
+
return 'Said. They can read it now. Carry on.';
|
|
1580
|
+
},
|
|
1581
|
+
},
|
|
1056
1582
|
// ── Report ───────────────────────────────────────────────────────────────
|
|
1057
1583
|
{
|
|
1058
1584
|
name: 'report_activity',
|
|
1059
1585
|
levels: ALL,
|
|
1060
1586
|
description: 'Say what you are doing right now, in one short line, in the words a person watching would use. '
|
|
1587
|
+
+ 'The line says what you are FINDING OUT or what you are CHANGING, not the steps you are '
|
|
1588
|
+
+ 'running to do it: "Checking for clock use, CI config, and running the test suite once" tells '
|
|
1589
|
+
+ 'a reader nothing, where "Working out whether the code can tell what happened this week" tells '
|
|
1590
|
+
+ 'them what turns on it. You do not need to have seen anybody\'s message to write one. '
|
|
1061
1591
|
+ 'It replaces whatever you last said. Call it when you start something that will take a while, '
|
|
1062
1592
|
+ 'so a run that is working and a run that is wedged do not look the same.',
|
|
1063
|
-
input: {
|
|
1064
|
-
|
|
1593
|
+
input: {
|
|
1594
|
+
activity: z.string().min(1).describe('One line, present tense, naming what you are finding out or changing. "Working out whether '
|
|
1595
|
+
+ 'the code can tell what happened this week".'),
|
|
1596
|
+
},
|
|
1597
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1065
1598
|
const { activity } = args;
|
|
1066
1599
|
/* ═══ THROUGH AN RPC, SO THE LINE AND ITS TIME ARE ONE STATEMENT ON ONE
|
|
1067
1600
|
CLOCK. ═══ This was a plain table update. The card is now one list in
|
|
@@ -1070,7 +1603,11 @@ const TOOLS = [
|
|
|
1070
1603
|
Supabase's. `panel3_report_activity` carries the same liveness
|
|
1071
1604
|
predicate the update did, in the same statement, so `whileRunning` is
|
|
1072
1605
|
still what reads the refusal. */
|
|
1073
|
-
await whileRunning(client.rpc('panel3_report_activity', {
|
|
1606
|
+
await whileRunning(client.rpc('panel3_report_activity', {
|
|
1607
|
+
p_run_id: runId,
|
|
1608
|
+
p_activity: activity,
|
|
1609
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1610
|
+
}), runId, 'record what you are doing on');
|
|
1074
1611
|
return 'Noted.';
|
|
1075
1612
|
},
|
|
1076
1613
|
},
|
|
@@ -1082,12 +1619,14 @@ const TOOLS = [
|
|
|
1082
1619
|
+ 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
|
|
1083
1620
|
+ 'you go, not only at the end.',
|
|
1084
1621
|
input: { report: z.string().min(1) },
|
|
1085
|
-
handler: async ({ client, runId }, args) => {
|
|
1622
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1086
1623
|
const { report } = args;
|
|
1087
|
-
await whileRunning(
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1624
|
+
await whileRunning(processToken === undefined
|
|
1625
|
+
? client.from('panel3_runs').update({ report }).eq('id', runId)
|
|
1626
|
+
.in('state', STILL_WRITING).is('ended_at', null).select('id')
|
|
1627
|
+
: client.rpc('panel3_write_report', {
|
|
1628
|
+
p_run_id: runId, p_report: report, p_process_token: processToken,
|
|
1629
|
+
}), runId, 'write the report on');
|
|
1091
1630
|
return 'Report written.';
|
|
1092
1631
|
},
|
|
1093
1632
|
},
|
|
@@ -1134,6 +1673,9 @@ const TOOLS = [
|
|
|
1134
1673
|
question carried them, and absent rather than printed as empty. */
|
|
1135
1674
|
...(a.category ? [` ${a.category}`] : []),
|
|
1136
1675
|
...(a.context ? [` why ${a.context}`] : []),
|
|
1676
|
+
...(a.related_artifact_id ? [
|
|
1677
|
+
` artifact ${a.related_artifact_id} presented revision ${a.related_artifact_revision ?? 'not recorded'}`,
|
|
1678
|
+
] : []),
|
|
1137
1679
|
` Q ${a.question ?? '(this question could not be read)'}`,
|
|
1138
1680
|
...(a.options ?? []).map((option) => ` - ${option}`),
|
|
1139
1681
|
` A ${a.answer ?? 'not answered yet'}`,
|
|
@@ -1147,11 +1689,28 @@ const TOOLS = [
|
|
|
1147
1689
|
decision. Level 3 still escalates, and the tool is absent there rather
|
|
1148
1690
|
than present and refusing. */
|
|
1149
1691
|
levels: [1, 2],
|
|
1150
|
-
description: 'Put a question to the person, and stop.
|
|
1151
|
-
+ '
|
|
1152
|
-
+ '
|
|
1153
|
-
+ '
|
|
1154
|
-
+ '
|
|
1692
|
+
description: 'Put a question to the person, and stop. A Level 1 launcher uses this only for a destination '
|
|
1693
|
+
+ 'or codebase it truly cannot choose; it launches the owner for every work or product '
|
|
1694
|
+
+ 'decision. A Level 2 owner MUST use this tool when work cannot continue '
|
|
1695
|
+
+ 'until the person answers, including when they must choose between options; an ordinary '
|
|
1696
|
+
+ 'reply is not a question path and completing the card with a question is wrong. Ask only '
|
|
1697
|
+
+ 'what you genuinely cannot settle from the '
|
|
1698
|
+
+ 'record, by dispatching someone to find out, or by answering it yourself. Use the shortest '
|
|
1699
|
+
+ 'question and context the person can answer safely. Ask ONE thing, in the words they would '
|
|
1700
|
+
+ 'use, and say what you will do with each answer. If you are putting on a question that came '
|
|
1701
|
+
+ 'up from work you sent out, name it in question_id and write it as they need to read it: they '
|
|
1702
|
+
+ 'have not seen any of it. For an artifact approval, name its Work Item and live artifact, '
|
|
1703
|
+
+ 'offer approve or request changes, and present one revision at a time. THE QUESTION LINE '
|
|
1704
|
+
+ 'ITSELF SAYS WHAT THEY ARE APPROVING, in plain words, never further down in the context: '
|
|
1705
|
+
+ 'approving a document that describes work is not the same as approving the work, and the '
|
|
1706
|
+
+ 'person cannot tell those apart from the title of an artifact. Write it the way these are '
|
|
1707
|
+
+ 'written: "Plan is written. Please read it and approve or ask for changes", "Ok to write a '
|
|
1708
|
+
+ 'spec.md and attach it to this work item?", "The work item has a detailed plan. Ok to start '
|
|
1709
|
+
+ 'building?". A question that names the artifact and leaves the reader to work out what '
|
|
1710
|
+
+ 'approving starts is the one this rule exists to stop. '
|
|
1711
|
+
+ 'After this call, stop immediately. '
|
|
1712
|
+
+ 'Do not repeat the question in '
|
|
1713
|
+
+ 'an ordinary reply or add a message saying that you asked it.',
|
|
1155
1714
|
input: {
|
|
1156
1715
|
question: z.string().min(1),
|
|
1157
1716
|
...DECIDING,
|
|
@@ -1162,8 +1721,11 @@ const TOOLS = [
|
|
|
1162
1721
|
+ 'question is not about a work item — whether a second item should exist, which item a '
|
|
1163
1722
|
+ 'request means, or anything about the conversation itself — rather than picking the '
|
|
1164
1723
|
+ 'nearest one.'),
|
|
1724
|
+
related_artifact_id: z.string().optional().describe('The live Analysis or Plan artifact on work_item_id that this decision presents for approval '
|
|
1725
|
+
+ 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
|
|
1726
|
+
+ 'its current process token is required. Leave it out for every ordinary question.'),
|
|
1165
1727
|
},
|
|
1166
|
-
handler: async (
|
|
1728
|
+
handler: async (caller, args) => asked(caller, args, true),
|
|
1167
1729
|
},
|
|
1168
1730
|
{
|
|
1169
1731
|
name: 'escalate',
|
|
@@ -1176,7 +1738,7 @@ const TOOLS = [
|
|
|
1176
1738
|
+ 'either, this is how it goes further up: name it in question_id and write it in your own '
|
|
1177
1739
|
+ 'words, with what you already know added.',
|
|
1178
1740
|
input: { question: z.string().min(1), ...DECIDING, question_id: PASSING_ON },
|
|
1179
|
-
handler: async (
|
|
1741
|
+
handler: async (caller, args) => asked(caller, args, false),
|
|
1180
1742
|
},
|
|
1181
1743
|
{
|
|
1182
1744
|
name: 'answer_escalation',
|
|
@@ -1189,7 +1751,7 @@ const TOOLS = [
|
|
|
1189
1751
|
+ 'has to stop and deal with. The one who asked is started again with your answer, so write it '
|
|
1190
1752
|
+ 'to them, plainly, and say what to do rather than what you would have done.',
|
|
1191
1753
|
input: { question_id: z.string(), answer: z.string().min(1) },
|
|
1192
|
-
handler: async ({ client, runId }, args) => {
|
|
1754
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1193
1755
|
const { question_id, answer } = args;
|
|
1194
1756
|
/* ═══ AN AGENT ANSWERS IN ITS OWN WORDS, WHATEVER SHAPE THE QUESTION WAS
|
|
1195
1757
|
ASKED IN. ═══ No selection and the answer as the note, which is the
|
|
@@ -1202,6 +1764,7 @@ const TOOLS = [
|
|
|
1202
1764
|
p_selected_options: [],
|
|
1203
1765
|
p_answer_note: answer,
|
|
1204
1766
|
p_by_run_id: runId,
|
|
1767
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1205
1768
|
});
|
|
1206
1769
|
if (error)
|
|
1207
1770
|
throw new Error(`could not answer question ${question_id}: ${error.message}`);
|
|
@@ -1245,7 +1808,8 @@ const TOOLS = [
|
|
|
1245
1808
|
+ 'the same files, or send them one at a time. Do not wait for it: what it writes goes on as it '
|
|
1246
1809
|
+ 'wrote it, and nobody edits it on the way.',
|
|
1247
1810
|
input: {
|
|
1248
|
-
codebase_id: z.string().uuid().describe('The id of the registered project codebase this work belongs to.'
|
|
1811
|
+
codebase_id: z.string().uuid().optional().describe('The id of the registered project codebase this work belongs to. A launcher may omit this '
|
|
1812
|
+
+ 'for record-only work; an owner dispatching a worker must provide it.'),
|
|
1249
1813
|
responsibility: z.string().min(1).describe('What this agent owns, in one or two sentences, complete enough to act on with no other '
|
|
1250
1814
|
+ 'context: what to find out or change, and in which part of the codebase.'),
|
|
1251
1815
|
boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
|
|
@@ -1253,15 +1817,20 @@ const TOOLS = [
|
|
|
1253
1817
|
},
|
|
1254
1818
|
handler: async (caller, args) => {
|
|
1255
1819
|
const { codebase_id, responsibility, boundary, work_item_id } = args;
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if (!projectId) {
|
|
1259
|
-
throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
|
|
1820
|
+
if (caller.level === 2 && !codebase_id) {
|
|
1821
|
+
throw new Error('A worker must be attached to a registered project codebase.');
|
|
1260
1822
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1823
|
+
let codebase = null;
|
|
1824
|
+
if (codebase_id) {
|
|
1825
|
+
const projectId = await projectOfCard(caller);
|
|
1826
|
+
if (!projectId) {
|
|
1827
|
+
throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
|
|
1828
|
+
}
|
|
1829
|
+
codebase = (await listCodebases(caller.client, projectId))
|
|
1830
|
+
.find((candidate) => candidate.id === codebase_id) ?? null;
|
|
1831
|
+
if (!codebase) {
|
|
1832
|
+
throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
|
|
1833
|
+
}
|
|
1265
1834
|
}
|
|
1266
1835
|
/* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
|
|
1267
1836
|
the words in the brief; `panel3_dispatch` decides the level on the row,
|
|
@@ -1277,11 +1846,9 @@ const TOOLS = [
|
|
|
1277
1846
|
asking. See `workBrief`'s own doc for why the two are different things
|
|
1278
1847
|
carried the same way. */
|
|
1279
1848
|
const attachments = (await loadAttachments(caller.client, caller.cardId)).map(attachmentLine);
|
|
1280
|
-
const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, {
|
|
1281
|
-
id: codebase.id,
|
|
1282
|
-
|
|
1283
|
-
identity: codebase.gitRemoteUrl,
|
|
1284
|
-
}), codebase);
|
|
1849
|
+
const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
|
|
1850
|
+
id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
|
|
1851
|
+
}), codebase, caller.processToken);
|
|
1285
1852
|
/* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
|
|
1286
1853
|
KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
|
|
1287
1854
|
because it is registered once for both levels that hold the tool; this
|
|
@@ -1293,8 +1860,8 @@ const TOOLS = [
|
|
|
1293
1860
|
return (`Started, and it is working now. Its id is ${runId}, and list_child_runs will say how it is `
|
|
1294
1861
|
+ 'getting on. '
|
|
1295
1862
|
+ (childLevel === 2
|
|
1296
|
-
? '
|
|
1297
|
-
+ '
|
|
1863
|
+
? 'That owner now has the conversation. Your launcher work is finished: write no reply, '
|
|
1864
|
+
+ 'ask nothing else, and exit immediately.'
|
|
1298
1865
|
: 'What it writes comes back to you and to nobody else. When everybody you have sent has '
|
|
1299
1866
|
+ 'finished you are started again with what each of them wrote, and the one answer that '
|
|
1300
1867
|
+ 'covers them is yours to write, so do not wait here for it.'));
|
|
@@ -1314,10 +1881,14 @@ const TOOLS = [
|
|
|
1314
1881
|
+ 'worth finishing. It says how many agents it stopped, and it stops nothing rather than '
|
|
1315
1882
|
+ 'reaching outside what is yours. To stop your own work, just finish.',
|
|
1316
1883
|
input: { run_id: z.string() },
|
|
1317
|
-
handler: async ({ client, runId }, args) => {
|
|
1884
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1318
1885
|
const { run_id } = args;
|
|
1319
1886
|
const { data, error } = await client
|
|
1320
|
-
.rpc('panel3_stop_run', {
|
|
1887
|
+
.rpc('panel3_stop_run', {
|
|
1888
|
+
p_by_run_id: runId,
|
|
1889
|
+
p_run_id: run_id,
|
|
1890
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1891
|
+
});
|
|
1321
1892
|
if (error)
|
|
1322
1893
|
throw new Error(`could not stop run ${run_id}: ${error.message}`);
|
|
1323
1894
|
const stopped = data;
|
|
@@ -1382,10 +1953,11 @@ async function receipt({ client, runId, cardId }, kind, refId, label) {
|
|
|
1382
1953
|
await only(client.from('panel3_outputs').insert({ card_id: cardId, run_id: runId, kind, ref_id: refId, label }).select('id'), 'record', `the ${kind} on the card`);
|
|
1383
1954
|
}
|
|
1384
1955
|
/** The names one level is served, in the order they are registered. Exported for
|
|
1385
|
-
* the same reason `
|
|
1956
|
+
* the same reason `cs show` exists: a rule nobody can print is a rule nobody
|
|
1386
1957
|
* can check. */
|
|
1387
|
-
export function toolNamesForLevel(level) {
|
|
1388
|
-
return TOOLS.filter((t) => t.levels.includes(level)
|
|
1958
|
+
export function toolNamesForLevel(level, isOwner = false) {
|
|
1959
|
+
return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
|
|
1960
|
+
.map((t) => t.name);
|
|
1389
1961
|
}
|
|
1390
1962
|
/**
|
|
1391
1963
|
* One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
|
|
@@ -1396,10 +1968,21 @@ export function toolNamesForLevel(level) {
|
|
|
1396
1968
|
* standing up the MCP transport `buildServer` wraps it in.
|
|
1397
1969
|
*/
|
|
1398
1970
|
export function toolHandler(name) {
|
|
1971
|
+
return toolNamed(name).handler;
|
|
1972
|
+
}
|
|
1973
|
+
/** One tool's registered input schema and description, by name. Exported for
|
|
1974
|
+
* what a handler test cannot see: whether a bound lives in Zod, where it would
|
|
1975
|
+
* reject before `buildServer`'s try/catch and reach every level sharing the
|
|
1976
|
+
* schema, or in the handler, where it comes back as a correctable failure. */
|
|
1977
|
+
export function toolShape(name) {
|
|
1978
|
+
const { description, input } = toolNamed(name);
|
|
1979
|
+
return { description, input };
|
|
1980
|
+
}
|
|
1981
|
+
function toolNamed(name) {
|
|
1399
1982
|
const tool = TOOLS.find((t) => t.name === name);
|
|
1400
1983
|
if (!tool)
|
|
1401
1984
|
throw new Error(`no tool named ${name}`);
|
|
1402
|
-
return tool
|
|
1985
|
+
return tool;
|
|
1403
1986
|
}
|
|
1404
1987
|
// ---------------------------------------------------------------------------
|
|
1405
1988
|
/**
|
|
@@ -1416,7 +1999,7 @@ function buildServer(caller) {
|
|
|
1416
1999
|
+ 'hold rather than one that is missing.',
|
|
1417
2000
|
});
|
|
1418
2001
|
for (const tool of TOOLS) {
|
|
1419
|
-
if (!tool.levels.includes(caller.level))
|
|
2002
|
+
if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
|
|
1420
2003
|
continue;
|
|
1421
2004
|
server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
|
|
1422
2005
|
try {
|
|
@@ -1426,7 +2009,7 @@ function buildServer(caller) {
|
|
|
1426
2009
|
the risky ones. This is one of the two chokepoints the rule rests
|
|
1427
2010
|
on; `writeAnswer` in run.ts is the other. It is identity for the
|
|
1428
2011
|
runs that read no credential, which is nearly all of them. */
|
|
1429
|
-
const safe = redactArgs(caller
|
|
2012
|
+
const safe = redactArgs(secretScope(caller), args);
|
|
1430
2013
|
return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
|
|
1431
2014
|
}
|
|
1432
2015
|
catch (error) {
|
|
@@ -1447,6 +2030,18 @@ function buildServer(caller) {
|
|
|
1447
2030
|
}
|
|
1448
2031
|
return server;
|
|
1449
2032
|
}
|
|
2033
|
+
export const toolsUrl = (port, runId, processToken) => `http://127.0.0.1:${port}/mcp/${runId}${processToken ? `/${processToken}` : ''}`;
|
|
2034
|
+
/** The per-request fence used by already-open MCP sessions. */
|
|
2035
|
+
export async function processActivationIsCurrent(client, runId, processToken) {
|
|
2036
|
+
const current = await rows(client.from('panel3_runs')
|
|
2037
|
+
.select('process_token, card:panel3_cards!panel3_runs_card_id_fkey!inner(conversation_run_id)')
|
|
2038
|
+
.eq('id', runId)
|
|
2039
|
+
.eq('process_token', processToken)
|
|
2040
|
+
.in('state', ['running', 'asked'])
|
|
2041
|
+
.is('ended_at', null)
|
|
2042
|
+
.eq('card.conversation_run_id', runId), 'verify', `the current process activation for run ${runId}`);
|
|
2043
|
+
return current.length > 0;
|
|
2044
|
+
}
|
|
1450
2045
|
/**
|
|
1451
2046
|
* Start the v3 tools server on loopback, for the signed-in user this client
|
|
1452
2047
|
* carries.
|
|
@@ -1477,6 +2072,7 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1477
2072
|
/** Which run each open session belongs to, so a connection cannot change run
|
|
1478
2073
|
* partway through. Bound at `initialize`, cleared with the session. */
|
|
1479
2074
|
const sessionRuns = new Map();
|
|
2075
|
+
const sessionTokens = new Map();
|
|
1480
2076
|
/** Known once the socket is bound, which is before any request can arrive. */
|
|
1481
2077
|
let port = 0;
|
|
1482
2078
|
const fail = (res, status, why) => {
|
|
@@ -1484,7 +2080,11 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1484
2080
|
};
|
|
1485
2081
|
async function handle(req, res) {
|
|
1486
2082
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
1487
|
-
const
|
|
2083
|
+
const parts = url.pathname.startsWith('/mcp/')
|
|
2084
|
+
? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
|
|
2085
|
+
: [];
|
|
2086
|
+
const runId = parts[0] ?? null;
|
|
2087
|
+
const processToken = parts[1];
|
|
1488
2088
|
if (!runId) {
|
|
1489
2089
|
fail(res, 404, 'Not found. The v3 tools server serves /mcp/<run-id> and nothing else.');
|
|
1490
2090
|
return;
|
|
@@ -1501,6 +2101,17 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1501
2101
|
fail(res, 400, 'Bad Request: this session belongs to a different run.');
|
|
1502
2102
|
return;
|
|
1503
2103
|
}
|
|
2104
|
+
const expected = sessionTokens.get(sessionId);
|
|
2105
|
+
if (expected !== processToken) {
|
|
2106
|
+
fail(res, 400, 'Bad Request: this session belongs to a different process activation.');
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (expected !== undefined) {
|
|
2110
|
+
if (!(await processActivationIsCurrent(client, runId, expected))) {
|
|
2111
|
+
fail(res, 403, `Run ${runId} belongs to a newer process activation.`);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
1504
2115
|
await existing.handleRequest(req, res);
|
|
1505
2116
|
return;
|
|
1506
2117
|
}
|
|
@@ -1517,7 +2128,9 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1517
2128
|
signed-in user's RLS, so a run belonging to somebody else is not found
|
|
1518
2129
|
rather than refused — which is the same answer, told without confirming
|
|
1519
2130
|
the row exists. */
|
|
1520
|
-
const runs = await rows(client.from('panel3_runs')
|
|
2131
|
+
const runs = await rows(client.from('panel3_runs')
|
|
2132
|
+
.select('id, card_id, level, state, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
2133
|
+
.eq('id', runId), 'read', `run ${runId}`);
|
|
1521
2134
|
const run = runs[0];
|
|
1522
2135
|
if (!run) {
|
|
1523
2136
|
fail(res, 404, `Not found: there is no run ${runId}.`);
|
|
@@ -1535,11 +2148,21 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1535
2148
|
if (run.level !== 1 && run.level !== 2 && run.level !== 3) {
|
|
1536
2149
|
throw new Error(`run ${runId} has level ${run.level}, which is not a level this product has`);
|
|
1537
2150
|
}
|
|
2151
|
+
const isOwner = run.card?.conversation_run_id === run.id;
|
|
2152
|
+
if (isOwner && (!processToken || processToken !== run.process_token)) {
|
|
2153
|
+
fail(res, 403, `Run ${runId} belongs to a different process activation.`);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (!isOwner && processToken !== undefined) {
|
|
2157
|
+
fail(res, 403, `Run ${runId} has no process activation token.`);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
1538
2160
|
const transport = new StreamableHTTPServerTransport({
|
|
1539
2161
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
1540
2162
|
onsessioninitialized: (sid) => {
|
|
1541
2163
|
transports.set(sid, transport);
|
|
1542
2164
|
sessionRuns.set(sid, runId);
|
|
2165
|
+
sessionTokens.set(sid, processToken);
|
|
1543
2166
|
},
|
|
1544
2167
|
// The same DNS-rebinding guard v2's server carries: a page on a public
|
|
1545
2168
|
// domain that re-resolves to 127.0.0.1 cannot drive this server from a
|
|
@@ -1552,10 +2175,12 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1552
2175
|
if (sid) {
|
|
1553
2176
|
transports.delete(sid);
|
|
1554
2177
|
sessionRuns.delete(sid);
|
|
2178
|
+
sessionTokens.delete(sid);
|
|
1555
2179
|
}
|
|
1556
2180
|
};
|
|
1557
2181
|
const server = buildServer({
|
|
1558
|
-
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
2182
|
+
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
2183
|
+
processToken, isOwner, dispatch,
|
|
1559
2184
|
});
|
|
1560
2185
|
await server.connect(transport);
|
|
1561
2186
|
await transport.handleRequest(req, res, body);
|
|
@@ -1581,11 +2206,12 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1581
2206
|
});
|
|
1582
2207
|
port = http.address().port;
|
|
1583
2208
|
return {
|
|
1584
|
-
urlFor: (runId) =>
|
|
2209
|
+
urlFor: (runId, processToken) => toolsUrl(port, runId, processToken),
|
|
1585
2210
|
async close() {
|
|
1586
2211
|
await Promise.all([...transports.values()].map((t) => t.close().catch(() => { })));
|
|
1587
2212
|
transports.clear();
|
|
1588
2213
|
sessionRuns.clear();
|
|
2214
|
+
sessionTokens.clear();
|
|
1589
2215
|
await new Promise((resolve, reject) => {
|
|
1590
2216
|
http.close((err) => (err ? reject(err) : resolve()));
|
|
1591
2217
|
});
|