@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 +251 -12
- package/package.json +1 -1
- package/src/acp-runner.ts +34 -7
- package/src/client.ts +4 -4
- package/src/index.ts +212 -3
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
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
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.
|
|
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
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
131
|
-
return { stopReason
|
|
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.
|
|
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
|
-
|
|
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
|
}
|