@shipers-dev/multi 0.6.8 → 0.7.0

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/index.js CHANGED
@@ -96,8 +96,8 @@ async function request(url, options) {
96
96
  }
97
97
  var apiClient = {
98
98
  get: (url) => request(url),
99
- post: (url, body) => request(url, { method: "POST", body: JSON.stringify(body) }),
100
- patch: (url, body) => request(url, { method: "PATCH", body: JSON.stringify(body) }),
99
+ post: (url, body, opts) => request(url, { method: "POST", body: JSON.stringify(body), headers: opts?.headers }),
100
+ patch: (url, body, opts) => request(url, { method: "PATCH", body: JSON.stringify(body), headers: opts?.headers }),
101
101
  delete: (url) => request(url, { method: "DELETE" })
102
102
  };
103
103
 
@@ -5599,7 +5599,7 @@ var LOG_PATH2 = join2(MULTI_DIR, "logs", "agent.log");
5599
5599
  var SKILLS_DIR = join2(MULTI_DIR, "skills");
5600
5600
  var STOP_PATH = join2(MULTI_DIR, "stop.flag");
5601
5601
  var TASKS_DB_PATH = join2(MULTI_DIR, "tasks.db");
5602
- var VERSION = "0.6.7";
5602
+ var VERSION = "0.7.0";
5603
5603
  var COMMANDS = {
5604
5604
  setup: "Register this device with a workspace",
5605
5605
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5838,6 +5838,26 @@ async function cmdConnect(apiUrl, config) {
5838
5838
  workerWake = null;
5839
5839
  } catch {}
5840
5840
  };
5841
+ const localAgentIds = new Set;
5842
+ const refreshLocalAgents = async () => {
5843
+ try {
5844
+ const res = await apiClient.get(`${apiUrl}/api/devices/${config.deviceId}/agents`);
5845
+ const rows = res.data?.results || res.data || [];
5846
+ if (Array.isArray(rows)) {
5847
+ localAgentIds.clear();
5848
+ for (const a of rows)
5849
+ if (a?.id)
5850
+ localAgentIds.add(a.id);
5851
+ }
5852
+ } catch {}
5853
+ };
5854
+ await refreshLocalAgents();
5855
+ const enqueueLocal = (task) => {
5856
+ const taskId = task?.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
5857
+ db.run("INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)", [taskId, "pending", JSON.stringify(task)]);
5858
+ notifyWorker();
5859
+ return taskId;
5860
+ };
5841
5861
  const port = await pickFreePort();
