@shipers-dev/multi 0.10.0 → 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.0",
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");
@@ -6194,30 +6336,38 @@ async function cmdConnect(apiUrl, config) {
6194
6336
  process.on("SIGINT", () => shutdown("SIGINT"));
6195
6337
  process.on("SIGTERM", () => shutdown("SIGTERM"));
6196
6338
  schedule();
6339
+ let restarting = false;
6197
6340
  const restartTunnel = async (reason) => {
6198
- if (!alive)
6341
+ if (!alive || restarting)
6199
6342
  return;
6200
- log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
6343
+ restarting = true;
6201
6344
  try {
6202
- tunnel?.child.kill();
6203
- } catch {}
6204
- for (let attempt = 1;alive; attempt++) {
6205
- const next = await startTunnel(port);
6206
- if (next) {
6207
- tunnel = next;
6208
- log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6209
- try {
6210
- const pending = await heartbeat();
6211
- if (pending > 0)
6212
- drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
6213
- } catch (e) {
6214
- log(`heartbeat error after tunnel restart: ${String(e)}`);
6345
+ log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
6346
+ const old = tunnel;
6347
+ tunnel = null;
6348
+ try {
6349
+ old?.child.kill();
6350
+ } catch {}
6351
+ for (let attempt = 1;alive; attempt++) {
6352
+ const next = await startTunnel(port);
6353
+ if (next) {
6354
+ tunnel = next;
6355
+ log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6356
+ try {
6357
+ const pending = await heartbeat();
6358
+ if (pending > 0)
6359
+ drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
6360
+ } catch (e) {
6361
+ log(`heartbeat error after tunnel restart: ${String(e)}`);
6362
+ }
6363
+ return;
6215
6364
  }
6216
- return;
6365
+ const wait = Math.min(30000, 2000 * attempt);
6366
+ log(`tunnel restart failed, retry in ${wait}ms`);
6367
+ await sleep(wait);
6217
6368
  }
6218
- const wait = Math.min(30000, 2000 * attempt);
6219
- log(`tunnel restart failed, retry in ${wait}ms`);
6220
- await sleep(wait);
6369
+ } finally {
6370
+ restarting = false;
6221
6371
  }
6222
6372
  };
6223
6373
  (async () => {
@@ -6239,7 +6389,7 @@ async function cmdConnect(apiUrl, config) {
6239
6389
  const PROBE_EVERY = 6;
6240
6390
  while (alive) {
6241
6391
  await sleep(20000);
6242
- if (existsSync3(STOP_PATH)) {
6392
+ if (existsSync4(STOP_PATH)) {
6243
6393
  await shutdown("stop flag");
6244
6394
  break;
6245
6395
  }
@@ -6295,8 +6445,8 @@ async function probeTunnel(url) {
6295
6445
  }
6296
6446
  }
6297
6447
  async function cmdConnectDetached(apiUrl) {
6298
- if (existsSync3(PID_PATH)) {
6299
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
6448
+ if (existsSync4(PID_PATH)) {
6449
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
6300
6450
  if (pid && isRunning(pid)) {
6301
6451
  console.log(`\u274C Daemon already running (pid ${pid}).`);
6302
6452
  process.exit(1);
@@ -6370,7 +6520,7 @@ async function markStopped(apiUrl, issueId, reason) {
6370
6520
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6371
6521
  const issueId = task.issue_id;
6372
6522
  const isFollowup = !!task.followup;
6373
- 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;
6374
6524
  let workingDir = baseWorkingDir;
6375
6525
  let worktreeBranch = "";
6376
6526
  if (baseWorkingDir) {
@@ -6388,13 +6538,13 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
6388
6538
  await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
6389
6539
  let attachmentRefs = [];
6390
6540
  if (task.from_comment_id) {
6391
- const baseDir = workingDir || join3(MULTI_DIR, "tmp", issueId);
6392
- 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);
6393
6543
  attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
6394
6544
  if (attachmentRefs.length)
6395
6545
  log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
6396
6546
  }
6397
- const outDir = join3(workingDir || join3(MULTI_DIR, "tmp", issueId), ".multi-out");
6547
+ const outDir = join4(workingDir || join4(MULTI_DIR2, "tmp", issueId), ".multi-out");
6398
6548
  let liveCommentId;
6399
6549
  let liveBody = "";
6400
6550
  let hadError = false;
@@ -6891,11 +7041,11 @@ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-p
6891
7041
  }
6892
7042
  }
6893
7043
  } catch {}
6894
- return `# Planning & delegation
7044
+ return `# Planning, delegation, and self-service
6895
7045
 
6896
- 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.
6897
7047
 
6898
- Syntax:
7048
+ Issue actions:
6899
7049
 
6900
7050
  \`\`\`multi-plan
6901
7051
  {"actions":[
@@ -6905,14 +7055,26 @@ Syntax:
6905
7055
  ]}
6906
7056
  \`\`\`
6907
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
+
6908
7070
  Rules:
6909
- - Omit the block entirely if no actions are needed.
6910
- - Max 10 actions per turn; additional actions are dropped.
6911
- - Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
6912
- - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
6913
- - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
6914
- - \`delegate\` is shorthand for reassigning and resetting status to todo.
6915
- - 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"}).
6916
7078
 
6917
7079
  ${agentsBlock ? `Available agents you can delegate to:
6918
7080
  ${agentsBlock}
@@ -6946,11 +7108,24 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6946
7108
  }
6947
7109
  const depth = typeof parentTask.planning_depth === "number" ? parentTask.planning_depth : 0;
6948
7110
  if (depth >= PLANNING_DEPTH_LIMIT) {
6949
- const blocked = actions.filter((a) => a.type === "create" || a.type === "delegate").length;
7111
+ const blocked = actions.filter((a) => a.type !== "update").length;
6950
7112
  actions = actions.filter((a) => a.type === "update");
6951
7113
  if (blocked)
6952
- lines.push(`- \u26A0 ${blocked} create/delegate action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
6953
- }
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
+ });
6954
7129
  const parentId = parentTask.issue_id;
6955
7130
  const parentProjectId = await (async () => {
6956
7131
  try {
@@ -6960,7 +7135,7 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
6960
7135
  return null;
6961
7136
  }
6962
7137
  })();
6963
- const headers = { "x-agent-id": parentTask.agent_id };
7138
+ const headers = { "x-agent-id": parentTask.agent_id, "x-origin-issue-id": parentTask.issue_id };
6964
7139
  if (typeof ctx.refreshLocalAgents === "function") {
6965
7140
  try {
6966
7141
  await ctx.refreshLocalAgents();
@@ -7000,6 +7175,44 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
7000
7175
  continue;
7001
7176
  }
7002
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}`);
7003
7216
  }
7004
7217
  } catch (e) {
7005
7218
  lines.push(`- \u274C ${a.type} failed: ${String(e)}`);
@@ -7054,25 +7267,25 @@ function statusIcon(status) {
7054
7267
  }
7055
7268
  }
7056
7269
  async function resolveAcpAdapter(agentType, detectedPath) {
7057
- if (agentType === "pi" && detectedPath && existsSync3(detectedPath)) {
7270
+ if (agentType === "pi" && detectedPath && existsSync4(detectedPath)) {
7058
7271
  return [detectedPath, "--mode", "rpc"];
7059
7272
  }
7060
7273
  const adapterName = "claude-code-acp";
7061
7274
  const candidates = [
7062
- join3(HOME2, ".bun", "install", "global", "node_modules", ".bin", adapterName)
7275
+ join4(HOME3, ".bun", "install", "global", "node_modules", ".bin", adapterName)
7063
7276
  ];
7064
7277
  try {
7065
7278
  const here = new URL(import.meta.url).pathname;
7066
7279
  let dir = here;
7067
7280
  for (let i = 0;i < 8; i++) {
7068
- dir = dirname2(dir);
7069
- const bin = join3(dir, "node_modules", ".bin", adapterName);
7070
- if (existsSync3(bin))
7281
+ dir = dirname3(dir);
7282
+ const bin = join4(dir, "node_modules", ".bin", adapterName);
7283
+ if (existsSync4(bin))
7071
7284
  return [bin];
7072
7285
  }
7073
7286
  } catch {}
7074
7287
  for (const c of candidates)
7075
- if (existsSync3(c))
7288
+ if (existsSync4(c))
7076
7289
  return [c];
7077
7290
  return null;
7078
7291
  }
@@ -7124,7 +7337,7 @@ async function postStream(apiUrl, issueId, event_type, payload) {
7124
7337
  try {
7125
7338
  ensureDirs();
7126
7339
  const date = new Date().toISOString().slice(0, 10);
7127
- const path = join3(MULTI_DIR, "logs", `events-${date}.ndjson`);
7340
+ const path = join4(MULTI_DIR2, "logs", `events-${date}.ndjson`);
7128
7341
  appendFileSync2(path, JSON.stringify({ ts: Date.now(), issue_id: issueId, event_type, payload }) + `
7129
7342
  `);
7130
7343
  } catch {}
@@ -7136,7 +7349,7 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
7136
7349
  const items = list.data?.results || list.data || [];
7137
7350
  if (!Array.isArray(items) || items.length === 0)
7138
7351
  return [];
7139
- mkdirSync3(destDir, { recursive: true });
7352
+ mkdirSync4(destDir, { recursive: true });
7140
7353
  const token = authTokenHeader();
7141
7354
  const out = [];
7142
7355
  for (const it of items) {
@@ -7145,8 +7358,8 @@ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
7145
7358
  continue;
7146
7359
  const buf = new Uint8Array(await res.arrayBuffer());
7147
7360
  const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
7148
- const p = join3(destDir, safe);
7149
- writeFileSync3(p, buf);
7361
+ const p = join4(destDir, safe);
7362
+ writeFileSync4(p, buf);
7150
7363
  out.push({ filename: it.filename, path: p });
7151
7364
  }
7152
7365
  return out;
@@ -7159,16 +7372,16 @@ function authTokenHeader() {
7159
7372
  return cfg.token ? `Bearer ${cfg.token}` : null;
7160
7373
  }
7161
7374
  async function uploadOutputDir(apiUrl, commentId, dir) {
7162
- if (!existsSync3(dir))
7375
+ if (!existsSync4(dir))
7163
7376
  return 0;
7164
7377
  const files = [];
7165
7378
  const walk = (d, depth = 0) => {
7166
7379
  if (depth > 3)
7167
7380
  return;
7168
- for (const name of readdirSync(d)) {
7169
- const p = join3(d, name);
7381
+ for (const name of readdirSync2(d)) {
7382
+ const p = join4(d, name);
7170
7383
  try {
7171
- const st = statSync(p);
7384
+ const st = statSync2(p);
7172
7385
  if (st.isDirectory())
7173
7386
  walk(p, depth + 1);
7174
7387
  else if (st.isFile())
@@ -7183,7 +7396,7 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
7183
7396
  let uploaded = 0;
7184
7397
  for (const f of files) {
7185
7398
  try {
7186
- const data = readFileSync3(f);
7399
+ const data = readFileSync4(f);
7187
7400
  const form = new FormData;
7188
7401
  const blob = new Blob([data]);
7189
7402
  form.append("file", blob, f.split("/").pop() || "file");
@@ -7410,7 +7623,7 @@ async function cmdStatus(apiUrl, config) {
7410
7623
  process.exit(1);
7411
7624
  }
7412
7625
  const d = res.data;
7413
- const pid = existsSync3(PID_PATH) ? readFileSync3(PID_PATH, "utf8").trim() : null;
7626
+ const pid = existsSync4(PID_PATH) ? readFileSync4(PID_PATH, "utf8").trim() : null;
7414
7627
  const daemon = pid && isRunning(Number(pid)) ? `running (pid ${pid})` : "stopped";
7415
7628
  console.log(`
7416
7629
  Device Status
@@ -7424,18 +7637,18 @@ Daemon: ${daemon}
7424
7637
  `);
7425
7638
  }
7426
7639
  async function cmdStop() {
7427
- if (!existsSync3(PID_PATH)) {
7640
+ if (!existsSync4(PID_PATH)) {
7428
7641
  console.log("No daemon running.");
7429
7642
  return;
7430
7643
  }
7431
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
7644
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
7432
7645
  if (!pid || !isRunning(pid)) {
7433
7646
  unlinkSync(PID_PATH);
7434
7647
  console.log("Cleaned stale pidfile.");
7435
7648
  return;
7436
7649
  }
7437
7650
  ensureDirs();
7438
- writeFileSync3(STOP_PATH, "1");
7651
+ writeFileSync4(STOP_PATH, "1");
7439
7652
  try {
7440
7653
  process.kill(pid, "SIGTERM");
7441
7654
  console.log(`Sent SIGTERM to ${pid}`);
@@ -7446,11 +7659,11 @@ async function cmdReset(issueId) {
7446
7659
  console.error("Usage: multi-agent reset --issue <issue_id>");
7447
7660
  process.exit(2);
7448
7661
  }
7449
- if (!existsSync3(PORT_PATH)) {
7662
+ if (!existsSync4(PORT_PATH)) {
7450
7663
  console.error("Daemon not running (no port file).");
7451
7664
  process.exit(1);
7452
7665
  }
7453
- const port = Number(readFileSync3(PORT_PATH, "utf8").trim());
7666
+ const port = Number(readFileSync4(PORT_PATH, "utf8").trim());
7454
7667
  const config = loadConfig();
7455
7668
  if (!config.dispatchSecret) {
7456
7669
  console.error("No dispatchSecret in config \u2014 run `multi-agent setup` first.");
@@ -7469,11 +7682,11 @@ async function cmdReset(issueId) {
7469
7682
  console.log(body);
7470
7683
  }
7471
7684
  async function cmdRestart(apiUrl) {
7472
- if (existsSync3(PID_PATH)) {
7473
- const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
7685
+ if (existsSync4(PID_PATH)) {
7686
+ const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
7474
7687
  if (pid && isRunning(pid)) {
7475
7688
  ensureDirs();
7476
- writeFileSync3(STOP_PATH, "1");
7689
+ writeFileSync4(STOP_PATH, "1");
7477
7690
  try {
7478
7691
  process.kill(pid, "SIGTERM");
7479
7692
  } catch {}
@@ -7489,12 +7702,12 @@ async function cmdRestart(apiUrl) {
7489
7702
  }
7490
7703
  }
7491
7704
  try {
7492
- if (existsSync3(PID_PATH))
7705
+ if (existsSync4(PID_PATH))
7493
7706
  unlinkSync(PID_PATH);
7494
7707
  } catch {}
7495
7708
  }
7496
7709
  try {
7497
- if (existsSync3(STOP_PATH))
7710
+ if (existsSync4(STOP_PATH))
7498
7711
  unlinkSync(STOP_PATH);
7499
7712
  } catch {}
7500
7713
  console.log("\uD83D\uDD04 Relaunching daemon...");
@@ -7513,11 +7726,11 @@ async function cmdRestart(apiUrl) {
7513
7726
  process.exit(0);
7514
7727
  }
7515
7728
  async function cmdLogs() {
7516
- if (!existsSync3(LOG_PATH2)) {
7729
+ if (!existsSync4(LOG_PATH2)) {
7517
7730
  console.log("No logs yet.");
7518
7731
  return;
7519
7732
  }
7520
- const content = readFileSync3(LOG_PATH2, "utf8");
7733
+ const content = readFileSync4(LOG_PATH2, "utf8");
7521
7734
  console.log(content.split(`
7522
7735
  `).slice(-100).join(`
7523
7736
  `));
@@ -7563,16 +7776,16 @@ function openTasksDb() {
7563
7776
  }
7564
7777
  function loadConfig() {
7565
7778
  try {
7566
- if (!existsSync3(CONFIG_PATH))
7779
+ if (!existsSync4(CONFIG_PATH))
7567
7780
  return {};
7568
- return JSON.parse(readFileSync3(CONFIG_PATH, "utf8"));
7781
+ return JSON.parse(readFileSync4(CONFIG_PATH, "utf8"));
7569
7782
  } catch {
7570
7783
  return {};
7571
7784
  }
7572
7785
  }
7573
7786
  function saveConfig(config) {
7574
7787
  ensureDirs();
7575
- writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2));
7788
+ writeFileSync4(CONFIG_PATH, JSON.stringify(config, null, 2));
7576
7789
  }
7577
7790
  function sleep(ms) {
7578
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.0",
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
  {
@@ -481,27 +479,36 @@ async function cmdConnect(apiUrl: string, config: Config) {
481
479
  schedule();
482
480
 
483
481
  // Tunnel self-heal: relaunch cloudflared if child exits or DNS stops resolving.
482
+ // `restarting` guards against two entries racing (probe-failure + exit-watcher
483
+ // both firing when we kill the child as part of our own restart).
484
+ let restarting = false;
484
485
  const restartTunnel = async (reason: string) => {
485
- if (!alive) return;
486
- log(`🔁 Restarting tunnel (${reason})`);
487
- try { tunnel?.child.kill(); } catch {}
488
- // Small backoff so we don't spam cloudflared edge under prolonged outage.
489
- for (let attempt = 1; alive; attempt++) {
490
- const next = await startTunnel(port);
491
- if (next) {
492
- tunnel = next;
493
- log(`☁️ Tunnel up: ${tunnel.url}`);
494
- try {
495
- const pending = await heartbeat();
496
- if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
497
- } catch (e) {
498
- log(`heartbeat error after tunnel restart: ${String(e)}`);
486
+ if (!alive || restarting) return;
487
+ restarting = true;
488
+ try {
489
+ log(`🔁 Restarting tunnel (${reason})`);
490
+ const old = tunnel;
491
+ tunnel = null; // null first so the exit-watcher's `tunnel === t` check skips our own kill
492
+ try { old?.child.kill(); } catch {}
493
+ for (let attempt = 1; alive; attempt++) {
494
+ const next = await startTunnel(port);
495
+ if (next) {
496
+ tunnel = next;
497
+ log(`☁️ Tunnel up: ${tunnel.url}`);
498
+ try {
499
+ const pending = await heartbeat();
500
+ if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
501
+ } catch (e) {
502
+ log(`heartbeat error after tunnel restart: ${String(e)}`);
503
+ }
504
+ return;
499
505
  }
500
- return;
506
+ const wait = Math.min(30000, 2000 * attempt);
507
+ log(`tunnel restart failed, retry in ${wait}ms`);
508
+ await sleep(wait);
501
509
  }
502
- const wait = Math.min(30000, 2000 * attempt);
503
- log(`tunnel restart failed, retry in ${wait}ms`);
504
- await sleep(wait);
510
+ } finally {
511
+ restarting = false;
505
512
  }
506
513
  };
507
514
 
@@ -512,6 +519,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
512
519
  if (!t) { await sleep(1000); continue; }
513
520
  const code = await t.child.exited;
514
521
  if (!alive) return;
522
+ // If `tunnel` was nulled by restartTunnel, we killed it ourselves — skip.
515
523
  if (tunnel === t) await restartTunnel(`cloudflared exited code=${code}`);
516
524
  }
517
525
  })();
@@ -1092,11 +1100,11 @@ You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-p
1092
1100
  }
1093
1101
  } catch {}
1094
1102
 
1095
- return `# Planning & delegation
1103
+ return `# Planning, delegation, and self-service
1096
1104
 
1097
- 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.
1098
1106
 
1099
- Syntax:
1107
+ Issue actions:
1100
1108
 
1101
1109
  \`\`\`multi-plan
1102
1110
  {"actions":[
@@ -1106,14 +1114,26 @@ Syntax:
1106
1114
  ]}
1107
1115
  \`\`\`
1108
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
+
1109
1129
  Rules:
1110
- - Omit the block entirely if no actions are needed.
1111
- - Max 10 actions per turn; additional actions are dropped.
1112
- - Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
1113
- - \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
1114
- - \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
1115
- - \`delegate\` is shorthand for reassigning and resetting status to todo.
1116
- - 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'}).
1117
1137
 
1118
1138
  ${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`;
1119
1139
  }
@@ -1121,7 +1141,12 @@ ${agentsBlock ? `Available agents you can delegate to:\n${agentsBlock}\n` : ''}`
1121
1141
  type PlanAction =
1122
1142
  | { type: 'create'; project_id?: string; title: string; description?: string; priority?: string; assignee_type?: string; assignee_id?: string; parent_id?: string }
1123
1143
  | { type: 'update'; id: string; title?: string; description?: string; status?: string; priority?: string; assignee_type?: string; assignee_id?: string }
1124
- | { 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 };
1125
1150
 
1126
1151
  // Extract JSON action blocks fenced as ```multi-plan ... ```
1127
1152
  function extractPlanActions(text: string): PlanAction[] {
@@ -1153,10 +1178,23 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1153
1178
  // `planning_depth` is carried on each dispatched task (set server-side from issue row).
1154
1179
  const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
1155
1180
  if (depth >= PLANNING_DEPTH_LIMIT) {
1156
- const blocked = actions.filter(a => a.type === 'create' || a.type === 'delegate').length;
1181
+ const blocked = actions.filter(a => a.type !== 'update').length;
1157
1182
  actions = actions.filter(a => a.type === 'update');
1158
- 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})`);
1159
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
+ });
1160
1198
  const parentId = parentTask.issue_id;
1161
1199
  const parentProjectId = await (async () => {
1162
1200
  try {
@@ -1164,7 +1202,7 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1164
1202
  return r.data?.project_id;
1165
1203
  } catch { return null; }
1166
1204
  })();
1167
- 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 };
1168
1206
 
1169
1207
  // Refresh the set of agents linked to this device once per plan execution.
1170
1208
  if (typeof ctx.refreshLocalAgents === 'function') {
@@ -1193,6 +1231,26 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
1193
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 });
1194
1232
  if (!res.success) { lines.push(`- ❌ delegate ${a.id}: ${res.error || res.status}`); continue; }
1195
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}`);
1196
1254
  }
1197
1255
  } catch (e) {
1198
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
+ }