@ctrl-spc/cs 0.7.11 → 0.7.13

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.
@@ -322,6 +322,15 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
322
322
  }
323
323
  }
324
324
  if (failure !== null) {
325
+ // Codex wraps provider errors in turn.failed.error.message. A rejected
326
+ // request cannot recover by sending the same model and effort again.
327
+ try {
328
+ const provider = JSON.parse(failure);
329
+ if (provider?.status === 400 && provider?.error?.type === 'invalid_request_error') {
330
+ return { ok: false, reason: `codex could not finish the turn: ${provider.error.message ?? failure}`, retryable: false };
331
+ }
332
+ }
333
+ catch { /* Ordinary native failure text keeps the existing recovery policy. */ }
325
334
  return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
326
335
  }
327
336
  if (exitCode !== 0) {
@@ -332,6 +341,17 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
332
341
  }
333
342
  return { ok: true, text: text.trim() };
334
343
  }
344
+ /** Claude prints provider/model rejections on stdout, even with a nonzero exit. */
345
+ export function claudeAnswer(stdout, exitCode = 0, stderr = '') {
346
+ if (/^\[claude-code:unrecognized_model\]/m.test(stdout + '\n' + stderr)) {
347
+ return { ok: false, retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
348
+ }
349
+ if (exitCode !== 0)
350
+ return { ok: false, reason: `claude exited ${exitCode}${tail(stderr) || tail(stdout)}` };
351
+ if (stdout.trim() === '')
352
+ return { ok: false, reason: `claude exited 0 and said nothing${tail(stderr)}` };
353
+ return { ok: true, text: stdout.trim() };
354
+ }
335
355
  /** Enough for any answer a person reads, and a ceiling so a runaway process
336
356
  * cannot exhaust this daemon's memory. */
337
357
  const MAX_OUTPUT_CHARS = 1_000_000;
@@ -547,22 +567,11 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
547
567
  // 'Reading prompt from stdin' on stderr, including on non-zero exits.
548
568
  finish(codexAnswer(stdout, code ?? 1, stderr));
549
569
  }
550
- else if (code !== 0) {
551
- /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
552
- failure on stdout and exits non-zero having written nothing to
553
- stderr. Reporting only "exited 1" would throw away the one sentence
554
- that says what went wrong. */
555
- finish({ ok: false, reason: `${agent} exited ${code}${tail(stderr) || tail(stdout)}` });
556
- }
557
- else if (stdout.trim() === '') {
558
- finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
559
- }
560
570
  else {
561
- const text = stdout.trim();
562
- finish({
563
- ok: true,
564
- text: truncated ? `${text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` : text,
565
- });
571
+ const answer = claudeAnswer(stdout, code ?? 1, stderr);
572
+ finish(answer.ok && truncated
573
+ ? { ...answer, text: `${answer.text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` }
574
+ : answer);
566
575
  }
567
576
  });
568
577
  /* THE PROMPT, AND THEN END OF INPUT, because `claude -p` reads stdin to the
@@ -127,11 +127,12 @@ import { returned } from './client.js';
127
127
  import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
128
128
  import { rememberSecret, redactArgs } from './secrets.js';
129
129
  import { workBrief } from './prompt.js';
130
+ import { listCodexModels } from './codex-models.js';
130
131
  /* The harness this daemon spawns with, which is the process this server runs
131
132
  in. It is what `panel3_runs.harness` is written from at spawn, so reading it
132
133
  here is the same fact without a second round trip. */
133
134
  import { harness } from './spawn.js';
134
- import { listCodebases } from '../codebases.js';
135
+ import { listCodebases, listWorkItemCodebaseTargets, editWorkItemCodebaseTarget } from '../codebases.js';
135
136
  import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage, workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING } from '../workflows.js';
136
137
  /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
137
138
  ═══ Stages and steps are the work item's own record, shared by both