5842
5862
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
5843
5863
  const server = Bun.serve({
@@ -5922,7 +5942,7 @@ async function cmdConnect(apiUrl, config) {
5922
5942
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5923
5943
  try {
5924
5944
  const task = JSON.parse(row.payload);
5925
- await handleRunTask(apiUrl, config.deviceId, task, detected);
5945
+ await handleRunTask(apiUrl, config.deviceId, task, detected, { localAgentIds, enqueueLocal, refreshLocalAgents });
5926
5946
  db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5927
5947
  } catch (e) {
5928
5948
  log(`task ${row.id} error: ${String(e)}`);
@@ -6002,7 +6022,7 @@ async function parseTunnelUrl(stream2) {
6002
6022
  }
6003
6023
  return null;
6004
6024
  }
6005
- async function handleRunTask(apiUrl, deviceId, task, detected) {
6025
+ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6006
6026
  const issueId = task.issue_id;
6007
6027
  const isFollowup = !!task.followup;
6008
6028
  const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
@@ -6308,6 +6328,7 @@ ${body}
6308
6328
  } catch (e) {
6309
6329
  log(`preamble fetch failed: ${String(e)}`);
6310
6330
  }
6331
+ preamble += await buildPlanningPreamble(apiUrl, task);
6311
6332
  const prompt = preamble ? `${preamble}
6312
6333
  ---
6313
6334
 
@@ -6367,6 +6388,7 @@ ${body}
6367
6388
  }
6368
6389
  }
6369
6390
  } catch {}
6391
+ preamble += await buildPlanningPreamble(apiUrl, task);
6370
6392
  const base = `${task.title}
6371
6393
 
6372
6394
  ${task.description || ""}`.trim();
@@ -6420,6 +6442,19 @@ ${userPart}` : userPart;
6420
6442
  if (n > 0)
6421
6443
  log(` \uD83D\uDCCE uploaded ${n} output file(s)`);
6422
6444
  }
6445
+ if (ctx) {
6446
+ const fullText = turn.blocks.filter((b) => b.kind === "text").map((b) => b.text).join(`
6447
+ `);
6448
+ const actions = extractPlanActions(fullText);
6449
+ if (actions.length) {
6450
+ const summary = await executePlanActions(apiUrl, task, actions, ctx);
6451
+ if (summary) {
6452
+ try {
6453
+ await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: "agent", author_id: task.agent_id, author_name: "agent", body: summary });
6454
+ } catch {}
6455
+ }
6456
+ }
6457
+ }
6423
6458
  if (!hasAssistantText && !hadError) {
6424
6459
  const stopReason = turn.result?.stopReason || "unknown";
6425
6460
  await postComment(`\u26A0\uFE0F Agent returned no output (stopReason=${stopReason}). Adapter may be stuck on a stale session \u2014 try starting a new issue or clearing session_id.`);
@@ -6439,6 +6474,180 @@ ${userPart}` : userPart;
6439
6474
  log(` \u2717 ${task.key} failed: ${String(e)}`);
6440
6475
  }
6441
6476
  }
6477
+ async function buildPlanningPreamble(apiUrl, task) {
6478
+ const depth = typeof task.planning_depth === "number" ? task.planning_depth : 0;
6479
+ if (depth >= 1) {
6480
+ return `# Planning (sub-task context)
6481
+
6482
+ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-plan\` block to update your own issue's status (e.g. mark done/failed) but CANNOT create child issues or delegate further.
6483
+
6484
+ \`\`\`multi-plan
6485
+ {"actions":[{"type":"update","id":"<this issue id>","status":"done"}]}
6486
+ \`\`\`
6487
+
6488
+ `;
6489
+ }
6490
+ let projectId = "";
6491
+ let agentsBlock = "";
6492
+ try {
6493
+ const issueRes = await apiClient.get(`${apiUrl}/api/issues/${task.issue_id}`);
6494
+ projectId = issueRes.data?.project_id || "";
6495
+ const agentRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
6496
+ const workspaceId = agentRes.data?.workspace_id;
6497
+ if (workspaceId) {
6498
+ const ag = await apiClient.get(`${apiUrl}/api/agents?workspace_id=${workspaceId}`);
6499
+ const list = Array.isArray(ag.data) ? ag.data : [];
6500
+ const others = list.filter((a) => a.id !== task.agent_id);
6501
+ if (others.length) {
6502
+ agentsBlock = others.map((a) => `- \`${a.id}\` (${a.name}${a.type ? `, ${a.type}` : ""})`).join(`
6503
+ `);
6504
+ }
6505
+ }
6506
+ } catch {}
6507
+ return `# Planning & delegation
6508
+
6509
+ After finishing your reply, you may append ONE fenced block to create child issues, update any issue in this project, or delegate to another agent. The daemon parses this block and executes the actions after your turn.
6510
+
6511
+ Syntax:
6512
+
6513
+ \`\`\`multi-plan
6514
+ {"actions":[
6515
+ {"type":"create","title":"...","description":"...","assignee_type":"agent","assignee_id":"<agent id>"},
6516
+ {"type":"update","id":"<issue id>","status":"done"},
6517
+ {"type":"delegate","id":"<issue id>","assignee_id":"<agent id>"}
6518
+ ]}
6519
+ \`\`\`
6520
+
6521
+ Rules:
6522
+ - Omit the block entirely if no actions are needed.
6523
+ - Max 10 actions per turn; additional actions are dropped.
6524
+ - Sub-issues spawned from your plan run one-shot: they cannot themselves spawn further children (only \`update\` allowed there).
6525
+ - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
6526
+ - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
6527
+ - \`delegate\` is shorthand for reassigning and resetting status to todo.
6528
+ - Only target issues in the current project (${projectId || "this project"}).
6529
+
6530
+ ${agentsBlock ? `Available agents you can delegate to:
6531
+ ${agentsBlock}
6532
+ ` : ""}`;
6533
+ }
6534
+ function extractPlanActions(text) {
6535
+ const out = [];
6536
+ if (!text)
6537
+ return out;
6538
+ const re = /```multi-plan\s*\n([\s\S]*?)\n```/g;
6539
+ let m;
6540
+ while ((m = re.exec(text)) !== null) {
6541
+ try {
6542
+ const parsed = JSON.parse(m[1]);
6543
+ const actions = Array.isArray(parsed?.actions) ? parsed.actions : Array.isArray(parsed) ? parsed : [parsed];
6544
+ for (const a of actions)
6545
+ if (a && typeof a === "object" && typeof a.type === "string")
6546
+ out.push(a);
6547
+ } catch {}
6548
+ }
6549
+ return out;
6550
+ }
6551
+ var PLAN_ACTION_LIMIT = 10;
6552
+ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6553
+ const lines = [];
6554
+ let truncated = false;
6555
+ if (actions.length > PLAN_ACTION_LIMIT) {
6556
+ truncated = true;
6557
+ actions = actions.slice(0, PLAN_ACTION_LIMIT);
6558
+ }
6559
+ const depth = typeof parentTask.planning_depth === "number" ? parentTask.planning_depth : 0;
6560
+ if (depth >= 1) {
6561
+ const blocked = actions.filter((a) => a.type === "create" || a.type === "delegate").length;
6562
+ actions = actions.filter((a) => a.type === "update");
6563
+ if (blocked)
6564
+ lines.push(`- \u26A0 ${blocked} create/delegate action(s) blocked (planning depth limit)`);
6565
+ }
6566
+ const parentId = parentTask.issue_id;
6567
+ const parentProjectId = await (async () => {
6568
+ try {
6569
+ const r = await apiClient.get(`${apiUrl}/api/issues/${parentId}`);
6570
+ return r.data?.project_id;
6571
+ } catch {
6572
+ return null;
6573
+ }
6574
+ })();
6575
+ const headers = { "x-agent-id": parentTask.agent_id };
6576
+ await ctx.refreshLocalAgents();
6577
+ for (const a of actions) {
6578
+ try {
6579
+ if (a.type === "create") {
6580
+ const body = {
6581
+ action: "create",
6582
+ project_id: a.project_id || parentProjectId,
6583
+ title: a.title,
6584
+ description: a.description,
6585
+ priority: a.priority,
6586
+ assignee_type: a.assignee_type,
6587
+ assignee_id: a.assignee_id,
6588
+ parent_id: a.parent_id || parentId
6589
+ };
6590
+ const res = await apiClient.post(`${apiUrl}/api/issues/agent/mutate`, body, { headers });
6591
+ if (!res.success) {
6592
+ lines.push(`- \u274C create "${a.title}": ${res.error || res.status}`);
6593
+ continue;
6594
+ }
6595
+ const created = res.data;
6596
+ lines.push(`- \u2713 created **${created.key}** \u2014 ${created.title}${created.assignee_id ? ` \u2192 @${created.assignee_id}` : ""}`);
6597
+ if (created.assignee_type === "agent" && created.assignee_id && ctx.localAgentIds.has(created.assignee_id)) {
6598
+ ctx.enqueueLocal({
6599
+ issue_id: created.id,
6600
+ key: created.key,
6601
+ title: created.title,
6602
+ description: created.description,
6603
+ agent_id: created.assignee_id,
6604
+ session_id: null,
6605
+ working_dir: parentTask.working_dir || null,
6606
+ planning_depth: depth + 1
6607
+ });
6608
+ lines.push(` \u21B3 dispatched locally (same runtime)`);
6609
+ }
6610
+ } else if (a.type === "update") {
6611
+ const res = await apiClient.post(`${apiUrl}/api/issues/agent/mutate`, { action: "update", ...a }, { headers });
6612
+ if (!res.success) {
6613
+ lines.push(`- \u274C update ${a.id}: ${res.error || res.status}`);
6614
+ continue;
6615
+ }
6616
+ lines.push(`- \u2713 updated ${res.data.key}`);
6617
+ } else if (a.type === "delegate") {
6618
+ const res = await apiClient.post(`${apiUrl}/api/issues/agent/mutate`, { action: "update", id: a.id, assignee_type: "agent", assignee_id: a.assignee_id, status: "todo" }, { headers });
6619
+ if (!res.success) {
6620
+ lines.push(`- \u274C delegate ${a.id}: ${res.error || res.status}`);
6621
+ continue;
6622
+ }
6623
+ lines.push(`- \u2713 delegated ${res.data.key} \u2192 ${a.assignee_id}`);
6624
+ if (ctx.localAgentIds.has(a.assignee_id)) {
6625
+ ctx.enqueueLocal({
6626
+ issue_id: res.data.id,
6627
+ key: res.data.key,
6628
+ title: res.data.title,
6629
+ description: res.data.description,
6630
+ agent_id: a.assignee_id,
6631
+ session_id: res.data.session_id || null,
6632
+ working_dir: parentTask.working_dir || null,
6633
+ planning_depth: depth + 1
6634
+ });
6635
+ lines.push(` \u21B3 dispatched locally (same runtime)`);
6636
+ }
6637
+ }
6638
+ } catch (e) {
6639
+ lines.push(`- \u274C ${a.type} failed: ${String(e)}`);
6640
+ }
6641
+ }
6642
+ if (truncated)
6643
+ lines.push(`- \u26A0 action list truncated at ${PLAN_ACTION_LIMIT}`);
6644
+ if (!lines.length)
6645
+ return "";
6646
+ return `**Planning actions**
6647
+
6648
+ ${lines.join(`
6649
+ `)}`;
6650
+ }
6442
6651
  function stripMd(s) {
6443
6652
  return s.replace(/[`*_]/g, "").trim();
6444
6653
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/client.ts CHANGED
@@ -38,9 +38,9 @@ async function request<T>(url: string, options?: RequestInit): Promise<ApiRespon
38
38
 
39
39
  export const apiClient = {
40
40
  get: <T = unknown>(url: string) => request<T>(url),
41
- post: <T = unknown>(url: string, body: unknown) =>
42
- request<T>(url, { method: 'POST', body: JSON.stringify(body) }),
43
- patch: <T = unknown>(url: string, body: unknown) =>
44
- request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
41
+ post: <T = unknown>(url: string, body: unknown, opts?: { headers?: Record<string, string> }) =>
42
+ request<T>(url, { method: 'POST', body: JSON.stringify(body), headers: opts?.headers }),
43
+ patch: <T = unknown>(url: string, body: unknown, opts?: { headers?: Record<string, string> }) =>
44
+ request<T>(url, { method: 'PATCH', body: JSON.stringify(body), headers: opts?.headers }),
45
45
  delete: <T = unknown>(url: string) => request<T>(url, { method: 'DELETE' }),
46
46
  };
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
17
17
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
18
18
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
19
19
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
20
- const VERSION = '0.6.7';
20
+ const VERSION = '0.7.0';
21
21
 
22
22
  const COMMANDS = {
23
23
  setup: 'Register this device with a workspace',
@@ -257,6 +257,29 @@ async function cmdConnect(apiUrl: string, config: Config) {
257
257
  let workerWake: (() => void) | null = null;
258
258
  const notifyWorker = () => { try { workerWake?.(); workerWake = null; } catch {} };
259
259
 
260
+ // Agents linked to this device (refreshed periodically). Used to short-circuit
261
+ // server dispatch when an agent-created child issue is assigned to an agent
262
+ // running on the same runtime.
263
+ const localAgentIds = new Set<string>();
264
+ const refreshLocalAgents = async () => {
265
+ try {
266
+ const res = await apiClient.get<any>(`${apiUrl}/api/devices/${config.deviceId}/agents`);
267
+ const rows = res.data?.results || res.data || [];
268
+ if (Array.isArray(rows)) {
269
+ localAgentIds.clear();
270
+ for (const a of rows) if (a?.id) localAgentIds.add(a.id);
271
+ }
272
+ } catch {}
273
+ };
274
+ await refreshLocalAgents();
275
+
276
+ const enqueueLocal = (task: any) => {
277
+ const taskId = task?.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
278
+ db.run('INSERT INTO tasks (id, status, payload) VALUES (?, ?, ?)', [taskId, 'pending', JSON.stringify(task)]);
279
+ notifyWorker();
280
+ return taskId;
281
+ };
282
+
260
283
  // Local HTTP server on a free port
261
284
  const port = await pickFreePort();
262
285
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
@@ -328,7 +351,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
328
351
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
329
352
  try {
330
353
  const task = JSON.parse(row.payload);
331
- await handleRunTask(apiUrl, config.deviceId!, task, detected);
354
+ await handleRunTask(apiUrl, config.deviceId!, task, detected, { localAgentIds, enqueueLocal, refreshLocalAgents });
332
355
  db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
333
356
  } catch (e) {
334
357
  log(`task ${row.id} error: ${String(e)}`);
@@ -406,7 +429,13 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
406
429
  return null;
407
430
  }
408
431
 
409
- async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
432
+ interface RuntimeCtx {
433
+ localAgentIds: Set<string>;
434
+ enqueueLocal: (task: any) => string;
435
+ refreshLocalAgents: () => Promise<void>;
436
+ }
437
+
438
+ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
410
439
  const issueId = task.issue_id;
411
440
  const isFollowup = !!task.followup;
412
441
  const workingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
@@ -658,6 +687,8 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
658
687
  log(`preamble fetch failed: ${String(e)}`);
659
688
  }
660
689
 
690
+ preamble += await buildPlanningPreamble(apiUrl, task);
691
+
661
692
  const prompt = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
662
693
 
663
694
  // Pick adapter for the agent's declared type if we have it, else first ACP-capable
@@ -699,6 +730,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
699
730
  }
700
731
  }
701
732
  } catch {}
733
+ preamble += await buildPlanningPreamble(apiUrl, task);
702
734
  const base = `${task.title}\n\n${task.description || ''}`.trim();
703
735
  let userPart: string;
704
736
  if (task.followup) {
@@ -733,6 +765,18 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
733
765
  if (n > 0) log(` 📎 uploaded ${n} output file(s)`);
734
766
  }
735
767
 
768
+ // Post-turn: scan agent text for `multi-plan` fenced blocks and execute mutations.
769
+ if (ctx) {
770
+ const fullText = turn.blocks.filter(b => b.kind === 'text').map(b => (b as any).text).join('\n');
771
+ const actions = extractPlanActions(fullText);
772
+ if (actions.length) {
773
+ const summary = await executePlanActions(apiUrl, task, actions, ctx);
774
+ if (summary) {
775
+ try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body: summary }); } catch {}
776
+ }
777
+ }
778
+ }
779
+
736
780
  // Visible fallback: agent ran but emitted nothing user-facing
737
781
  if (!hasAssistantText && !hadError) {
738
782
  const stopReason = turn.result?.stopReason || 'unknown';
@@ -750,6 +794,164 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
750
794
  }
751
795
  }
752
796
 
797
+ // Appended to agent prompt. Teaches the agent how to emit planning actions the
798
+ // daemon will execute after the turn: create sub-issues, update any issue in
799
+ // the project, or delegate to other agents.
800
+ async function buildPlanningPreamble(apiUrl: string, task: any): Promise<string> {
801
+ const depth = typeof task.planning_depth === 'number' ? task.planning_depth : 0;
802
+ if (depth >= 1) {
803
+ return `# Planning (sub-task context)
804
+
805
+ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-plan\` block to update your own issue's status (e.g. mark done/failed) but CANNOT create child issues or delegate further.
806
+
807
+ \`\`\`multi-plan
808
+ {"actions":[{"type":"update","id":"<this issue id>","status":"done"}]}
809
+ \`\`\`
810
+
811
+ `;
812
+ }
813
+ let projectId = '';
814
+ let agentsBlock = '';
815
+ try {
816
+ const issueRes = await apiClient.get<any>(`${apiUrl}/api/issues/${task.issue_id}`);
817
+ projectId = issueRes.data?.project_id || '';
818
+ const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
819
+ const workspaceId = agentRes.data?.workspace_id;
820
+ if (workspaceId) {
821
+ const ag = await apiClient.get<any[]>(`${apiUrl}/api/agents?workspace_id=${workspaceId}`);
822
+ const list = Array.isArray(ag.data) ? ag.data : [];
823
+ const others = list.filter(a => a.id !== task.agent_id);
824
+ if (others.length) {
825
+ agentsBlock = others.map(a => `- \`${a.id}\` (${a.name}${a.type ? `, ${a.type}` : ''})`).join('\n');
826
+ }
827
+ }
828
+ } catch {}
829
+
830
+ return `# Planning & delegation
831
+
832
+ After finishing your reply, you may append ONE fenced block to create child issues, update any issue in this project, or delegate to another agent. The daemon parses this block and executes the actions after your turn.
833
+
834
+ Syntax:
835
+
836
+ \`\`\`multi-plan
837
+ {"actions":[
838
+ {"type":"create","title":"...","description":"...","assignee_type":"agent","assignee_id":"<agent id>"},
839
+ {"type":"update","id":"<issue id>","status":"done"},
840
+ {"type":"delegate","id":"<issue id>","assignee_id":"<agent id>"}
841
+ ]}
842
+ \`\`\`
843
+
844
+ Rules:
845
+ - Omit the block entirely if no actions are needed.
846
+ - Max 10 actions per turn; additional actions are dropped.
847
+ - Sub-issues spawned from your plan run one-shot: they cannot themselves spawn further children (only \`update\` allowed there).
848
+ - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
849
+ - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
850
+ - \`delegate\` is shorthand for reassigning and resetting status to todo.
851
+ - Only target issues in the current project (${projectId || 'this project'}).
852
+
853
+ ${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`;
854
+ }
855
+
856
+ type PlanAction =
857
+ | { type: 'create'; project_id?: string; title: string; description?: string; priority?: string; assignee_type?: string; assignee_id?: string; parent_id?: string }
858
+ | { type: 'update'; id: string; title?: string; description?: string; status?: string; priority?: string; assignee_type?: string; assignee_id?: string }
859
+ | { type: 'delegate'; id: string; assignee_id: string };
860
+
861
+ // Extract JSON action blocks fenced as ```multi-plan ... ```
862
+ function extractPlanActions(text: string): PlanAction[] {
863
+ const out: PlanAction[] = [];
864
+ if (!text) return out;
865
+ const re = /```multi-plan\s*\n([\s\S]*?)\n```/g;
866
+ let m: RegExpExecArray | null;
867
+ while ((m = re.exec(text)) !== null) {
868
+ try {
869
+ const parsed = JSON.parse(m[1]);
870
+ const actions = Array.isArray(parsed?.actions) ? parsed.actions : Array.isArray(parsed) ? parsed : [parsed];
871
+ for (const a of actions) if (a && typeof a === 'object' && typeof a.type === 'string') out.push(a as PlanAction);
872
+ } catch {}
873
+ }
874
+ return out;
875
+ }
876
+
877
+ const PLAN_ACTION_LIMIT = 10;
878
+
879
+ async function executePlanActions(apiUrl: string, parentTask: any, actions: PlanAction[], ctx: RuntimeCtx): Promise<string> {
880
+ const lines: string[] = [];
881
+ let truncated = false;
882
+ if (actions.length > PLAN_ACTION_LIMIT) {
883
+ truncated = true;
884
+ actions = actions.slice(0, PLAN_ACTION_LIMIT);
885
+ }
886
+ // Prevent agent recursion: a child turn's plan cannot itself spawn more children.
887
+ // `planning_depth` is propagated from parent task; depth >= 1 blocks create/delegate.
888
+ const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
889
+ if (depth >= 1) {
890
+ const blocked = actions.filter(a => a.type === 'create' || a.type === 'delegate').length;
891
+ actions = actions.filter(a => a.type === 'update');
892
+ if (blocked) lines.push(`- ⚠ ${blocked} create/delegate action(s) blocked (planning depth limit)`);
893
+ }
894
+ const parentId = parentTask.issue_id;
895
+ const parentProjectId = await (async () => {
896
+ try {
897
+ const r = await apiClient.get<any>(`${apiUrl}/api/issues/${parentId}`);
898
+ return r.data?.project_id;
899
+ } catch { return null; }
900
+ })();
901
+ const headers = { 'x-agent-id': parentTask.agent_id };
902
+
903
+ // Refresh the set of agents linked to this device once per plan execution.
904
+ await ctx.refreshLocalAgents();
905
+
906
+ for (const a of actions) {
907
+ try {
908
+ if (a.type === 'create') {
909
+ const body = {
910
+ action: 'create' as const,
911
+ project_id: a.project_id || parentProjectId,
912
+ title: a.title, description: a.description,
913
+ priority: a.priority, assignee_type: a.assignee_type, assignee_id: a.assignee_id,
914
+ parent_id: a.parent_id || parentId,
915
+ };
916
+ const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, body, { headers });
917
+ if (!res.success) { lines.push(`- ❌ create "${a.title}": ${res.error || res.status}`); continue; }
918
+ const created = res.data;
919
+ lines.push(`- ✓ created **${created.key}** — ${created.title}${created.assignee_id ? ` → @${created.assignee_id}` : ''}`);
920
+ // Same-runtime shortcut: if new issue's agent is linked locally, enqueue directly.
921
+ if (created.assignee_type === 'agent' && created.assignee_id && ctx.localAgentIds.has(created.assignee_id)) {
922
+ ctx.enqueueLocal({
923
+ issue_id: created.id, key: created.key, title: created.title, description: created.description,
924
+ agent_id: created.assignee_id, session_id: null, working_dir: parentTask.working_dir || null,
925
+ planning_depth: depth + 1,
926
+ });
927
+ lines.push(` ↳ dispatched locally (same runtime)`);
928
+ }
929
+ } else if (a.type === 'update') {
930
+ const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', ...a }, { headers });
931
+ if (!res.success) { lines.push(`- ❌ update ${a.id}: ${res.error || res.status}`); continue; }
932
+ lines.push(`- ✓ updated ${res.data.key}`);
933
+ } else if (a.type === 'delegate') {
934
+ const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', id: a.id, assignee_type: 'agent', assignee_id: a.assignee_id, status: 'todo' }, { headers });
935
+ if (!res.success) { lines.push(`- ❌ delegate ${a.id}: ${res.error || res.status}`); continue; }
936
+ lines.push(`- ✓ delegated ${res.data.key} → ${a.assignee_id}`);
937
+ if (ctx.localAgentIds.has(a.assignee_id)) {
938
+ ctx.enqueueLocal({
939
+ issue_id: res.data.id, key: res.data.key, title: res.data.title, description: res.data.description,
940
+ agent_id: a.assignee_id, session_id: res.data.session_id || null, working_dir: parentTask.working_dir || null,
941
+ planning_depth: depth + 1,
942
+ });
943
+ lines.push(` ↳ dispatched locally (same runtime)`);
944
+ }
945
+ }
946
+ } catch (e) {
947
+ lines.push(`- ❌ ${a.type} failed: ${String(e)}`);
948
+ }
949
+ }
950
+ if (truncated) lines.push(`- ⚠ action list truncated at ${PLAN_ACTION_LIMIT}`);
951
+ if (!lines.length) return '';
952
+ return `**Planning actions**\n\n${lines.join('\n')}`;
953
+ }
954
+
753
955
  function stripMd(s: string): string {
754
956
  return s.replace(/[`*_]/g, '').trim();
755
957
  }