@shipers-dev/multi 0.6.7 → 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
 
@@ -5199,6 +5199,7 @@ async function runAcp(opts) {
5199
5199
  const stream2 = ndJsonStream(output, input);
5200
5200
  let activeSessionId = opts.sessionId || null;
5201
5201
  let recording = false;
5202
+ let chunkCount = 0;
5202
5203
  const alwaysAllow = new Set;
5203
5204
  const client = {
5204
5205
  async sessionUpdate(params) {
@@ -5263,12 +5264,33 @@ async function runAcp(opts) {
5263
5264
  }
5264
5265
  }
5265
5266
  recording = true;
5266
- const res = await conn.prompt({
5267
- sessionId: activeSessionId,
5268
- prompt: [{ type: "text", text: opts.prompt }]
5269
- });
5270
- await opts.onEvent({ event_type: "result", payload: { stopReason: res.stopReason } });
5271
- return { stopReason: res.stopReason, sessionId: activeSessionId };
5267
+ const runPrompt = async () => {
5268
+ chunkCount = 0;
5269
+ return await conn.prompt({
5270
+ sessionId: activeSessionId,
5271
+ prompt: [{ type: "text", text: opts.prompt }]
5272
+ });
5273
+ };
5274
+ let res = await runPrompt();
5275
+ let stopReason = res.stopReason;
5276
+ if (chunkCount === 0 && opts.sessionId) {
5277
+ await opts.onEvent({ event_type: "progress", payload: { message: `resumed session produced no output (stopReason=${stopReason}); retrying with fresh session` } });
5278
+ try {
5279
+ const fresh = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] });
5280
+ activeSessionId = fresh.sessionId;
5281
+ if (opts.onSession)
5282
+ await opts.onSession(fresh.sessionId);
5283
+ res = await runPrompt();
5284
+ stopReason = res.stopReason;
5285
+ } catch (e) {
5286
+ await opts.onEvent({ event_type: "error", payload: { message: `retry with fresh session failed: ${String(e)}` } });
5287
+ }
5288
+ }
5289
+ if (chunkCount === 0) {
5290
+ await opts.onEvent({ event_type: "error", payload: { message: `agent produced no output (stopReason=${stopReason})` } });
5291
+ }
5292
+ await opts.onEvent({ event_type: "result", payload: { stopReason } });
5293
+ return { stopReason, sessionId: activeSessionId };
5272
5294
  } finally {
5273
5295
  try {
5274
5296
  child.kill();
@@ -5281,12 +5303,15 @@ async function runAcp(opts) {
5281
5303
  case "agent_message_chunk":
5282
5304
  case "agent_thought_chunk": {
5283
5305
  const text = extractText(u.content);
5284
- if (text)
5306
+ if (text) {
5307
+ chunkCount++;
5285
5308
  await o.onEvent({ event_type: "assistant_text", payload: { text } });
5309
+ }
5286
5310
  break;
5287
5311
  }
5288
5312
  case "tool_call":
5289
5313
  case "tool_call_update": {
5314
+ chunkCount++;
5290
5315
  await o.onEvent({ event_type: "tool_call", payload: {
5291
5316
  id: u.toolCallId || u.id,
5292
5317
  tool: u.title || u.toolName || "tool",
@@ -5574,7 +5599,7 @@ var LOG_PATH2 = join2(MULTI_DIR, "logs", "agent.log");
5574
5599
  var SKILLS_DIR = join2(MULTI_DIR, "skills");
5575
5600
  var STOP_PATH = join2(MULTI_DIR, "stop.flag");
5576
5601
  var TASKS_DB_PATH = join2(MULTI_DIR, "tasks.db");
5577
- var VERSION = "0.6.7";
5602
+ var VERSION = "0.7.0";
5578
5603
  var COMMANDS = {
5579
5604
  setup: "Register this device with a workspace",
5580
5605
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5813,6 +5838,26 @@ async function cmdConnect(apiUrl, config) {
5813
5838
  workerWake = null;
5814
5839
  } catch {}
5815
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
+ };
5816
5861
  const port = await pickFreePort();
5817
5862
  const expectedAuth = `Bearer ${config.dispatchSecret}`;
5818
5863
  const server = Bun.serve({
@@ -5897,7 +5942,7 @@ async function cmdConnect(apiUrl, config) {
5897
5942
  db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
5898
5943
  try {
5899
5944
  const task = JSON.parse(row.payload);
5900
- await handleRunTask(apiUrl, config.deviceId, task, detected);
5945
+ await handleRunTask(apiUrl, config.deviceId, task, detected, { localAgentIds, enqueueLocal, refreshLocalAgents });
5901
5946
  db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
5902
5947
  } catch (e) {
5903
5948
  log(`task ${row.id} error: ${String(e)}`);
@@ -5977,7 +6022,7 @@ async function parseTunnelUrl(stream2) {
5977
6022
  }
5978
6023
  return null;
5979
6024
  }
5980
- async function handleRunTask(apiUrl, deviceId, task, detected) {
6025
+ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
5981
6026
  const issueId = task.issue_id;
5982
6027
  const isFollowup = !!task.followup;
5983
6028
  const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
@@ -6283,6 +6328,7 @@ ${body}
6283
6328
  } catch (e) {
6284
6329
  log(`preamble fetch failed: ${String(e)}`);
6285
6330
  }
6331
+ preamble += await buildPlanningPreamble(apiUrl, task);
6286
6332
  const prompt = preamble ? `${preamble}
6287
6333
  ---
6288
6334
 
@@ -6342,6 +6388,7 @@ ${body}
6342
6388
  }
6343
6389
  }
6344
6390
  } catch {}
6391
+ preamble += await buildPlanningPreamble(apiUrl, task);
6345
6392
  const base = `${task.title}
6346
6393
 
6347
6394
  ${task.description || ""}`.trim();
@@ -6395,6 +6442,24 @@ ${userPart}` : userPart;
6395
6442
  if (n > 0)
6396
6443
  log(` \uD83D\uDCCE uploaded ${n} output file(s)`);
6397
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
+ }
6458
+ if (!hasAssistantText && !hadError) {
6459
+ const stopReason = turn.result?.stopReason || "unknown";
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.`);
6461
+ log(` \u26A0 ${task.key} produced no assistant output (stopReason=${stopReason})`);
6462
+ }
6398
6463
  if (hadError) {
6399
6464
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
6400
6465
  log(` \u2717 ${task.key} failed`);
@@ -6409,6 +6474,180 @@ ${userPart}` : userPart;
6409
6474
  log(` \u2717 ${task.key} failed: ${String(e)}`);
6410
6475
  }
6411
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
+ }
6412
6651
  function stripMd(s) {
6413
6652
  return s.replace(/[`*_]/g, "").trim();
6414
6653
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -57,6 +57,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
57
57
 
58
58
  let activeSessionId: string | null = opts.sessionId || null;
59
59
  let recording = false; // only forward events after prompt() starts
60
+ let chunkCount = 0; // assistant_text + tool_call chunks seen during prompt()
60
61
  const alwaysAllow = new Set<string>(); // keys: toolName|kind that user said "always allow"
61
62
 
62
63
  const client: Client = {
@@ -122,13 +123,38 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
122
123
  }
123
124
 
124
125
  recording = true;
125
- const res = await conn.prompt({
126
- sessionId: activeSessionId!,
127
- prompt: [{ type: 'text', text: opts.prompt }],
128
- } as any);
126
+ const runPrompt = async () => {
127
+ chunkCount = 0;
128
+ return await conn.prompt({
129
+ sessionId: activeSessionId!,
130
+ prompt: [{ type: 'text', text: opts.prompt }],
131
+ } as any);
132
+ };
133
+
134
+ let res = await runPrompt();
135
+ let stopReason = (res as any).stopReason;
136
+
137
+ // Resumed session that returned zero chunks → session is likely stale in the adapter.
138
+ // Retry once with a fresh session so user gets an actual response.
139
+ if (chunkCount === 0 && opts.sessionId) {
140
+ await opts.onEvent({ event_type: 'progress', payload: { message: `resumed session produced no output (stopReason=${stopReason}); retrying with fresh session` } });
141
+ try {
142
+ const fresh = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
143
+ activeSessionId = fresh.sessionId;
144
+ if (opts.onSession) await opts.onSession(fresh.sessionId);
145
+ res = await runPrompt();
146
+ stopReason = (res as any).stopReason;
147
+ } catch (e) {
148
+ await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${String(e)}` } });
149
+ }
150
+ }
151
+
152
+ if (chunkCount === 0) {
153
+ await opts.onEvent({ event_type: 'error', payload: { message: `agent produced no output (stopReason=${stopReason})` } });
154
+ }
129
155
 
130
- await opts.onEvent({ event_type: 'result', payload: { stopReason: (res as any).stopReason } });
131
- return { stopReason: (res as any).stopReason, sessionId: activeSessionId! };
156
+ await opts.onEvent({ event_type: 'result', payload: { stopReason } });
157
+ return { stopReason, sessionId: activeSessionId! };
132
158
  } finally {
133
159
  try { child.kill(); } catch {}
134
160
  }
@@ -140,11 +166,12 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
140
166
  case 'agent_message_chunk':
141
167
  case 'agent_thought_chunk': {
142
168
  const text = extractText(u.content);
143
- if (text) await o.onEvent({ event_type: 'assistant_text', payload: { text } });
169
+ if (text) { chunkCount++; await o.onEvent({ event_type: 'assistant_text', payload: { text } }); }
144
170
  break;
145
171
  }
146
172
  case 'tool_call':
147
173
  case 'tool_call_update': {
174
+ chunkCount++;
148
175
  await o.onEvent({ event_type: 'tool_call', payload: {
149
176
  id: u.toolCallId || u.id, tool: u.title || u.toolName || 'tool', kind: u.kind, status: u.status, input: u.rawInput, locations: u.locations,
150
177
  }});
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,25 @@ 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
+
780
+ // Visible fallback: agent ran but emitted nothing user-facing
781
+ if (!hasAssistantText && !hadError) {
782
+ const stopReason = turn.result?.stopReason || 'unknown';
783
+ await postComment(`⚠️ Agent returned no output (stopReason=${stopReason}). Adapter may be stuck on a stale session — try starting a new issue or clearing session_id.`);
784
+ log(` ⚠ ${task.key} produced no assistant output (stopReason=${stopReason})`);
785
+ }
786
+
736
787
  if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
737
788
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
738
789
  } catch (e) {
@@ -743,6 +794,164 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
743
794
  }
744
795
  }
745
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
+
746
955
  function stripMd(s: string): string {
747
956
  return s.replace(/[`*_]/g, '').trim();
748
957
  }