@@ -300,10 +301,9 @@ async function asked(caller, args, askPerson) {
300
301
  p_question: question,
301
302
  p_category: category,
302
303
  p_context: context,
303
- /* Undefined, not a default: the defaults live in the function signature,
304
- so there is one place that says what an unshaped question is. */
305
- p_answer_mode: answer_mode,
306
- p_options: options,
304
+ // Token-aware RPC overloads require these arguments, even for a free-text question.
305
+ p_answer_mode: answer_mode ?? 'free_text',
306
+ p_options: options ?? [],
307
307
  p_question_id: question_id ?? null,
308
308
  p_ask_person: askPerson,
309
309
  /* ═══ ONLY `ask_question` OFFERS THIS, AND THE DATABASE REFUSES IT ON AN
@@ -829,13 +829,14 @@ const TOOLS = [
829
829
  handler: async (caller, args) => productResult(await placeWorkItemHandler(caller.client, args)),
830
830
  },
831
831
  {
832
- name: 'reorder_backlog', levels: [1],
832
+ name: 'reorder_backlog', levels: [1, 2],
833
833
  description: 'Move one backlog work item before another, or to the bottom. Read the current order first and explain the reason.',
834
834
  input: { project_id: z.string().uuid(), task_id: z.string().uuid(), before_task_id: z.string().uuid().optional(), reason: z.string().min(1) },
835
835
  handler: async (caller, args) => {
836
836
  const a = args;
837
837
  const items = await rows(caller.client.from('tasks').select('id, revision')
838
- .eq('project_id', a.project_id).eq('status', 'backlog').is('archived_at', null).eq('is_idea', false).order('position').order('created_at').order('id'), 'read', 'the backlog');
838
+ .eq('project_id', a.project_id).eq('status', 'backlog').is('archived_at', null).eq('is_idea', false)
839
+ .in('id', a.before_task_id ? [a.task_id, a.before_task_id] : [a.task_id]), 'read', 'the backlog');
839
840
  const moving = items.find(item => item.id === a.task_id);
840
841
  if (!moving || (a.before_task_id && !items.some(item => item.id === a.before_task_id)))
841
842
  throw new Error('Both items must be live backlog work in this project. Nothing was moved.');
@@ -963,32 +964,44 @@ const TOOLS = [
963
964
  {
964
965
  name: 'list_work_items',
965
966
  levels: ALL,
966
- description: 'The work items (tasks) on the board, newest first. Optionally filtered to one project and one '
967
- + 'status. Archived items and product ideas are not work items and are not listed.',
967
+ description: 'Live work items in saved manual board order, top to bottom within each project and status, '
968
+ + 'not creation order or a temporary UI sort. For backlog priority, pass project_id and status backlog. '
969
+ + 'Read all pages using next_offset until it is null before reporting the full order. Re-read after a reorder or a human change.',
968
970
  input: {
969
971
  project_id: z.string().optional().describe('Only items in this project.'),
970
972
  status: z.enum(['backlog', 'in_progress', 'done']).optional().describe('Only items in this status.'),
973
+ offset: z.number().int().min(0).default(0).describe('Use next_offset from the previous page; start at zero.'),
971
974
  },
972
975
  handler: async ({ client }, args) => {
973
- const { project_id, status } = args;
976
+ const { project_id, status, offset = 0 } = args;
974
977
  let query = client
975
978
  .from('tasks')
976
- .select('id, name, status, project_id, epic_id, sprint_id, due_date, created_at')
979
+ .select('id, name, status, project_id, epic_id, sprint_id, due_date, position, created_at', { count: 'exact' })
977
980
  .eq('is_idea', false)
978
981
  .is('archived_at', null)
979
- .order('created_at', { ascending: false });
982
+ .order('project_id').order('status').order('position').order('created_at').order('id');
980
983
  if (project_id)
981
984
  query = query.eq('project_id', project_id);
982
985
  if (status)
983
986
  query = query.eq('status', status);
984
- const items = await rows(query, 'read', 'work items');
985
- return listed(items.map((t) => line(t.id, t.status.padEnd(11), t.name, t.due_date ? `due ${t.due_date}` : null)), 'There are no work items matching that.');
987
+ const result = await query.range(offset, offset + 99);
988
+ const items = await rows(Promise.resolve(result), 'read', 'work items');
989
+ if (result.count === null)
990
+ throw new Error('Could not read the work-item total; the full order is unavailable.');
991
+ if (items.length === 0 && offset < result.count)
992
+ throw new Error('Work items changed while reading this page. Restart from offset zero.');
993
+ return JSON.stringify({
994
+ order: 'Saved manual board order, top to bottom within each project and status',
995
+ total: result.count,
996
+ next_offset: offset + items.length < result.count ? offset + items.length : null,
997
+ items,
998
+ });
986
999
  },
987
1000
  },
988
1001
  {
989
1002
  name: 'get_work_item',
990
1003
  levels: ALL,
991
- description: 'One work item in full: its description, status, placement, and the artifacts on it.',
1004
+ description: 'One work item in full: description, status, placement, saved codebase targets, available project codebases, and artifacts. Saved targets belong to the work item, not just this conversation.',
992
1005
  input: { work_item_id: z.string() },
993
1006
  handler: async ({ client }, args) => {
994
1007
  const { work_item_id } = args;
@@ -1001,6 +1014,10 @@ const TOOLS = [
1001
1014
  const feedback = await rows(client.from('artifact_feedback').select('*').eq('task_id', work_item_id).order('created_at'), 'read', 'the artifact feedback');
1002
1015
  const decisions = await rows(client.from('decisions').select('id, category, question, state, selected_options, answer_note, related_artifact_id').eq('task_id', work_item_id).order('asked_at'), 'read', 'the work item decisions');
1003
1016
  const comments = await rows(client.from('comments').select('id, body, created_at').eq('task_id', work_item_id).order('created_at'), 'read', 'the work item comments');
1017
+ const codebases = (await listCodebases(client, item.project_id))
1018
+ .sort((a, b) => a.name.localeCompare(b.name))
1019
+ .map(({ id, name, gitRemoteUrl }) => ({ id, name, git_remote_url: gitRemoteUrl }));
1020
+ const targets = await listWorkItemCodebaseTargets(client, work_item_id);
1004
1021
  return [
1005
1022
  `${item.name}`,
1006
1023
  `id ${item.id}`,
@@ -1009,6 +1026,8 @@ const TOOLS = [
1009
1026
  `epic ${item.epics?.name ?? 'none'}`,
1010
1027
  `sprint ${item.sprints?.name ?? 'none'}`,
1011
1028
  `due ${item.due_date ?? 'no date'}`,
1029
+ 'PROJECT CODEBASES', JSON.stringify(codebases),
1030
+ 'SAVED CODEBASE TARGETS', JSON.stringify(targets),
1012
1031
  '',
1013
1032
  'DESCRIPTION',
1014
1033
  item.description.trim() === '' ? '(empty)' : item.description,
@@ -1165,10 +1184,15 @@ const TOOLS = [
1165
1184
  `id ${workflow.id}`,
1166
1185
  `about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
1167
1186
  '',
1187
+ `ending ${workflow.ending}`,
1188
+ `BRANCH CONDITIONS ${workflow.branches.length}`,
1189
+ ...workflow.branches.map(branch => `if ${branch.when}, run workflow ${branch.runsWorkflowId}, then resume this workflow`),
1190
+ '',
1168
1191
  `STAGES ${workflow.stages.length}`,
1169
1192
  ...workflow.stages.flatMap((stage, i) => [
1170
1193
  '',
1171
1194
  line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
1195
+ `stage_id ${stage.id} (use stage number ${i + 1} for reword_stage and keep)`,
1172
1196
  '',
1173
1197
  /* THE WHOLE BODY. The body IS the stage document, so truncating it is
1174
1198
  truncating the process. An empty one is a stage nobody has written
@@ -1191,8 +1215,9 @@ const TOOLS = [
1191
1215
  '',
1192
1216
  ]
1193
1217
  : []),
1194
- 'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
1195
- + 'above. It is not a document to summarise, quote back or file away.',
1218
+ 'When assigned execution, follow this workflow stage by stage. When assigned authoring '
1219
+ + 'or review only, edit or inspect it without starting work. Stage IDs above identify '
1220
+ + 'library stages; IDs inside Markdown tool links identify tool occurrences, not stages.',
1196
1221
  ].join('\n');
1197
1222
  },
1198
1223
  },
@@ -1210,7 +1235,9 @@ const TOOLS = [
1210
1235
  + 'is earlier than N or the same stage; a forward jump is refused and nothing is written. '
1211
1236
  + 'Takes no organisation or project: the workflow goes into the organisation this '
1212
1237
  + "conversation's project belongs to. It appears on /workflows at once. Never put an "
1213
- + 'absolute local path in any field.',
1238
+ + 'absolute local path in any field. '
1239
+ + 'branches are global conditions: when a condition holds, run runs_workflow_id in the same organisation and resume. '
1240
+ + 'ending is end or next-in-backlog. Omitted settings are preserved on edit; branches: [] clears branches.',
1214
1241
  input: {
1215
1242
  name: z.string(),
1216
1243
  description: z.string().optional(),
@@ -1224,9 +1251,14 @@ const TOOLS = [
1224
1251
  to_stage: z.number().int(),
1225
1252
  condition: z.string(),
1226
1253
  })).optional(),
1254
+ branches: z.array(z.object({
1255
+ when: z.string().trim().min(1),
1256
+ runs_workflow_id: z.string().uuid(),
1257
+ })).optional().describe('the whole new branch list; omitted preserves current branches, [] clears them'),
1258
+ ending: z.enum(['end', 'next-in-backlog']).optional(),
1227
1259
  },
1228
1260
  handler: async (caller, args) => {
1229
- const { name, description, stages, exits } = args;
1261
+ const { name, description, stages, exits, branches, ending } = args;
1230
1262
  /* ═══ CHECKED HERE IN THE NUMBERING THE AGENT SENT. ═══
1231
1263
  `cliv2_build_workflow` enforces every one of these itself, and its
1232
1264
  messages count stages from 0, a numbering this tool never showed the
@@ -1269,6 +1301,8 @@ const TOOLS = [
1269
1301
  body: stage.body,
1270
1302
  })),
1271
1303
  exits: wanted,
1304
+ branches: branches?.map(branch => ({ when: branch.when, runsWorkflowId: branch.runs_workflow_id })),
1305
+ ending,
1272
1306
  fromAgent: harness(),
1273
1307
  });
1274
1308
  await receipt(caller, 'workflow', id, workflowName);
@@ -1364,8 +1398,12 @@ const TOOLS = [
1364
1398
  + 'must be earlier than from_stage or the same, and exits off one stage are tried in the order '
1365
1399
  + 'sent. name and description left out are left as they are; an empty description clears it. '
1366
1400
  + 'To change the wording of an '
1367
- + 'existing stage use reword_stage. Refused, with nothing written, while any work item is '
1368
- + 'running the workflow. Never put an absolute local path in any field.',
1401
+ + 'existing stage use reword_stage first, then keep its stage number here. Read the current '
1402
+ + 'workflow before editing. Never replace a retained stage with a new name/body entry just '
1403
+ + 'to rename it, copy its wording, or bypass a protected-body refusal. Refused, with nothing written, while any work item is '
1404
+ + 'running the workflow. Never put an absolute local path in any field. '
1405
+ + 'branches are global conditions: when a condition holds, run runs_workflow_id in the same organisation and resume. '
1406
+ + 'ending is end or next-in-backlog. Omitted settings are preserved on edit; branches: [] clears branches.',
1369
1407
  input: {
1370
1408
  workflow_id: z.string(),
1371
1409
  name: z.string().optional(),
@@ -1383,9 +1421,14 @@ const TOOLS = [
1383
1421
  to_stage: z.number().int(),
1384
1422
  condition: z.string(),
1385
1423
  })).optional().describe('the whole new list of conditional ways back; send [] for none'),
1424
+ branches: z.array(z.object({
1425
+ when: z.string().trim().min(1),
1426
+ runs_workflow_id: z.string().uuid(),
1427
+ })).optional().describe('the whole new branch list; omitted preserves current branches, [] clears them'),
1428
+ ending: z.enum(['end', 'next-in-backlog']).optional(),
1386
1429
  },
1387
1430
  handler: async (caller, args) => {
1388
- const { workflow_id, name, description, stages, exits } = args;
1431
+ const { workflow_id, name, description, stages, exits, branches, ending } = args;
1389
1432
  const workflowName = name?.trim();
1390
1433
  if (workflowName === '')
1391
1434
  throw new Error('a workflow needs a name. Nothing was written.');
@@ -1436,10 +1479,8 @@ const TOOLS = [
1436
1479
  description: description?.trim() ?? null,
1437
1480
  stages: entries,
1438
1481
  exits: zeroBasedExits(exits, entries.length),
1439
- /* THE BRANCHES GO BACK AS THEY CAME. The RPC rewrites them from what it
1440
- is sent and nothing in v3 sets one, so sending anything else would
1441
- delete what a person set on `/workflows`. */
1442
- branches: workflow.branches,
1482
+ branches: branches?.map(branch => ({ when: branch.when, runsWorkflowId: branch.runs_workflow_id })) ?? workflow.branches,
1483
+ ending,
1443
1484
  fromAgent: harness(),
1444
1485
  });
