@shipers-dev/multi 0.10.1 → 0.11.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
@@ -5696,14 +5696,153 @@ async function ensureWorktree(workingDir, issueKey) {
5696
5696
  return { path: wtPath, branch, created: true };
5697
5697
  }
5698
5698
 
5699
+ // src/materializer.ts
5700
+ import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, rmSync, symlinkSync, lstatSync } from "fs";
5701
+ import { join as join3, dirname as dirname2 } from "path";
5702
+ var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5703
+ var MULTI_DIR = join3(HOME2, ".multi");
5704
+ var MULTI_SKILLS = join3(MULTI_DIR, "skills");
5705
+ var MULTI_AGENTS = join3(MULTI_DIR, "agents");
5706
+ var STATE_PATH = join3(MULTI_DIR, "materialized.json");
5707
+ var CLAUDE_DIR = join3(HOME2, ".claude");
5708
+ var CLAUDE_SKILLS = join3(CLAUDE_DIR, "skills");
5709
+ var CLAUDE_AGENTS = join3(CLAUDE_DIR, "agents");
5710
+ var MARKER = ".multi-managed";
5711
+ function slugify(s) {
5712
+ return s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "unnamed";
5713
+ }
5714
+ function loadState() {
5715
+ try {
5716
+ return JSON.parse(readFileSync3(STATE_PATH, "utf8"));
5717
+ } catch {
5718
+ return { revision: -1, skill_slugs: [], agent_slugs: [] };
5719
+ }
5720
+ }
5721
+ function saveState(s) {
5722
+ mkdirSync3(MULTI_DIR, { recursive: true });
5723
+ writeFileSync3(STATE_PATH, JSON.stringify(s, null, 2));
5724
+ }
5725
+ function safeRmManaged(path) {
5726
+ if (!existsSync3(path))
5727
+ return;
5728
+ try {
5729
+ const st = lstatSync(path);
5730
+ if (st.isSymbolicLink()) {
5731
+ rmSync(path);
5732
+ return;
5733
+ }
5734
+ if (st.isDirectory() && existsSync3(join3(path, MARKER))) {
5735
+ rmSync(path, { recursive: true, force: true });
5736
+ return;
5737
+ }
5738
+ if (st.isFile() && path.endsWith(".md")) {
5739
+ const head = readFileSync3(path, "utf8").slice(0, 200);
5740
+ if (head.includes("multi-managed: true"))
5741
+ rmSync(path);
5742
+ }
5743
+ } catch {}
5744
+ }
5745
+ function writeManagedSkill(slug, skill) {
5746
+ const dir = join3(MULTI_SKILLS, slug);
5747
+ mkdirSync3(dir, { recursive: true });
5748
+ writeFileSync3(join3(dir, MARKER), `skill_id=${skill.id}
5749
+ revision=${Date.now()}
5750
+ `);
5751
+ if (skill.body)
5752
+ writeFileSync3(join3(dir, "SKILL.md"), skill.body);
5753
+ for (const f of skill.files || []) {
5754
+ if (f.path.includes("..") || f.path.startsWith("/"))
5755
+ continue;
5756
+ if (f.path === MARKER)
5757
+ continue;
5758
+ const out = join3(dir, f.path);
5759
+ mkdirSync3(dirname2(out), { recursive: true });
5760
+ writeFileSync3(out, f.content);
5761
+ }
5762
+ mkdirSync3(CLAUDE_SKILLS, { recursive: true });
5763
+ const link = join3(CLAUDE_SKILLS, slug);
5764
+ safeRmManaged(link);
5765
+ if (!existsSync3(link)) {
5766
+ try {
5767
+ symlinkSync(dir, link, "dir");
5768
+ } catch {}
5769
+ }
5770
+ }
5771
+ function writeManagedAgent(slug, agent, skillsForAgent) {
5772
+ if (agent.type !== "claude-code")
5773
+ return;
5774
+ mkdirSync3(CLAUDE_AGENTS, { recursive: true });
5775
+ const out = join3(CLAUDE_AGENTS, `${slug}.md`);
5776
+ safeRmManaged(out);
5777
+ const tools = agent.allowed_tools ? `
5778
+ tools: ${agent.allowed_tools}` : "";
5779
+ const skillsLine = skillsForAgent.length ? `
5780
+ skills: [${skillsForAgent.join(", ")}]` : "";
5781
+ const fm = `---
5782
+ name: ${agent.name}
5783
+ description: managed by multi-agent (id=${agent.id})${tools}${skillsLine}
5784
+ multi-managed: true
5785
+ ---
5786
+
5787
+ `;
5788
+ writeFileSync3(out, fm + (agent.prompt || ""));
5789
+ }
5790
+ async function materializeBundle(apiUrl, deviceId, log) {
5791
+ const res = await apiClient.get(`${apiUrl}/api/devices/${deviceId}/agent_bundle`);
5792
+ if (!res.success || !res.data) {
5793
+ log(`materialize: bundle fetch failed: ${res.error || "unknown"}`);
5794
+ return null;
5795
+ }
5796
+ const bundle = res.data;
5797
+ const prev = loadState();
5798
+ const linksByAgent = new Map;
5799
+ for (const l of bundle.links) {
5800
+ if (!linksByAgent.has(l.agent_id))
5801
+ linksByAgent.set(l.agent_id, []);
5802
+ linksByAgent.get(l.agent_id).push(l.skill_id);
5803
+ }
5804
+ const newSkillSlugs = [];
5805
+ const skillIdToSlug = new Map;
5806
+ for (const s of bundle.skills) {
5807
+ const slug = slugify(s.name);
5808
+ skillIdToSlug.set(s.id, slug);
5809
+ writeManagedSkill(slug, s);
5810
+ newSkillSlugs.push(slug);
5811
+ }
5812
+ const newAgentSlugs = [];
5813
+ for (const a of bundle.agents) {
5814
+ const slug = slugify(a.name);
5815
+ const skillSlugs = (linksByAgent.get(a.id) || []).map((id) => skillIdToSlug.get(id)).filter(Boolean);
5816
+ writeManagedAgent(slug, a, skillSlugs);
5817
+ newAgentSlugs.push(slug);
5818
+ }
5819
+ for (const old of prev.skill_slugs) {
5820
+ if (!newSkillSlugs.includes(old)) {
5821
+ safeRmManaged(join3(MULTI_SKILLS, old));
5822
+ safeRmManaged(join3(CLAUDE_SKILLS, old));
5823
+ }
5824
+ }
5825
+ for (const old of prev.agent_slugs) {
5826
+ if (!newAgentSlugs.includes(old)) {
5827
+ safeRmManaged(join3(CLAUDE_AGENTS, `${old}.md`));
5828
+ }
5829
+ }
5830
+ saveState({ revision: bundle.revision, skill_slugs: newSkillSlugs, agent_slugs: newAgentSlugs });
5831
+ log(`materialize: revision=${bundle.revision} agents=${bundle.agents.length} skills=${bundle.skills.length}`);
5832
+ return { revision: bundle.revision };
5833
+ }
5834
+ function lastMaterializedRevision() {
5835
+ return loadState().revision;
5836
+ }
5837
+
5699
5838
  // src/index.ts
