@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.6.8",
3
+ "version": "0.8.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.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
- async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
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 base = `${task.title}\n\n${task.description || ''}`.trim();
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 = `# New user message — THIS is the current request\n\n${cleanFollowup}\n\n---\n*Earlier thread (${task.key} "${task.title}") is only background context. Do not re-address the original task unless the new message explicitly refers back to it.*`;
634
+ userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
630
635
  } else {
631
- userPart = stripSelfMention(base || task.title, preferType);
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
- const base = `${task.title}\n\n${task.description || ''}`.trim();
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 = `# New user message — THIS is the current request\n\n${cleanFollowup}\n\n---\n*Earlier thread (${task.key} "${task.title}") is only background context. Do not re-address the original task unless the new message explicitly refers back to it.*`;
714
+ userPart = `${issueContext}\n\n---\n\n## New user message — current request\n\n${cleanFollowup}`;
707
715
  } else {
708
- userPart = stripSelfMention(base || task.title, preferType);
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
  }