@shipers-dev/multi 0.6.8 → 0.8.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 +518 -353
- package/package.json +1 -1
- package/src/client.ts +4 -4
- package/src/index.ts +170 -9
package/package.json
CHANGED
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.8.0';
|
|
21
21
|
|
|
22
22
|
const COMMANDS = {
|
|
23
23
|
setup: 'Register this device with a workspace',
|
|
@@ -257,6 +257,7 @@ 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
|
+
|
|
260
261
|
// Local HTTP server on a free port
|
|
261
262
|
const port = await pickFreePort();
|
|
262
263
|
const expectedAuth = `Bearer ${config.dispatchSecret}`;
|
|
@@ -328,7 +329,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
328
329
|
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
329
330
|
try {
|
|
330
331
|
const task = JSON.parse(row.payload);
|
|
331
|
-
await handleRunTask(apiUrl, config.deviceId!, task, detected);
|
|
332
|
+
await handleRunTask(apiUrl, config.deviceId!, task, detected, {});
|
|
332
333
|
db.run("UPDATE tasks SET status = 'done', finished_at = unixepoch() WHERE id = ?", [row.id]);
|
|
333
334
|
} catch (e) {
|
|
334
335
|
log(`task ${row.id} error: ${String(e)}`);
|
|
@@ -406,7 +407,11 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
|
|
|
406
407
|
return null;
|
|
407
408
|
}
|
|
408
409
|
|
|
409
|
-
|
|
410
|
+
interface RuntimeCtx {
|
|
411
|
+
// Reserved for future runtime-scoped helpers.
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[], ctx?: RuntimeCtx) {
|
|
410
415
|
const issueId = task.issue_id;
|
|
411
416
|
const isFollowup = !!task.followup;
|
|
412
417
|
const workingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
|
|
@@ -622,13 +627,13 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
622
627
|
|
|
623
628
|
try {
|
|
624
629
|
if (useAcp) {
|
|
625
|
-
const
|
|
630
|
+
const issueContext = `## Issue ${task.key}: ${task.title}${task.description ? `\n\n${task.description}` : ''}`;
|
|
626
631
|
let userPart: string;
|
|
627
632
|
if (task.followup) {
|
|
628
633
|
const cleanFollowup = stripSelfMention(task.followup, preferType);
|
|
629
|
-
userPart =
|
|
634
|
+
userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
|
|
630
635
|
} else {
|
|
631
|
-
userPart = stripSelfMention(
|
|
636
|
+
userPart = stripSelfMention(issueContext, preferType);
|
|
632
637
|
}
|
|
633
638
|
if (attachmentRefs.length) {
|
|
634
639
|
const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
|
|
@@ -658,6 +663,8 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
658
663
|
log(`preamble fetch failed: ${String(e)}`);
|
|
659
664
|
}
|
|
660
665
|
|
|
666
|
+
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
667
|
+
|
|
661
668
|
const prompt = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
|
|
662
669
|
|
|
663
670
|
// Pick adapter for the agent's declared type if we have it, else first ACP-capable
|
|
@@ -699,13 +706,14 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
699
706
|
}
|
|
700
707
|
}
|
|
701
708
|
} catch {}
|
|
702
|
-
|
|
709
|
+
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
710
|
+
const issueContext = `## Issue ${task.key}: ${task.title}${task.description ? `\n\n${task.description}` : ''}`;
|
|
703
711
|
let userPart: string;
|
|
704
712
|
if (task.followup) {
|
|
705
713
|
const cleanFollowup = stripSelfMention(task.followup, preferType);
|
|
706
|
-
userPart =
|
|
714
|
+
userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
|
|
707
715
|
} else {
|
|
708
|
-
userPart = stripSelfMention(
|
|
716
|
+
userPart = stripSelfMention(issueContext, preferType);
|
|
709
717
|
}
|
|
710
718
|
if (attachmentRefs.length) {
|
|
711
719
|
const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
|
|
@@ -733,6 +741,18 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
733
741
|
if (n > 0) log(` 📎 uploaded ${n} output file(s)`);
|
|
734
742
|
}
|
|
735
743
|
|
|
744
|
+
// Post-turn: scan agent text for `multi-plan` fenced blocks and execute mutations.
|
|
745
|
+
if (ctx) {
|
|
746
|
+
const fullText = turn.blocks.filter(b => b.kind === 'text').map(b => (b as any).text).join('\n');
|
|
747
|
+
const actions = extractPlanActions(fullText);
|
|
748
|
+
if (actions.length) {
|
|
749
|
+
const summary = await executePlanActions(apiUrl, task, actions, ctx);
|
|
750
|
+
if (summary) {
|
|
751
|
+
try { await apiClient.post(`${apiUrl}/api/issues/${issueId}/comments`, { author_type: 'agent', author_id: task.agent_id, author_name: 'agent', body: summary }); } catch {}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
736
756
|
// Visible fallback: agent ran but emitted nothing user-facing
|
|
737
757
|
if (!hasAssistantText && !hadError) {
|
|
738
758
|
const stopReason = turn.result?.stopReason || 'unknown';
|
|
@@ -750,6 +770,147 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
750
770
|
}
|
|
751
771
|
}
|
|
752
772
|
|
|
773
|
+
// Appended to agent prompt. Teaches the agent how to emit planning actions the
|
|
774
|
+
// daemon will execute after the turn: create sub-issues, update any issue in
|
|
775
|
+
// the project, or delegate to other agents.
|
|
776
|
+
async function buildPlanningPreamble(apiUrl: string, task: any): Promise<string> {
|
|
777
|
+
const depth = typeof task.planning_depth === 'number' ? task.planning_depth : 0;
|
|
778
|
+
if (depth >= 1) {
|
|
779
|
+
return `# Planning (sub-task context)
|
|
780
|
+
|
|
781
|
+
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.
|
|
782
|
+
|
|
783
|
+
\`\`\`multi-plan
|
|
784
|
+
{"actions":[{"type":"update","id":"<this issue id>","status":"done"}]}
|
|
785
|
+
\`\`\`
|
|
786
|
+
|
|
787
|
+
`;
|
|
788
|
+
}
|
|
789
|
+
let projectId = '';
|
|
790
|
+
let agentsBlock = '';
|
|
791
|
+
try {
|
|
792
|
+
const issueRes = await apiClient.get<any>(`${apiUrl}/api/issues/${task.issue_id}`);
|
|
793
|
+
projectId = issueRes.data?.project_id || '';
|
|
794
|
+
const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
795
|
+
const workspaceId = agentRes.data?.workspace_id;
|
|
796
|
+
if (workspaceId) {
|
|
797
|
+
const ag = await apiClient.get<any[]>(`${apiUrl}/api/agents?workspace_id=${workspaceId}`);
|
|
798
|
+
const list = Array.isArray(ag.data) ? ag.data : [];
|
|
799
|
+
const others = list.filter(a => a.id !== task.agent_id);
|
|
800
|
+
if (others.length) {
|
|
801
|
+
agentsBlock = others.map(a => `- \`${a.id}\` (${a.name}${a.type ? `, ${a.type}` : ''})`).join('\n');
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
} catch {}
|
|
805
|
+
|
|
806
|
+
return `# Planning & delegation
|
|
807
|
+
|
|
808
|
+
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.
|
|
809
|
+
|
|
810
|
+
Syntax:
|
|
811
|
+
|
|
812
|
+
\`\`\`multi-plan
|
|
813
|
+
{"actions":[
|
|
814
|
+
{"type":"create","title":"...","description":"...","assignee_type":"agent","assignee_id":"<agent id>"},
|
|
815
|
+
{"type":"update","id":"<issue id>","status":"done"},
|
|
816
|
+
{"type":"delegate","id":"<issue id>","assignee_id":"<agent id>"}
|
|
817
|
+
]}
|
|
818
|
+
\`\`\`
|
|
819
|
+
|
|
820
|
+
Rules:
|
|
821
|
+
- Omit the block entirely if no actions are needed.
|
|
822
|
+
- Max 10 actions per turn; additional actions are dropped.
|
|
823
|
+
- Sub-issues spawned from your plan run one-shot: they cannot themselves spawn further children (only \`update\` allowed there).
|
|
824
|
+
- \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
|
|
825
|
+
- \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
|
|
826
|
+
- \`delegate\` is shorthand for reassigning and resetting status to todo.
|
|
827
|
+
- Only target issues in the current project (${projectId || 'this project'}).
|
|
828
|
+
|
|
829
|
+
${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
type PlanAction =
|
|
833
|
+
| { type: 'create'; project_id?: string; title: string; description?: string; priority?: string; assignee_type?: string; assignee_id?: string; parent_id?: string }
|
|
834
|
+
| { type: 'update'; id: string; title?: string; description?: string; status?: string; priority?: string; assignee_type?: string; assignee_id?: string }
|
|
835
|
+
| { type: 'delegate'; id: string; assignee_id: string };
|
|
836
|
+
|
|
837
|
+
// Extract JSON action blocks fenced as ```multi-plan ... ```
|
|
838
|
+
function extractPlanActions(text: string): PlanAction[] {
|
|
839
|
+
const out: PlanAction[] = [];
|
|
840
|
+
if (!text) return out;
|
|
841
|
+
const re = /```multi-plan\s*\n([\s\S]*?)\n```/g;
|
|
842
|
+
let m: RegExpExecArray | null;
|
|
843
|
+
while ((m = re.exec(text)) !== null) {
|
|
844
|
+
try {
|
|
845
|
+
const parsed = JSON.parse(m[1]);
|
|
846
|
+
const actions = Array.isArray(parsed?.actions) ? parsed.actions : Array.isArray(parsed) ? parsed : [parsed];
|
|
847
|
+
for (const a of actions) if (a && typeof a === 'object' && typeof a.type === 'string') out.push(a as PlanAction);
|
|
848
|
+
} catch {}
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const PLAN_ACTION_LIMIT = 10;
|
|
854
|
+
|
|
855
|
+
async function executePlanActions(apiUrl: string, parentTask: any, actions: PlanAction[], _ctx: RuntimeCtx): Promise<string> {
|
|
856
|
+
const lines: string[] = [];
|
|
857
|
+
let truncated = false;
|
|
858
|
+
if (actions.length > PLAN_ACTION_LIMIT) {
|
|
859
|
+
truncated = true;
|
|
860
|
+
actions = actions.slice(0, PLAN_ACTION_LIMIT);
|
|
861
|
+
}
|
|
862
|
+
// Prevent agent recursion: a child turn's plan cannot itself spawn more children.
|
|
863
|
+
// `planning_depth` is carried on each dispatched task (set server-side from issue row).
|
|
864
|
+
const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
|
|
865
|
+
if (depth >= 1) {
|
|
866
|
+
const blocked = actions.filter(a => a.type === 'create' || a.type === 'delegate').length;
|
|
867
|
+
actions = actions.filter(a => a.type === 'update');
|
|
868
|
+
if (blocked) lines.push(`- ⚠ ${blocked} create/delegate action(s) blocked (planning depth limit)`);
|
|
869
|
+
}
|
|
870
|
+
const parentId = parentTask.issue_id;
|
|
871
|
+
const parentProjectId = await (async () => {
|
|
872
|
+
try {
|
|
873
|
+
const r = await apiClient.get<any>(`${apiUrl}/api/issues/${parentId}`);
|
|
874
|
+
return r.data?.project_id;
|
|
875
|
+
} catch { return null; }
|
|
876
|
+
})();
|
|
877
|
+
const headers = { 'x-agent-id': parentTask.agent_id };
|
|
878
|
+
|
|
879
|
+
// Refresh the set of agents linked to this device once per plan execution.
|
|
880
|
+
await ctx.refreshLocalAgents();
|
|
881
|
+
|
|
882
|
+
for (const a of actions) {
|
|
883
|
+
try {
|
|
884
|
+
if (a.type === 'create') {
|
|
885
|
+
const body = {
|
|
886
|
+
action: 'create' as const,
|
|
887
|
+
project_id: a.project_id || parentProjectId,
|
|
888
|
+
title: a.title, description: a.description,
|
|
889
|
+
priority: a.priority, assignee_type: a.assignee_type, assignee_id: a.assignee_id,
|
|
890
|
+
parent_id: a.parent_id || parentId,
|
|
891
|
+
};
|
|
892
|
+
const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, body, { headers });
|
|
893
|
+
if (!res.success) { lines.push(`- ❌ create "${a.title}": ${res.error || res.status}`); continue; }
|
|
894
|
+
const created = res.data;
|
|
895
|
+
lines.push(`- ✓ created **${created.key}** — ${created.title}${created.assignee_id ? ` → @${created.assignee_id}` : ''} (autonomy=${created.autonomy_level || 'ask'})`);
|
|
896
|
+
} else if (a.type === 'update') {
|
|
897
|
+
const res = await apiClient.post<any>(`${apiUrl}/api/issues/agent/mutate`, { action: 'update', ...a }, { headers });
|
|
898
|
+
if (!res.success) { lines.push(`- ❌ update ${a.id}: ${res.error || res.status}`); continue; }
|
|
899
|
+
lines.push(`- ✓ updated ${res.data.key}`);
|
|
900
|
+
} else if (a.type === 'delegate') {
|
|
901
|
+
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 });
|
|
902
|
+
if (!res.success) { lines.push(`- ❌ delegate ${a.id}: ${res.error || res.status}`); continue; }
|
|
903
|
+
lines.push(`- ✓ delegated ${res.data.key} → ${a.assignee_id}`);
|
|
904
|
+
}
|
|
905
|
+
} catch (e) {
|
|
906
|
+
lines.push(`- ❌ ${a.type} failed: ${String(e)}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (truncated) lines.push(`- ⚠ action list truncated at ${PLAN_ACTION_LIMIT}`);
|
|
910
|
+
if (!lines.length) return '';
|
|
911
|
+
return `**Planning actions**\n\n${lines.join('\n')}`;
|
|
912
|
+
}
|
|
913
|
+
|
|
753
914
|
function stripMd(s: string): string {
|
|
754
915
|
return s.replace(/[`*_]/g, '').trim();
|
|
755
916
|
}
|