@ctrl-spc/cs 0.7.0 → 0.7.1
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/codex-home.js +62 -1
- package/dist/panel3/prompt.js +329 -61
- package/dist/panel3/run.js +584 -56
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +2 -1
- package/dist/panel3/spawn.js +80 -18
- package/dist/panel3/tools.js +353 -48
- package/dist/workflows.js +68 -0
- package/package.json +1 -1
package/dist/panel3/tools.js
CHANGED
|
@@ -121,6 +121,7 @@ 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 { readWorkflow } from '../workflows.js';
|
|
124
125
|
import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, withAskContent, } from './show.js';
|
|
125
126
|
// ---------------------------------------------------------------------------
|
|
126
127
|
// READING AND WRITING. Every query goes through the same guard every other v3
|
|
@@ -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
|
|
@@ -651,6 +709,92 @@ const TOOLS = [
|
|
|
651
709
|
].join('\n');
|
|
652
710
|
},
|
|
653
711
|
},
|
|
712
|
+
{
|
|
713
|
+
name: 'get_workflow',
|
|
714
|
+
/* ═══ LEVEL 2 ALONE, AND THAT IS A DELIBERATE DIVERGENCE FROM `get_skill`'s
|
|
715
|
+
`ALL`. ═══ `get_skill`'s own comment argues level 1 needs it because "a
|
|
716
|
+
coordinator that cannot read the skill cannot write a responsibility that
|
|
717
|
+
respects it". That argument does not carry across, and the difference is
|
|
718
|
+
what a launcher is writing. A skill is a METHOD that applies to whatever
|
|
719
|
+
single responsibility level 1 writes, so a launcher ignorant of it writes
|
|
720
|
+
a responsibility that contradicts it. A workflow is not applied to the
|
|
721
|
+
launcher's responsibility: it is a SEQUENCE the OWNER decomposes, and the
|
|
722
|
+
launcher writes one line — see this conversation through — for the agent
|
|
723
|
+
that reads the process itself. `attachmentLine` already prints the
|
|
724
|
+
workflow's NAME in the launcher's prompt, so it can name the process in
|
|
725
|
+
what it writes without reading a stage of it.
|
|
726
|
+
|
|
727
|
+
AND NOT LEVEL 3 EITHER. A worker owns one piece and nothing else; handing
|
|
728
|
+
it the whole recipe invites it to run stages that are not its. What it
|
|
729
|
+
does need — its own stage's document — reaches it in its brief, because
|
|
730
|
+
`workBrief` tells the owner to put it there. Same narrowing, same reason,
|
|
731
|
+
as `get_credential` below.
|
|
732
|
+
|
|
733
|
+
AND THERE IS NO `list_workflows` BESIDE IT, for `get_skill`'s reason: the
|
|
734
|
+
id arrived in the brief, off the card's own attachment row, put there by
|
|
735
|
+
the person. A v3 agent never goes looking for a workflow. */
|
|
736
|
+
levels: [2],
|
|
737
|
+
description: 'Read the workflow attached to this conversation. A workflow is the PROCESS this work '
|
|
738
|
+
+ 'follows: named stages in an order, each with a document to work to. Call this with the id '
|
|
739
|
+
+ 'printed beside the workflow in what was attached, and read all of it before you decide how '
|
|
740
|
+
+ 'this work is split.',
|
|
741
|
+
input: { workflow_id: z.string() },
|
|
742
|
+
handler: async ({ client }, args) => {
|
|
743
|
+
const { workflow_id } = args;
|
|
744
|
+
const workflow = await readWorkflow(client, workflow_id);
|
|
745
|
+
/* ═══ EXITS RESOLVE TO A NUMBERED STAGE, NEVER TO A RAW UUID. ═══
|
|
746
|
+
`to_stage_id` is a uuid and no v3 tool at any level takes one, so a raw
|
|
747
|
+
id would be a reference the reader cannot follow. This matters
|
|
748
|
+
concretely: exits are the only surviving representation of a REPEAT, and
|
|
749
|
+
every org is seeded with a feature loop whose exits are mostly backward.
|
|
750
|
+
A rendering that dropped them would present the one workflow every org
|
|
751
|
+
actually has as a straight line, which is a different process. */
|
|
752
|
+
const numberOf = new Map(workflow.stages.map((stage, i) => [stage.id, i + 1]));
|
|
753
|
+
const exitsOf = (stageId) => workflow.exits
|
|
754
|
+
.filter((exit) => exit.stageId === stageId)
|
|
755
|
+
.map((exit) => {
|
|
756
|
+
const number = numberOf.get(exit.toStageId);
|
|
757
|
+
const stage = workflow.stages.find((candidate) => candidate.id === exit.toStageId);
|
|
758
|
+
return number && stage
|
|
759
|
+
? `if ${exit.condition}, go to stage ${number} ${stage.name}`
|
|
760
|
+
: `if ${exit.condition}, go back to an earlier stage`;
|
|
761
|
+
});
|
|
762
|
+
return [
|
|
763
|
+
workflow.name,
|
|
764
|
+
`id ${workflow.id}`,
|
|
765
|
+
`about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
|
|
766
|
+
'',
|
|
767
|
+
`STAGES ${workflow.stages.length}`,
|
|
768
|
+
...workflow.stages.flatMap((stage, i) => [
|
|
769
|
+
'',
|
|
770
|
+
line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
|
|
771
|
+
'',
|
|
772
|
+
/* THE WHOLE BODY. The body IS the stage document, so truncating it is
|
|
773
|
+
truncating the process. An empty one is a stage nobody has written
|
|
774
|
+
yet, which is a real state and is said rather than hidden. */
|
|
775
|
+
stage.body.trim() === '' ? '(this stage has not been written yet)' : stage.body,
|
|
776
|
+
...exitsOf(stage.id),
|
|
777
|
+
]),
|
|
778
|
+
'',
|
|
779
|
+
/* ═══ THE PERSON'S ENDING CHOICE, SAID RATHER THAN OBEYED OR DISCARDED.
|
|
780
|
+
═══ They picked it in the web app's own picker. v3 has no backlog read
|
|
781
|
+
at any level — `list_work_items` is newest-first with no rank — so
|
|
782
|
+
this cannot be done here. Rendering the raw value would invite
|
|
783
|
+
improvisation and rendering nothing would silently discard their
|
|
784
|
+
choice, which is the untruthful failure the guide forbids. The slice
|
|
785
|
+
that gives v3 a backlog read replaces this sentence. */
|
|
786
|
+
...(workflow.ending === 'next-in-backlog'
|
|
787
|
+
? [
|
|
788
|
+
'This workflow is set to start again on the next backlog item. That is not available '
|
|
789
|
+
+ 'here: stop when the last stage is done, and say so.',
|
|
790
|
+
'',
|
|
791
|
+
]
|
|
792
|
+
: []),
|
|
793
|
+
'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
|
|
794
|
+
+ 'above. It is not a document to summarise, quote back or file away.',
|
|
795
|
+
].join('\n');
|
|
796
|
+
},
|
|
797
|
+
},
|
|
654
798
|
{
|
|
655
799
|
name: 'get_credential',
|
|
656
800
|
/* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
|
|
@@ -675,7 +819,7 @@ const TOOLS = [
|
|
|
675
819
|
+ 'beside the credential in what was attached. The value is yours to USE in the work you were '
|
|
676
820
|
+ 'sent to do; what you must not do is publish it.',
|
|
677
821
|
input: { credential_id: z.string() },
|
|
678
|
-
handler: async ({ client, runId }, args) => {
|
|
822
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
679
823
|
const { credential_id } = args;
|
|
680
824
|
const found = await rows(client.from('credentials').select('id, name, kind, username').eq('id', credential_id), 'read', `credential ${credential_id}`);
|
|
681
825
|
/* ═══ ONE MESSAGE FOR THREE DIFFERENT FACTS, AND THAT IS DELIBERATE. ═══
|
|
@@ -711,7 +855,7 @@ const TOOLS = [
|
|
|
711
855
|
BEFORE the agent can write anything containing it. ═══ See secrets.ts:
|
|
712
856
|
from here on, this value cannot reach a panel3_ row through any tool
|
|
713
857
|
this run calls or through the answer it ends with. */
|
|
714
|
-
rememberSecret(runId, secret, row.name);
|
|
858
|
+
rememberSecret(secretScope({ runId, processToken }), secret, row.name);
|
|
715
859
|
const username = typeof value.username === 'string' ? value.username : null;
|
|
716
860
|
return [
|
|
717
861
|
row.name,
|
|
@@ -746,13 +890,14 @@ const TOOLS = [
|
|
|
746
890
|
handler: async ({ client }, args) => {
|
|
747
891
|
const { artifact_id } = args;
|
|
748
892
|
const artifact = await only(client.from('artifacts')
|
|
749
|
-
.select('id, task_id, type, format, title, content, storage_path, created_at')
|
|
893
|
+
.select('id, task_id, type, format, title, content, storage_path, revision, created_at')
|
|
750
894
|
.eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
|
|
751
895
|
return [
|
|
752
896
|
`${artifact.title ?? '(untitled)'}`,
|
|
753
897
|
`id ${artifact.id}`,
|
|
754
898
|
`work item ${artifact.task_id}`,
|
|
755
899
|
`type ${artifact.type} (${artifact.format})`,
|
|
900
|
+
`revision ${artifact.revision}`,
|
|
756
901
|
'',
|
|
757
902
|
/* A stored file and an empty body are different facts and are said
|
|
758
903
|
differently. Returning '' for a PNG would read as an artifact with
|
|
@@ -1053,15 +1198,81 @@ const TOOLS = [
|
|
|
1053
1198
|
return `Updated artifact ${artifact.title ?? artifact.id}.`;
|
|
1054
1199
|
},
|
|
1055
1200
|
},
|
|
1201
|
+
// ── Say something now ────────────────────────────────────────────────────
|
|
1202
|
+
//
|
|
1203
|
+
// ═══ LEVEL 2 ONLY, AND THE DATABASE IS WHAT ENFORCES IT. ═══ `panel3_say`
|
|
1204
|
+
// refuses any run that is not the one `panel3_cards.conversation_run_id`
|
|
1205
|
+
// names, and only a level 2 run is ever appointed, so a worker cannot reach
|
|
1206
|
+
// the person through this however the list below is edited. The tool is absent
|
|
1207
|
+
// at 1 and 3 as well, so an agent that has no business calling it is not
|
|
1208
|
+
// offered it and then refused.
|
|
1209
|
+
//
|
|
1210
|
+
// ═══ WHY IT EXISTS AT ALL, GIVEN `panel3_answer`. ═══ Nothing reached the
|
|
1211
|
+
// person until the owner's process exited, and a turn that ends by asking
|
|
1212
|
+
// writes no message at all. Measured 2026-08-24: "Write a plan for this item"
|
|
1213
|
+
// was silent for two and a half minutes and then said a question. An
|
|
1214
|
+
// acknowledgement cannot be an exit, because an exit is the end of the turn.
|
|
1215
|
+
{
|
|
1216
|
+
name: 'say',
|
|
1217
|
+
levels: [2],
|
|
1218
|
+
description: 'Say something to the person NOW, without stopping. Use it the moment you know what you are '
|
|
1219
|
+
+ 'going to do, before you start doing it: they have seen only their own message, and until '
|
|
1220
|
+
+ 'you say something the card shows them a pulse. One or two sentences in their words, saying '
|
|
1221
|
+
+ 'what you are about to do and what they will get. Use it again if what you are doing changes '
|
|
1222
|
+
+ 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
|
|
1223
|
+
+ 'and does not replace the reply or the question you finish with: keep working after it. Do '
|
|
1224
|
+
+ 'not use it for a running commentary, and do not use it to ask anything.',
|
|
1225
|
+
input: {
|
|
1226
|
+
message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
|
|
1227
|
+
+ 'the plan and bring it to you."'),
|
|
1228
|
+
},
|
|
1229
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1230
|
+
const { message } = args;
|
|
1231
|
+
/* ═══ THE SAME BOUND AS A QUESTION, BECAUSE IT IS THE SAME CARD. ═══
|
|
1232
|
+
Issue 7. Without this the bound on `ask_question` is theatre: an owner
|
|
1233
|
+
refused on a question can put the same paragraph in front of the same
|
|
1234
|
+
person through this door, and it does not even end its turn to do it. */
|
|
1235
|
+
if (message.length > PERSON_READS_LIMIT) {
|
|
1236
|
+
throw new Error(`NOTHING WAS WRITTEN: that is ${message.length} characters and they read it on a card `
|
|
1237
|
+
+ `three inches wide. The limit is ${PERSON_READS_LIMIT}. One or two sentences saying `
|
|
1238
|
+
+ 'what you are about to do; what you found goes in the artifact or your report.');
|
|
1239
|
+
}
|
|
1240
|
+
const { data, error } = await client.rpc('panel3_say', {
|
|
1241
|
+
p_run_id: runId,
|
|
1242
|
+
p_body: message,
|
|
1243
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1244
|
+
});
|
|
1245
|
+
if (error)
|
|
1246
|
+
throw new Error(`could not say that on this card: ${error.message}`);
|
|
1247
|
+
/* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
|
|
1248
|
+
SPOKE. ═══ Three reasons collapse to one sentence because the caller
|
|
1249
|
+
cannot tell them apart and all three mean the same thing to it: this
|
|
1250
|
+
conversation is not yours to speak on any more. An owner that thinks it
|
|
1251
|
+
acknowledged and did not is the failure this tool exists to remove,
|
|
1252
|
+
arrived at from the other side. */
|
|
1253
|
+
if (data === null) {
|
|
1254
|
+
throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
|
|
1255
|
+
+ 'or somebody else owns it now. Do not tell anybody you said anything.');
|
|
1256
|
+
}
|
|
1257
|
+
return 'Said. They can read it now. Carry on.';
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1056
1260
|
// ── Report ───────────────────────────────────────────────────────────────
|
|
1057
1261
|
{
|
|
1058
1262
|
name: 'report_activity',
|
|
1059
1263
|
levels: ALL,
|
|
1060
1264
|
description: 'Say what you are doing right now, in one short line, in the words a person watching would use. '
|
|
1265
|
+
+ 'The line says what you are FINDING OUT or what you are CHANGING, not the steps you are '
|
|
1266
|
+
+ 'running to do it: "Checking for clock use, CI config, and running the test suite once" tells '
|
|
1267
|
+
+ 'a reader nothing, where "Working out whether the code can tell what happened this week" tells '
|
|
1268
|
+
+ 'them what turns on it. You do not need to have seen anybody\'s message to write one. '
|
|
1061
1269
|
+ 'It replaces whatever you last said. Call it when you start something that will take a while, '
|
|
1062
1270
|
+ 'so a run that is working and a run that is wedged do not look the same.',
|
|
1063
|
-
input: {
|
|
1064
|
-
|
|
1271
|
+
input: {
|
|
1272
|
+
activity: z.string().min(1).describe('One line, present tense, naming what you are finding out or changing. "Working out whether '
|
|
1273
|
+
+ 'the code can tell what happened this week".'),
|
|
1274
|
+
},
|
|
1275
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1065
1276
|
const { activity } = args;
|
|
1066
1277
|
/* ═══ THROUGH AN RPC, SO THE LINE AND ITS TIME ARE ONE STATEMENT ON ONE
|
|
1067
1278
|
CLOCK. ═══ This was a plain table update. The card is now one list in
|
|
@@ -1070,7 +1281,11 @@ const TOOLS = [
|
|
|
1070
1281
|
Supabase's. `panel3_report_activity` carries the same liveness
|
|
1071
1282
|
predicate the update did, in the same statement, so `whileRunning` is
|
|
1072
1283
|
still what reads the refusal. */
|
|
1073
|
-
await whileRunning(client.rpc('panel3_report_activity', {
|
|
1284
|
+
await whileRunning(client.rpc('panel3_report_activity', {
|
|
1285
|
+
p_run_id: runId,
|
|
1286
|
+
p_activity: activity,
|
|
1287
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1288
|
+
}), runId, 'record what you are doing on');
|
|
1074
1289
|
return 'Noted.';
|
|
1075
1290
|
},
|
|
1076
1291
|
},
|
|
@@ -1082,12 +1297,14 @@ const TOOLS = [
|
|
|
1082
1297
|
+ 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
|
|
1083
1298
|
+ 'you go, not only at the end.',
|
|
1084
1299
|
input: { report: z.string().min(1) },
|
|
1085
|
-
handler: async ({ client, runId }, args) => {
|
|
1300
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1086
1301
|
const { report } = args;
|
|
1087
|
-
await whileRunning(
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1302
|
+
await whileRunning(processToken === undefined
|
|
1303
|
+
? client.from('panel3_runs').update({ report }).eq('id', runId)
|
|
1304
|
+
.in('state', STILL_WRITING).is('ended_at', null).select('id')
|
|
1305
|
+
: client.rpc('panel3_write_report', {
|
|
1306
|
+
p_run_id: runId, p_report: report, p_process_token: processToken,
|
|
1307
|
+
}), runId, 'write the report on');
|
|
1091
1308
|
return 'Report written.';
|
|
1092
1309
|
},
|
|
1093
1310
|
},
|
|
@@ -1134,6 +1351,9 @@ const TOOLS = [
|
|
|
1134
1351
|
question carried them, and absent rather than printed as empty. */
|
|
1135
1352
|
...(a.category ? [` ${a.category}`] : []),
|
|
1136
1353
|
...(a.context ? [` why ${a.context}`] : []),
|
|
1354
|
+
...(a.related_artifact_id ? [
|
|
1355
|
+
` artifact ${a.related_artifact_id} presented revision ${a.related_artifact_revision ?? 'not recorded'}`,
|
|
1356
|
+
] : []),
|
|
1137
1357
|
` Q ${a.question ?? '(this question could not be read)'}`,
|
|
1138
1358
|
...(a.options ?? []).map((option) => ` - ${option}`),
|
|
1139
1359
|
` A ${a.answer ?? 'not answered yet'}`,
|
|
@@ -1147,11 +1367,28 @@ const TOOLS = [
|
|
|
1147
1367
|
decision. Level 3 still escalates, and the tool is absent there rather
|
|
1148
1368
|
than present and refusing. */
|
|
1149
1369
|
levels: [1, 2],
|
|
1150
|
-
description: 'Put a question to the person, and stop.
|
|
1151
|
-
+ '
|
|
1152
|
-
+ '
|
|
1153
|
-
+ '
|
|
1154
|
-
+ '
|
|
1370
|
+
description: 'Put a question to the person, and stop. A Level 1 launcher uses this only for a destination '
|
|
1371
|
+
+ 'or codebase it truly cannot choose; it launches the owner for every work or product '
|
|
1372
|
+
+ 'decision. A Level 2 owner MUST use this tool when work cannot continue '
|
|
1373
|
+
+ 'until the person answers, including when they must choose between options; an ordinary '
|
|
1374
|
+
+ 'reply is not a question path and completing the card with a question is wrong. Ask only '
|
|
1375
|
+
+ 'what you genuinely cannot settle from the '
|
|
1376
|
+
+ 'record, by dispatching someone to find out, or by answering it yourself. Use the shortest '
|
|
1377
|
+
+ 'question and context the person can answer safely. Ask ONE thing, in the words they would '
|
|
1378
|
+
+ 'use, and say what you will do with each answer. If you are putting on a question that came '
|
|
1379
|
+
+ 'up from work you sent out, name it in question_id and write it as they need to read it: they '
|
|
1380
|
+
+ 'have not seen any of it. For an artifact approval, name its Work Item and live artifact, '
|
|
1381
|
+
+ 'offer approve or request changes, and present one revision at a time. THE QUESTION LINE '
|
|
1382
|
+
+ 'ITSELF SAYS WHAT THEY ARE APPROVING, in plain words, never further down in the context: '
|
|
1383
|
+
+ 'approving a document that describes work is not the same as approving the work, and the '
|
|
1384
|
+
+ 'person cannot tell those apart from the title of an artifact. Write it the way these are '
|
|
1385
|
+
+ 'written: "Plan is written. Please read it and approve or ask for changes", "Ok to write a '
|
|
1386
|
+
+ 'spec.md and attach it to this work item?", "The work item has a detailed plan. Ok to start '
|
|
1387
|
+
+ 'building?". A question that names the artifact and leaves the reader to work out what '
|
|
1388
|
+
+ 'approving starts is the one this rule exists to stop. '
|
|
1389
|
+
+ 'After this call, stop immediately. '
|
|
1390
|
+
+ 'Do not repeat the question in '
|
|
1391
|
+
+ 'an ordinary reply or add a message saying that you asked it.',
|
|
1155
1392
|
input: {
|
|
1156
1393
|
question: z.string().min(1),
|
|
1157
1394
|
...DECIDING,
|
|
@@ -1162,8 +1399,11 @@ const TOOLS = [
|
|
|
1162
1399
|
+ 'question is not about a work item — whether a second item should exist, which item a '
|
|
1163
1400
|
+ 'request means, or anything about the conversation itself — rather than picking the '
|
|
1164
1401
|
+ 'nearest one.'),
|
|
1402
|
+
related_artifact_id: z.string().optional().describe('The live Analysis or Plan artifact on work_item_id that this decision presents for approval '
|
|
1403
|
+
+ 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
|
|
1404
|
+
+ 'its current process token is required. Leave it out for every ordinary question.'),
|
|
1165
1405
|
},
|
|
1166
|
-
handler: async (
|
|
1406
|
+
handler: async (caller, args) => asked(caller, args, true),
|
|
1167
1407
|
},
|
|
1168
1408
|
{
|
|
1169
1409
|
name: 'escalate',
|
|
@@ -1176,7 +1416,7 @@ const TOOLS = [
|
|
|
1176
1416
|
+ 'either, this is how it goes further up: name it in question_id and write it in your own '
|
|
1177
1417
|
+ 'words, with what you already know added.',
|
|
1178
1418
|
input: { question: z.string().min(1), ...DECIDING, question_id: PASSING_ON },
|
|
1179
|
-
handler: async (
|
|
1419
|
+
handler: async (caller, args) => asked(caller, args, false),
|
|
1180
1420
|
},
|
|
1181
1421
|
{
|
|
1182
1422
|
name: 'answer_escalation',
|
|
@@ -1189,7 +1429,7 @@ const TOOLS = [
|
|
|
1189
1429
|
+ 'has to stop and deal with. The one who asked is started again with your answer, so write it '
|
|
1190
1430
|
+ 'to them, plainly, and say what to do rather than what you would have done.',
|
|
1191
1431
|
input: { question_id: z.string(), answer: z.string().min(1) },
|
|
1192
|
-
handler: async ({ client, runId }, args) => {
|
|
1432
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1193
1433
|
const { question_id, answer } = args;
|
|
1194
1434
|
/* ═══ AN AGENT ANSWERS IN ITS OWN WORDS, WHATEVER SHAPE THE QUESTION WAS
|
|
1195
1435
|
ASKED IN. ═══ No selection and the answer as the note, which is the
|
|
@@ -1202,6 +1442,7 @@ const TOOLS = [
|
|
|
1202
1442
|
p_selected_options: [],
|
|
1203
1443
|
p_answer_note: answer,
|
|
1204
1444
|
p_by_run_id: runId,
|
|
1445
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1205
1446
|
});
|
|
1206
1447
|
if (error)
|
|
1207
1448
|
throw new Error(`could not answer question ${question_id}: ${error.message}`);
|
|
@@ -1245,7 +1486,8 @@ const TOOLS = [
|
|
|
1245
1486
|
+ 'the same files, or send them one at a time. Do not wait for it: what it writes goes on as it '
|
|
1246
1487
|
+ 'wrote it, and nobody edits it on the way.',
|
|
1247
1488
|
input: {
|
|
1248
|
-
codebase_id: z.string().uuid().describe('The id of the registered project codebase this work belongs to.'
|
|
1489
|
+
codebase_id: z.string().uuid().optional().describe('The id of the registered project codebase this work belongs to. A launcher may omit this '
|
|
1490
|
+
+ 'for record-only work; an owner dispatching a worker must provide it.'),
|
|
1249
1491
|
responsibility: z.string().min(1).describe('What this agent owns, in one or two sentences, complete enough to act on with no other '
|
|
1250
1492
|
+ 'context: what to find out or change, and in which part of the codebase.'),
|
|
1251
1493
|
boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
|
|
@@ -1253,15 +1495,21 @@ const TOOLS = [
|
|
|
1253
1495
|
},
|
|
1254
1496
|
handler: async (caller, args) => {
|
|
1255
1497
|
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.');
|
|
1498
|
+
if (caller.level === 2 && !codebase_id) {
|
|
1499
|
+
throw new Error('A worker must be attached to a registered project codebase.');
|
|
1260
1500
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1501
|
+
let codebase = null;
|
|
1502
|
+
if (codebase_id) {
|
|
1503
|
+
const cards = await rows(caller.client.from('panel3_cards').select('project_id').eq('id', caller.cardId), 'read', `the project for card ${caller.cardId}`);
|
|
1504
|
+
const projectId = cards[0]?.project_id;
|
|
1505
|
+
if (!projectId) {
|
|
1506
|
+
throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
|
|
1507
|
+
}
|
|
1508
|
+
codebase = (await listCodebases(caller.client, projectId))
|
|
1509
|
+
.find((candidate) => candidate.id === codebase_id) ?? null;
|
|
1510
|
+
if (!codebase) {
|
|
1511
|
+
throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
|
|
1512
|
+
}
|
|
1265
1513
|
}
|
|
1266
1514
|
/* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
|
|
1267
1515
|
the words in the brief; `panel3_dispatch` decides the level on the row,
|
|
@@ -1277,11 +1525,9 @@ const TOOLS = [
|
|
|
1277
1525
|
asking. See `workBrief`'s own doc for why the two are different things
|
|
1278
1526
|
carried the same way. */
|
|
1279
1527
|
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);
|
|
1528
|
+
const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
|
|
1529
|
+
id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
|
|
1530
|
+
}), codebase, caller.processToken);
|
|
1285
1531
|
/* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
|
|
1286
1532
|
KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
|
|
1287
1533
|
because it is registered once for both levels that hold the tool; this
|
|
@@ -1293,8 +1539,8 @@ const TOOLS = [
|
|
|
1293
1539
|
return (`Started, and it is working now. Its id is ${runId}, and list_child_runs will say how it is `
|
|
1294
1540
|
+ 'getting on. '
|
|
1295
1541
|
+ (childLevel === 2
|
|
1296
|
-
? '
|
|
1297
|
-
+ '
|
|
1542
|
+
? 'That owner now has the conversation. Your launcher work is finished: write no reply, '
|
|
1543
|
+
+ 'ask nothing else, and exit immediately.'
|
|
1298
1544
|
: 'What it writes comes back to you and to nobody else. When everybody you have sent has '
|
|
1299
1545
|
+ 'finished you are started again with what each of them wrote, and the one answer that '
|
|
1300
1546
|
+ 'covers them is yours to write, so do not wait here for it.'));
|
|
@@ -1314,10 +1560,14 @@ const TOOLS = [
|
|
|
1314
1560
|
+ 'worth finishing. It says how many agents it stopped, and it stops nothing rather than '
|
|
1315
1561
|
+ 'reaching outside what is yours. To stop your own work, just finish.',
|
|
1316
1562
|
input: { run_id: z.string() },
|
|
1317
|
-
handler: async ({ client, runId }, args) => {
|
|
1563
|
+
handler: async ({ client, runId, processToken }, args) => {
|
|
1318
1564
|
const { run_id } = args;
|
|
1319
1565
|
const { data, error } = await client
|
|
1320
|
-
.rpc('panel3_stop_run', {
|
|
1566
|
+
.rpc('panel3_stop_run', {
|
|
1567
|
+
p_by_run_id: runId,
|
|
1568
|
+
p_run_id: run_id,
|
|
1569
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1570
|
+
});
|
|
1321
1571
|
if (error)
|
|
1322
1572
|
throw new Error(`could not stop run ${run_id}: ${error.message}`);
|
|
1323
1573
|
const stopped = data;
|
|
@@ -1384,8 +1634,9 @@ async function receipt({ client, runId, cardId }, kind, refId, label) {
|
|
|
1384
1634
|
/** The names one level is served, in the order they are registered. Exported for
|
|
1385
1635
|
* the same reason `cs3 show` exists: a rule nobody can print is a rule nobody
|
|
1386
1636
|
* can check. */
|
|
1387
|
-
export function toolNamesForLevel(level) {
|
|
1388
|
-
return TOOLS.filter((t) => t.levels.includes(level)
|
|
1637
|
+
export function toolNamesForLevel(level, isOwner = false) {
|
|
1638
|
+
return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
|
|
1639
|
+
.map((t) => t.name);
|
|
1389
1640
|
}
|
|
1390
1641
|
/**
|
|
1391
1642
|
* One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
|
|
@@ -1396,10 +1647,21 @@ export function toolNamesForLevel(level) {
|
|
|
1396
1647
|
* standing up the MCP transport `buildServer` wraps it in.
|
|
1397
1648
|
*/
|
|
1398
1649
|
export function toolHandler(name) {
|
|
1650
|
+
return toolNamed(name).handler;
|
|
1651
|
+
}
|
|
1652
|
+
/** One tool's registered input schema and description, by name. Exported for
|
|
1653
|
+
* what a handler test cannot see: whether a bound lives in Zod, where it would
|
|
1654
|
+
* reject before `buildServer`'s try/catch and reach every level sharing the
|
|
1655
|
+
* schema, or in the handler, where it comes back as a correctable failure. */
|
|
1656
|
+
export function toolShape(name) {
|
|
1657
|
+
const { description, input } = toolNamed(name);
|
|
1658
|
+
return { description, input };
|
|
1659
|
+
}
|
|
1660
|
+
function toolNamed(name) {
|
|
1399
1661
|
const tool = TOOLS.find((t) => t.name === name);
|
|
1400
1662
|
if (!tool)
|
|
1401
1663
|
throw new Error(`no tool named ${name}`);
|
|
1402
|
-
return tool
|
|
1664
|
+
return tool;
|
|
1403
1665
|
}
|
|
1404
1666
|
// ---------------------------------------------------------------------------
|
|
1405
1667
|
/**
|
|
@@ -1416,7 +1678,7 @@ function buildServer(caller) {
|
|
|
1416
1678
|
+ 'hold rather than one that is missing.',
|
|
1417
1679
|
});
|
|
1418
1680
|
for (const tool of TOOLS) {
|
|
1419
|
-
if (!tool.levels.includes(caller.level))
|
|
1681
|
+
if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
|
|
1420
1682
|
continue;
|
|
1421
1683
|
server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
|
|
1422
1684
|
try {
|
|
@@ -1426,7 +1688,7 @@ function buildServer(caller) {
|
|
|
1426
1688
|
the risky ones. This is one of the two chokepoints the rule rests
|
|
1427
1689
|
on; `writeAnswer` in run.ts is the other. It is identity for the
|
|
1428
1690
|
runs that read no credential, which is nearly all of them. */
|
|
1429
|
-
const safe = redactArgs(caller
|
|
1691
|
+
const safe = redactArgs(secretScope(caller), args);
|
|
1430
1692
|
return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
|
|
1431
1693
|
}
|
|
1432
1694
|
catch (error) {
|
|
@@ -1447,6 +1709,18 @@ function buildServer(caller) {
|
|
|
1447
1709
|
}
|
|
1448
1710
|
return server;
|
|
1449
1711
|
}
|
|
1712
|
+
export const toolsUrl = (port, runId, processToken) => `http://127.0.0.1:${port}/mcp/${runId}${processToken ? `/${processToken}` : ''}`;
|
|
1713
|
+
/** The per-request fence used by already-open MCP sessions. */
|
|
1714
|
+
export async function processActivationIsCurrent(client, runId, processToken) {
|
|
1715
|
+
const current = await rows(client.from('panel3_runs')
|
|
1716
|
+
.select('process_token, card:panel3_cards!panel3_runs_card_id_fkey!inner(conversation_run_id)')
|
|
1717
|
+
.eq('id', runId)
|
|
1718
|
+
.eq('process_token', processToken)
|
|
1719
|
+
.in('state', ['running', 'asked'])
|
|
1720
|
+
.is('ended_at', null)
|
|
1721
|
+
.eq('card.conversation_run_id', runId), 'verify', `the current process activation for run ${runId}`);
|
|
1722
|
+
return current.length > 0;
|
|
1723
|
+
}
|
|
1450
1724
|
/**
|
|
1451
1725
|
* Start the v3 tools server on loopback, for the signed-in user this client
|
|
1452
1726
|
* carries.
|
|
@@ -1477,6 +1751,7 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1477
1751
|
/** Which run each open session belongs to, so a connection cannot change run
|
|
1478
1752
|
* partway through. Bound at `initialize`, cleared with the session. */
|
|
1479
1753
|
const sessionRuns = new Map();
|
|
1754
|
+
const sessionTokens = new Map();
|
|
1480
1755
|
/** Known once the socket is bound, which is before any request can arrive. */
|
|
1481
1756
|
let port = 0;
|
|
1482
1757
|
const fail = (res, status, why) => {
|
|
@@ -1484,7 +1759,11 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1484
1759
|
};
|
|
1485
1760
|
async function handle(req, res) {
|
|
1486
1761
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
1487
|
-
const
|
|
1762
|
+
const parts = url.pathname.startsWith('/mcp/')
|
|
1763
|
+
? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
|
|
1764
|
+
: [];
|
|
1765
|
+
const runId = parts[0] ?? null;
|
|
1766
|
+
const processToken = parts[1];
|
|
1488
1767
|
if (!runId) {
|
|
1489
1768
|
fail(res, 404, 'Not found. The v3 tools server serves /mcp/<run-id> and nothing else.');
|
|
1490
1769
|
return;
|
|
@@ -1501,6 +1780,17 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1501
1780
|
fail(res, 400, 'Bad Request: this session belongs to a different run.');
|
|
1502
1781
|
return;
|
|
1503
1782
|
}
|
|
1783
|
+
const expected = sessionTokens.get(sessionId);
|
|
1784
|
+
if (expected !== processToken) {
|
|
1785
|
+
fail(res, 400, 'Bad Request: this session belongs to a different process activation.');
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (expected !== undefined) {
|
|
1789
|
+
if (!(await processActivationIsCurrent(client, runId, expected))) {
|
|
1790
|
+
fail(res, 403, `Run ${runId} belongs to a newer process activation.`);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1504
1794
|
await existing.handleRequest(req, res);
|
|
1505
1795
|
return;
|
|
1506
1796
|
}
|
|
@@ -1517,7 +1807,9 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1517
1807
|
signed-in user's RLS, so a run belonging to somebody else is not found
|
|
1518
1808
|
rather than refused — which is the same answer, told without confirming
|
|
1519
1809
|
the row exists. */
|
|
1520
|
-
const runs = await rows(client.from('panel3_runs')
|
|
1810
|
+
const runs = await rows(client.from('panel3_runs')
|
|
1811
|
+
.select('id, card_id, level, state, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
1812
|
+
.eq('id', runId), 'read', `run ${runId}`);
|
|
1521
1813
|
const run = runs[0];
|
|
1522
1814
|
if (!run) {
|
|
1523
1815
|
fail(res, 404, `Not found: there is no run ${runId}.`);
|
|
@@ -1535,11 +1827,21 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1535
1827
|
if (run.level !== 1 && run.level !== 2 && run.level !== 3) {
|
|
1536
1828
|
throw new Error(`run ${runId} has level ${run.level}, which is not a level this product has`);
|
|
1537
1829
|
}
|
|
1830
|
+
const isOwner = run.card?.conversation_run_id === run.id;
|
|
1831
|
+
if (isOwner && (!processToken || processToken !== run.process_token)) {
|
|
1832
|
+
fail(res, 403, `Run ${runId} belongs to a different process activation.`);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (!isOwner && processToken !== undefined) {
|
|
1836
|
+
fail(res, 403, `Run ${runId} has no process activation token.`);
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1538
1839
|
const transport = new StreamableHTTPServerTransport({
|
|
1539
1840
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
1540
1841
|
onsessioninitialized: (sid) => {
|
|
1541
1842
|
transports.set(sid, transport);
|
|
1542
1843
|
sessionRuns.set(sid, runId);
|
|
1844
|
+
sessionTokens.set(sid, processToken);
|
|
1543
1845
|
},
|
|
1544
1846
|
// The same DNS-rebinding guard v2's server carries: a page on a public
|
|
1545
1847
|
// domain that re-resolves to 127.0.0.1 cannot drive this server from a
|
|
@@ -1552,10 +1854,12 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1552
1854
|
if (sid) {
|
|
1553
1855
|
transports.delete(sid);
|
|
1554
1856
|
sessionRuns.delete(sid);
|
|
1857
|
+
sessionTokens.delete(sid);
|
|
1555
1858
|
}
|
|
1556
1859
|
};
|
|
1557
1860
|
const server = buildServer({
|
|
1558
|
-
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
1861
|
+
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
1862
|
+
processToken, isOwner, dispatch,
|
|
1559
1863
|
});
|
|
1560
1864
|
await server.connect(transport);
|
|
1561
1865
|
await transport.handleRequest(req, res, body);
|
|
@@ -1581,11 +1885,12 @@ export async function startToolsServer(client, dispatch) {
|
|
|
1581
1885
|
});
|
|
1582
1886
|
port = http.address().port;
|
|
1583
1887
|
return {
|
|
1584
|
-
urlFor: (runId) =>
|
|
1888
|
+
urlFor: (runId, processToken) => toolsUrl(port, runId, processToken),
|
|
1585
1889
|
async close() {
|
|
1586
1890
|
await Promise.all([...transports.values()].map((t) => t.close().catch(() => { })));
|
|
1587
1891
|
transports.clear();
|
|
1588
1892
|
sessionRuns.clear();
|
|
1893
|
+
sessionTokens.clear();
|
|
1589
1894
|
await new Promise((resolve, reject) => {
|
|
1590
1895
|
http.close((err) => (err ? reject(err) : resolve()));
|
|
1591
1896
|
});
|