5700
5839
  import { parseArgs } from "util";
5701
- import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
5702
- import { join as join3, dirname as dirname2 } from "path";
5840
+ import { mkdirSync as mkdirSync4, existsSync as existsSync4, writeFileSync as writeFileSync4, readFileSync as readFileSync4, appendFileSync as appendFileSync2, unlinkSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
5841
+ import { join as join4, dirname as dirname3 } from "path";
5703
5842
  // package.json
5704
5843
  var package_default = {
5705
5844
  name: "@shipers-dev/multi",
5706
- version: "0.10.1",
5845
+ version: "0.11.0",
5707
5846
  type: "module",
5708
5847
  bin: {
5709
5848
  "multi-agent": "./dist/index.js"
@@ -5719,15 +5858,15 @@ var package_default = {
5719
5858
  };
5720
5859
 
5721
5860
  // src/index.ts
5722
- var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
5723
- var MULTI_DIR = join3(HOME2, ".multi");
5724
- var CONFIG_PATH = join3(MULTI_DIR, "config.json");
5725
- var PID_PATH = join3(MULTI_DIR, "agent.pid");
5726
- var PORT_PATH = join3(MULTI_DIR, "agent.port");
5727
- var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
5728
- var SKILLS_DIR = join3(MULTI_DIR, "skills");
5729
- var STOP_PATH = join3(MULTI_DIR, "stop.flag");
5730
- var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
5861
+ var HOME3 = process.env.HOME || process.env.USERPROFILE || ".";
5862
+ var MULTI_DIR2 = join4(HOME3, ".multi");
5863
+ var CONFIG_PATH = join4(MULTI_DIR2, "config.json");
5864
+ var PID_PATH = join4(MULTI_DIR2, "agent.pid");
5865
+ var PORT_PATH = join4(MULTI_DIR2, "agent.port");
5866
+ var LOG_PATH2 = join4(MULTI_DIR2, "logs", "agent.log");
5867
+ var SKILLS_DIR = join4(MULTI_DIR2, "skills");
5868
+ var STOP_PATH = join4(MULTI_DIR2, "stop.flag");
5869
+ var TASKS_DB_PATH = join4(MULTI_DIR2, "tasks.db");
5731
5870
  var VERSION = package_default.version;
5732
5871
  var COMMANDS = {
5733
5872
  setup: "Register this device with a workspace",
@@ -5740,9 +5879,9 @@ var COMMANDS = {
5740
5879
  reset: "Reset acpx session for an issue (--issue <id>)"
5741
5880
  };
5742
5881
  function ensureDirs() {
5743
- for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
5744
- if (!existsSync3(d))
5745
- mkdirSync3(d, { recursive: true });
5882
+ for (const d of [MULTI_DIR2, join4(MULTI_DIR2, "logs"), SKILLS_DIR]) {
5883
+ if (!existsSync4(d))
5884
+ mkdirSync4(d, { recursive: true });
5746
5885
  }
5747
5886
  }
5748
5887
  function log(msg) {
@@ -5912,23 +6051,16 @@ async function cmdSetup(name, apiUrl) {
5912
6051
  const workspaceId = dev.data?.workspace_id;
5913
6052
  if (workspaceId) {
5914
6053
  saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, workspaceId, apiUrl });
5915
- await syncSkills(apiUrl, workspaceId);
6054
+ try {
6055
+ await materializeBundle(apiUrl, approved.device_id, (m) => console.log(` ${m}`));
6056
+ } catch (e) {
6057
+ console.log(` materialize failed: ${String(e)}`);
6058
+ }
5916
6059
  }
5917
6060
  console.log(`
5918
6061
  Next: link to an agent with: multi-agent link --agent <agentId>`);
5919
6062
  console.log("Then: multi-agent connect");
5920
6063
  }
5921
- async function syncSkills(apiUrl, workspaceId) {
5922
- const res = await apiClient.get(`${apiUrl}/api/skills?workspace_id=${workspaceId}`);
5923
- if (!res.success || !Array.isArray(res.data))
5924
- return;
5925
- ensureDirs();
5926
- for (const skill of res.data) {
5927
- writeFileSync3(join3(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
5928
- }
5929
- if (res.data.length)
5930
- console.log(` Synced ${res.data.length} skill(s) \u2192 ${SKILLS_DIR}`);
5931
- }
5932
6064
  async function cmdLink(apiUrl, config, agentId) {
5933
6065
  if (!config.deviceId) {
5934
6066
  console.log('\u274C Not registered. Run "multi-agent setup" first.');
@@ -5954,8 +6086,8 @@ async function cmdConnect(apiUrl, config) {
5954
6086
  console.log('\u274C Missing dispatch secret. Re-pair via "multi-agent setup".');
5955
6087
  process.exit(1);
5956
6088
  }
5957
- if (existsSync3(PID_PATH)) {
5958
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6089
+ if (existsSync4(PID_PATH)) {
6090
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
5959
6091
  if (pid && isRunning(pid)) {
5960
6092
  console.log(`\u274C Daemon already running (pid ${pid}).`);
5961
6093
  process.exit(1);
@@ -5963,8 +6095,8 @@ async function cmdConnect(apiUrl, config) {
5963
6095
  unlinkSync(PID_PATH);
5964
6096
  }
5965
6097
  ensureDirs();
5966
- writeFileSync3(PID_PATH, String(process.pid));
5967
- if (existsSync3(STOP_PATH))
6098
+ writeFileSync4(PID_PATH, String(process.pid));
6099
+ if (existsSync4(STOP_PATH))
5968
6100
  unlinkSync(STOP_PATH);
5969
6101
  const detected = await detectAgents();
5970
6102
  log(`\uD83D\uDE80 Starting daemon for device ${config.deviceId} (pid ${process.pid})`);
@@ -6145,7 +6277,7 @@ async function cmdConnect(apiUrl, config) {
6145
6277
  });
6146
6278
  log(`\uD83C\uDF10 Local server: http://127.0.0.1:${port}`);
6147
6279
  try {
6148
- writeFileSync3(PORT_PATH, String(port));
6280
+ writeFileSync4(PORT_PATH, String(port));
6149
6281
  } catch {}
6150
6282
  let tunnel = await startTunnel(port);
6151
6283
  if (!tunnel) {
@@ -6158,6 +6290,16 @@ async function cmdConnect(apiUrl, config) {
6158
6290
  log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6159
6291
  const heartbeat = async () => {
6160
6292
  const res = await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
6293
+ if (res.success && res.data) {
6294
+ const remoteRev = Number(res.data.agent_skill_revision ?? 0);
6295
+ if (remoteRev > 0 && remoteRev !== lastMaterializedRevision()) {
6296
+ try {
6297
+ await materializeBundle(apiUrl, config.deviceId, log);
6298
+ } catch (e) {
6299
+ log(`materialize error: ${String(e)}`);
6300
+ }
6301
+ }
6302
+ }
6161
6303
  return res.success && res.data?.pending_dispatches || 0;
6162
6304
  };
6163
6305
  {
@@ -6181,11 +6323,11 @@ async function cmdConnect(apiUrl, config) {
6181
6323
  try {
6182
6324
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
6183
6325
  } catch {}
6184
- if (existsSync3(PID_PATH))
6326
+ if (existsSync4(PID_PATH))
6185
6327
  unlinkSync(PID_PATH);
6186
- if (existsSync3(STOP_PATH))
6328
+ if (existsSync4(STOP_PATH))
6187
6329
  unlinkSync(STOP_PATH);
6188
- if (existsSync3(PORT_PATH))
6330
+ if (existsSync4(PORT_PATH))
6189
6331
  unlinkSync(PORT_PATH);
6190
6332
  db.close();
6191
6333
  log("\uD83D\uDC4B Disconnected");
@@ -6247,7 +6389,7 @@ async function cmdConnect(apiUrl, config) {
6247
6389
  const PROBE_EVERY = 6;
6248
6390
  while (alive) {
6249
6391
  await sleep(20000);
6250
- if (existsSync3(STOP_PATH)) {
6392
+ if (existsSync4(STOP_PATH)) {
6251
6393
  await shutdown("stop flag");
6252
6394
  break;
6253
6395
  }
@@ -6303,8 +6445,8 @@ async function probeTunnel(url) {
6303
6445
  }
6304
6446
  }
6305
6447
  async function cmdConnectDetached(apiUrl) {
6306
- if (existsSync3(PID_PATH)) {
6307
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6448
+ if (existsSync4(PID_PATH)) {
6449
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
6308
6450
  if (pid && isRunning(pid)) {
6309
6451
  console.log(`\u274C Daemon already running (pid ${pid}).`);
6310
6452
  process.exit(1);
@@ -6378,7 +6520,7 @@ async function markStopped(apiUrl, issueId, reason) {
6378
6520
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6379
6521
  const issueId = task.issue_id;
6380
6522
  const isFollowup = !!task.followup;
6381
- const baseWorkingDir = task.working_dir && existsSync3(task.working_dir) ? task.working_dir : undefined;
6523
+ const baseWorkingDir = task.working_dir && existsSync4(task.working_dir) ? task.working_dir : undefined;
6382
6524
  let workingDir = baseWorkingDir;
6383
6525
  let worktreeBranch = "";
6384
6526
  if (baseWorkingDir) {
@@ -6396,13 +6538,13 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6396
6538
  await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
6397
6539
  let attachmentRefs = [];
6398
6540
  if (task.from_comment_id) {
6399
- const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
6400
- const inDir = join3(baseDir, ".multi-in", task.from_comment_id);
6541
+ const baseDir = workingDir || join4(MULTI_DIR2, "tmp", issueId);
6542
+ const inDir = join4(baseDir, ".multi-in", task.from_comment_id);
6401
6543
  attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
6402
6544
  if (attachmentRefs.length)
6403
6545
  log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
6404
6546
  }
6405
- const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
6547
+ const outDir = join4(workingDir || join4(MULTI_DIR2, "tmp", issueId), ".multi-out");
6406
6548
  let liveCommentId;
6407
6549
  let liveBody = "";
6408
6550
  let hadError = false;
@@ -6899,11 +7041,11 @@ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-p
6899
7041
  }
6900
7042
  }
6901
7043
  } catch {}
6902
- return `# Planning & delegation
7044
+ return `# Planning, delegation, and self-service
6903
7045
 
6904
- 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.
7046
+ After finishing your reply, you may append ONE fenced \`multi-plan\` block. The daemon parses it and executes after your turn.
6905
7047
 
6906
- Syntax:
7048
+ Issue actions:
6907
7049
 
6908
7050
  \`\`\`multi-plan
6909
7051
  {"actions":[
@@ -6913,14 +7055,26 @@ Syntax:
6913
7055
  ]}
6914
7056
  \`\`\`
6915
7057
 
7058
+ Agent + skill self-service (use sparingly \u2014 only when you genuinely need a new capability that isn't covered by an existing agent or skill):
7059
+
7060
+ \`\`\`multi-plan
7061
+ {"actions":[
7062
+ {"type":"agent.create","name":"refactor-bot","agent_type":"claude-code","prompt":"You refactor TS code...","skill_ids":["sk_xxx"],"allowed_tools":["Read","Edit","Bash"]},
7063
+ {"type":"agent.update","id":"ag_xxx","prompt":"new prompt..."},
7064
+ {"type":"skill.create","name":"run-tests","description":"Run the test suite","body":"---\\nname: run-tests\\n---\\n\\n# Run tests\\n..."},
7065
+ {"type":"skill.attach","agent_id":"ag_xxx","skill_id":"sk_yyy"},
7066
+ {"type":"skill.detach","agent_id":"ag_xxx","skill_id":"sk_yyy"}
7067
+ ]}
7068
+ \`\`\`
7069
+
6916
7070
  Rules:
6917
- - Omit the block entirely if no actions are needed.
6918
- - Max 10 actions per turn; additional actions are dropped.
6919
- - Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
6920
- - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
6921
- - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
6922
- - \`delegate\` is shorthand for reassigning and resetting status to todo.
6923
- - Only target issues in the current project (${projectId || "this project"}).
7071
+ - Omit the block if no actions are needed.
7072
+ - Max 10 actions per turn. Sub-caps: agent.create=2, skill.create=3 per turn.
7073
+ - Planning depth capped at ${PLANNING_DEPTH_LIMIT}. At depth \u2265 1 you can only \`update\` your own issue \u2014 no creates, no agents, no skills.
7074
+ - \`agent.create\` produces an agent in **pending** status. A human must approve it before any issue can be dispatched to it.
7075
+ - \`skill.create\` ALWAYS waits for human review (skill bodies become future system prompts).
7076
+ - \`allowed_tools\` on a new agent must be a subset of your own tools.
7077
+ - \`create\` / \`update\` / \`delegate\` target issues only in the current project (${projectId || "this project"}).
6924
7078
 
6925
7079
  ${agentsBlock ? `Available agents you can delegate to:
6926
7080
  ${agentsBlock}
@@ -6954,11 +7108,24 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6954
7108
  }
6955
7109
  const depth = typeof parentTask.planning_depth === "number" ? parentTask.planning_depth : 0;
6956
7110
  if (depth >= PLANNING_DEPTH_LIMIT) {
6957
- const blocked = actions.filter((a) => a.type === "create" || a.type === "delegate").length;
7111
+ const blocked = actions.filter((a) => a.type !== "update").length;
6958
7112
  actions = actions.filter((a) => a.type === "update");
6959
7113
  if (blocked)
6960
- lines.push(`- \u26A0 ${blocked} create/delegate action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
6961
- }
7114
+ lines.push(`- \u26A0 ${blocked} non-update action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
7115
+ }
7116
+ const SUBCAPS = { "agent.create": 2, "skill.create": 3, "skill.attach": 5, "skill.detach": 5, "agent.update": 5 };
7117
+ const counts = {};
7118
+ actions = actions.filter((a) => {
7119
+ const cap = SUBCAPS[a.type];
7120
+ if (cap === undefined)
7121
+ return true;
7122
+ counts[a.type] = (counts[a.type] || 0) + 1;
7123
+ if (counts[a.type] > cap) {
7124
+ lines.push(`- \u26A0 ${a.type} sub-cap ${cap} hit, dropping extra`);
7125
+ return false;
7126
+ }
7127
+ return true;
7128
+ });
6962
7129
  const parentId = parentTask.issue_id;
6963
7130
  const parentProjectId = await (async () => {
6964
7131
  try {
@@ -6968,7 +7135,7 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6968
7135
  return null;
6969
7136
  }
6970
7137
  })();
6971
- const headers = { "x-agent-id": parentTask.agent_id };
7138
+ const headers = { "x-agent-id": parentTask.agent_id, "x-origin-issue-id": parentTask.issue_id };
6972
7139
  if (typeof ctx.refreshLocalAgents === "function") {
6973
7140
  try {
6974
7141
  await ctx.refreshLocalAgents();
@@ -7008,6 +7175,44 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
7008
7175
  continue;
7009
7176
  }
7010
7177
  lines.push(`- \u2713 delegated ${res.data.key} \u2192 ${a.assignee_id}`);
7178
+ } else if (a.type === "agent.create") {
7179
+ const res = await apiClient.post(`${apiUrl}/api/agent_ops/agents/mutate`, { action: "create", name: a.name, type: a.agent_type, prompt: a.prompt, skill_ids: a.skill_ids, allowed_tools: a.allowed_tools }, { headers });
7180
+ if (!res.success) {
7181
+ lines.push(`- \u274C agent.create "${a.name}": ${res.error || res.status}`);
7182
+ continue;
7183
+ }
7184
+ if (res.data?.queued)
7185
+ lines.push(`- \u23F3 agent.create "${a.name}" queued for human approval (op ${res.data.pending_op_id})`);
7186
+ else
7187
+ lines.push(`- \u2713 agent.create "${a.name}" \u2192 ${res.data?.agent_id} (status=pending \u2014 needs human approval before dispatch)`);
7188
+ } else if (a.type === "agent.update") {
7189
+ const res = await apiClient.post(`${apiUrl}/api/agent_ops/agents/mutate`, { action: "update", ...a }, { headers });
7190
+ if (!res.success) {
7191
+ lines.push(`- \u274C agent.update ${a.id}: ${res.error || res.status}`);
7192
+ continue;
7193
+ }
7194
+ if (res.data?.queued)
7195
+ lines.push(`- \u23F3 agent.update ${a.id} queued`);
7196
+ else
7197
+ lines.push(`- \u2713 agent.update ${a.id}`);
7198
+ } else if (a.type === "skill.create") {
7199
+ const res = await apiClient.post(`${apiUrl}/api/agent_ops/skills/mutate`, { action: "create", name: a.name, version: a.version, description: a.description, body: a.body, files: a.files }, { headers });
7200
+ if (!res.success) {
7201
+ lines.push(`- \u274C skill.create "${a.name}": ${res.error || res.status}`);
7202
+ continue;
7203
+ }
7204
+ lines.push(`- \u23F3 skill.create "${a.name}" queued for human review (op ${res.data?.pending_op_id})`);
7205
+ } else if (a.type === "skill.attach" || a.type === "skill.detach") {
7206
+ const action = a.type === "skill.attach" ? "attach_skill" : "detach_skill";
7207
+ const res = await apiClient.post(`${apiUrl}/api/agent_ops/agents/mutate`, { action, agent_id: a.agent_id, skill_id: a.skill_id }, { headers });
7208
+ if (!res.success) {
7209
+ lines.push(`- \u274C ${a.type} ${a.skill_id}\u2192${a.agent_id}: ${res.error || res.status}`);
7210
+ continue;
7211
+ }
7212
+ if (res.data?.queued)
7213
+ lines.push(`- \u23F3 ${a.type} queued`);
7214
+ else
7215
+ lines.push(`- \u2713 ${a.type} ${a.skill_id} \u2194 ${a.agent_id}`);
7011
7216
  }
7012
7217
  } catch (e) {
7013
7218
  lines.push(`- \u274C ${a.type} failed: ${String(e)}`);
@@ -7062,25 +7267,25 @@ function statusIcon(status) {
7062
7267
  }
7063
7268
  }
7064
7269
  async function resolveAcpAdapter(agentType, detectedPath) {
7065
- if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
7270
+ if (agentType === "pi" && detectedPath && existsSync4(detectedPath)) {
7066
7271
  return [detectedPath, "--mode", "rpc"];
7067
7272
  }
7068
7273
  const adapterName = "claude-code-acp";
7069
7274
  const candidates = [
7070
- join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
7275
+ join4(HOME3, ".bun", "install", "global", "node_modules", ".bin", adapterName)
7071
7276
  ];
7072
7277
  try {
7073
7278
  const here = new URL(import.meta.url).pathname;
7074
7279
  let dir = here;
7075
7280
  for (let i = 0;i < 8; i++) {
7076
- dir = dirname2(dir);
7077
- const bin = join3(dir, "node_modules", ".bin", adapterName);
7078
- if (existsSync3(bin))
7281
+ dir = dirname3(dir);
7282
+ const bin = join4(dir, "node_modules", ".bin", adapterName);
7283
+ if (existsSync4(bin))
7079
7284
  return [bin];
7080
7285
  }
7081
7286
  } catch {}
7082
7287
  for (const c of candidates)
7083
- if (existsSync3(c))
7288
+ if (existsSync4(c))
7084
7289
  return [c];
7085
7290
  return null;
7086
7291
  }
@@ -7132,7 +7337,7 @@ async function postStream(apiUrl, issueId, event_type, payload) {
7132
7337
  try {
7133
7338
  ensureDirs();
7134
7339
  const date = new Date().toISOString().slice(0, 10);
7135
- const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
7340
+ const path = join4(MULTI_DIR2, "logs", `events-${date}.ndjson`);
7136
7341
  appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
7137
7342
  `);
7138
7343
  } catch {}
@@ -7144,7 +7349,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
7144
7349
  const items = list.data?.results || list.data || [];
7145
7350
  if (!Array.isArray(items) || items.length === 0)
7146
7351
  return [];
7147
- mkdirSync3(destDir, { recursive: true });
7352
+ mkdirSync4(destDir, { recursive: true });
7148
7353
  const token = authTokenHeader();
7149
7354
  const out = [];
7150
7355
  for (const it of items) {
@@ -7153,8 +7358,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
7153
7358
  continue;
7154
7359
  const buf = new Uint8Array(await res.arrayBuffer());
7155
7360
  const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
7156
- const p = join3(destDir, safe);
7157
- writeFileSync3(p, buf);
7361
+ const p = join4(destDir, safe);
7362
+ writeFileSync4(p, buf);
7158
7363
  out.push({ filename: it.filename, path: p });
7159
7364
  }
7160
7365
  return out;
@@ -7167,16 +7372,16 @@ function authTokenHeader() {
7167
7372
  return cfg.token ? `Bearer ${cfg.token}` : null;
7168
7373
  }
7169
7374
  async function uploadOutputDir(apiUrl, commentId, dir) {
7170
- if (!existsSync3(dir))
7375
+ if (!existsSync4(dir))
7171
7376
  return 0;
7172
7377
  const files = [];
7173
7378
  const walk = (d, depth = 0) => {
7174
7379
  if (depth > 3)
7175
7380
  return;
7176
- for (const name of readdirSync(d)) {
7177
- const p = join3(d, name);
7381
+ for (const name of readdirSync2(d)) {
7382
+ const p = join4(d, name);
7178
7383
  try {
7179
- const st = statSync(p);
7384
+ const st = statSync2(p);
7180
7385
  if (st.isDirectory())
7181
7386
  walk(p, depth + 1);
7182
7387
  else if (st.isFile())
@@ -7191,7 +7396,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
7191
7396
  let uploaded = 0;
7192
7397
  for (const f of files) {
7193
7398
  try {
7194
- const data = readFileSync3(f);
7399
+ const data = readFileSync4(f);
7195
7400
  const form = new FormData;
7196
7401
  const blob = new Blob([data]);
7197
7402
  form.append("file", blob, f.split("/").pop() || "file");
@@ -7418,7 +7623,7 @@ async function cmdStatus(apiUrl, config) {
7418
7623
  process.exit(1);
7419
7624
  }
7420
7625
  const d = res.data;
7421
- const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
7626
+ const pid = existsSync4(PID_PATH) ? readFileSync4(PID_PATH, "utf8").trim() : null;
7422
7627
  const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
7423
7628
  console.log(`
7424
7629
  Device Status
@@ -7432,18 +7637,18 @@ Daemon: ${daemon}
7432
7637
  `);
7433
7638
  }
7434
7639
  async function cmdStop() {
7435
- if (!existsSync3(PID_PATH)) {
7640
+ if (!existsSync4(PID_PATH)) {
7436
7641
  console.log("No daemon running.");
7437
7642
  return;
7438
7643
  }
7439
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
7644
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
7440
7645
  if (!pid || !isRunning(pid)) {
7441
7646
  unlinkSync(PID_PATH);
7442
7647
  console.log("Cleaned stale pidfile.");
7443
7648
  return;
7444
7649
  }
7445
7650
  ensureDirs();
7446
- writeFileSync3(STOP_PATH, "1");
7651
+ writeFileSync4(STOP_PATH, "1");
7447
7652
  try {
7448
7653
  process.kill(pid, "SIGTERM");
7449
7654
  console.log(`Sent SIGTERM to ${pid}`);
@@ -7454,11 +7659,11 @@ async function cmdReset(issueId) {
7454
7659
  console.error("Usage: multi-agent reset --issue <issue_id>");
7455
7660
  process.exit(2);
7456
7661
  }
7457
- if (!existsSync3(PORT_PATH)) {
7662
+ if (!existsSync4(PORT_PATH)) {
7458
7663
  console.error("Daemon not running (no port file).");
7459
7664
  process.exit(1);
7460
7665
  }
7461
- const port = Number(readFileSync3(PORT_PATH, "utf8").trim());
7666
+ const port = Number(readFileSync4(PORT_PATH, "utf8").trim());
7462
7667
  const config = loadConfig();
7463
7668
  if (!config.dispatchSecret) {
7464
7669
  console.error("No dispatchSecret in config \u2014 run `multi-agent setup` first.");
@@ -7477,11 +7682,11 @@ async function cmdReset(issueId) {
7477
7682
  console.log(body);
7478
7683
  }
7479
7684
  async function cmdRestart(apiUrl) {
7480
- if (existsSync3(PID_PATH)) {
7481
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
7685
+ if (existsSync4(PID_PATH)) {
7686
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
7482
7687
  if (pid && isRunning(pid)) {
7483
7688
  ensureDirs();
7484
- writeFileSync3(STOP_PATH, "1");
7689
+ writeFileSync4(STOP_PATH, "1");
7485
7690
  try {
7486
7691
  process.kill(pid, "SIGTERM");
7487
7692
  } catch {}
@@ -7497,12 +7702,12 @@ async function cmdRestart(apiUrl) {
7497
7702
  }
7498
7703
  }
7499
7704
  try {
7500
- if (existsSync3(PID_PATH))
7705
+ if (existsSync4(PID_PATH))
7501
7706
  unlinkSync(PID_PATH);
7502
7707
  } catch {}
7503
7708
  }
7504
7709
  try {
7505
- if (existsSync3(STOP_PATH))
7710
+ if (existsSync4(STOP_PATH))
7506
7711
  unlinkSync(STOP_PATH);
7507
7712
  } catch {}
7508
7713
  console.log("\uD83D\uDD04 Relaunching daemon...");
@@ -7521,11 +7726,11 @@ async function cmdRestart(apiUrl) {
7521
7726
  process.exit(0);
7522
7727
  }
7523
7728
  async function cmdLogs() {
7524
- if (!existsSync3(LOG_PATH2)) {
7729
+ if (!existsSync4(LOG_PATH2)) {
7525
7730
  console.log("No logs yet.");
7526
7731
  return;
7527
7732
  }
7528
- const content = readFileSync3(LOG_PATH2, "utf8");
7733
+ const content = readFileSync4(LOG_PATH2, "utf8");
7529
7734
  console.log(content.split(`
7530
7735
  `).slice(-100).join(`
7531
7736
  `));
@@ -7571,16 +7776,16 @@ function openTasksDb() {
7571
7776
  }
7572
7777
  function loadConfig() {
7573
7778
  try {
7574
- if (!existsSync3(CONFIG_PATH))
7779
+ if (!existsSync4(CONFIG_PATH))
7575
7780
  return {};
7576
- return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
7781
+ return JSON.parse(readFileSync4(CONFIG_PATH, "utf8"));
7577
7782
  } catch {
7578
7783
  return {};
7579
7784
  }
7580
7785
  }
7581
7786
  function saveConfig(config) {
7582
7787
  ensureDirs();
7583
- writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
7788
+ writeFileSync4(CONFIG_PATH, JSON.stringify(config, null, 2));
7584
7789
  }
7585
7790
  function sleep(ms) {
7586
7791
  return new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { Database } from 'bun:sqlite';
6
6
  import { runAcp } from './acp-runner';
7
7
  import { runAcpx } from './acpx-runner';
8
8
  import { ensureWorktree } from './worktree';
9
+ import { materializeBundle, lastMaterializedRevision } from './materializer';
9
10
  import { parseArgs } from 'util';
10
11
  import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
11
12
  import { join, dirname } from 'path';
@@ -204,22 +205,12 @@ async function cmdSetup(name?: string, apiUrl?: string) {
204
205
  const workspaceId = dev.data?.workspace_id;
205
206
  if (workspaceId) {
206
207
  saveConfig({ deviceId: approved.device_id, token: approved.token, dispatchSecret: approved.dispatch_secret, workspaceId, apiUrl });
207
- await syncSkills(apiUrl!, workspaceId);
208
+ try { await materializeBundle(apiUrl!, approved.device_id, (m) => console.log(` ${m}`)); } catch (e) { console.log(` materialize failed: ${String(e)}`); }
208
209
  }
209
210
  console.log('\nNext: link to an agent with: multi-agent link --agent <agentId>');
210
211
  console.log('Then: multi-agent connect');
211
212
  }
212
213
 
213
- async function syncSkills(apiUrl: string, workspaceId: string) {
214
- const res = await apiClient.get<any[]>(`${apiUrl}/api/skills?workspace_id=${workspaceId}`);
215
- if (!res.success || !Array.isArray(res.data)) return;
216
- ensureDirs();
217
- for (const skill of res.data) {
218
- writeFileSync(join(SKILLS_DIR, `${skill.name}.json`), JSON.stringify(skill, null, 2));
219
- }
220
- if (res.data.length) console.log(` Synced ${res.data.length} skill(s) → ${SKILLS_DIR}`);
221
- }
222
-
223
214
  async function cmdLink(apiUrl: string, config: Config, agentId?: string) {
224
215
  if (!config.deviceId) {
225
216
  console.log('❌ Not registered. Run "multi-agent setup" first.');
@@ -447,7 +438,14 @@ async function cmdConnect(apiUrl: string, config: Config) {
447
438
  log(`☁️ Tunnel up: ${tunnel.url}`);
448
439
 
449
440
  const heartbeat = async (): Promise<number> => {
450
- const res = await apiClient.post<{ pending_dispatches?: number }>(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
441
+ const res = await apiClient.post<{ pending_dispatches?: number; agent_skill_revision?: number }>(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
442
+ if (res.success && res.data) {
443
+ const remoteRev = Number(res.data.agent_skill_revision ?? 0);
444
+ if (remoteRev > 0 && remoteRev !== lastMaterializedRevision()) {
445
+ try { await materializeBundle(apiUrl, config.deviceId!, log); }
446
+ catch (e) { log(`materialize error: ${String(e)}`); }
447
+ }
448
+ }
451
449
  return (res.success && res.data?.pending_dispatches) || 0;
452
450
  };
453
451
  {
@@ -1102,11 +1100,11 @@ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-p
1102
1100
  }
1103
1101
  } catch {}
1104
1102
 
1105
- return `# Planning & delegation
1103
+ return `# Planning, delegation, and self-service
1106
1104
 
1107
- 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.
1105
+ After finishing your reply, you may append ONE fenced \`multi-plan\` block. The daemon parses it and executes after your turn.
1108
1106
 
1109
- Syntax:
1107
+ Issue actions:
1110
1108
 
1111
1109
  \`\`\`multi-plan
1112
1110
  {"actions":[
@@ -1116,14 +1114,26 @@ Syntax:
1116
1114
  ]}
1117
1115
  \`\`\`
1118
1116
 
1117
+ Agent + skill self-service (use sparingly — only when you genuinely need a new capability that isn't covered by an existing agent or skill):
1118
+
1119
+ \`\`\`multi-plan
1120
+ {"actions":[
1121
+ {"type":"agent.create","name":"refactor-bot","agent_type":"claude-code","prompt":"You refactor TS code...","skill_ids":["sk_xxx"],"allowed_tools":["Read","Edit","Bash"]},
1122
+ {"type":"agent.update","id":"ag_xxx","prompt":"new prompt..."},
1123
+ {"type":"skill.create","name":"run-tests","description":"Run the test suite","body":"---\\nname: run-tests\\n---\\n\\n# Run tests\\n..."},
1124
+ {"type":"skill.attach","agent_id":"ag_xxx","skill_id":"sk_yyy"},
1125
+ {"type":"skill.detach","agent_id":"ag_xxx","skill_id":"sk_yyy"}
1126
+ ]}
1127
+ \`\`\`
1128
+
1119
1129
  Rules:
1120
- - Omit the block entirely if no actions are needed.
1121
- - Max 10 actions per turn; additional actions are dropped.
1122
- - Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
1123
- - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
1124
- - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
1125
- - \`delegate\` is shorthand for reassigning and resetting status to todo.
1126
- - Only target issues in the current project (${projectId || 'this project'}).
1130
+ - Omit the block if no actions are needed.
1131
+ - Max 10 actions per turn. Sub-caps: agent.create=2, skill.create=3 per turn.
1132
+ - Planning depth capped at ${PLANNING_DEPTH_LIMIT}. At depth 1 you can only \`update\` your own issue — no creates, no agents, no skills.
1133
+ - \`agent.create\` produces an agent in **pending** status. A human must approve it before any issue can be dispatched to it.
1134
+ - \`skill.create\` ALWAYS waits for human review (skill bodies become future system prompts).
1135
+ - \`allowed_tools\` on a new agent must be a subset of your own tools.
1136
+ - \`create\` / \`update\` / \`delegate\` target issues only in the current project (${projectId || 'this project'}).
1127
1137
 
1128
1138
  ${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`;
1129
1139
  }
@@ -1131,7 +1141,12 @@ ${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`
1131
1141
  type PlanAction =
1132
1142
  | { type: 'create'; project_id?: string; title: string; description?: string; priority?: string; assignee_type?: string; assignee_id?: string; parent_id?: string }
1133
1143
  | { type: 'update'; id: string; title?: string; description?: string; status?: string; priority?: string; assignee_type?: string; assignee_id?: string }
1134
- | { type: 'delegate'; id: string; assignee_id: string };
1144
+ | { type: 'delegate'; id: string; assignee_id: string }
1145
+ | { type: 'agent.create'; name: string; agent_type: string; prompt?: string; skill_ids?: string[]; allowed_tools?: string[] }
1146
+ | { type: 'agent.update'; id: string; name?: string; prompt?: string | null; allowed_tools?: string[] | null }
1147
+ | { type: 'skill.create'; name: string; version?: string; description?: string; body: string; files?: { path: string; content: string }[] }
1148
+ | { type: 'skill.attach'; agent_id: string; skill_id: string }
1149
+ | { type: 'skill.detach'; agent_id: string; skill_id: string };
1135
1150
 
1136
1151
  // Extract JSON action blocks fenced as ```multi-plan ... ```
1137
1152
  function extractPlanActions(text: string): PlanAction[] {
@@ -1163,10 +1178,23 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1163
1178
  // `planning_depth` is carried on each dispatched task (set server-side from issue row).
1164
1179
  const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
1165
1180
  if (depth >= PLANNING_DEPTH_LIMIT) {
1166
- const blocked = actions.filter(a => a.type === 'create' || a.type === 'delegate').length;
1181
+ const blocked = actions.filter(a => a.type !== 'update').length;
1167
1182
  actions = actions.filter(a => a.type === 'update');
1168
- if (blocked) lines.push(`- ⚠ ${blocked} create/delegate action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
1183
+ if (blocked) lines.push(`- ⚠ ${blocked} non-update action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
1169
1184
  }
1185
+ // Sub-caps: agent/skill creation is rare, prevent runaway turns.
1186
+ const SUBCAPS = { 'agent.create': 2, 'skill.create': 3, 'skill.attach': 5, 'skill.detach': 5, 'agent.update': 5 } as Record<string, number>;
1187
+ const counts: Record<string, number> = {};
1188
+ actions = actions.filter(a => {
1189
+ const cap = SUBCAPS[a.type];
1190
+ if (cap === undefined) return true;
1191
+ counts[a.type] = (counts[a.type] || 0) + 1;
1192
+ if (counts[a.type] > cap) {
1193
+ lines.push(`- ⚠ ${a.type} sub-cap ${cap} hit, dropping extra`);
1194
+ return false;
1195
+ }
1196
+ return true;
1197
+ });
1170
1198
  const parentId = parentTask.issue_id;
1171
1199
  const parentProjectId = await (async () => {
1172
1200
  try {
@@ -1174,7 +1202,7 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1174
1202
  return r.data?.project_id;
1175
1203
  } catch { return null; }
1176
1204
  })();
1177
- const headers = { 'x-agent-id': parentTask.agent_id };
1205
+ const headers: Record<string, string> = { 'x-agent-id': parentTask.agent_id, 'x-origin-issue-id': parentTask.issue_id };
1178
1206
 
1179
1207
  // Refresh the set of agents linked to this device once per plan execution.
1180
1208
  if (typeof ctx.refreshLocalAgents === 'function') {
@@ -1203,6 +1231,26 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1203
1231
  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 });
1204
1232
  if (!res.success) { lines.push(`- ❌ delegate ${a.id}: ${res.error || res.status}`); continue; }
1205
1233
  lines.push(`- ✓ delegated ${res.data.key} → ${a.assignee_id}`);
1234
+ } else if (a.type === 'agent.create') {
1235
+ const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action: 'create', name: a.name, type: a.agent_type, prompt: a.prompt, skill_ids: a.skill_ids, allowed_tools: a.allowed_tools }, { headers });
1236
+ if (!res.success) { lines.push(`- ❌ agent.create "${a.name}": ${res.error || res.status}`); continue; }
1237
+ if (res.data?.queued) lines.push(`- ⏳ agent.create "${a.name}" queued for human approval (op ${res.data.pending_op_id})`);
1238
+ else lines.push(`- ✓ agent.create "${a.name}" → ${res.data?.agent_id} (status=pending — needs human approval before dispatch)`);
1239
+ } else if (a.type === 'agent.update') {
1240
+ const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action: 'update', ...a }, { headers });
1241
+ if (!res.success) { lines.push(`- ❌ agent.update ${a.id}: ${res.error || res.status}`); continue; }
1242
+ if (res.data?.queued) lines.push(`- ⏳ agent.update ${a.id} queued`);
1243
+ else lines.push(`- ✓ agent.update ${a.id}`);
1244
+ } else if (a.type === 'skill.create') {
1245
+ const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/skills/mutate`, { action: 'create', name: a.name, version: a.version, description: a.description, body: a.body, files: a.files }, { headers });
1246
+ if (!res.success) { lines.push(`- ❌ skill.create "${a.name}": ${res.error || res.status}`); continue; }
1247
+ lines.push(`- ⏳ skill.create "${a.name}" queued for human review (op ${res.data?.pending_op_id})`);
1248
+ } else if (a.type === 'skill.attach' || a.type === 'skill.detach') {
1249
+ const action = a.type === 'skill.attach' ? 'attach_skill' : 'detach_skill';
1250
+ const res = await apiClient.post<any>(`${apiUrl}/api/agent_ops/agents/mutate`, { action, agent_id: a.agent_id, skill_id: a.skill_id }, { headers });
1251
+ if (!res.success) { lines.push(`- ❌ ${a.type} ${a.skill_id}→${a.agent_id}: ${res.error || res.status}`); continue; }
1252
+ if (res.data?.queued) lines.push(`- ⏳ ${a.type} queued`);
1253
+ else lines.push(`- ✓ ${a.type} ${a.skill_id} ↔ ${a.agent_id}`);
1206
1254
  }
1207
1255
  } catch (e) {
1208
1256
  lines.push(`- ❌ ${a.type} failed: ${String(e)}`);
@@ -0,0 +1,166 @@
1
+ // Materialize agents + skills onto disk so claude-code (and the agent itself
2
+ // via Read/Bash) can load them. Pulls /api/devices/:id/agent_bundle on demand
3
+ // (heartbeat revision mismatch), writes to ~/.multi/skills/<slug>/ and ~/.multi/agents/,
4
+ // then symlinks/copies into ~/.claude/{skills,agents} marked with .multi-managed
5
+ // so we never clobber a user-authored skill or agent definition.
6
+
7
+ import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync, symlinkSync, readdirSync, statSync, lstatSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { apiClient } from './client';
10
+
11
+ const HOME = process.env.HOME || process.env.USERPROFILE || '.';
12
+ const MULTI_DIR = join(HOME, '.multi');
13
+ const MULTI_SKILLS = join(MULTI_DIR, 'skills');
14
+ const MULTI_AGENTS = join(MULTI_DIR, 'agents');
15
+ const STATE_PATH = join(MULTI_DIR, 'materialized.json');
16
+ const CLAUDE_DIR = join(HOME, '.claude');
17
+ const CLAUDE_SKILLS = join(CLAUDE_DIR, 'skills');
18
+ const CLAUDE_AGENTS = join(CLAUDE_DIR, 'agents');
19
+ const MARKER = '.multi-managed';
20
+
21
+ type BundleSkill = {
22
+ id: string;
23
+ name: string;
24
+ version: string | null;
25
+ description: string | null;
26
+ body: string | null;
27
+ files: { path: string; content: string }[];
28
+ };
29
+
30
+ type BundleAgent = {
31
+ id: string;
32
+ name: string;
33
+ type: string;
34
+ prompt: string | null;
35
+ approval_status: string;
36
+ allowed_tools: string | null;
37
+ };
38
+
39
+ type Bundle = {
40
+ revision: number;
41
+ agents: BundleAgent[];
42
+ skills: BundleSkill[];
43
+ links: { agent_id: string; skill_id: string }[];
44
+ };
45
+
46
+ type State = {
47
+ revision: number;
48
+ skill_slugs: string[];
49
+ agent_slugs: string[];
50
+ };
51
+
52
+ function slugify(s: string): string {
53
+ return s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unnamed';
54
+ }
55
+
56
+ function loadState(): State {
57
+ try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
58
+ catch { return { revision: -1, skill_slugs: [], agent_slugs: [] }; }
59
+ }
60
+
61
+ function saveState(s: State) {
62
+ mkdirSync(MULTI_DIR, { recursive: true });
63
+ writeFileSync(STATE_PATH, JSON.stringify(s, null, 2));
64
+ }
65
+
66
+ function safeRmManaged(path: string) {
67
+ if (!existsSync(path)) return;
68
+ try {
69
+ const st = lstatSync(path);
70
+ if (st.isSymbolicLink()) { rmSync(path); return; }
71
+ if (st.isDirectory() && existsSync(join(path, MARKER))) {
72
+ rmSync(path, { recursive: true, force: true });
73
+ return;
74
+ }
75
+ if (st.isFile() && path.endsWith('.md')) {
76
+ const head = readFileSync(path, 'utf8').slice(0, 200);
77
+ if (head.includes('multi-managed: true')) rmSync(path);
78
+ }
79
+ } catch {}
80
+ }
81
+
82
+ function writeManagedSkill(slug: string, skill: BundleSkill) {
83
+ const dir = join(MULTI_SKILLS, slug);
84
+ mkdirSync(dir, { recursive: true });
85
+ writeFileSync(join(dir, MARKER), `skill_id=${skill.id}\nrevision=${Date.now()}\n`);
86
+ if (skill.body) writeFileSync(join(dir, 'SKILL.md'), skill.body);
87
+ for (const f of skill.files || []) {
88
+ if (f.path.includes('..') || f.path.startsWith('/')) continue; // path traversal guard
89
+ if (f.path === MARKER) continue;
90
+ const out = join(dir, f.path);
91
+ mkdirSync(dirname(out), { recursive: true });
92
+ writeFileSync(out, f.content);
93
+ }
94
+ // Symlink into ~/.claude/skills/<slug>; replace any prior managed link/dir.
95
+ mkdirSync(CLAUDE_SKILLS, { recursive: true });
96
+ const link = join(CLAUDE_SKILLS, slug);
97
+ safeRmManaged(link);
98
+ if (!existsSync(link)) {
99
+ try { symlinkSync(dir, link, 'dir'); } catch {}
100
+ }
101
+ }
102
+
103
+ function writeManagedAgent(slug: string, agent: BundleAgent, skillsForAgent: string[]) {
104
+ if (agent.type !== 'claude-code') return; // acpx agents don't read ~/.claude/agents
105
+ mkdirSync(CLAUDE_AGENTS, { recursive: true });
106
+ const out = join(CLAUDE_AGENTS, `${slug}.md`);
107
+ safeRmManaged(out);
108
+ const tools = agent.allowed_tools ? `\ntools: ${agent.allowed_tools}` : '';
109
+ const skillsLine = skillsForAgent.length ? `\nskills: [${skillsForAgent.join(', ')}]` : '';
110
+ const fm = `---\nname: ${agent.name}\ndescription: managed by multi-agent (id=${agent.id})${tools}${skillsLine}\nmulti-managed: true\n---\n\n`;
111
+ writeFileSync(out, fm + (agent.prompt || ''));
112
+ }
113
+
114
+ export async function materializeBundle(apiUrl: string, deviceId: string, log: (m: string) => void): Promise<{ revision: number } | null> {
115
+ const res = await apiClient.get<Bundle>(`${apiUrl}/api/devices/${deviceId}/agent_bundle`);
116
+ if (!res.success || !res.data) {
117
+ log(`materialize: bundle fetch failed: ${res.error || 'unknown'}`);
118
+ return null;
119
+ }
120
+ const bundle = res.data;
121
+ const prev = loadState();
122
+
123
+ const linksByAgent = new Map<string, string[]>();
124
+ for (const l of bundle.links) {
125
+ if (!linksByAgent.has(l.agent_id)) linksByAgent.set(l.agent_id, []);
126
+ linksByAgent.get(l.agent_id)!.push(l.skill_id);
127
+ }
128
+
129
+ const newSkillSlugs: string[] = [];
130
+ const skillIdToSlug = new Map<string, string>();
131
+ for (const s of bundle.skills) {
132
+ const slug = slugify(s.name);
133
+ skillIdToSlug.set(s.id, slug);
134
+ writeManagedSkill(slug, s);
135
+ newSkillSlugs.push(slug);
136
+ }
137
+
138
+ const newAgentSlugs: string[] = [];
139
+ for (const a of bundle.agents) {
140
+ const slug = slugify(a.name);
141
+ const skillSlugs = (linksByAgent.get(a.id) || []).map(id => skillIdToSlug.get(id)).filter(Boolean) as string[];
142
+ writeManagedAgent(slug, a, skillSlugs);
143
+ newAgentSlugs.push(slug);
144
+ }
145
+
146
+ // Prune managed entries no longer in bundle.
147
+ for (const old of prev.skill_slugs) {
148
+ if (!newSkillSlugs.includes(old)) {
149
+ safeRmManaged(join(MULTI_SKILLS, old));
150
+ safeRmManaged(join(CLAUDE_SKILLS, old));
151
+ }
152
+ }
153
+ for (const old of prev.agent_slugs) {
154
+ if (!newAgentSlugs.includes(old)) {
155
+ safeRmManaged(join(CLAUDE_AGENTS, `${old}.md`));
156
+ }
157
+ }
158
+
159
+ saveState({ revision: bundle.revision, skill_slugs: newSkillSlugs, agent_slugs: newAgentSlugs });
160
+ log(`materialize: revision=${bundle.revision} agents=${bundle.agents.length} skills=${bundle.skills.length}`);
161
+ return { revision: bundle.revision };
162
+ }
163
+
164
+ export function lastMaterializedRevision(): number {
165
+ return loadState().revision;
166
+ }