1445
1486
  /* THE LABEL IS THE NAME THAT SURVIVED THE EDIT, which is the reply's own:
@@ -2010,7 +2051,8 @@ const TOOLS = [
2010
2051
  levels: [2],
2011
2052
  description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
2012
2053
  + 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
2013
- + 'the main branch. You do not write the question or the answers and you never merge anything: '
2054
+ + 'the main branch. First record verified completion with write_report(work_complete=true). '
2055
+ + 'Use say for progress; this tool cannot announce work you intend to do. You do not write the question or the answers and you never merge anything: '
2014
2056
  + 'the product composes both, and it performs the merge itself if they choose to put the work '
2015
2057
  + 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
2016
2058
  + 'you are started again with what the product did with their answer.',
@@ -2030,8 +2072,13 @@ const TOOLS = [
2030
2072
  + 'sentence saying what changed.');
2031
2073
  }
2032
2074
  const run = await only(client.from('panel3_runs')
2033
- .select('codebase_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
2075
+ .select('codebase_id, branch, base, completion_requested_token, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
2034
2076
  .eq('id', runId), 'read', 'which branch this card\'s work is on');
2077
+ if (run.completion_requested_token !== processToken) {
2078
+ throw new Error('NOTHING WAS WRITTEN and nobody was asked. The assignment has not been declared complete in this turn. '
2079
+ + 'Finish and verify every requested codebase first, then call write_report with work_complete=true before offer_ending. '
2080
+ + 'Use say for a progress update, not offer_ending.');
2081
+ }
2035
2082
  if (!run.codebase_id || !run.branch || !run.base || !run.card?.project_id) {
2036
2083
  throw new Error('NOTHING WAS WRITTEN. This card has no codebase, branch and base recorded, so there is no '
2037
2084
  + 'ending to offer. Say what you did and finish the card.');
@@ -2174,6 +2221,20 @@ const TOOLS = [
2174
2221
  return `Created work item ${item.name}, id ${item.id}.`;
2175
2222
  },
2176
2223
  },
2224
+ {
2225
+ name: 'edit_work_item_codebase',
2226
+ levels: [1, 2],
2227
+ description: 'Add or remove one SAVED work-item codebase target, preserving all other targets and attributes. Read get_work_item first for current targets and project codebase IDs. To select multiple codebases, add each; to replace a target, add its replacement before removing it. Duplicate additions are harmless. Re-read after edits or human changes. This edits the work item itself and provides an Open work item receipt; it does not attach to the conversation or execute files.',
2228
+ input: { work_item_id: z.string().uuid(), codebase_id: z.string().uuid(), action: z.enum(['add', 'remove']) },
2229
+ handler: async (caller, args) => {
2230
+ const a = args;
2231
+ const item = await only(caller.client.from('tasks')
2232
+ .select('id, name, project_id').eq('id', a.work_item_id), 'read', 'the work item');
2233
+ const targets = await editWorkItemCodebaseTarget(caller.client, item.id, item.project_id, a.codebase_id, a.action === 'add');
2234
+ await receiptOnce(caller, 'work_item', item.id, item.name);
2235
+ return JSON.stringify({ work_item_id: item.id, saved_codebase_targets: targets });
2236
+ },
2237
+ },
2177
2238
  // ── Do the work, and produce objects ─────────────────────────────────────
2178
2239
  //
2179
2240
  // ═══ EVERY LEVEL, AND THAT IS ux.md's FOURTH RULE. ═══ "Producing an object
@@ -2763,7 +2824,7 @@ const TOOLS = [
2763
2824
  await whileRunning(client.rpc('panel3_request_completion', {
2764
2825
  p_run_id: runId, p_process_token: processToken, p_summary: report,
2765
2826
  }), runId, 'declare completion of');
2766
- return 'Completion recorded. Give your final answer and exit. Done waits until all processes have exited.';
2827
+ return 'Completion recorded. If the finished work needs an ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
2767
2828
  }
2768
2829
  await whileRunning(processToken === undefined
2769
2830
  ? client.from('panel3_runs').update({ report }).eq('id', runId)
@@ -2929,6 +2990,13 @@ const TOOLS = [
2929
2990
  },
2930
2991
  },
2931
2992
  // ── Dispatch and stop ────────────────────────────────────────────────────
2993
+ {
2994
+ name: 'list_codex_models',
2995
+ levels: [1, 2],
2996
+ description: 'Read the models and reasoning efforts currently offered by the installed Codex harness on this machine. Call before choosing a Codex child model yourself. This is discovery, not an allowlist for explicit user model IDs or aliases. If discovery fails, omit autonomous model/effort choices to use the installed defaults; never guess an identifier.',
2997
+ input: {},
2998
+ handler: async () => JSON.stringify(await listCodexModels()),
2999
+ },
2932
3000
  {
2933
3001
  name: 'dispatch',
2934
3002
  /* ═══ ABSENT AT LEVEL 3, WHICH IS HOW "DEPTH STOPS AT THREE" IS ENFORCED.
@@ -2962,7 +3030,7 @@ const TOOLS = [
2962
3030
  + 'context: what to find out or change, and in which part of the codebase.'),
2963
3031
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
2964
3032
  work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
2965
- 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.'),
3033
+ model: z.string().min(1).refine(value => !/^(?:codex|claude(?: code)?)(?:\s|$)/i.test(value.trim()), 'Use only the model ID, without the Claude or Codex harness label. Omit model to use the installed default.').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: for Claude, prefer supported unversioned aliases such as sonnet or opus; do not invent version-suffixed aliases. For Codex, first call list_codex_models and choose from its result. If uncertain, omit for the harness or machine default, never parent inheritance.'),
2966
3034
  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.'),
2967
3035
  work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
2968
3036
  + 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
@@ -2992,6 +3060,9 @@ const TOOLS = [
2992
3060
  + 'the work, do not describe it.');
2993
3061
  }
2994
3062
  }
3063
+ const attached = await loadAttachments(caller.client, caller.cardId);
3064
+ const workItems = attached.filter(attachment => attachment.kind === 'work_item');
3065
+ const scopedWorkItem = work_item_id ?? (workItems.length === 1 ? workItems[0].ref_id : undefined);
2995
3066
  let codebase = null;
2996
3067
  if (codebase_id) {
2997
3068
  const projectId = await projectOfCard(caller);
@@ -3003,6 +3074,19 @@ const TOOLS = [
3003
3074
  if (!codebase) {
3004
3075
  throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
3005
3076
  }
3077
+ if (!scopedWorkItem && workItems.length > 1) {
3078
+ throw new Error('No agent was started. Identify the work item for this piece before choosing its codebase.');
3079
+ }
3080
+ if (scopedWorkItem) {
3081
+ await only(caller.client.from('tasks').select('id').eq('id', scopedWorkItem)
3082
+ .eq('project_id', projectId).is('archived_at', null).eq('is_idea', false), 'read', 'the live work item in this conversation\'s project');
3083
+ const targets = await listWorkItemCodebaseTargets(caller.client, scopedWorkItem);
3084
+ if (!targets.includes(codebase.gitRemoteUrl)) {
3085
+ throw new Error('No agent was started. This codebase is not a saved target of the work item. '
3086
+ + 'Read get_work_item for its current saved targets; if none are saved, ask the person to select one. '
3087
+ + 'Do not fall back to another project codebase or change targets to bypass this check.');
3088
+ }
3089
+ }
3006
3090
  }
3007
3091
  /* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
3008
3092
  the words in the brief; `panel3_dispatch` decides the level on the row,
@@ -3017,7 +3101,6 @@ const TOOLS = [
3017
3101
  three hops from level 1 still knows which item it is working, without
3018
3102
  asking. See `workBrief`'s own doc for why the two are different things
3019
3103
  carried the same way. */
