agent-trellis 0.8.0 → 0.9.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/README.md +45 -206
- package/dist/adapters/zcode.d.ts +21 -0
- package/dist/adapters/zcode.js +234 -0
- package/dist/cli.js +119 -10
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +4 -1
- package/dist/commands/mcp.d.ts +56 -14
- package/dist/commands/mcp.js +105 -28
- package/dist/commands/mcpGateway.js +2 -1
- package/dist/commands/mcpImport.d.ts +21 -2
- package/dist/commands/mcpImport.js +30 -7
- package/dist/commands/memory.d.ts +13 -1
- package/dist/commands/memory.js +15 -0
- package/dist/commands/migrate.js +4 -1
- package/dist/commands/onboard.js +15 -4
- package/dist/commands/remoteSkill.d.ts +90 -0
- package/dist/commands/remoteSkill.js +392 -0
- package/dist/commands/rollback.js +55 -1
- package/dist/commands/secretsAudit.js +4 -1
- package/dist/commands/skill.d.ts +37 -6
- package/dist/commands/skill.js +73 -15
- package/dist/commands/sync.js +3 -1
- package/dist/core/adapter.d.ts +11 -1
- package/dist/core/canonical.d.ts +17 -1
- package/dist/core/canonical.js +54 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.js +1 -0
- package/dist/lib/agentBridge.d.ts +169 -0
- package/dist/lib/agentBridge.js +367 -0
- package/dist/lib/agentBridgeProvider.d.ts +14 -0
- package/dist/lib/agentBridgeProvider.js +95 -0
- package/dist/lib/backup.d.ts +42 -0
- package/dist/lib/backup.js +36 -3
- package/dist/lib/chatAgents.d.ts +42 -0
- package/dist/lib/chatAgents.js +59 -0
- package/dist/lib/dirDigest.d.ts +11 -0
- package/dist/lib/dirDigest.js +52 -0
- package/dist/lib/envVarNames.d.ts +3 -0
- package/dist/lib/envVarNames.js +19 -0
- package/dist/lib/installAgent.d.ts +1 -1
- package/dist/lib/installAgent.js +1 -1
- package/dist/lib/mcpMigrateRead.d.ts +1 -0
- package/dist/lib/mcpMigrateRead.js +19 -1
- package/dist/lib/realHomeSnapshot.js +5 -0
- package/dist/lib/remoteSkillLock.d.ts +26 -0
- package/dist/lib/remoteSkillLock.js +88 -0
- package/dist/lib/remoteSkillSource.d.ts +45 -0
- package/dist/lib/remoteSkillSource.js +206 -0
- package/dist/lib/runtimeControlProvider.js +2 -0
- package/dist/lib/taskStore.js +1 -1
- package/dist/lib/terminalPicker.js +33 -6
- package/dist/lib/zcodeOwnership.d.ts +17 -0
- package/dist/lib/zcodeOwnership.js +37 -0
- package/dist/pi-bridge/bundle.js +2 -1
- package/dist/probes/codex.d.ts +7 -0
- package/dist/probes/codex.js +37 -5
- package/dist/probes/zcode.d.ts +32 -0
- package/dist/probes/zcode.js +140 -0
- package/docs/architecture.md +9 -5
- package/docs/assets/brand-v3/trellis-mark-v3-a.png +0 -0
- package/docs/assets/brand-v3/trellis-mark-v3-a.svg +10 -0
- package/docs/assets/poster-series-v3/README.md +60 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-en.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-en.svg +51 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-zh.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-zh.svg +51 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-en.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-en.svg +48 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-zh.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-zh.svg +48 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-en.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-en.svg +56 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-zh.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-zh.svg +56 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-en.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-en.svg +47 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-zh.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-zh.svg +47 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-en.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-en.svg +51 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.png +0 -0
- package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.svg +51 -0
- package/docs/getting-started.md +244 -14
- package/docs/overview.md +6 -3
- package/docs/overview.zh-CN.md +5 -3
- package/docs/roadmap.md +2 -1
- package/docs/video/README.md +76 -0
- package/docs/video/build/audio/01-opening.aiff +0 -0
- package/docs/video/build/audio/01-opening.mp3 +0 -0
- package/docs/video/build/audio/02-pain.aiff +0 -0
- package/docs/video/build/audio/02-pain.mp3 +0 -0
- package/docs/video/build/audio/03-solution.aiff +0 -0
- package/docs/video/build/audio/03-solution.mp3 +0 -0
- package/docs/video/build/audio/04-install.aiff +0 -0
- package/docs/video/build/audio/04-install.mp3 +0 -0
- package/docs/video/build/audio/04-setup.mp3 +0 -0
- package/docs/video/build/audio/05-dryrun.mp3 +0 -0
- package/docs/video/build/audio/05-init.aiff +0 -0
- package/docs/video/build/audio/05-init.mp3 +0 -0
- package/docs/video/build/audio/05-interactive.mp3 +0 -0
- package/docs/video/build/audio/06-dryrun.aiff +0 -0
- package/docs/video/build/audio/06-dryrun.mp3 +0 -0
- package/docs/video/build/audio/06-onboard.mp3 +0 -0
- package/docs/video/build/audio/06-run.mp3 +0 -0
- package/docs/video/build/audio/07-onboard.aiff +0 -0
- package/docs/video/build/audio/07-onboard.mp3 +0 -0
- package/docs/video/build/audio/07-verify.mp3 +0 -0
- package/docs/video/build/audio/08-closing.mp3 +0 -0
- package/docs/video/build/audio/08-doctor.aiff +0 -0
- package/docs/video/build/audio/08-doctor.mp3 +0 -0
- package/docs/video/build/audio/09-rollback.aiff +0 -0
- package/docs/video/build/audio/09-rollback.mp3 +0 -0
- package/docs/video/build/audio/10-principles.aiff +0 -0
- package/docs/video/build/audio/10-principles.mp3 +0 -0
- package/docs/video/build/audio/11-closing.aiff +0 -0
- package/docs/video/build/audio/11-closing.mp3 +0 -0
- package/docs/video/build/frames/01-opening-0.png +0 -0
- package/docs/video/build/frames/02-pain-1.png +0 -0
- package/docs/video/build/frames/02-pain-2.png +0 -0
- package/docs/video/build/frames/03-solution-3.png +0 -0
- package/docs/video/build/frames/04-install-4.png +0 -0
- package/docs/video/build/frames/04-setup-4.png +0 -0
- package/docs/video/build/frames/04-setup-5.png +0 -0
- package/docs/video/build/frames/05-dryrun-6.png +0 -0
- package/docs/video/build/frames/05-init-5.png +0 -0
- package/docs/video/build/frames/05-interactive-5.png +0 -0
- package/docs/video/build/frames/05-interactive-6.png +0 -0
- package/docs/video/build/frames/06-dryrun-6.png +0 -0
- package/docs/video/build/frames/06-dryrun-7.png +0 -0
- package/docs/video/build/frames/06-onboard-7.png +0 -0
- package/docs/video/build/frames/06-onboard-8.png +0 -0
- package/docs/video/build/frames/06-run-7.png +0 -0
- package/docs/video/build/frames/06-run-8.png +0 -0
- package/docs/video/build/frames/07-onboard-10.png +0 -0
- package/docs/video/build/frames/07-onboard-7.png +0 -0
- package/docs/video/build/frames/07-onboard-8.png +0 -0
- package/docs/video/build/frames/07-onboard-9.png +0 -0
- package/docs/video/build/frames/07-verify-10.png +0 -0
- package/docs/video/build/frames/07-verify-8.png +0 -0
- package/docs/video/build/frames/07-verify-9.png +0 -0
- package/docs/video/build/frames/08-closing-10.png +0 -0
- package/docs/video/build/frames/08-closing-11.png +0 -0
- package/docs/video/build/frames/08-doctor-11.png +0 -0
- package/docs/video/build/frames/08-doctor-9.png +0 -0
- package/docs/video/build/frames/09-rollback-10.png +0 -0
- package/docs/video/build/frames/09-rollback-12.png +0 -0
- package/docs/video/build/frames/10-principles-11.png +0 -0
- package/docs/video/build/frames/10-principles-13.png +0 -0
- package/docs/video/build/frames/11-closing-12.png +0 -0
- package/docs/video/build/frames/11-closing-14.png +0 -0
- package/docs/video/build/segments/01-opening.mp4 +0 -0
- package/docs/video/build/segments/01-opening.txt +3 -0
- package/docs/video/build/segments/02-pain.mp4 +0 -0
- package/docs/video/build/segments/02-pain.txt +5 -0
- package/docs/video/build/segments/03-solution.mp4 +0 -0
- package/docs/video/build/segments/03-solution.txt +3 -0
- package/docs/video/build/segments/04-install.mp4 +0 -0
- package/docs/video/build/segments/04-install.txt +3 -0
- package/docs/video/build/segments/04-setup.mp4 +0 -0
- package/docs/video/build/segments/04-setup.txt +3 -0
- package/docs/video/build/segments/05-dryrun.mp4 +0 -0
- package/docs/video/build/segments/05-dryrun.txt +3 -0
- package/docs/video/build/segments/05-init.mp4 +0 -0
- package/docs/video/build/segments/05-init.txt +3 -0
- package/docs/video/build/segments/05-interactive.mp4 +0 -0
- package/docs/video/build/segments/05-interactive.txt +5 -0
- package/docs/video/build/segments/06-dryrun.mp4 +0 -0
- package/docs/video/build/segments/06-dryrun.txt +3 -0
- package/docs/video/build/segments/06-onboard.mp4 +0 -0
- package/docs/video/build/segments/06-onboard.txt +5 -0
- package/docs/video/build/segments/06-run.mp4 +0 -0
- package/docs/video/build/segments/06-run.txt +3 -0
- package/docs/video/build/segments/07-onboard.mp4 +0 -0
- package/docs/video/build/segments/07-onboard.txt +5 -0
- package/docs/video/build/segments/07-verify.mp4 +0 -0
- package/docs/video/build/segments/07-verify.txt +5 -0
- package/docs/video/build/segments/08-closing.mp4 +0 -0
- package/docs/video/build/segments/08-closing.txt +3 -0
- package/docs/video/build/segments/08-doctor.mp4 +0 -0
- package/docs/video/build/segments/08-doctor.txt +3 -0
- package/docs/video/build/segments/09-rollback.mp4 +0 -0
- package/docs/video/build/segments/09-rollback.txt +3 -0
- package/docs/video/build/segments/10-principles.mp4 +0 -0
- package/docs/video/build/segments/10-principles.txt +3 -0
- package/docs/video/build/segments/11-closing.mp4 +0 -0
- package/docs/video/build/segments/11-closing.txt +3 -0
- package/docs/video/build/segments/final.txt +8 -0
- package/docs/video/build-ai/audio/01-hook.mp3 +0 -0
- package/docs/video/build-ai/audio/02-pain.mp3 +0 -0
- package/docs/video/build-ai/audio/03-solution.mp3 +0 -0
- package/docs/video/build-ai/audio/04-how.mp3 +0 -0
- package/docs/video/build-ai/audio/05-safe.mp3 +0 -0
- package/docs/video/build-ai/audio/06-closing.mp3 +0 -0
- package/docs/video/build-ai/clips/01-hook.mp4 +0 -0
- package/docs/video/build-ai/clips/02-pain.mp4 +0 -0
- package/docs/video/build-ai/clips/03-solution.mp4 +0 -0
- package/docs/video/build-ai/clips/04-how.mp4 +0 -0
- package/docs/video/build-ai/clips/05-safe.mp4 +0 -0
- package/docs/video/build-ai/clips/06-closing.mp4 +0 -0
- package/docs/video/build-ai/first-frame-02-pain.png +0 -0
- package/docs/video/build-ai/first-frame-03-solution.png +0 -0
- package/docs/video/build-ai/first-frame-04-how.png +0 -0
- package/docs/video/build-ai/first-frame-05-safe.png +0 -0
- package/docs/video/build-ai/segments/01-hook.caption.txt +2 -0
- package/docs/video/build-ai/segments/01-hook.mp4 +0 -0
- package/docs/video/build-ai/segments/02-pain.mp4 +0 -0
- package/docs/video/build-ai/segments/03-solution.mp4 +0 -0
- package/docs/video/build-ai/segments/04-how.mp4 +0 -0
- package/docs/video/build-ai/segments/05-safe.mp4 +0 -0
- package/docs/video/build-ai/segments/06-closing.mp4 +0 -0
- package/docs/video/build-ai/segments/cap-01-hook.png +0 -0
- package/docs/video/build-ai/segments/cap-02-pain.png +0 -0
- package/docs/video/build-ai/segments/cap-03-solution.png +0 -0
- package/docs/video/build-ai/segments/cap-04-how.png +0 -0
- package/docs/video/build-ai/segments/cap-05-safe.png +0 -0
- package/docs/video/build-ai/segments/cap-06-closing.png +0 -0
- package/docs/video/build-ai/segments/final.txt +6 -0
- package/docs/video/build-posters/audio/01-hook.mp3 +0 -0
- package/docs/video/build-posters/audio/02-pain.mp3 +0 -0
- package/docs/video/build-posters/audio/03-solution.mp3 +0 -0
- package/docs/video/build-posters/audio/04-how.mp3 +0 -0
- package/docs/video/build-posters/audio/05-safe.mp3 +0 -0
- package/docs/video/build-posters/audio/06-closing.mp3 +0 -0
- package/docs/video/build-posters/cap-01-hook.png +0 -0
- package/docs/video/build-posters/cap-02-pain.png +0 -0
- package/docs/video/build-posters/cap-03-solution.png +0 -0
- package/docs/video/build-posters/cap-04-how.png +0 -0
- package/docs/video/build-posters/cap-05-safe.png +0 -0
- package/docs/video/build-posters/cap-06-closing.png +0 -0
- package/docs/video/build-posters/card-01-hook.png +0 -0
- package/docs/video/build-posters/card-06-closing.png +0 -0
- package/docs/video/build-posters/segments/01-hook.mp4 +0 -0
- package/docs/video/build-posters/segments/02-pain.mp4 +0 -0
- package/docs/video/build-posters/segments/03-solution.mp4 +0 -0
- package/docs/video/build-posters/segments/04-how.mp4 +0 -0
- package/docs/video/build-posters/segments/05-safe.mp4 +0 -0
- package/docs/video/build-posters/segments/06-closing.mp4 +0 -0
- package/docs/video/build-posters/segments/final.txt +6 -0
- package/docs/video/generate-video-ai.mjs +334 -0
- package/docs/video/generate-video-posters.mjs +204 -0
- package/docs/video/generate-video.mjs +426 -0
- package/docs/video/outline.zh-CN.md +102 -0
- package/docs/video/trellis-demo-video-ai.mp4 +0 -0
- package/docs/video/trellis-demo-video.mp4 +0 -0
- package/docs/video/voiceover.zh-CN.md +49 -0
- package/package.json +5 -2
- package/schema/servers.example.yaml +1 -1
- package/docs/assets/agent-trellis-architecture-horizontal.svg +0 -80
- package/docs/assets/agent-trellis-architecture-vertical.svg +0 -82
- package/docs/assets/trellis-mark.svg +0 -12
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Agent delegation (trellis-agent-bridge): lets a managed Agent call
|
|
3
|
+
* another managed Agent's own verified non-interactive/headless mode as a
|
|
4
|
+
* one-shot subprocess call, so each Agent's strengths can be used without a
|
|
5
|
+
* central orchestrator or any cross-machine protocol.
|
|
6
|
+
*
|
|
7
|
+
* Configuration lives at `~/.trellis/mcp/agent-bridge.yaml`, sibling to
|
|
8
|
+
* `servers.yaml`, and is fully optional — its absence means zero behavior
|
|
9
|
+
* change (design.md Migration Plan).
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
16
|
+
export const DEFAULT_DELEGATION_TIMEOUT_MS = 120_000;
|
|
17
|
+
export const DEFAULT_MAX_DELEGATION_DEPTH = 2;
|
|
18
|
+
/** Carries the current delegation depth down the real process tree — the
|
|
19
|
+
* gateway that receives a delegated call inherits this from its spawning
|
|
20
|
+
* shell, so depth travels with the chain itself without a live shared
|
|
21
|
+
* registry (design.md Decision 3). */
|
|
22
|
+
export const DELEGATION_DEPTH_ENV = "TRELLIS_DELEGATION_DEPTH";
|
|
23
|
+
const PROMPT_PLACEHOLDER = "{prompt}";
|
|
24
|
+
const PERSONA_PLACEHOLDER = "{persona}";
|
|
25
|
+
const SESSION_PLACEHOLDER = "{sessionId}";
|
|
26
|
+
const FORCE_KILL_GRACE_MS = 5_000;
|
|
27
|
+
export function agentBridgePath(homeDir) {
|
|
28
|
+
return join(homeDir, ".trellis", "mcp", "agent-bridge.yaml");
|
|
29
|
+
}
|
|
30
|
+
/** Reads the current chain depth from an environment map (defaults to
|
|
31
|
+
* `process.env`, injectable for tests). Anything absent or malformed
|
|
32
|
+
* reads as depth 0 — the safe "not part of a chain yet" default. */
|
|
33
|
+
export function readDelegationDepth(env = process.env) {
|
|
34
|
+
const raw = env[DELEGATION_DEPTH_ENV];
|
|
35
|
+
const parsed = raw ? Number.parseInt(raw, 10) : 0;
|
|
36
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
37
|
+
}
|
|
38
|
+
const OUTPUT_FORMATS = ["text", "json", "stream-json"];
|
|
39
|
+
const STREAM_PROTOCOLS = ["claude", "zcode"];
|
|
40
|
+
export function loadAgentBridgeConfig(homeDir) {
|
|
41
|
+
const path = agentBridgePath(homeDir);
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return { targets: {} };
|
|
44
|
+
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
45
|
+
if (parsed.maxDepth !== undefined && (!Number.isInteger(parsed.maxDepth) || parsed.maxDepth < 1)) {
|
|
46
|
+
throw new Error(`agent-bridge.yaml: "maxDepth" must be a positive integer`);
|
|
47
|
+
}
|
|
48
|
+
const targets = {};
|
|
49
|
+
for (const [agentId, raw] of Object.entries(parsed.targets ?? {})) {
|
|
50
|
+
if (!raw)
|
|
51
|
+
continue;
|
|
52
|
+
if (!ALL_AGENTS.includes(agentId)) {
|
|
53
|
+
throw new Error(`agent-bridge.yaml: "${agentId}" is not a supported Agent id`);
|
|
54
|
+
}
|
|
55
|
+
if (!raw.command || !Array.isArray(raw.args) || raw.args.length === 0) {
|
|
56
|
+
throw new Error(`agent-bridge.yaml: target "${agentId}" must declare "command" and a non-empty "args"`);
|
|
57
|
+
}
|
|
58
|
+
if (raw.resumeArgs !== undefined && (!Array.isArray(raw.resumeArgs) || raw.resumeArgs.length === 0)) {
|
|
59
|
+
throw new Error(`agent-bridge.yaml: target "${agentId}" has an invalid "resumeArgs"`);
|
|
60
|
+
}
|
|
61
|
+
if (raw.outputFormat !== undefined && !OUTPUT_FORMATS.includes(raw.outputFormat)) {
|
|
62
|
+
throw new Error(`agent-bridge.yaml: target "${agentId}" has unknown outputFormat "${raw.outputFormat}"`);
|
|
63
|
+
}
|
|
64
|
+
if (raw.streamProtocol !== undefined && !STREAM_PROTOCOLS.includes(raw.streamProtocol)) {
|
|
65
|
+
throw new Error(`agent-bridge.yaml: target "${agentId}" has unknown streamProtocol "${raw.streamProtocol}"`);
|
|
66
|
+
}
|
|
67
|
+
targets[agentId] = {
|
|
68
|
+
command: raw.command,
|
|
69
|
+
args: raw.args,
|
|
70
|
+
...(raw.resumeArgs ? { resumeArgs: raw.resumeArgs } : {}),
|
|
71
|
+
...(raw.outputFormat ? { outputFormat: raw.outputFormat } : {}),
|
|
72
|
+
...(raw.streamProtocol ? { streamProtocol: raw.streamProtocol } : {}),
|
|
73
|
+
...(raw.timeoutMs ? { timeoutMs: raw.timeoutMs } : {}),
|
|
74
|
+
...(raw.tags ? { tags: raw.tags } : {}),
|
|
75
|
+
...(raw.persona ? { persona: raw.persona } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { targets, ...(parsed.maxDepth !== undefined ? { maxDepth: parsed.maxDepth } : {}) };
|
|
79
|
+
}
|
|
80
|
+
/** Shared SIGTERM→SIGKILL escalation used by both a timeout firing and a
|
|
81
|
+
* caller-initiated cancellation (streaming only) — kept as one function so
|
|
82
|
+
* the two call sites can never drift. */
|
|
83
|
+
function killWithEscalation(child) {
|
|
84
|
+
child.kill("SIGTERM");
|
|
85
|
+
const forceKill = setTimeout(() => child.kill("SIGKILL"), FORCE_KILL_GRACE_MS);
|
|
86
|
+
forceKill.unref?.();
|
|
87
|
+
}
|
|
88
|
+
function buildArgs(template, vars) {
|
|
89
|
+
return template.map((arg) => {
|
|
90
|
+
if (arg === PROMPT_PLACEHOLDER)
|
|
91
|
+
return vars.prompt;
|
|
92
|
+
if (arg === PERSONA_PLACEHOLDER)
|
|
93
|
+
return vars.persona ?? "";
|
|
94
|
+
if (arg === SESSION_PLACEHOLDER)
|
|
95
|
+
return vars.sessionId ?? "";
|
|
96
|
+
return arg;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Best-effort output extraction from a `json`/`stream-json` target's
|
|
100
|
+
* stdout: the response text plus, when present, a session id the caller
|
|
101
|
+
* can pass back as `sessionId` in a later call to resume this exact
|
|
102
|
+
* conversation. A parse failure or unrecognized shape degrades to the raw
|
|
103
|
+
* trimmed output with no session id, rather than throwing — a CLI's
|
|
104
|
+
* output-format change should surface as a stranger answer, not a crash
|
|
105
|
+
* (design.md Risks). */
|
|
106
|
+
function extractOutput(raw, outputFormat) {
|
|
107
|
+
const trimmed = raw.trim();
|
|
108
|
+
if (outputFormat !== "json" && outputFormat !== "stream-json")
|
|
109
|
+
return { output: trimmed };
|
|
110
|
+
const lines = trimmed.split("\n").filter((line) => line.trim().length > 0);
|
|
111
|
+
const lastLine = lines[lines.length - 1] ?? trimmed;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(lastLine);
|
|
114
|
+
if (typeof parsed === "string")
|
|
115
|
+
return { output: parsed };
|
|
116
|
+
if (parsed && typeof parsed === "object") {
|
|
117
|
+
const record = parsed;
|
|
118
|
+
let output;
|
|
119
|
+
for (const key of ["result", "output", "text", "response", "message"]) {
|
|
120
|
+
if (typeof record[key] === "string") {
|
|
121
|
+
output = record[key];
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let sessionId;
|
|
126
|
+
for (const key of ["session_id", "sessionId"]) {
|
|
127
|
+
if (typeof record[key] === "string") {
|
|
128
|
+
sessionId = record[key];
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { output: output ?? JSON.stringify(parsed), ...(sessionId ? { sessionId } : {}) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// fall through to raw text
|
|
137
|
+
}
|
|
138
|
+
return { output: trimmed };
|
|
139
|
+
}
|
|
140
|
+
/** Spawns the target's verified non-interactive invocation, enforces
|
|
141
|
+
* `timeoutMs` with SIGTERM-then-SIGKILL escalation, and normalizes the
|
|
142
|
+
* result into one envelope regardless of which CLI is underneath. Does
|
|
143
|
+
* NOT itself enforce a depth ceiling — refusing at the limit is the
|
|
144
|
+
* caller's job (AgentBridgeProvider), since only it knows the configured
|
|
145
|
+
* `maxDepth`; this function only propagates whatever depth it's given. */
|
|
146
|
+
export async function runDelegatedCall(targetAgent, target, prompt, opts) {
|
|
147
|
+
const timeoutMs = opts?.timeoutMs ?? target.timeoutMs ?? DEFAULT_DELEGATION_TIMEOUT_MS;
|
|
148
|
+
const persona = opts?.persona ?? target.persona;
|
|
149
|
+
let template = target.args;
|
|
150
|
+
if (opts?.sessionId) {
|
|
151
|
+
if (!target.resumeArgs) {
|
|
152
|
+
return { status: "failed", output: `agent "${targetAgent}" has no resumeArgs configured — cannot resume sessionId "${opts.sessionId}"`, durationMs: 0, targetAgent };
|
|
153
|
+
}
|
|
154
|
+
template = target.resumeArgs;
|
|
155
|
+
}
|
|
156
|
+
const hasPersonaSlot = template.includes(PERSONA_PLACEHOLDER);
|
|
157
|
+
const effectivePrompt = persona && !hasPersonaSlot ? `[Persona: ${persona}]\n\n${prompt}` : prompt;
|
|
158
|
+
const args = buildArgs(template, { prompt: effectivePrompt, persona, sessionId: opts?.sessionId });
|
|
159
|
+
const childEnv = { ...process.env, [DELEGATION_DEPTH_ENV]: String((opts?.depth ?? 0) + 1) };
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
let settled = false;
|
|
163
|
+
let stdout = "";
|
|
164
|
+
let stderr = "";
|
|
165
|
+
const child = spawn(target.command, args, { cwd: opts?.cwd, env: childEnv, stdio: ["ignore", "pipe", "pipe"] });
|
|
166
|
+
const killTimer = setTimeout(() => killWithEscalation(child), timeoutMs);
|
|
167
|
+
const finish = (value) => {
|
|
168
|
+
if (settled)
|
|
169
|
+
return;
|
|
170
|
+
settled = true;
|
|
171
|
+
clearTimeout(killTimer);
|
|
172
|
+
resolve(value);
|
|
173
|
+
};
|
|
174
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf-8"); });
|
|
175
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf-8"); });
|
|
176
|
+
child.on("error", (err) => finish({ status: "failed", output: err.message, durationMs: Date.now() - start, targetAgent }));
|
|
177
|
+
child.on("close", (code, signal) => {
|
|
178
|
+
const durationMs = Date.now() - start;
|
|
179
|
+
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
|
180
|
+
finish({ status: "timeout", output: (stdout || stderr).trim(), durationMs, targetAgent });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (code !== 0) {
|
|
184
|
+
finish({ status: "failed", output: (stderr || stdout).trim(), durationMs, targetAgent });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
finish({ status: "completed", ...extractOutput(stdout, target.outputFormat), durationMs, targetAgent });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
export function tryParseClaudeStreamJsonLine(line) {
|
|
192
|
+
const trimmed = line.trim();
|
|
193
|
+
if (!trimmed)
|
|
194
|
+
return undefined;
|
|
195
|
+
let json;
|
|
196
|
+
try {
|
|
197
|
+
json = JSON.parse(trimmed);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (!json || typeof json !== "object")
|
|
203
|
+
return undefined;
|
|
204
|
+
const record = json;
|
|
205
|
+
if (record.type === "system" && record.subtype === "init") {
|
|
206
|
+
const sessionId = typeof record.session_id === "string" ? record.session_id : undefined;
|
|
207
|
+
return { kind: "init", ...(sessionId ? { sessionId } : {}) };
|
|
208
|
+
}
|
|
209
|
+
if (record.type === "result") {
|
|
210
|
+
const output = typeof record.result === "string" ? record.result : "";
|
|
211
|
+
const sessionId = typeof record.session_id === "string" ? record.session_id : undefined;
|
|
212
|
+
return { kind: "final", output, ...(sessionId ? { sessionId } : {}) };
|
|
213
|
+
}
|
|
214
|
+
if (record.type === "assistant" || record.type === "user") {
|
|
215
|
+
const message = record.message;
|
|
216
|
+
const content = Array.isArray(message?.content) ? message.content : undefined;
|
|
217
|
+
if (!content)
|
|
218
|
+
return undefined;
|
|
219
|
+
for (const block of content) {
|
|
220
|
+
if (!block || typeof block !== "object")
|
|
221
|
+
continue;
|
|
222
|
+
const b = block;
|
|
223
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
224
|
+
return { kind: "text-delta", text: b.text };
|
|
225
|
+
if (b.type === "tool_use" && typeof b.id === "string" && typeof b.name === "string") {
|
|
226
|
+
return { kind: "tool-call", toolCallId: b.id, name: b.name, input: b.input };
|
|
227
|
+
}
|
|
228
|
+
if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
|
|
229
|
+
return { kind: "tool-result", toolCallId: b.tool_use_id, output: b.content };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
/** ZCode's documented headless result envelope is deliberately distinct from
|
|
237
|
+
* Claude-compatible streams: `{ type: "result", response, sessionId }`.
|
|
238
|
+
* Intermediate ZCode protocol events evolve with the runtime, so unknown
|
|
239
|
+
* records remain raw chunks rather than being guessed into false tool/text
|
|
240
|
+
* events. */
|
|
241
|
+
export function tryParseZcodeStreamJsonLine(line) {
|
|
242
|
+
const trimmed = line.trim();
|
|
243
|
+
if (!trimmed)
|
|
244
|
+
return undefined;
|
|
245
|
+
let json;
|
|
246
|
+
try {
|
|
247
|
+
json = JSON.parse(trimmed);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
if (!json || typeof json !== "object")
|
|
253
|
+
return undefined;
|
|
254
|
+
const record = json;
|
|
255
|
+
if (record.type !== "result" || typeof record.response !== "string")
|
|
256
|
+
return undefined;
|
|
257
|
+
const sessionId = typeof record.sessionId === "string" ? record.sessionId : undefined;
|
|
258
|
+
return { kind: "final", output: record.response, protocol: "zcode", ...(sessionId ? { sessionId } : {}) };
|
|
259
|
+
}
|
|
260
|
+
/** Streaming counterpart to `runDelegatedCall`: same argv-building,
|
|
261
|
+
* timeout escalation, and persona/session handling, but emits `onChunk`
|
|
262
|
+
* for every line of stdout as it arrives instead of buffering until the
|
|
263
|
+
* process exits, and supports cooperative cancellation via `signal`. Takes
|
|
264
|
+
* a plain `string` target id (not `AgentId`) so it can serve both a future
|
|
265
|
+
* streaming MCP delegation tool and today's GUI chat feature, whose
|
|
266
|
+
* targets come from `chat-agents.yaml` and are not required to be one of
|
|
267
|
+
* the fully-managed Agents in `ALL_AGENTS`. */
|
|
268
|
+
export async function runDelegatedCallStreaming(targetAgent, target, prompt, opts) {
|
|
269
|
+
const timeoutMs = opts.timeoutMs ?? target.timeoutMs ?? DEFAULT_DELEGATION_TIMEOUT_MS;
|
|
270
|
+
const persona = opts.persona ?? target.persona;
|
|
271
|
+
let template = target.args;
|
|
272
|
+
if (opts.sessionId) {
|
|
273
|
+
if (!target.resumeArgs) {
|
|
274
|
+
return {
|
|
275
|
+
status: "failed",
|
|
276
|
+
output: `agent "${targetAgent}" has no resumeArgs configured — cannot resume sessionId "${opts.sessionId}"`,
|
|
277
|
+
durationMs: 0,
|
|
278
|
+
targetAgent,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
template = target.resumeArgs;
|
|
282
|
+
}
|
|
283
|
+
const hasPersonaSlot = template.includes(PERSONA_PLACEHOLDER);
|
|
284
|
+
const effectivePrompt = persona && !hasPersonaSlot ? `[Persona: ${persona}]\n\n${prompt}` : prompt;
|
|
285
|
+
const args = buildArgs(template, { prompt: effectivePrompt, persona, sessionId: opts.sessionId });
|
|
286
|
+
const childEnv = { ...process.env, [DELEGATION_DEPTH_ENV]: String((opts.depth ?? 0) + 1) };
|
|
287
|
+
const start = Date.now();
|
|
288
|
+
const isStreamJson = target.outputFormat === "stream-json";
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
let settled = false;
|
|
291
|
+
let lineBuffer = "";
|
|
292
|
+
let stdoutAll = "";
|
|
293
|
+
let stderr = "";
|
|
294
|
+
let structuredOutput = "";
|
|
295
|
+
let sessionId;
|
|
296
|
+
let cancelled = false;
|
|
297
|
+
const child = spawn(target.command, args, { cwd: opts.cwd, env: childEnv, stdio: ["ignore", "pipe", "pipe"] });
|
|
298
|
+
const killTimer = setTimeout(() => killWithEscalation(child), timeoutMs);
|
|
299
|
+
const onAbort = () => {
|
|
300
|
+
cancelled = true;
|
|
301
|
+
killWithEscalation(child);
|
|
302
|
+
};
|
|
303
|
+
opts.signal?.addEventListener("abort", onAbort);
|
|
304
|
+
const finish = (value) => {
|
|
305
|
+
if (settled)
|
|
306
|
+
return;
|
|
307
|
+
settled = true;
|
|
308
|
+
clearTimeout(killTimer);
|
|
309
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
310
|
+
resolve(value);
|
|
311
|
+
};
|
|
312
|
+
const emitLine = (line) => {
|
|
313
|
+
if (!line)
|
|
314
|
+
return;
|
|
315
|
+
let parsed;
|
|
316
|
+
if (isStreamJson) {
|
|
317
|
+
parsed = target.streamProtocol === "zcode" ? tryParseZcodeStreamJsonLine(line) : tryParseClaudeStreamJsonLine(line);
|
|
318
|
+
if (parsed?.kind === "text-delta")
|
|
319
|
+
structuredOutput += structuredOutput ? `\n${parsed.text}` : parsed.text;
|
|
320
|
+
if (parsed?.kind === "final")
|
|
321
|
+
structuredOutput = parsed.output;
|
|
322
|
+
if (parsed?.kind === "init" && parsed.sessionId)
|
|
323
|
+
sessionId = parsed.sessionId;
|
|
324
|
+
if (parsed?.kind === "final" && parsed.sessionId)
|
|
325
|
+
sessionId = parsed.sessionId;
|
|
326
|
+
}
|
|
327
|
+
opts.onChunk({ raw: line, ...(parsed ? { parsed } : {}) });
|
|
328
|
+
};
|
|
329
|
+
child.stdout?.on("data", (chunk) => {
|
|
330
|
+
const text = chunk.toString("utf-8");
|
|
331
|
+
stdoutAll += text;
|
|
332
|
+
lineBuffer += text;
|
|
333
|
+
const lines = lineBuffer.split("\n");
|
|
334
|
+
lineBuffer = lines.pop() ?? "";
|
|
335
|
+
for (const line of lines)
|
|
336
|
+
emitLine(line);
|
|
337
|
+
});
|
|
338
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf-8"); });
|
|
339
|
+
child.on("error", (err) => finish({ status: "failed", output: err.message, durationMs: Date.now() - start, targetAgent }));
|
|
340
|
+
child.on("close", (code, signal) => {
|
|
341
|
+
if (lineBuffer) {
|
|
342
|
+
emitLine(lineBuffer);
|
|
343
|
+
lineBuffer = "";
|
|
344
|
+
}
|
|
345
|
+
const durationMs = Date.now() - start;
|
|
346
|
+
const finalOutput = isStreamJson ? structuredOutput : stdoutAll;
|
|
347
|
+
if (cancelled) {
|
|
348
|
+
finish({ status: "cancelled", output: finalOutput.trim(), durationMs, targetAgent, ...(sessionId ? { sessionId } : {}) });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
|
352
|
+
finish({ status: "timeout", output: (finalOutput || stderr).trim(), durationMs, targetAgent, ...(sessionId ? { sessionId } : {}) });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (code !== 0) {
|
|
356
|
+
finish({ status: "failed", output: (stderr || finalOutput).trim(), durationMs, targetAgent });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (isStreamJson) {
|
|
360
|
+
finish({ status: "completed", output: finalOutput.trim(), durationMs, targetAgent, ...(sessionId ? { sessionId } : {}) });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const extracted = extractOutput(stdoutAll, target.outputFormat);
|
|
364
|
+
finish({ status: "completed", output: extracted.output, durationMs, targetAgent, ...(extracted.sessionId ? { sessionId: extracted.sessionId } : {}) });
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
3
|
+
/** Exposes every `agent-bridge.yaml` target — other than the calling
|
|
4
|
+
* Agent itself — as one MCP tool per target Agent. Routing which target
|
|
5
|
+
* to call stays with the caller's own model, exactly like ordinary MCP
|
|
6
|
+
* tool selection; this provider only executes the call once chosen, and
|
|
7
|
+
* refuses one that would exceed the configured delegation depth before
|
|
8
|
+
* ever spawning a process. */
|
|
9
|
+
export declare class AgentBridgeProvider implements TrellisProvider {
|
|
10
|
+
readonly id = "agent_bridge";
|
|
11
|
+
private readonly toolTargets;
|
|
12
|
+
listTools(context: RuntimeContext): Tool[];
|
|
13
|
+
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_MAX_DELEGATION_DEPTH, loadAgentBridgeConfig, readDelegationDepth, runDelegatedCall } from "./agentBridge.js";
|
|
2
|
+
import { resolveZcodeProfile } from "../probes/zcode.js";
|
|
3
|
+
function toolNameFor(agentId) {
|
|
4
|
+
return `trellis.agent_bridge.run_${agentId.replaceAll("-", "_")}`;
|
|
5
|
+
}
|
|
6
|
+
function toolFor(agentId, target) {
|
|
7
|
+
const tagText = target.tags?.length ? ` Good for: ${target.tags.join(", ")}.` : "";
|
|
8
|
+
const resumeText = target.resumeArgs
|
|
9
|
+
? " Pass a prior call's returned sessionId to continue that exact conversation."
|
|
10
|
+
: " Does not support resuming a prior conversation — every call starts fresh.";
|
|
11
|
+
return {
|
|
12
|
+
name: toolNameFor(agentId),
|
|
13
|
+
description: `Delegate a one-shot prompt to "${agentId}"'s own non-interactive mode and return its result.${tagText} Optionally set "persona" to shape how it responds for this call.${resumeText} Requires explicit confirm=true — this spawns a real subprocess with real side effects, not a read-only lookup.`,
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
prompt: { type: "string" },
|
|
18
|
+
persona: { type: "string", description: "Overrides this target's default persona/role for this call only." },
|
|
19
|
+
sessionId: { type: "string", description: "Resume a prior call's conversation instead of starting fresh; requires the target to support resuming." },
|
|
20
|
+
cwd: { type: "string" },
|
|
21
|
+
timeoutMs: { type: "number" },
|
|
22
|
+
confirm: { type: "boolean" },
|
|
23
|
+
},
|
|
24
|
+
required: ["prompt", "confirm"],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function result(value) {
|
|
31
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value };
|
|
32
|
+
}
|
|
33
|
+
function failure(message) {
|
|
34
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
35
|
+
}
|
|
36
|
+
/** Exposes every `agent-bridge.yaml` target — other than the calling
|
|
37
|
+
* Agent itself — as one MCP tool per target Agent. Routing which target
|
|
38
|
+
* to call stays with the caller's own model, exactly like ordinary MCP
|
|
39
|
+
* tool selection; this provider only executes the call once chosen, and
|
|
40
|
+
* refuses one that would exceed the configured delegation depth before
|
|
41
|
+
* ever spawning a process. */
|
|
42
|
+
export class AgentBridgeProvider {
|
|
43
|
+
id = "agent_bridge";
|
|
44
|
+
toolTargets = new Map();
|
|
45
|
+
listTools(context) {
|
|
46
|
+
const config = loadAgentBridgeConfig(context.homeDir);
|
|
47
|
+
this.toolTargets.clear();
|
|
48
|
+
const tools = [];
|
|
49
|
+
for (const [agentId, target] of Object.entries(config.targets)) {
|
|
50
|
+
if (agentId === context.agentId)
|
|
51
|
+
continue;
|
|
52
|
+
const tool = toolFor(agentId, target);
|
|
53
|
+
this.toolTargets.set(tool.name, agentId);
|
|
54
|
+
tools.push(tool);
|
|
55
|
+
}
|
|
56
|
+
return tools;
|
|
57
|
+
}
|
|
58
|
+
async callTool(name, args, context) {
|
|
59
|
+
const input = (args ?? {});
|
|
60
|
+
const targetAgent = this.toolTargets.get(name);
|
|
61
|
+
if (!targetAgent)
|
|
62
|
+
return failure(`unknown agent bridge tool "${name}"`);
|
|
63
|
+
if (input.confirm !== true)
|
|
64
|
+
return failure(`agent bridge call "${name}" requires confirm=true — it spawns a real subprocess with real side effects`);
|
|
65
|
+
const prompt = String(input.prompt ?? "").trim();
|
|
66
|
+
if (!prompt)
|
|
67
|
+
return failure("prompt must not be empty");
|
|
68
|
+
const config = loadAgentBridgeConfig(context.homeDir);
|
|
69
|
+
const target = config.targets[targetAgent];
|
|
70
|
+
if (!target)
|
|
71
|
+
return failure(`agent "${targetAgent}" is no longer configured for delegation`);
|
|
72
|
+
if (targetAgent === "zcode") {
|
|
73
|
+
const profile = resolveZcodeProfile(context.homeDir);
|
|
74
|
+
if (!profile?.supportsExecution || target.command !== profile.executable) {
|
|
75
|
+
return failure("zcode delegation requires a compatible public zcode CLI selected by the local ZCode probe");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const maxDepth = config.maxDepth ?? DEFAULT_MAX_DELEGATION_DEPTH;
|
|
79
|
+
const currentDepth = readDelegationDepth();
|
|
80
|
+
if (currentDepth >= maxDepth) {
|
|
81
|
+
return failure(`delegation depth limit reached (${currentDepth}/${maxDepth}) — refusing to call "${targetAgent}" to avoid a delegation cycle`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
85
|
+
const timeoutMs = typeof input.timeoutMs === "number" ? input.timeoutMs : undefined;
|
|
86
|
+
const persona = typeof input.persona === "string" && input.persona.trim() ? input.persona : undefined;
|
|
87
|
+
const sessionId = typeof input.sessionId === "string" && input.sessionId.trim() ? input.sessionId : undefined;
|
|
88
|
+
const outcome = await runDelegatedCall(targetAgent, target, prompt, { cwd, timeoutMs, persona, sessionId, depth: currentDepth });
|
|
89
|
+
return result(outcome);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/lib/backup.d.ts
CHANGED
|
@@ -31,6 +31,38 @@ export type BackupOperation = {
|
|
|
31
31
|
kind: "symlink-remove";
|
|
32
32
|
path: string;
|
|
33
33
|
beforeLinkTarget: string;
|
|
34
|
+
}
|
|
35
|
+
/** A whole directory created where none existed — e.g. `skill add`
|
|
36
|
+
* copying a canonical Skill in. New operations carry the resulting
|
|
37
|
+
* directory digest so rollback can refuse a tree edited after the run;
|
|
38
|
+
* the optional field preserves compatibility with old backup manifests. */
|
|
39
|
+
| {
|
|
40
|
+
kind: "dir-create";
|
|
41
|
+
path: string;
|
|
42
|
+
afterDigest?: string;
|
|
43
|
+
}
|
|
44
|
+
/** A whole directory removed — e.g. `skill remove`. `beforeDir` is a
|
|
45
|
+
* full recursive copy of the directory's pre-removal content, stored
|
|
46
|
+
* under this run's own directory — the directory equivalent of
|
|
47
|
+
* `file-overwrite`'s single-file `beforeFile` snapshot. Conflict
|
|
48
|
+
* detection is existence-only (does the path exist again since this
|
|
49
|
+
* run), not a deep content hash — a directory tree's content-drift
|
|
50
|
+
* check would need its own manifest of per-file hashes, which no
|
|
51
|
+
* caller of this module has needed yet; if that changes, extend here
|
|
52
|
+
* rather than approximating silently. */
|
|
53
|
+
| {
|
|
54
|
+
kind: "dir-remove";
|
|
55
|
+
path: string;
|
|
56
|
+
beforeDir: string;
|
|
57
|
+
}
|
|
58
|
+
/** A whole directory replaced in-place. The before-tree snapshot and the
|
|
59
|
+
* exact after-tree digest let rollback refuse a locally edited remote Skill
|
|
60
|
+
* instead of overwriting it. */
|
|
61
|
+
| {
|
|
62
|
+
kind: "dir-replace";
|
|
63
|
+
path: string;
|
|
64
|
+
beforeDir: string;
|
|
65
|
+
afterDigest: string;
|
|
34
66
|
};
|
|
35
67
|
export interface BackupManifest {
|
|
36
68
|
runId: string;
|
|
@@ -44,6 +76,16 @@ export interface BackupSession {
|
|
|
44
76
|
createSymlink(path: string, linkTarget: string): Promise<void>;
|
|
45
77
|
repairSymlink(path: string, oldLinkTarget: string, newLinkTarget: string): Promise<void>;
|
|
46
78
|
removeSymlink(path: string, oldLinkTarget: string): Promise<void>;
|
|
79
|
+
/** Copies `sourceDir`'s content to `path` (which must not already
|
|
80
|
+
* exist) and records it as a `dir-create`. */
|
|
81
|
+
createDirFromSource(path: string, sourceDir: string): void;
|
|
82
|
+
/** Snapshots `path`'s current content into this run's own storage,
|
|
83
|
+
* then removes it, and records it as a `dir-remove`. */
|
|
84
|
+
removeDir(path: string): void;
|
|
85
|
+
/** Snapshots an existing directory then copies `sourceDir` in its place,
|
|
86
|
+
* recorded as one operation so rollback can restore it without an
|
|
87
|
+
* intermediate same-path conflict. */
|
|
88
|
+
replaceDirFromSource(path: string, sourceDir: string): void;
|
|
47
89
|
hasOperations(): boolean;
|
|
48
90
|
/** Writes manifest.json. No-op (creates nothing) if zero operations
|
|
49
91
|
* were ever recorded. */
|
package/dist/lib/backup.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* that never records anything creates nothing on disk.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { readlink, rm, symlink } from "node:fs/promises";
|
|
14
|
-
import { basename, join } from "node:path";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { directoryDigest } from "./dirDigest.js";
|
|
15
16
|
function sha256(content) {
|
|
16
17
|
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
17
18
|
}
|
|
@@ -60,6 +61,7 @@ export function openBackupSession(homeDir, command) {
|
|
|
60
61
|
const operations = [];
|
|
61
62
|
let dirCreated = false;
|
|
62
63
|
let fileIndex = 0;
|
|
64
|
+
let dirIndex = 0;
|
|
63
65
|
function ensureDir() {
|
|
64
66
|
if (dirCreated)
|
|
65
67
|
return;
|
|
@@ -87,7 +89,12 @@ export function openBackupSession(homeDir, command) {
|
|
|
87
89
|
else {
|
|
88
90
|
operations.push({ kind: "file-create", path, afterHash });
|
|
89
91
|
}
|
|
90
|
-
|
|
92
|
+
// A lock/provenance update must not leave a partially-written JSON file
|
|
93
|
+
// if the process stops mid-write. This also strengthens the existing
|
|
94
|
+
// backup-aware file writes without changing their manifest contract.
|
|
95
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${fileIndex}.tmp`);
|
|
96
|
+
writeFileSync(temporary, content);
|
|
97
|
+
renameSync(temporary, path);
|
|
91
98
|
},
|
|
92
99
|
async createSymlink(path, linkTarget) {
|
|
93
100
|
ensureDir();
|
|
@@ -106,6 +113,32 @@ export function openBackupSession(homeDir, command) {
|
|
|
106
113
|
await rm(path, { force: true });
|
|
107
114
|
operations.push({ kind: "symlink-remove", path, beforeLinkTarget: oldLinkTarget });
|
|
108
115
|
},
|
|
116
|
+
createDirFromSource(path, sourceDir) {
|
|
117
|
+
ensureDir();
|
|
118
|
+
mkdirSync(path, { recursive: true });
|
|
119
|
+
cpSync(sourceDir, path, { recursive: true });
|
|
120
|
+
operations.push({ kind: "dir-create", path, afterDigest: directoryDigest(path) });
|
|
121
|
+
},
|
|
122
|
+
removeDir(path) {
|
|
123
|
+
ensureDir();
|
|
124
|
+
const relSnapshot = join("dirs", `${dirIndex}-${basename(path)}`);
|
|
125
|
+
dirIndex += 1;
|
|
126
|
+
cpSync(path, join(runDir, relSnapshot), { recursive: true });
|
|
127
|
+
rmSync(path, { recursive: true, force: true });
|
|
128
|
+
operations.push({ kind: "dir-remove", path, beforeDir: relSnapshot });
|
|
129
|
+
},
|
|
130
|
+
replaceDirFromSource(path, sourceDir) {
|
|
131
|
+
if (!existsSync(path))
|
|
132
|
+
throw new Error(`Cannot replace missing directory: ${path}`);
|
|
133
|
+
ensureDir();
|
|
134
|
+
const relSnapshot = join("dirs", `${dirIndex}-${basename(path)}`);
|
|
135
|
+
dirIndex += 1;
|
|
136
|
+
cpSync(path, join(runDir, relSnapshot), { recursive: true });
|
|
137
|
+
rmSync(path, { recursive: true, force: true });
|
|
138
|
+
mkdirSync(path, { recursive: true });
|
|
139
|
+
cpSync(sourceDir, path, { recursive: true });
|
|
140
|
+
operations.push({ kind: "dir-replace", path, beforeDir: relSnapshot, afterDigest: directoryDigest(path) });
|
|
141
|
+
},
|
|
109
142
|
hasOperations() {
|
|
110
143
|
return operations.length > 0;
|
|
111
144
|
},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config for the GUI's in-app chat feature: which CLI agents can be chatted
|
|
3
|
+
* with from the desktop app, keyed by an arbitrary string id (not `AgentId`
|
|
4
|
+
* — chat targets are not necessarily fully-managed Agents with adapters,
|
|
5
|
+
* sync, and probes; they're just "a non-interactive CLI I can spawn and
|
|
6
|
+
* stream from"). Kept as its own file + config path, sibling to
|
|
7
|
+
* `agent-bridge.yaml`, so delegation's `ALL_AGENTS` constraint (needed for
|
|
8
|
+
* the cross-Agent-delegation use case) never has to bend for chat's needs.
|
|
9
|
+
*
|
|
10
|
+
* Configuration lives at `~/.trellis/mcp/chat-agents.yaml`. Its absence
|
|
11
|
+
* means zero targets, not an error — the same "optional, additive" shape
|
|
12
|
+
* as `agent-bridge.yaml`.
|
|
13
|
+
*/
|
|
14
|
+
import type { StreamProtocol } from "./agentBridge.js";
|
|
15
|
+
export type ChatAgentOutputFormat = "text" | "json" | "stream-json";
|
|
16
|
+
export interface ChatAgentTargetConfig {
|
|
17
|
+
/** Human-readable name shown in the GUI's agent picker, e.g. "Claude Code". */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Binary to spawn, e.g. "claude", "qoder". */
|
|
20
|
+
command: string;
|
|
21
|
+
/** Argv template for a fresh call. One element may be the literal
|
|
22
|
+
* placeholder `"{prompt}"`, substituted with the caller's message at
|
|
23
|
+
* call time. */
|
|
24
|
+
args: string[];
|
|
25
|
+
/** Argv template used instead of `args` when the caller supplies a
|
|
26
|
+
* `sessionId` to resume — must reference `"{sessionId}"`. */
|
|
27
|
+
resumeArgs?: string[];
|
|
28
|
+
/** How to interpret stdout. Omit for plain text. */
|
|
29
|
+
outputFormat?: ChatAgentOutputFormat;
|
|
30
|
+
/** Optional stream parser for a CLI with a non-Claude event envelope. */
|
|
31
|
+
streamProtocol?: StreamProtocol;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Free-text, informational only — surfaced in the GUI as a hint. */
|
|
34
|
+
tags?: string[];
|
|
35
|
+
/** Default persona/role text applied to every call to this target. */
|
|
36
|
+
persona?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ChatAgentConfig {
|
|
39
|
+
targets: Record<string, ChatAgentTargetConfig>;
|
|
40
|
+
}
|
|
41
|
+
export declare function chatAgentsPath(homeDir: string): string;
|
|
42
|
+
export declare function loadChatAgentConfig(homeDir: string): ChatAgentConfig;
|