@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 +319 -106
- package/package.json +1 -1
- package/src/index.ts +102 -44
- 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.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
|
|
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");
|
|
@@ -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
|
-
|
|
6343
|
+
restarting = true;
|
|
6201
6344
|
try {
|
|
6202
|
-
tunnel
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
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
|
-
|
|
6365
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
6366
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
6367
|
+
await sleep(wait);
|
|
6217
6368
|
}
|
|
6218
|
-
|
|
6219
|
-
|
|
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 (
|
|
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 (
|
|
6299
|
-
const pid = Number(
|
|
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 &&
|
|
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 ||
|
|
6392
|
-
const inDir =
|
|
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 =
|
|
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
|
|
7044
|
+
return `# Planning, delegation, and self-service
|
|
6895
7045
|
|
|
6896
|
-
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.
|
|
6897
7047
|
|
|
6898
|
-
|
|
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
|
|
6910
|
-
- Max 10 actions per turn
|
|
6911
|
-
- Planning depth
|
|
6912
|
-
- \`create\`
|
|
6913
|
-
- \`
|
|
6914
|
-
- \`
|
|
6915
|
-
-
|
|
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
|
|
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}
|
|
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 &&
|
|
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
|
-
|
|
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 =
|
|
7069
|
-
const bin =
|
|
7070
|
-
if (
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
7149
|
-
|
|
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 (!
|
|
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
|
|
7169
|
-
const p =
|
|
7381
|
+
for (const name of readdirSync2(d)) {
|
|
7382
|
+
const p = join4(d, name);
|
|
7170
7383
|
try {
|
|
7171
|
-
const st =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
7640
|
+
if (!existsSync4(PID_PATH)) {
|
|
7428
7641
|
console.log("No daemon running.");
|
|
7429
7642
|
return;
|
|
7430
7643
|
}
|
|
7431
|
-
const pid = Number(
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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 (
|
|
7473
|
-
const pid = Number(
|
|
7685
|
+
if (existsSync4(PID_PATH)) {
|
|
7686
|
+
const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
|
|
7474
7687
|
if (pid && isRunning(pid)) {
|
|
7475
7688
|
ensureDirs();
|
|
7476
|
-
|
|
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 (
|
|
7705
|
+
if (existsSync4(PID_PATH))
|
|
7493
7706
|
unlinkSync(PID_PATH);
|
|
7494
7707
|
} catch {}
|
|
7495
7708
|
}
|
|
7496
7709
|
try {
|
|
7497
|
-
if (
|
|
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 (!
|
|
7729
|
+
if (!existsSync4(LOG_PATH2)) {
|
|
7517
7730
|
console.log("No logs yet.");
|
|
7518
7731
|
return;
|
|
7519
7732
|
}
|
|
7520
|
-
const content =
|
|
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 (!
|
|
7779
|
+
if (!existsSync4(CONFIG_PATH))
|
|
7567
7780
|
return {};
|
|
7568
|
-
return JSON.parse(
|
|
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
|
-
|
|
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
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
|
{
|
|
@@ -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
|
-
|
|
487
|
-
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
506
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
507
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
508
|
+
await sleep(wait);
|
|
501
509
|
}
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
1103
|
+
return `# Planning, delegation, and self-service
|
|
1096
1104
|
|
|
1097
|
-
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.
|
|
1098
1106
|
|
|
1099
|
-
|
|
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
|
|
1111
|
-
- Max 10 actions per turn
|
|
1112
|
-
- Planning depth
|
|
1113
|
-
- \`create\`
|
|
1114
|
-
- \`
|
|
1115
|
-
- \`
|
|
1116
|
-
-
|
|
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
|
|
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}
|
|
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
|
+
}
|