3020
- const attached = await loadAttachments(caller.client, caller.cardId);
3021
3104
  /* ═══ THE CONVERSATION IS NAMED HERE OR IT IS NEVER NAMED. ═══ Sending
3022
3105
  somebody resolves the working copy, and resolving it cuts the branch
3023
3106
  from whatever the conversation is called at that moment and stamps it
@@ -3032,7 +3115,7 @@ const TOOLS = [
3032
3115
  .select('id'), 'name', `card ${caller.cardId}`);
3033
3116
  }
3034
3117
  const attachments = attached.map(attachmentLine);
3035
- const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
3118
+ const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, scopedWorkItem, attachments, codebase === null ? undefined : {
3036
3119
  id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
3037
3120
  }), codebase, caller.processToken, { model, effort });
3038
3121
  /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
package/dist/presence.js CHANGED
@@ -9,6 +9,8 @@ import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
9
9
  import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
10
10
  /* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
11
11
  import { sweepStrandedCodexHomes } from './codex-home.js';
12
+ import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
13
+ import { startPanel } from './panel3/run.js';
12
14
  let presence = null;
13
15
  /** In-flight guard: startPresence yields to the event loop (network setSession)
14
16
  * before `presence` is assigned, so a plain `if (presence)` check lets two
@@ -246,21 +248,57 @@ async function cleanupSupersededRows(p) {
246
248
  }
247
249
  }
248
250
  async function pollCommands(p) {
251
+ if (p.pollingCommands)
252
+ return;
253
+ p.pollingCommands = true;
249
254
  try {
250
255
  const { data, error } = await p.client
251
256
  .from('cliv2_commands')
252
257
  .update({ status: 'ack', acked_at: new Date().toISOString() })
253
258
  .eq('machine_id', p.identity.id)
254
259
  .eq('status', 'pending')
260
+ .eq('command', 'ping')
255
261
  .select('id, command');
256
262
  if (error)
257
263
  throw error;
258
264
  for (const cmd of data ?? [])
259
265
  console.log(`Acked ${cmd.command} (${cmd.id})`);
266
+ const { data: restarts, error: restartError } = await p.client
267
+ .from('cliv2_commands')
268
+ .update({ status: 'processing' })
269
+ .eq('machine_id', p.identity.id)
270
+ .eq('status', 'pending')
271
+ .eq('command', 'restart_worker')
272
+ .select('id, created_at');
273
+ if (restartError)
274
+ throw restartError;
275
+ for (const command of restarts ?? []) {
276
+ let result = 'Worker restarted';
277
+ let status = 'ack';
278
+ try {
279
+ if (Date.now() - Date.parse(command.created_at) > 30_000) {
280
+ throw new Error('This restart request expired. Try again while the machine is online.');
281
+ }
282
+ if (!p.panel)
283
+ throw new Error('The worker has not started. Open Companion on this machine and reconnect.');
284
+ await p.panel.restart();
285
+ }
286
+ catch (error) {
287
+ status = 'failed';
288
+ result = error instanceof Error ? error.message : String(error);
289
+ }
290
+ const { error: saved } = await p.client.from('cliv2_commands')
291
+ .update({ status, result, acked_at: new Date().toISOString() }).eq('id', command.id);
292
+ if (saved)
293
+ throw saved;
294
+ }
260
295
  }
261
296
  catch (err) {
262
297
  console.warn(`command poll failed, will retry: ${err.message}`);
263
298
  }
299
+ finally {
300
+ p.pollingCommands = false;
301
+ }
264
302
  }
265
303
  /**
266
304
  * One turn of the orchestrator listener (feature 16, Slice 3). All the logic is
@@ -298,12 +336,17 @@ async function pollOrchestrator(p) {
298
336
  * the companion guard for that; the terminal daemon lets it surface. No-op if
299
337
  * already running. */
