@shipers-dev/multi 0.10.1 → 0.11.1
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 +292 -87
- package/package.json +3 -3
- package/src/acp-runner.ts +2 -2
- package/src/index.ts +76 -28
- package/src/materializer.ts +166 -0
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
|
|
5702
|
-
import { join as
|
|
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.
|
|
5845
|
+
version: "0.11.1",
|
|
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
|
|
5723
|
-
var
|
|
5724
|
-
var CONFIG_PATH =
|
|
5725
|
-
var PID_PATH =
|
|
5726
|
-
var PORT_PATH =
|
|
5727
|
-
var LOG_PATH2 =
|
|
5728
|
-
var SKILLS_DIR =
|
|
5729
|
-
var STOP_PATH =
|
|
5730
|
-
var TASKS_DB_PATH =
|
|
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 [
|
|
5744
|
-
if (!
|
|
5745
|
-
|
|
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
|
-
|
|
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 (
|
|
5958
|
-
const pid = Number(
|
|
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
|
-
|
|
5967
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
6326
|
+
if (existsSync4(PID_PATH))
|
|
6185
6327
|
unlinkSync(PID_PATH);
|
|
6186
|
-
if (
|
|
6328
|
+
if (existsSync4(STOP_PATH))
|
|
6187
6329
|
unlinkSync(STOP_PATH);
|
|
6188
|
-
if (
|
|
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 (
|
|
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 (
|
|
6307
|
-
const pid = Number(
|
|
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 &&
|
|
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 ||
|
|
6400
|
-
const inDir =
|
|
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 =
|
|
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
|
|
7044
|
+
return `# Planning, delegation, and self-service
|
|
6903
7045
|
|
|
6904
|
-
After finishing your reply, you may append ONE fenced block
|
|
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
|
-
|
|
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
|
|
6918
|
-
- Max 10 actions per turn
|
|
6919
|
-
- Planning depth
|
|
6920
|
-
- \`create\`
|
|
6921
|
-
- \`
|
|
6922
|
-
- \`
|
|
6923
|
-
-
|
|
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\` is auto-approved on auto-autonomy issues. Caps + rate limits prevent abuse.
|
|
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
|
|
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}
|
|
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}`);
|
|
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 &&
|
|
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
|
-
|
|
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 =
|
|
7077
|
-
const bin =
|
|
7078
|
-
if (
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
7157
|
-
|
|
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 (!
|
|
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
|
|
7177
|
-
const p =
|
|
7381
|
+
for (const name of readdirSync2(d)) {
|
|
7382
|
+
const p = join4(d, name);
|
|
7178
7383
|
try {
|
|
7179
|
-
const st =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
7640
|
+
if (!existsSync4(PID_PATH)) {
|
|
7436
7641
|
console.log("No daemon running.");
|
|
7437
7642
|
return;
|
|
7438
7643
|
}
|
|
7439
|
-
const pid = Number(
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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 (
|
|
7481
|
-
const pid = Number(
|
|
7685
|
+
if (existsSync4(PID_PATH)) {
|
|
7686
|
+
const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
|
|
7482
7687
|
if (pid && isRunning(pid)) {
|
|
7483
7688
|
ensureDirs();
|
|
7484
|
-
|
|
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 (
|
|
7705
|
+
if (existsSync4(PID_PATH))
|
|
7501
7706
|
unlinkSync(PID_PATH);
|
|
7502
7707
|
} catch {}
|
|
7503
7708
|
}
|
|
7504
7709
|
try {
|
|
7505
|
-
if (
|
|
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 (!
|
|
7729
|
+
if (!existsSync4(LOG_PATH2)) {
|
|
7525
7730
|
console.log("No logs yet.");
|
|
7526
7731
|
return;
|
|
7527
7732
|
}
|
|
7528
|
-
const content =
|
|
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 (!
|
|
7779
|
+
if (!existsSync4(CONFIG_PATH))
|
|
7575
7780
|
return {};
|
|
7576
|
-
return JSON.parse(
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"multi-agent": "./dist/index.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build": "bun build src/index.ts --outdir=dist --target=bun"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@
|
|
14
|
-
"@
|
|
13
|
+
"@agentclientprotocol/sdk": "^0.20.0",
|
|
14
|
+
"@agentclientprotocol/claude-agent-acp": "^0.31.0"
|
|
15
15
|
}
|
|
16
16
|
}
|
package/src/acp-runner.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Spawns the adapter as a subprocess over stdio, converts ACP events → our StreamEvent shape
|
|
3
3
|
// and handles client-side callbacks (requestPermission forwarded to server, fs read/write local).
|
|
4
4
|
|
|
5
|
-
import { ClientSideConnection, ndJsonStream } from '@
|
|
6
|
-
import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse } from '@
|
|
5
|
+
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';
|
|
6
|
+
import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse } from '@agentclientprotocol/sdk';
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
8
8
|
import { dirname } from 'path';
|
|
9
9
|
import { apiClient } from './client';
|
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
|
|
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
|
|
1103
|
+
return `# Planning, delegation, and self-service
|
|
1106
1104
|
|
|
1107
|
-
After finishing your reply, you may append ONE fenced block
|
|
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
|
-
|
|
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
|
|
1121
|
-
- Max 10 actions per turn
|
|
1122
|
-
- Planning depth
|
|
1123
|
-
- \`create\`
|
|
1124
|
-
- \`
|
|
1125
|
-
- \`
|
|
1126
|
-
-
|
|
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\` is auto-approved on auto-autonomy issues. Caps + rate limits prevent abuse.
|
|
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
|
|
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}
|
|
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}`);
|
|
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)}`);
|
|
@@ -1256,8 +1304,8 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
|
|
|
1256
1304
|
return [detectedPath, '--mode', 'rpc'];
|
|
1257
1305
|
}
|
|
1258
1306
|
|
|
1259
|
-
// claude-code →
|
|
1260
|
-
const adapterName = 'claude-
|
|
1307
|
+
// claude-code → claude-agent-acp adapter wrapper (stdio ACP)
|
|
1308
|
+
const adapterName = 'claude-agent-acp';
|
|
1261
1309
|
const candidates = [
|
|
1262
1310
|
join(HOME, '.bun', 'install', 'global', 'node_modules', '.bin', adapterName),
|
|
1263
1311
|
];
|
|
@@ -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
|
+
}
|