@ctrl-spc/cs 0.7.8 → 0.7.10
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 +3 -6
- package/dist/mcp.js +40 -23
- package/dist/panel3/prompt.js +17 -0
- package/dist/panel3/run.js +32 -9
- package/dist/panel3/show.js +5 -1
- package/dist/panel3/spawn.js +24 -13
- package/dist/panel3/tools.js +27 -14
- package/dist/workflow-tool-mentions.js +37 -2
- package/dist/workflows.js +4 -4
- package/package.json +1 -1
package/dist/codex-home.js
CHANGED
|
@@ -215,12 +215,6 @@ subagentsEnabled = true, persistentPanelOwner = false) {
|
|
|
215
215
|
const lines = [
|
|
216
216
|
'# Written by CTRL+SPC for ONE codex run. Not the user\'s config; never read',
|
|
217
217
|
'# by anything but the worker this was written for.',
|
|
218
|
-
/* The Windows Desktop bundle can lag the account's server-selected default
|
|
219
|
-
model. When that default requires a newer client, every headless worker
|
|
220
|
-
exits before its first action. Pin the current broadly-supported model
|
|
221
|
-
only for that bundled runtime; other platforms keep Codex's own model
|
|
222
|
-
selection. */
|
|
223
|
-
...(runtimePlatform === 'win32' ? ['model = "gpt-5.5"', ''] : []),
|
|
224
218
|
...(persistentPanelOwner ? [
|
|
225
219
|
'sandbox_mode = "workspace-write"',
|
|
226
220
|
'',
|
|
@@ -258,6 +252,9 @@ subagentsEnabled = true, persistentPanelOwner = false) {
|
|
|
258
252
|
if (server) {
|
|
259
253
|
const url = mcpUrl(server, runTodoId);
|
|
260
254
|
lines.push('[mcp_servers.ctrl-spc]', `url = ${JSON.stringify(url)}`,
|
|
255
|
+
// A missing product tool connection must fail startup, not leave a run
|
|
256
|
+
// producing answers without its dispatch and completion tools.
|
|
257
|
+
'required = true',
|
|
261
258
|
/* See the block comment above: without this every tool call is cancelled
|
|
262
259
|
client-side before it leaves the worker. */
|
|
263
260
|
'default_tools_approval_mode = "approve"', '');
|
package/dist/mcp.js
CHANGED
|
@@ -14,8 +14,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
14
14
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
15
15
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
16
16
|
import { z } from 'zod';
|
|
17
|
-
import { workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING,
|
|
18
|
-
import { workflowToolForName } from './workflow-tool-mentions.js';
|
|
17
|
+
import { workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING, authorToolMentions } from './workflows.js';
|
|
18
|
+
import { workflowToolForName, validateApprovalAction } from './workflow-tool-mentions.js';
|
|
19
19
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
20
20
|
import { agentPath } from './agents.js';
|
|
21
21
|
import { mcpToken, readMcpToken, readSession } from './config.js';
|
|
@@ -7802,24 +7802,19 @@ export async function readWorkflowLibraryHandler(client, args, userId) {
|
|
|
7802
7802
|
// workflow ids actually present rather than as PostgREST embeds, the same
|
|
7803
7803
|
// two-step `listClientsHandler` uses.
|
|
7804
7804
|
const workflowIds = workflows.map((workflow) => workflow.id);
|
|
7805
|
-
const links =
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
.select('workflow_id, stage_id, to_stage_id, condition, position')
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
? ((await must(client
|
|
7819
|
-
.from('cliv2_workflow_branches')
|
|
7820
|
-
.select('workflow_id, when_condition, runs_workflow_id, position')
|
|
7821
|
-
.in('workflow_id', workflowIds))) ?? [])
|
|
7822
|
-
: [];
|
|
7805
|
+
const links = [];
|
|
7806
|
+
const exits = [];
|
|
7807
|
+
const branches = [];
|
|
7808
|
+
// Like artifact counts above, bound GET filters so a large library remains readable.
|
|
7809
|
+
for (let offset = 0; offset < workflowIds.length; offset += COUNT_BATCH) {
|
|
7810
|
+
const ids = workflowIds.slice(offset, offset + COUNT_BATCH);
|
|
7811
|
+
links.push(...(await must(client.from('cliv2_workflow_stages_in_workflow')
|
|
7812
|
+
.select('workflow_id, stage_id, position').in('workflow_id', ids))) ?? []);
|
|
7813
|
+
exits.push(...(await must(client.from('cliv2_workflow_stage_exits')
|
|
7814
|
+
.select('workflow_id, stage_id, to_stage_id, condition, position').in('workflow_id', ids))) ?? []);
|
|
7815
|
+
branches.push(...(await must(client.from('cliv2_workflow_branches')
|
|
7816
|
+
.select('workflow_id, when_condition, runs_workflow_id, position').in('workflow_id', ids))) ?? []);
|
|
7817
|
+
}
|
|
7823
7818
|
const orgIds = [
|
|
7824
7819
|
...new Set([...stages.map((s) => s.org_id), ...workflows.map((w) => w.org_id)]),
|
|
7825
7820
|
];
|
|
@@ -7952,7 +7947,7 @@ export async function buildWorkflowHandler(client, args, fromAgent) {
|
|
|
7952
7947
|
prose.push([`stages[${index}].description`, stage.description]);
|
|
7953
7948
|
}
|
|
7954
7949
|
if (typeof stage.body === 'string') {
|
|
7955
|
-
|
|
7950
|
+
stage.body = authorToolMentions(stage.body);
|
|
7956
7951
|
prose.push([`stages[${index}].body`, stage.body]);
|
|
7957
7952
|
}
|
|
7958
7953
|
});
|
|
@@ -8050,7 +8045,7 @@ export async function editWorkflowHandler(client, args, fromAgent) {
|
|
|
8050
8045
|
prose.push([`stages[${index}].description`, stage.description]);
|
|
8051
8046
|
}
|
|
8052
8047
|
if (typeof stage.body === 'string') {
|
|
8053
|
-
|
|
8048
|
+
stage.body = authorToolMentions(stage.body);
|
|
8054
8049
|
prose.push([`stages[${index}].body`, stage.body]);
|
|
8055
8050
|
}
|
|
8056
8051
|
});
|
|
@@ -8693,6 +8688,7 @@ export const TOOL_NAMES = [
|
|
|
8693
8688
|
// 18c Slice 1: the run's own activity, unasked, on the request's card.
|
|
8694
8689
|
'report_activity',
|
|
8695
8690
|
'ask_question',
|
|
8691
|
+
'require_approval',
|
|
8696
8692
|
'present_wireframes',
|
|
8697
8693
|
'present_mocks',
|
|
8698
8694
|
'resolve_feedback',
|
|
@@ -8801,6 +8797,7 @@ const SERVER_INSTRUCTIONS = `You are connected to CTRL+SPC, where this user's wo
|
|
|
8801
8797
|
- The PLAN for a work item: create_stage and create_step to lay it out, then update_step to record progress — in_progress when you start, a rewritten next_action as it changes, done when finished. Never create a second step to report on the first. These need a WORK ITEM and an approved scope: they are its committed plan, not a log of what you did. A panel request with no work item has no plan to write, so use report_activity and, if the work deserves an item of its own, ask for one. Never attach it to an unrelated work item to get past a refusal.
|
|
8802
8798
|
- Anything learned, decided or discovered: record_user_input, add_comment, record_context_exploration, propose_project_context. The transcript is never the record.
|
|
8803
8799
|
- Following a process: read_workflow_library, then start_workflow on the work item so its stages appear where the user can watch them.
|
|
8800
|
+
- Creating a reusable workflow: build_workflow. Describe the requested process in its stages; do not execute those stages or create a plan artifact about creating the workflow. Ask only the clarification needed to author it, through ask_question. A work item used to hold this conversation does not turn workflow authoring into code implementation. ${WORKFLOW_AUTHORING_TEACHING}
|
|
8804
8801
|
- A method the team has already written down: list_skills, then get_skill. A skill is a document TO FOLLOW for the rest of the work, not reference material to summarise. Look before improvising your own method.
|
|
8805
8802
|
- A secret the work needs: list_credentials, then get_credential. Fetch it rather than asking the user for something they have already stored, and never write a value into a file, a commit, a log or your answer.
|
|
8806
8803
|
Call list_tasks or get_task first when you need to know what the work is, and LOOK BEFORE YOU CREATE: if a work item already covers this, use it instead of adding a near-duplicate. If you are unsure whether a tool exists, look before falling back to the terminal.
|
|
@@ -10726,8 +10723,28 @@ runTodoIdSource = null) {
|
|
|
10726
10723
|
touchSession(connectionId);
|
|
10727
10724
|
return readWorkflowLibraryHandler(client, args, userId);
|
|
10728
10725
|
});
|
|
10726
|
+
server.registerTool('require_approval', {
|
|
10727
|
+
description: 'Pause for the user to approve a specific action before you perform it, including editing code, running a migration, or pushing commits. Name its exact scope in action and details; use relative paths, never absolute local paths. Stop on pending or denial; retry the identical call after the answer. Only an approved result permits that action. This tool does not execute it. ' + WORKFLOW_TOOL_TEACHING,
|
|
10728
|
+
inputSchema: {
|
|
10729
|
+
action: z.string().trim().min(1).max(240),
|
|
10730
|
+
details: z.string().trim().min(1).max(8000),
|
|
10731
|
+
workflow_tool_instance: z.string().uuid().optional(),
|
|
10732
|
+
},
|
|
10733
|
+
}, async (args) => {
|
|
10734
|
+
touchSession(connectionId);
|
|
10735
|
+
try {
|
|
10736
|
+
validateApprovalAction(args.action, args.details);
|
|
10737
|
+
const session = openSessions.get(connectionId) ?? null;
|
|
10738
|
+
await permissionForWorkflowCall(client, session, 'require_approval', args);
|
|
10739
|
+
return runGated(client, userId, session, 'require_approval', args, `${args.action} — ${args.details}`, async () => textResult({ approved: true, action: args.action, details: args.details,
|
|
10740
|
+
instruction: 'The user approved only this action and scope. You may now perform it.' }), await runTodoIdOf());
|
|
10741
|
+
}
|
|
10742
|
+
catch (error) {
|
|
10743
|
+
return errorResult(errorMessage(error));
|
|
10744
|
+
}
|
|
10745
|
+
});
|
|
10729
10746
|
server.registerTool('build_workflow', {
|
|
10730
|
-
description: 'Build a WHOLE workflow in one call in CTRL+SPC — its stages, the order they run in, the ' +
|
|
10747
|
+
description: WORKFLOW_AUTHORING_TEACHING + 'Build a WHOLE workflow in one call in CTRL+SPC — its stages, the order they run in, the ' +
|
|
10731
10748
|
'loops back between them, and the branch conditions that apply everywhere. This is the ' +
|
|
10732
10749
|
'only way to create a workflow: there is no create-stage tool, because assembling one ' +
|
|
10733
10750
|
'piece at a time is the work this removes. ' +
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -598,6 +598,12 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
|
|
|
598
598
|
'time, and before any reading that takes a while. It does not end your turn and it is not',
|
|
599
599
|
'your reply; you carry on straight afterwards.',
|
|
600
600
|
'',
|
|
601
|
+
'WORKFLOW AUTHORING',
|
|
602
|
+
'When asked to create a workflow, use create_workflow to save it; do not execute the workflow.',
|
|
603
|
+
'Ask clarifying questions with ask_question only when the missing answer changes its behavior.',
|
|
604
|
+
'Write the requested tool mentions and per-use permissions in the stage bodies using the tool schema instructions.',
|
|
605
|
+
'The saved workflow appears as a reviewable card in this conversation. Do not substitute a plan artifact or a prose-only reply.',
|
|
606
|
+
'',
|
|
601
607
|
'TRACEABILITY BEFORE A CODEBASE FILE CHANGES',
|
|
602
608
|
'A codebase-file change means ANY file change: source, config, docs, tests, migrations,',
|
|
603
609
|
'generated inputs, and assets all count. "Non-code" means no codebase file changes at all.',
|
|
@@ -1159,3 +1165,14 @@ function respawnPrompt(opening, brief, report, children) {
|
|
|
1159
1165
|
brief,
|
|
1160
1166
|
].join('\n');
|
|
1161
1167
|
}
|
|
1168
|
+
/** Selection belongs to each dispatch, not to the card or its parent's model. */
|
|
1169
|
+
export function modelChoiceRules(harness) {
|
|
1170
|
+
return [
|
|
1171
|
+
'MODEL AND EFFORT FOR EACH DISPATCH',
|
|
1172
|
+
`You run under ${harness}. Your dispatched children use the same harness.`,
|
|
1173
|
+
'The dispatch tool accepts optional model and effort strings. Honor the user\'s exact stated model and effort for the work they apply to, including downstream workers. Copy each requested value byte-for-byte into the dispatch argument: do not expand aliases, normalize spelling or case, or replace it with a canonical provider identifier. Carry those wishes and their scope verbatim in every applicable responsibility and boundary.',
|
|
1174
|
+
'When the user requests separate workers or assigns choices to separate parts, call dispatch for those parts. Doing their work yourself does not satisfy that request. If product tools are deferred, discover dispatch before starting; use the product ask_question and write_report tools for questions and completion.',
|
|
1175
|
+
'When the user has not specified a value, choose for this child\'s task using your knowledge of this harness. Pass your choice in the tool arguments; a sentence alone does not select it. Different workers may use different choices.',
|
|
1176
|
+
'Omitting a value chooses the harness default (or the machine-local model default), never the parent\'s model or effort. No product model catalog exists. Pass provider values unchanged; do not silently substitute after a rejection.',
|
|
1177
|
+
].join('\n');
|
|
1178
|
+
}
|
package/dist/panel3/run.js
CHANGED
|
@@ -748,6 +748,17 @@ async function setCardState(client, cardId, state) {
|
|
|
748
748
|
.select('id'), 'set the state of', `card ${cardId}`);
|
|
749
749
|
}
|
|
750
750
|
// ---------------------------------------------------------------------------
|
|
751
|
+
/** Each start reloads only this run's durable choices, including null defaults. */
|
|
752
|
+
export async function settingsForRun(client, runId) {
|
|
753
|
+
const rows = await returned(client.from('panel3_runs').select('model, effort, harness').eq('id', runId), 'read', `model and effort for run ${runId}`);
|
|
754
|
+
const row = rows[0];
|
|
755
|
+
if (!row || (row.harness !== null && row.harness !== 'claude' && row.harness !== 'codex')
|
|
756
|
+
|| (row.model !== null && typeof row.model !== 'string')
|
|
757
|
+
|| (row.effort !== null && typeof row.effort !== 'string')) {
|
|
758
|
+
throw new Error('The run has no readable harness, model and effort selection.');
|
|
759
|
+
}
|
|
760
|
+
return { model: row.model, effort: row.effort, harness: row.harness ?? undefined };
|
|
761
|
+
}
|
|
751
762
|
/**
|
|
752
763
|
* One card, from the turns the take handed over to the answer on its thread.
|
|
753
764
|
*
|
|
@@ -804,9 +815,11 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
804
815
|
no rules" — that is a different project than the one the person is on. So it
|
|
805
816
|
joins the three reads above inside this ending rather than beside it. */
|
|
806
817
|
let rules;
|
|
818
|
+
let settings;
|
|
807
819
|
try {
|
|
808
820
|
brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
|
|
809
821
|
rules = await standingRulesFor(client, runId);
|
|
822
|
+
settings = await settingsForRun(client, runId);
|
|
810
823
|
where = await workingDirectory(client, runId, LEVEL, false);
|
|
811
824
|
}
|
|
812
825
|
catch (error) {
|
|
@@ -823,7 +836,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
823
836
|
/* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
|
|
824
837
|
use a real one with, and the daemon's inherited cwd under a launchd login
|
|
825
838
|
item is the filesystem root. */
|
|
826
|
-
const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
|
|
839
|
+
const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd, undefined, settings);
|
|
827
840
|
out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
|
|
828
841
|
try {
|
|
829
842
|
/* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
|
|
@@ -1162,14 +1175,16 @@ async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
|
|
|
1162
1175
|
* Then the row, then the process, then the pid — constraint 8, in the only order
|
|
1163
1176
|
* that satisfies it.
|
|
1164
1177
|
*/
|
|
1165
|
-
async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken) {
|
|
1178
|
+
async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken, choice = {}) {
|
|
1166
1179
|
const { data, error } = await client.rpc('panel3_dispatch', {
|
|
1167
1180
|
p_parent_run_id: parentRunId,
|
|
1168
1181
|
p_brief: brief,
|
|
1169
1182
|
p_machine_id: machineId,
|
|
1170
1183
|
p_codebase_id: codebase?.id ?? null,
|
|
1171
1184
|
p_codebase_label: codebase?.name ?? null,
|
|
1172
|
-
|
|
1185
|
+
p_process_token: parentProcessToken ?? null,
|
|
1186
|
+
p_model: choice.model ?? null,
|
|
1187
|
+
p_effort: choice.effort ?? null,
|
|
1173
1188
|
});
|
|
1174
1189
|
if (error)
|
|
1175
1190
|
throw new Error(`could not start an agent under run ${parentRunId}: ${readableWriteError(error.message)}`);
|
|
@@ -1200,12 +1215,14 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
1200
1215
|
exists (`panel3_dispatch` wrote it above) and it carries this child's
|
|
1201
1216
|
codebase, which is what decides which codebase-scoped rules it is under. */
|
|
1202
1217
|
let rules;
|
|
1218
|
+
let settings;
|
|
1203
1219
|
try {
|
|
1204
1220
|
where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
|
|
1205
1221
|
prompt = level === 2
|
|
1206
1222
|
? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
|
|
1207
1223
|
: brief;
|
|
1208
1224
|
rules = await standingRulesFor(client, row.run_id);
|
|
1225
|
+
settings = await settingsForRun(client, row.run_id);
|
|
1209
1226
|
/* Inside this site's own try, for `standingRulesFor`'s reason: a failure to
|
|
1210
1227
|
assemble what the agent needs ends the run the way this path already ends
|
|
1211
1228
|
runs, rather than starting a process that is missing it. */
|
|
@@ -1218,7 +1235,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
1218
1235
|
}
|
|
1219
1236
|
const processToken = row.process_token ?? undefined;
|
|
1220
1237
|
const isOwner = level === 2;
|
|
1221
|
-
const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
|
|
1238
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined, settings);
|
|
1222
1239
|
if (started.pid === null) {
|
|
1223
1240
|
/* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
|
|
1224
1241
|
must never be left in quietly. The answer is already settled — nothing ran
|
|
@@ -1688,9 +1705,11 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1688
1705
|
is a `returned()` message naming tables and columns, which carries no local
|
|
1689
1706
|
path and is therefore shareable. */
|
|
1690
1707
|
let rules;
|
|
1708
|
+
let settings;
|
|
1691
1709
|
let pictures;
|
|
1692
1710
|
try {
|
|
1693
1711
|
rules = await standingRulesFor(client, runId);
|
|
1712
|
+
settings = await settingsForRun(client, runId);
|
|
1694
1713
|
/* ═══ WRITTEN AGAIN ON EVERY START, LIKE THE RULES. ═══ A resumed process is
|
|
1695
1714
|
a NEW process with a new copy of the working directory, so the files a
|
|
1696
1715
|
previous one was handed are not there any more, and the stored brief this
|
|
@@ -1709,7 +1728,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1709
1728
|
still running. See `retryPrompt`. */
|
|
1710
1729
|
withStandingRules(rules, afterPid === null
|
|
1711
1730
|
? resumePrompt(claimed.run_brief, claimed.run_report, children)
|
|
1712
|
-
: retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd);
|
|
1731
|
+
: retryPrompt(claimed.run_brief, claimed.run_report, children), where.block, [], pictures), level, tools.urlFor(runId), where.cwd, undefined, settings);
|
|
1713
1732
|
if (started.pid === null) {
|
|
1714
1733
|
/* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
|
|
1715
1734
|
must never be left in quietly. Same handling as a dispatch that could not
|
|
@@ -1816,9 +1835,11 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1816
1835
|
same way: the re-arm's claim has already happened, so a failure here ends
|
|
1817
1836
|
the run with its reason rather than leaving it claimed with no process. */
|
|
1818
1837
|
let rules;
|
|
1838
|
+
let settings;
|
|
1819
1839
|
let pictures;
|
|
1820
1840
|
try {
|
|
1821
1841
|
rules = await standingRulesFor(client, row.run_id);
|
|
1842
|
+
settings = await settingsForRun(client, row.run_id);
|
|
1822
1843
|
pictures = await picturesOnDisk(client, row.run_card_id, where, level);
|
|
1823
1844
|
}
|
|
1824
1845
|
catch (error) {
|
|
@@ -1826,7 +1847,7 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1826
1847
|
await endRun(client, level, row.run_id, row.run_card_id, why);
|
|
1827
1848
|
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
1828
1849
|
}
|
|
1829
|
-
const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd);
|
|
1850
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block, [], pictures), level, tools.urlFor(row.run_id), where.cwd, undefined, settings);
|
|
1830
1851
|
if (started.pid === null) {
|
|
1831
1852
|
const answer = await started.answered;
|
|
1832
1853
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
@@ -2064,6 +2085,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
2064
2085
|
says what the product DID with the person's answer, and the landing needs
|
|
2065
2086
|
the card's copy. Nothing in the prompt depended on it before. */
|
|
2066
2087
|
let rules;
|
|
2088
|
+
let settings;
|
|
2067
2089
|
/* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
|
|
2068
2090
|
the reason the check above gives: it writes, so it happens after the claim,
|
|
2069
2091
|
and a failure to make it is an activation that ends rather than one that
|
|
@@ -2087,6 +2109,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
2087
2109
|
try {
|
|
2088
2110
|
where = await ownerDirectory(client, candidate);
|
|
2089
2111
|
rules = await standingRulesFor(client, runId);
|
|
2112
|
+
settings = await settingsForRun(client, runId);
|
|
2090
2113
|
attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
|
|
2091
2114
|
.filter((line) => !line.startsWith('codebase ')));
|
|
2092
2115
|
/* ═══ THE OWNER'S OWN ACTIVATION, WHICH IS WHERE MOST PICTURES ARRIVE. ═══
|
|
@@ -2119,7 +2142,7 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
2119
2142
|
are not the same event and `prompt.ts` exists to stop an agent being
|
|
2120
2143
|
told an untrue reason for its own restart. */
|
|
2121
2144
|
afterPid !== null, landing);
|
|
2122
|
-
const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
|
|
2145
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block, attached, pictures), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) }, settings);
|
|
2123
2146
|
if (started.pid === null) {
|
|
2124
2147
|
const answer = await started.answered;
|
|
2125
2148
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
@@ -3100,8 +3123,8 @@ export async function run(args, injected) {
|
|
|
3100
3123
|
`tools` is referenced inside the callback it is being given, which is safe
|
|
3101
3124
|
for the plain reason that the callback can only run once a request has
|
|
3102
3125
|
arrived at a server that by then exists. */
|
|
3103
|
-
const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken) => {
|
|
3104
|
-
const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken);
|
|
3126
|
+
const tools = await startToolsServer(current(), async (parentRunId, brief, codebase, processToken, choice) => {
|
|
3127
|
+
const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
|
|
3105
3128
|
hold(child.runId, child.settled);
|
|
3106
3129
|
return { runId: child.runId };
|
|
3107
3130
|
});
|
package/dist/panel3/show.js
CHANGED
|
@@ -83,7 +83,7 @@ const read = (query, what) => returned(query, 'read', what);
|
|
|
83
83
|
const CARD_COLUMNS = 'id, project_id, title, state, created_at, archived_at';
|
|
84
84
|
const TURN_COLUMNS = 'id, card_id, role, body, created_at, addressed_at, run_id';
|
|
85
85
|
const RUN_COLUMNS = 'id, card_id, codebase_id, branch, parent_run_id, level, state, brief, report, failed_because, '
|
|
86
|
-
+ 'activity, machine_id, pid, started_at, resumed_at, read_back_at, ended_at';
|
|
86
|
+
+ 'activity, machine_id, pid, started_at, resumed_at, read_back_at, ended_at, model, effort';
|
|
87
87
|
const ASK_COLUMNS = `id, card_id, run_id, pending_run_id, answered_at, delivered_at, created_at, ${ASK_CONTENT_COLUMNS}`;
|
|
88
88
|
const OUTPUT_COLUMNS = 'id, card_id, run_id, kind, ref_id, label, created_at';
|
|
89
89
|
const ATTACHMENT_COLUMNS = 'id, card_id, kind, ref_id, label, created_at';
|
|
@@ -1125,6 +1125,10 @@ async function showRun(client, run) {
|
|
|
1125
1125
|
out(`RUN ${run.id}`);
|
|
1126
1126
|
out(` card ${run.card_id}${card ? ` ${card.title}` : ' (card not readable)'}`);
|
|
1127
1127
|
out(` level ${run.level}`);
|
|
1128
|
+
if (run.model != null)
|
|
1129
|
+
out(` model ${run.model}`);
|
|
1130
|
+
if (run.effort != null)
|
|
1131
|
+
out(` effort ${run.effort}`);
|
|
1128
1132
|
out(` state ${run.state}${isLive(run) ? ' (live)' : ''}`);
|
|
1129
1133
|
out(` parent ${run.parent_run_id ?? 'none, dispatched by the daemon'}`);
|
|
1130
1134
|
out(` machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ' no pid recorded'}`);
|
package/dist/panel3/spawn.js
CHANGED
|
@@ -124,6 +124,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
124
124
|
import { agentPath } from '../agents.js';
|
|
125
125
|
import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
|
|
126
126
|
import { windowsSafeSpawn } from '../win-shell.js';
|
|
127
|
+
import { modelChoiceRules } from './prompt.js';
|
|
127
128
|
const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
|
|
128
129
|
/**
|
|
129
130
|
* The harness named on this machine, or the reason the name is not one.
|
|
@@ -169,9 +170,9 @@ const allowedTools = (level) => [`mcp__${SERVER}__*`, ...(level === 1 ? [] : COD
|
|
|
169
170
|
* checked without starting a process — which is how the allowlist is proved,
|
|
170
171
|
* and how it stays provable after this task.
|
|
171
172
|
*/
|
|
172
|
-
export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession) {
|
|
173
|
+
export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession, choice = {}) {
|
|
173
174
|
if (agent === 'codex')
|
|
174
|
-
return codexArgs(level, toolsUrl, platform, ownerSession);
|
|
175
|
+
return codexArgs(level, toolsUrl, platform, ownerSession, choice);
|
|
175
176
|
const session = ownerSession
|
|
176
177
|
? ownerSession.resumeSessionId
|
|
177
178
|
? ['--resume', ownerSession.resumeSessionId]
|
|
@@ -187,6 +188,8 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
|
|
|
187
188
|
'--tools', builtIns(level),
|
|
188
189
|
'--allowedTools', allowedTools(level),
|
|
189
190
|
...session,
|
|
191
|
+
...(choice.model == null ? [] : ['--model', choice.model]),
|
|
192
|
+
...(choice.effort == null ? [] : ['--effort', choice.effort]),
|
|
190
193
|
/* STATED WHERE THERE IS SOMETHING TO STATE. `acceptEdits` grants file edits
|
|
191
194
|
to a headless process with nobody at a prompt to approve them, and it is
|
|
192
195
|
passed only to the levels that have a file tool to use it with: at level 1
|
|
@@ -221,7 +224,7 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
|
|
|
221
224
|
* `--json` makes the answer an `agent_message` item read out of the stream
|
|
222
225
|
* rather than whatever prose happened to reach stdout (see `codexAnswer`).
|
|
223
226
|
*/
|
|
224
|
-
function codexArgs(level, toolsUrl, platform, ownerSession) {
|
|
227
|
+
function codexArgs(level, toolsUrl, platform, ownerSession, choice = {}) {
|
|
225
228
|
const resume = ownerSession?.resumeSessionId;
|
|
226
229
|
return [
|
|
227
230
|
'exec',
|
|
@@ -229,6 +232,8 @@ function codexArgs(level, toolsUrl, platform, ownerSession) {
|
|
|
229
232
|
// The prompt arrives on stdin, exactly as it does for claude: `codex exec`
|
|
230
233
|
// reads it from there when no prompt argument is given.
|
|
231
234
|
'--json',
|
|
235
|
+
...(choice.model == null ? [] : ['-m', choice.model]),
|
|
236
|
+
...(choice.effort == null ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(choice.effort)}`]),
|
|
232
237
|
// The level 1 scratch directory is not a repository, and neither need a
|
|
233
238
|
// working copy be.
|
|
234
239
|
'--skip-git-repo-check',
|
|
@@ -241,13 +246,13 @@ function codexArgs(level, toolsUrl, platform, ownerSession) {
|
|
|
241
246
|
proven per-run home below. */
|
|
242
247
|
...(platform === 'win32' ? [
|
|
243
248
|
'--ignore-user-config',
|
|
244
|
-
'-c', 'model="gpt-5.5"',
|
|
245
249
|
'-c', 'features.apps=false',
|
|
246
250
|
// The same closing `codex-home.ts` writes into the per-run config, and
|
|
247
251
|
// for the same measured reason: `features.multi_agent=false` parses and
|
|
248
252
|
// leaves the tool reachable.
|
|
249
253
|
'-c', 'agents.enabled=false',
|
|
250
254
|
'-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
|
|
255
|
+
'-c', `mcp_servers.${SERVER}.required=true`,
|
|
251
256
|
'-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
|
|
252
257
|
'-c', 'windows.sandbox="unelevated"',
|
|
253
258
|
'-c', 'windows.sandbox_private_desktop=false',
|
|
@@ -291,7 +296,7 @@ function runKey(toolsUrl) {
|
|
|
291
296
|
* is the forbidden state ux.md is about, so the failure wins wherever both are
|
|
292
297
|
* present.
|
|
293
298
|
*/
|
|
294
|
-
export function codexAnswer(stdout) {
|
|
299
|
+
export function codexAnswer(stdout, exitCode = 0, stderr = '') {
|
|
295
300
|
let text = null;
|
|
296
301
|
let failure = null;
|
|
297
302
|
for (const line of stdout.split('\n')) {
|
|
@@ -319,6 +324,9 @@ export function codexAnswer(stdout) {
|
|
|
319
324
|
if (failure !== null) {
|
|
320
325
|
return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
|
|
321
326
|
}
|
|
327
|
+
if (exitCode !== 0) {
|
|
328
|
+
return { ok: false, reason: `codex exited ${exitCode}${tail(stderr) || tail(stdout)}` };
|
|
329
|
+
}
|
|
322
330
|
if (text === null || text.trim() === '') {
|
|
323
331
|
return { ok: false, reason: 'codex exited 0 without saying anything to the person' };
|
|
324
332
|
}
|
|
@@ -377,7 +385,7 @@ function tail(text, chars = 500) {
|
|
|
377
385
|
* process still alive" after the daemon that started it has been killed. So the
|
|
378
386
|
* caller gets the pid immediately, writes it, and then waits.
|
|
379
387
|
*/
|
|
380
|
-
export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
388
|
+
export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}) {
|
|
381
389
|
const failed = (reason) => ({
|
|
382
390
|
pid: null,
|
|
383
391
|
session: Promise.resolve(null),
|
|
@@ -385,7 +393,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
|
385
393
|
});
|
|
386
394
|
let agent;
|
|
387
395
|
try {
|
|
388
|
-
agent = harness();
|
|
396
|
+
agent = settings.harness ?? harness();
|
|
389
397
|
}
|
|
390
398
|
catch (err) {
|
|
391
399
|
// A machine configured for a harness this build has never heard of. See
|
|
@@ -395,7 +403,10 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
|
395
403
|
const launchedOwnerSession = ownerSession && !ownerSession.resumeSessionId
|
|
396
404
|
? { ...ownerSession, freshSessionId: randomUUID() }
|
|
397
405
|
: ownerSession;
|
|
398
|
-
|
|
406
|
+
// A local default applies only to an omitted choice; explicit run values always win.
|
|
407
|
+
const choice = { ...settings, model: settings.model ?? process.env[`CTRL_SPC_V3_${agent.toUpperCase()}_DEFAULT_MODEL`] };
|
|
408
|
+
const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession, choice);
|
|
409
|
+
prompt = `${modelChoiceRules(agent)}\n\n${prompt}`;
|
|
399
410
|
const bin = agentPath(agent);
|
|
400
411
|
if (!bin) {
|
|
401
412
|
return failed(`${agent} is not installed on this machine`);
|
|
@@ -531,6 +542,11 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
|
531
542
|
respawn behind it is refused. Naming the signal is the whole fix. */
|
|
532
543
|
finish({ ok: false, reason: `${agent} was ended by ${signal}${tail(stderr) || tail(stdout)}` });
|
|
533
544
|
}
|
|
545
|
+
else if (agent === 'codex') {
|
|
546
|
+
// Startup failures can put the real error in JSON stdout and only
|
|
547
|
+
// 'Reading prompt from stdin' on stderr, including on non-zero exits.
|
|
548
|
+
finish(codexAnswer(stdout, code ?? 1, stderr));
|
|
549
|
+
}
|
|
534
550
|
else if (code !== 0) {
|
|
535
551
|
/* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
|
|
536
552
|
failure on stdout and exits non-zero having written nothing to
|
|
@@ -541,11 +557,6 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
|
541
557
|
else if (stdout.trim() === '') {
|
|
542
558
|
finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
|
|
543
559
|
}
|
|
544
|
-
else if (agent === 'codex') {
|
|
545
|
-
/* The stream, not the buffer. See `codexAnswer`: stdout here is
|
|
546
|
-
protocol, and a failed turn that exited 0 is still a failure. */
|
|
547
|
-
finish(codexAnswer(stdout));
|
|
548
|
-
}
|
|
549
560
|
else {
|
|
550
561
|
const text = stdout.trim();
|
|
551
562
|
finish({
|
package/dist/panel3/tools.js
CHANGED
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
// `import type` loses its v3 header in the published `dist/`.
|
|
115
115
|
import { presentDesign } from '../design-review.js';
|
|
116
116
|
import { canonicalArgsHash, getDocumentHandler, proposeProjectContextHandler, placeWorkItemHandler, resolveFeedbackHandler, PROJECT_DOCUMENT_TYPES, errorMessage } from '../product-tools.js';
|
|
117
|
-
import { workflowToolForName, workflowToolAlwaysAllowed } from '../workflow-tool-mentions.js';
|
|
117
|
+
import { workflowToolForName, workflowToolAlwaysAllowed, validateApprovalAction } from '../workflow-tool-mentions.js';
|
|
118
118
|
import { captureUrlScreenshot } from '../browser.js';
|
|
119
119
|
import { createServer as createHttpServer } from 'node:http';
|
|
120
120
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -130,7 +130,7 @@ import { workBrief } from './prompt.js';
|
|
|
130
130
|
here is the same fact without a second round trip. */
|
|
131
131
|
import { harness } from './spawn.js';
|
|
132
132
|
import { listCodebases } from '../codebases.js';
|
|
133
|
-
import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING } from '../workflows.js';
|
|
133
|
+
import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING } from '../workflows.js';
|
|
134
134
|
/* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
|
|
135
135
|
═══ Stages and steps are the work item's own record, shared by both
|
|
136
136
|
generations and read by the web's Steps section. `steps.ts` owns the rows so
|
|
@@ -749,6 +749,13 @@ function productResult(result) {
|
|
|
749
749
|
return text;
|
|
750
750
|
}
|
|
751
751
|
const TOOLS = [
|
|
752
|
+
{
|
|
753
|
+
name: 'require_approval', levels: [1, 2],
|
|
754
|
+
description: 'Pause for the user to approve a specific action before you perform it, including editing code, running a migration, or pushing commits. Name the action and exact scope using relative paths, never absolute local paths. Stop on pending or denial and retry the identical call after the answer. Only an approved result permits that action. This tool does not execute it.',
|
|
755
|
+
input: { action: z.string().trim().min(1).max(240), details: z.string().trim().min(1).max(8000) },
|
|
756
|
+
handler: async (_caller, args) => JSON.stringify({ approved: true, action: args.action, details: args.details,
|
|
757
|
+
instruction: 'The user approved only this action and scope. You may now perform it.' }),
|
|
758
|
+
},
|
|
752
759
|
{
|
|
753
760
|
name: 'get_document', levels: ALL,
|
|
754
761
|
description: 'Read a project context document or instruction in full by id. Read-only.',
|
|
@@ -1164,7 +1171,7 @@ const TOOLS = [
|
|
|
1164
1171
|
described and can write it down as it was described. A launcher writes one
|
|
1165
1172
|
line and exits; a worker owns one piece of somebody else's process. */
|
|
1166
1173
|
levels: [2],
|
|
1167
|
-
description: "Write a new workflow into this organisation's library: a name, what it is about, and its "
|
|
1174
|
+
description: WORKFLOW_AUTHORING_TEACHING + "Write a new workflow into this organisation's library: a name, what it is about, and its "
|
|
1168
1175
|
+ 'stages in order, each with a one-line description and the markdown document an agent '
|
|
1169
1176
|
+ 'following it reads. Stages are numbered from 1, the way get_workflow prints them. exits '
|
|
1170
1177
|
+ 'are the conditional ways back: from_stage N, if condition, go back to to_stage M, where M '
|
|
@@ -1826,11 +1833,11 @@ const TOOLS = [
|
|
|
1826
1833
|
const agents = await rows(client.from('panel3_runs')
|
|
1827
1834
|
// `resumed_at` is read because "how long it has been going" is about
|
|
1828
1835
|
// the attempt that is running now. See `elapsed`.
|
|
1829
|
-
.select('id, level, parent_run_id, state, activity, started_at, resumed_at, ended_at')
|
|
1836
|
+
.select('id, level, parent_run_id, state, activity, started_at, resumed_at, ended_at, model, effort')
|
|
1830
1837
|
.eq('card_id', cardId).order('started_at'), 'read', 'the agents on this card');
|
|
1831
1838
|
return listed(agents.map((a) => {
|
|
1832
1839
|
const live = a.state === 'running' && !a.ended_at;
|
|
1833
|
-
return line(a.id, `L${a.level}`, a.parent_run_id ? `parent ${a.parent_run_id}` : 'no parent, dispatched by the daemon', live ? 'still running' : a.state, a.activity ?? null, live
|
|
1840
|
+
return line(a.id, `L${a.level}`, a.parent_run_id ? `parent ${a.parent_run_id}` : 'no parent, dispatched by the daemon', live ? 'still running' : a.state, a.activity ?? null, a.model == null ? null : `model ${a.model}`, a.effort == null ? null : `effort ${a.effort}`, live
|
|
1834
1841
|
? `going ${elapsed(a.started_at, null, a.resumed_at)}`
|
|
1835
1842
|
: `went ${elapsed(a.started_at, a.ended_at, a.resumed_at)}`);
|
|
1836
1843
|
}), 'Nothing has run on this card.');
|
|
@@ -1844,9 +1851,11 @@ const TOOLS = [
|
|
|
1844
1851
|
+ 'in your prompt; this is here for when you need to re-read them mid-run.',
|
|
1845
1852
|
input: {},
|
|
1846
1853
|
handler: async ({ client, runId }) => {
|
|
1847
|
-
const run = await only(client.from('panel3_runs').select('level, brief, report, activity').eq('id', runId), 'read', 'your own run');
|
|
1854
|
+
const run = await only(client.from('panel3_runs').select('level, brief, report, activity, model, effort').eq('id', runId), 'read', 'your own run');
|
|
1848
1855
|
return [
|
|
1849
1856
|
`You are a level ${run.level} agent.`,
|
|
1857
|
+
...(run.model == null ? [] : [`MODEL ${run.model}`]),
|
|
1858
|
+
...(run.effort == null ? [] : [`EFFORT ${run.effort}`]),
|
|
1850
1859
|
'',
|
|
1851
1860
|
'BRIEF, written when you were dispatched and never changed',
|
|
1852
1861
|
run.brief,
|
|
@@ -1871,9 +1880,9 @@ const TOOLS = [
|
|
|
1871
1880
|
+ 'dispatch the same work twice.',
|
|
1872
1881
|
input: {},
|
|
1873
1882
|
handler: async ({ client, runId }) => {
|
|
1874
|
-
const children = await rows(client.from('panel3_runs').select('id, level, state, activity, started_at, ended_at')
|
|
1883
|
+
const children = await rows(client.from('panel3_runs').select('id, level, state, activity, started_at, ended_at, model, effort')
|
|
1875
1884
|
.eq('parent_run_id', runId).order('started_at'), 'read', 'the runs you dispatched');
|
|
1876
|
-
return listed(children.map((c) => line(c.id, `L${c.level}`, c.state === 'running' && !c.ended_at ? 'still running' : c.state, c.activity ?? null)), 'You have dispatched nothing.');
|
|
1885
|
+
return listed(children.map((c) => line(c.id, `L${c.level}`, c.state === 'running' && !c.ended_at ? 'still running' : c.state, c.activity ?? null, c.model == null ? null : `model ${c.model}`, c.effort == null ? null : `effort ${c.effort}`)), 'You have dispatched nothing.');
|
|
1877
1886
|
},
|
|
1878
1887
|
},
|
|
1879
1888
|
{
|
|
@@ -2887,6 +2896,8 @@ const TOOLS = [
|
|
|
2887
2896
|
+ 'context: what to find out or change, and in which part of the codebase.'),
|
|
2888
2897
|
boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
|
|
2889
2898
|
work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
|
|
2899
|
+
model: z.string().min(1).optional().describe('Exact model for this child. Copy the user\'s applicable value verbatim, including aliases; never expand or normalize it. Otherwise choose for the task. Omit for the harness or machine default, never parent inheritance.'),
|
|
2900
|
+
effort: z.string().min(1).optional().describe('Exact effort for this child. Honor the user\'s applicable wish; otherwise choose independently. Passed unchanged to the harness.'),
|
|
2890
2901
|
work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
|
|
2891
2902
|
+ 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
|
|
2892
2903
|
+ 'attached: the conversation is called this from now on, and the branch the work goes on is '
|
|
@@ -2894,7 +2905,7 @@ const TOOLS = [
|
|
|
2894
2905
|
+ 'attached, because that item is already the name.'),
|
|
2895
2906
|
},
|
|
2896
2907
|
handler: async (caller, args) => {
|
|
2897
|
-
const { codebase_id, responsibility, boundary, work_item_id, work_name } = args;
|
|
2908
|
+
const { codebase_id, responsibility, boundary, work_item_id, work_name, model, effort } = args;
|
|
2898
2909
|
if (caller.level === 2 && !codebase_id) {
|
|
2899
2910
|
throw new Error('A worker must be attached to a registered project codebase.');
|
|
2900
2911
|
}
|
|
@@ -2957,7 +2968,7 @@ const TOOLS = [
|
|
|
2957
2968
|
const attachments = attached.map(attachmentLine);
|
|
2958
2969
|
const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
|
|
2959
2970
|
id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
|
|
2960
|
-
}), codebase, caller.processToken);
|
|
2971
|
+
}), codebase, caller.processToken, { model, effort });
|
|
2961
2972
|
/* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
|
|
2962
2973
|
KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
|
|
2963
2974
|
because it is registered once for both levels that hold the tool; this
|
|
@@ -3115,6 +3126,8 @@ async function invokeWorkflowTool(caller, tool, args) {
|
|
|
3115
3126
|
const invoke = () => tool.handler(caller, inputs);
|
|
3116
3127
|
if (!canonical || workflowToolAlwaysAllowed(canonical))
|
|
3117
3128
|
return invoke();
|
|
3129
|
+
if (canonical === 'require_approval')
|
|
3130
|
+
validateApprovalAction(String(a.action), String(a.details));
|
|
3118
3131
|
const attached = await loadAttachments(caller.client, caller.cardId);
|
|
3119
3132
|
const ids = attached.filter(item => item.kind === 'work_item').map(item => item.ref_id);
|
|
3120
3133
|
let workItemId = explicitSource;
|
|
@@ -3135,9 +3148,9 @@ async function invokeWorkflowTool(caller, tool, args) {
|
|
|
3135
3148
|
throw new Error('This planning action requires an explicit tool mention in the current workflow stage. Nothing was written.');
|
|
3136
3149
|
if (permission)
|
|
3137
3150
|
await validateWorkflowToolTarget(caller.client, workItemId, canonical, a);
|
|
3138
|
-
if (!permission || permission.approval === 'not-required')
|
|
3151
|
+
if (canonical !== 'require_approval' && (!permission || permission.approval === 'not-required'))
|
|
3139
3152
|
return invoke();
|
|
3140
|
-
const fingerprint = canonicalArgsHash({ tool: tool.name, workItemId, instance: permission
|
|
3153
|
+
const fingerprint = canonicalArgsHash({ tool: tool.name, workItemId, instance: permission?.id ?? null, args: inputs });
|
|
3141
3154
|
const category = `workflow_tool_${fingerprint}`;
|
|
3142
3155
|
const questions = await withAskContent(caller.client, await rows(caller.client.from('panel3_asks').select(`pending_run_id, answered_at, ${ASK_CONTENT_COLUMNS}`).eq('card_id', caller.cardId), 'read', 'workflow tool approvals'));
|
|
3143
3156
|
const previous = questions.find(question => question.category === category && question.decision_id !== null);
|
|
@@ -3151,8 +3164,8 @@ async function invokeWorkflowTool(caller, tool, args) {
|
|
|
3151
3164
|
if (caller.level === 3)
|
|
3152
3165
|
throw new Error('This workflow tool action requires user approval. Escalate the exact action to the conversation owner and stop; the owner must perform the approved call.');
|
|
3153
3166
|
await asked(caller, {
|
|
3154
|
-
question: `May I ${tool.name.replaceAll('_', ' ')}${a.title ? `: ${String(a.title).slice(0, 180)}` : ''}?`,
|
|
3155
|
-
category, context: 'This use of the tool requires approval in the workflow stage.',
|
|
3167
|
+
question: canonical === 'require_approval' ? `May I ${String(a.action)}?` : `May I ${tool.name.replaceAll('_', ' ')}${a.title ? `: ${String(a.title).slice(0, 180)}` : ''}?`,
|
|
3168
|
+
category, context: canonical === 'require_approval' ? String(a.details) : 'This use of the tool requires approval in the workflow stage.',
|
|
3156
3169
|
answer_mode: 'single_select', options: ['approve', 'deny'], work_item_id: workItemId,
|
|
3157
3170
|
}, true);
|
|
3158
3171
|
throw new Error('This workflow tool action is waiting for user approval. Nothing was written. Stop until the user answers, then retry the same call with the same arguments.');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { absolutePathToken } from './local-paths.js';
|
|
1
2
|
/** Shared by the stage editor and both tool servers. The stage body remains
|
|
2
3
|
* the record: each Markdown link names one invocation, never a tool-wide grant. */
|
|
3
4
|
export const WORKFLOW_TOOLS = [
|
|
5
|
+
{ id: 'require_approval', name: 'Require Approval', detail: 'Pause before any action, such as editing code, running migrations, or pushing commits.', alwaysAllowed: false, advanced: false },
|
|
4
6
|
{ id: 'create_artifact', name: 'Create Artifact', detail: 'Write a user story, plan, spec, diagram, or interactive mock.', alwaysAllowed: false, advanced: false },
|
|
5
7
|
{ id: 'update_artifact', name: 'Edit Artifact', detail: 'Revise an existing artifact.', alwaysAllowed: false, advanced: false },
|
|
6
8
|
{ id: 'ask_question', name: 'Ask Question', detail: 'Ask for feedback, a decision, or approval. Always available.', alwaysAllowed: true, advanced: false },
|
|
@@ -35,7 +37,7 @@ export function workflowToolForName(name) {
|
|
|
35
37
|
}
|
|
36
38
|
export function toolMentionText(tool, id, approval = 'required') {
|
|
37
39
|
const entry = WORKFLOW_TOOLS.find(entry => entry.id === tool);
|
|
38
|
-
return `[@${entry.name}](ctrl-spc://tool/${tool}/${id}?approval=${workflowToolAlwaysAllowed(tool) ? 'not-required' : approval})`;
|
|
40
|
+
return `[@${entry.name}](ctrl-spc://tool/${tool}/${id}?approval=${tool === 'require_approval' ? 'required' : workflowToolAlwaysAllowed(tool) ? 'not-required' : approval})`;
|
|
39
41
|
}
|
|
40
42
|
export function toolMentions(body) {
|
|
41
43
|
const mentions = [];
|
|
@@ -45,7 +47,7 @@ export function toolMentions(body) {
|
|
|
45
47
|
continue;
|
|
46
48
|
mentions.push({
|
|
47
49
|
id: match[2], tool: match[1],
|
|
48
|
-
approval: workflowToolAlwaysAllowed(match[1]) ? 'not-required' : match[3],
|
|
50
|
+
approval: match[1] === 'require_approval' ? 'required' : workflowToolAlwaysAllowed(match[1]) ? 'not-required' : match[3],
|
|
49
51
|
from: match.index, to: match.index + match[0].length,
|
|
50
52
|
});
|
|
51
53
|
}
|
|
@@ -78,6 +80,7 @@ export const WORKFLOW_TOOL_TEACHING = 'Tool mentions in workflow stage documents
|
|
|
78
80
|
'the tool itself opens the permission question before writing anything. Do not open a separate ask_question for permission to use that tool. ' +
|
|
79
81
|
'When the user approves that tool question, retry the same call with exactly the same arguments. Read tools and Ask Question never need permission. Permission does not grant access to another project or change your agent level. If an action is only available to your coordinator, escalate that action with its mention id. ' +
|
|
80
82
|
'Approval to use a tool is separate from approval of its output: still ask every review question written in the stage. Present Mock and Present Wireframe open their own output review; do not ask a duplicate review question. Proposing a scope or context change never accepts it. ' +
|
|
83
|
+
'Require Approval is a separate checkpoint for ANY action, including native shell and code tools: call require_approval with the specific action and its scope BEFORE doing it, then stop until that tool returns approval. It does not execute the action or approve other actions. Do not use ask_question instead of this checkpoint. ' +
|
|
81
84
|
'If your level cannot ask the user, escalate the review to the conversation owner and stop.';
|
|
82
85
|
/** Agents may preserve existing settings, but cannot author their own grants. */
|
|
83
86
|
export function assertAgentToolMentions(body, previous = '') {
|
|
@@ -91,3 +94,35 @@ export function assertAgentToolMentions(body, previous = '') {
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
}
|
|
97
|
+
/** Library authoring accepts compact links; the server owns occurrence IDs so
|
|
98
|
+
* agent-created mentions render exactly like ones inserted in the editor. */
|
|
99
|
+
export function authorToolMentions(body) {
|
|
100
|
+
const expanded = body.replace(/\[@[^\]\n]+\]\(ctrl-spc:\/\/tool\/([a-z_]+)\?approval=(required|not-required)\)/g, (_match, name, approval) => {
|
|
101
|
+
const tool = workflowToolForName(name);
|
|
102
|
+
if (!tool)
|
|
103
|
+
throw new Error(`Unknown workflow tool: ${name}. Nothing was written.`);
|
|
104
|
+
return toolMentionText(tool, crypto.randomUUID(), approval);
|
|
105
|
+
});
|
|
106
|
+
validateToolMentions(expanded);
|
|
107
|
+
let result = expanded;
|
|
108
|
+
for (const mention of toolMentions(expanded).reverse()) {
|
|
109
|
+
result = result.slice(0, mention.from) + toolMentionText(mention.tool, crypto.randomUUID(), mention.approval) + result.slice(mention.to);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
export const WORKFLOW_AUTHORING_TEACHING = 'When the user asks you to create a workflow, build it in the library; do not merely describe it or execute it. ' +
|
|
114
|
+
'Use ask_question for missing requirements that materially change the workflow, then continue after the answer. ' +
|
|
115
|
+
'Encode every requested tool use and permission as a Markdown mention in the stage body. ' +
|
|
116
|
+
'Use compact links: [@Create Artifact](ctrl-spc://tool/create_artifact?approval=not-required) to write a plan without asking; ' +
|
|
117
|
+
'[@Ask Question](ctrl-spc://tool/ask_question?approval=not-required) to review its output; ' +
|
|
118
|
+
'before editing code, [@Require Approval](ctrl-spc://tool/require_approval?approval=required). ' +
|
|
119
|
+
'The server assigns unique IDs and the user sees editable controls in the stage editor. ' +
|
|
120
|
+
'Set not-required only when the user permits that use. Otherwise default to required. Never convert permission to write a plan into permission to execute it. ' +
|
|
121
|
+
'Each occurrence has its own setting. Put the action and scope beside each Require Approval mention, including migrations or pushing commits. ' +
|
|
122
|
+
'Available tool IDs: ' + WORKFLOW_TOOLS.map(tool => `${tool.id} (${tool.name})`).join(', ') + '. ';
|
|
123
|
+
/** Approval prose is displayed in the hosted app; keep machine-local paths local. */
|
|
124
|
+
export function validateApprovalAction(action, details) {
|
|
125
|
+
if (absolutePathToken(action) || absolutePathToken(details)) {
|
|
126
|
+
throw new Error('Use repo-relative or scratch-relative paths in the approval action and scope, never absolute local paths. No approval was requested.');
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/workflows.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { absolutePathToken } from './local-paths.js';
|
|
2
2
|
import { listSteps } from './steps.js';
|
|
3
|
-
import { assertAgentToolMentions, toolMentions, validateToolMentions } from './workflow-tool-mentions.js';
|
|
4
|
-
export { WORKFLOW_TOOL_TEACHING, assertAgentToolMentions } from './workflow-tool-mentions.js';
|
|
3
|
+
import { assertAgentToolMentions, authorToolMentions, toolMentions, validateToolMentions } from './workflow-tool-mentions.js';
|
|
4
|
+
export { WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING, authorToolMentions, assertAgentToolMentions } from './workflow-tool-mentions.js';
|
|
5
5
|
/** Resolve permission from the current stage's saved document, never agent
|
|
6
6
|
* prose or a supplied boolean. Both harnesses use the same stage resolver. */
|
|
7
7
|
export async function workflowToolPermission(client, workItemId, tool, instance) {
|
|
@@ -140,7 +140,7 @@ export async function readWorkflow(client, workflowId) {
|
|
|
140
140
|
export async function buildWorkflow(client, input) {
|
|
141
141
|
const prose = [['name', input.name], ['description', input.description]];
|
|
142
142
|
input.stages.forEach((stage, i) => {
|
|
143
|
-
|
|
143
|
+
stage.body = authorToolMentions(stage.body);
|
|
144
144
|
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
145
145
|
});
|
|
146
146
|
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
|
@@ -253,7 +253,7 @@ export async function editWorkflow(client, input) {
|
|
|
253
253
|
input.stages.forEach((stage, i) => {
|
|
254
254
|
if ('stage_id' in stage)
|
|
255
255
|
return;
|
|
256
|
-
|
|
256
|
+
stage.body = authorToolMentions(stage.body);
|
|
257
257
|
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
258
258
|
});
|
|
259
259
|
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
package/package.json
CHANGED