300
338
  export async function startPresence() {
339
+ if (stopping)
340
+ await stopping;
301
341
  if (presence) {
302
342
  return { machineName: presence.identity.name, agents: presence.agents };
303
343
  }
304
344
  if (starting)
305
345
  return starting;
306
346
  starting = (async () => {
347
+ const lock = claimDaemonLock();
348
+ if (lock.held)
349
+ throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
307
350
  const identity = getMachineIdentity();
308
351
  const agents = detectAgents();
309
352
  const client = await getClient();
@@ -337,6 +380,7 @@ export async function startPresence() {
337
380
  throw new Error('Signed-in user could not be resolved. Sign in again.');
338
381
  const p = {
339
382
  client,
383
+ panel: null,
340
384
  userId,
341
385
  identity,
342
386
  agents,
@@ -482,11 +526,19 @@ export async function startPresence() {
482
526
  catch (err) {
483
527
  console.warn(`Agent tools server did not start: ${err.message}`);
484
528
  }
529
+ // Both cs open and cs start answer cards through this one lifecycle.
530
+ // Read the owner's client on every use, including after refresh or logout.
531
+ p.panel = startPanel(() => p.client);
485
532
  return { machineName: identity.name, agents };
486
533
  })();
487
534
  try {
488
535
  return await starting;
489
536
  }
537
+ catch (error) {
538
+ await stopPresence();
539
+ releaseDaemonLock();
540
+ throw error;
541
+ }
490
542
  finally {
491
543
  starting = null;
492
544
  }
@@ -535,6 +587,7 @@ export async function stopPresence({ unregister = false } = {}) {
535
587
  call `stopPresence()` and kill the presence belonging to a DIFFERENT
536
588
  session, one that is signed in and healthy. */
537
589
  p.authSubscription.unsubscribe();
590
+ await p.panel?.stop();
538
591
  // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
539
592
  // config so a logged-out machine leaves no dead server that would read "failed
540
593
  // to connect" on the next agent run. Fire-and-forget best-effort — never blocks
@@ -561,5 +614,6 @@ export async function stopPresence({ unregister = false } = {}) {
561
614
  }
562
615
  finally {
563
616
  stopping = null;
617
+ releaseDaemonLock();
564
618
  }
565
619
  }
@@ -123,6 +123,7 @@ export function authorToolMentions(body) {
123
123
  }
124
124
  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. ' +
125
125
  'Use ask_question for missing requirements that materially change the workflow, then continue after the answer. ' +
126
+ 'If the user requests plain prose or no tool mentions, write plain Markdown only: do not add tool links, permissions, or an approval template. ' +
126
127
  'Encode every requested tool use and permission as a Markdown mention in the stage body. ' +
127
128
  'Use compact links: [@Create Artifact](ctrl-spc://tool/create_artifact?approval=not-required) to write a plan without asking; ' +
128
129
  '[@Ask Question](ctrl-spc://tool/ask_question?approval=not-required) to review its output; ' +
package/dist/workflows.js CHANGED
@@ -144,6 +144,7 @@ export async function buildWorkflow(client, input) {
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]));
147
+ input.branches?.forEach((branch, i) => prose.push([`branch ${i + 1} condition`, branch.when]));
147
148
  for (const [field, value] of prose) {
148
149
  const token = absolutePathToken(value);
149
150
  if (token) {
@@ -158,8 +159,9 @@ export async function buildWorkflow(client, input) {
158
159
  p_description: input.description,
159
160
  p_stages: input.stages,
160
161
  p_exits: input.exits,
161
- p_branches: [],
162
+ p_branches: (input.branches ?? []).map(branch => ({ when: branch.when, runs_workflow_id: branch.runsWorkflowId })),
162
163
  p_from_agent: input.fromAgent,
164
+ ...(input.ending === undefined ? {} : { p_ending: input.ending }),
163
165
  });
164
166
  if (error)
165
167
  throw new Error(`could not create workflow ${input.name}: ${error.message}`);
@@ -239,11 +241,8 @@ export async function rewordStage(client, input) {
239
241
  * behalf. Its refusals come back verbatim behind one sentence naming what was
240
242
  * being edited.
241
243
  *
242
- * ═══ BRANCHES ARE PASSED BACK AS THEY WERE READ. ═══ The RPC replaces them from
243
- * `p_branches`, and nothing in v3 sets one, so the caller hands back the list
244
- * `readWorkflow` read a moment before. A branch written between that read and
245
- * this call is lost, which is the posture the web's `replaceStructure` records
246
- * for itself.
244
+ * Branches are the complete replacement list. Callers preserve the current
245
+ * list when no branch change was requested, matching the web editor.
247
246
  *
248
247
  * ═══ NULL NAME OR DESCRIPTION MEANS UNCHANGED, ═══ which is the RPC's own
249
248
  * contract: an agent adding a stage does not restate a name it is not touching.
@@ -257,6 +256,7 @@ export async function editWorkflow(client, input) {
257
256
  prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
258
257
  });
259
258
  input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
259
+ input.branches.forEach((branch, i) => prose.push([`branch ${i + 1} condition`, branch.when]));
260
260
  for (const [field, value] of prose) {
261
261
  const token = value === null ? null : absolutePathToken(value);
262
262
  if (token) {
@@ -273,6 +273,7 @@ export async function editWorkflow(client, input) {
273
273
  p_exits: input.exits,
274
274
  p_branches: input.branches.map((branch) => ({ when: branch.when, runs_workflow_id: branch.runsWorkflowId })),
275
275
  p_from_agent: input.fromAgent,
276
+ ...(input.ending === undefined ? {} : { p_ending: input.ending }),
276
277
  });
277
278
  if (error)
278
279
  throw new Error(`could not edit the workflow: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
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"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "tsc",
20
20
  "start": "npm run build && node dist/index.js",
21
- "test": "npm run build && node --test test/*.test.mjs"
21
+ "test": "npm run build && node --test --test-concurrency=1 test/*.test.mjs"
22
22
  },
23
23
  "license": "UNLICENSED",
24
24
  "dependencies": {