@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.
@@ -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, assertAgentToolMentions } from './workflows.js';
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 = workflowIds.length
7806
- ? ((await must(client
7807
- .from('cliv2_workflow_stages_in_workflow')
7808
- .select('workflow_id, stage_id, position')
7809
- .in('workflow_id', workflowIds))) ?? [])
7810
- : [];
7811
- const exits = workflowIds.length
7812
- ? ((await must(client
7813
- .from('cliv2_workflow_stage_exits')
7814
- .select('workflow_id, stage_id, to_stage_id, condition, position')
7815
- .in('workflow_id', workflowIds))) ?? [])
7816
- : [];
7817
- const branches = workflowIds.length
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
- assertAgentToolMentions(stage.body);
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
- assertAgentToolMentions(stage.body);
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. ' +
@@ -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
+ }
@@ -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
- ...(parentProcessToken === undefined ? {} : { p_process_token: parentProcessToken }),
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
  });
@@ -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'}`);
@@ -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
- const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession);
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({
@@ -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.id, args: inputs });
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
- assertAgentToolMentions(stage.body);
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
- assertAgentToolMentions(stage.body);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"