agent-trellis 0.6.3 → 0.8.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 +97 -38
- package/dist/adapters/claude-code.js +3 -2
- package/dist/adapters/codex.js +3 -2
- package/dist/adapters/jsonMcp.d.ts +3 -1
- package/dist/adapters/jsonMcp.js +18 -4
- package/dist/adapters/kimi-code.d.ts +21 -0
- package/dist/adapters/kimi-code.js +107 -0
- package/dist/adapters/kiro.js +3 -2
- package/dist/adapters/mcpPlan.d.ts +26 -2
- package/dist/adapters/mcpPlan.js +135 -15
- package/dist/adapters/pi.js +22 -3
- package/dist/adapters/symlinkPlan.d.ts +7 -0
- package/dist/adapters/symlinkPlan.js +16 -7
- package/dist/cli.js +129 -20
- package/dist/commands/doctor.d.ts +7 -2
- package/dist/commands/doctor.js +38 -2
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.js +26 -13
- package/dist/commands/kimi.d.ts +16 -0
- package/dist/commands/kimi.js +64 -0
- package/dist/commands/manage.d.ts +39 -0
- package/dist/commands/manage.js +126 -0
- package/dist/commands/mcp.d.ts +22 -1
- package/dist/commands/mcp.js +65 -2
- package/dist/commands/mcpGateway.d.ts +6 -0
- package/dist/commands/mcpGateway.js +34 -14
- package/dist/commands/mcpImport.d.ts +27 -0
- package/dist/commands/mcpImport.js +336 -0
- package/dist/commands/memory.d.ts +5 -3
- package/dist/commands/memory.js +12 -8
- package/dist/commands/migrate.d.ts +37 -7
- package/dist/commands/migrate.js +253 -27
- package/dist/commands/onboard.d.ts +74 -2
- package/dist/commands/onboard.js +463 -72
- package/dist/commands/onboardVerdict.js +20 -7
- package/dist/commands/rollback.js +4 -1
- package/dist/commands/secretsAudit.d.ts +21 -12
- package/dist/commands/secretsAudit.js +39 -10
- package/dist/commands/skill.d.ts +17 -0
- package/dist/commands/skill.js +47 -2
- package/dist/commands/sync.js +2 -0
- package/dist/core/canonical.d.ts +58 -4
- package/dist/core/canonical.js +183 -11
- package/dist/core/capabilitySelection.d.ts +45 -0
- package/dist/core/capabilitySelection.js +124 -0
- package/dist/core/types.d.ts +25 -2
- package/dist/core/types.js +7 -0
- package/dist/lib/backup.d.ts +2 -0
- package/dist/lib/backup.js +4 -0
- package/dist/lib/builtinSkills.d.ts +2 -0
- package/dist/lib/builtinSkills.js +4 -0
- package/dist/lib/cliMetadata.d.ts +1 -0
- package/dist/lib/cliMetadata.js +14 -0
- package/dist/lib/gatewayBackend.d.ts +28 -0
- package/dist/lib/gatewayBackend.js +72 -1
- package/dist/lib/installAgent.d.ts +7 -5
- package/dist/lib/installAgent.js +4 -3
- package/dist/lib/mcpConnect.js +5 -1
- package/dist/lib/mcpMigrateRead.d.ts +1 -0
- package/dist/lib/mcpMigrateRead.js +3 -0
- package/dist/lib/mcpRuntime.d.ts +46 -0
- package/dist/lib/mcpRuntime.js +185 -0
- package/dist/lib/mcpStatusProvider.d.ts +10 -0
- package/dist/lib/mcpStatusProvider.js +25 -0
- package/dist/lib/mcpToolRegistry.d.ts +18 -0
- package/dist/lib/mcpToolRegistry.js +107 -14
- package/dist/lib/memoryGraph.d.ts +1 -1
- package/dist/lib/memoryGraph.js +9 -3
- package/dist/lib/memoryProvider.d.ts +58 -0
- package/dist/lib/memoryProvider.js +175 -0
- package/dist/lib/memoryStatus.d.ts +17 -0
- package/dist/lib/memoryStatus.js +88 -0
- package/dist/lib/nativeMemory.d.ts +19 -0
- package/dist/lib/nativeMemory.js +129 -0
- package/dist/lib/runtimeControlProvider.d.ts +16 -0
- package/dist/lib/runtimeControlProvider.js +168 -0
- package/dist/lib/secretEnv.d.ts +17 -0
- package/dist/lib/secretEnv.js +31 -1
- package/dist/lib/skillProvider.d.ts +13 -0
- package/dist/lib/skillProvider.js +210 -0
- package/dist/lib/taskProvider.d.ts +7 -0
- package/dist/lib/taskProvider.js +43 -0
- package/dist/lib/taskStore.d.ts +46 -0
- package/dist/lib/taskStore.js +156 -0
- package/dist/lib/terminalPicker.d.ts +23 -28
- package/dist/lib/terminalPicker.js +54 -164
- package/dist/pi-bridge/bundle.js +493 -51
- package/dist/pi-bridge/index.js +42 -21
- package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
- package/dist/pi-bridge/schemaTranslate.js +4 -8
- package/dist/probes/kimi-code.d.ts +12 -0
- package/dist/probes/kimi-code.js +55 -0
- package/dist/sdk.d.ts +1 -1
- package/docs/architecture.md +224 -26
- package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
- package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
- package/docs/assets/trellis-mark.svg +12 -0
- package/docs/getting-started.md +403 -35
- package/docs/overview.md +131 -0
- package/docs/overview.zh-CN.md +100 -0
- package/docs/research.md +59 -0
- package/docs/roadmap.md +10 -5
- package/package.json +5 -3
- package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
- package/schema/capability-selection.example.yaml +23 -0
- package/schema/scope.example.yaml +1 -1
- package/schema/servers.example.yaml +15 -1
package/dist/lib/secretEnv.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* dotenv library's quoting/escaping/interpolation rules are more than
|
|
9
9
|
* this narrow need requires (design.md D4).
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
12
13
|
const LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
13
14
|
export function parseDotenv(content) {
|
|
14
15
|
const result = {};
|
|
@@ -44,3 +45,32 @@ export function resolveSecretEnv(names, policy) {
|
|
|
44
45
|
}
|
|
45
46
|
return result;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* `trellis migrate`'s static-env secret extraction
|
|
50
|
+
* (trellis-migrate-extract-static-env-secrets design.md D4) — appends
|
|
51
|
+
* one `NAME=value` line to a dotenv-format file in exactly the shape
|
|
52
|
+
* `parseDotenv` above already reads, creating the file (and its parent
|
|
53
|
+
* directory) if neither exists yet. Idempotent, the same three-way
|
|
54
|
+
* split every other Trellis writer uses: the name is absent (create),
|
|
55
|
+
* already present with the identical value (no-op — re-running migrate
|
|
56
|
+
* must not duplicate the line), or already present with a *different*
|
|
57
|
+
* value (conflict, left untouched — the file may hold a value the user
|
|
58
|
+
* already rotated by hand since the last run; silently overwriting it
|
|
59
|
+
* would be exactly the kind of value-clobbering this whole feature
|
|
60
|
+
* exists to prevent, just relocated to a new file).
|
|
61
|
+
*/
|
|
62
|
+
export function writeLocalSecretValue(path, name, value, backup) {
|
|
63
|
+
const existingContent = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
64
|
+
const existing = parseDotenv(existingContent);
|
|
65
|
+
if (Object.hasOwn(existing, name)) {
|
|
66
|
+
return existing[name] === value ? "already-present" : "conflict";
|
|
67
|
+
}
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
const separator = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : "";
|
|
70
|
+
const next = `${existingContent}${separator}${name}=${value}\n`;
|
|
71
|
+
if (backup)
|
|
72
|
+
backup.writeFile(path, next);
|
|
73
|
+
else
|
|
74
|
+
appendFileSync(path, `${separator}${name}=${value}\n`);
|
|
75
|
+
return "created";
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
3
|
+
export declare class SkillProvider implements TrellisProvider {
|
|
4
|
+
readonly id = "skills";
|
|
5
|
+
listTools(): Tool[];
|
|
6
|
+
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
7
|
+
listResources(context: RuntimeContext): Resource[];
|
|
8
|
+
readResource(uri: URL, context: RuntimeContext): ReadResourceResult;
|
|
9
|
+
private search;
|
|
10
|
+
private readSkill;
|
|
11
|
+
private readFile;
|
|
12
|
+
private readBoundedFile;
|
|
13
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve, relative, sep } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { isInScope } from "../core/adapter.js";
|
|
6
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
7
|
+
import { resolveScope } from "../core/types.js";
|
|
8
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
9
|
+
const SKILL_RESOURCE_PREFIX = "trellis://skills/";
|
|
10
|
+
const SEARCH_TOOL = {
|
|
11
|
+
name: "trellis.skills.search",
|
|
12
|
+
description: "Search in-scope Trellis skills by name or description, then read one selected skill on demand.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { query: { type: "string" }, limit: { type: "number", minimum: 1, maximum: 50 } },
|
|
16
|
+
required: ["query"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
20
|
+
};
|
|
21
|
+
const READ_TOOL = {
|
|
22
|
+
name: "trellis.skills.read",
|
|
23
|
+
description: "Read the SKILL.md instructions for one in-scope Trellis skill.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: { name: { type: "string" } },
|
|
27
|
+
required: ["name"],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
},
|
|
30
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
31
|
+
};
|
|
32
|
+
const READ_FILE_TOOL = {
|
|
33
|
+
name: "trellis.skills.read_file",
|
|
34
|
+
description: "Read one bounded, non-secret supporting file under an in-scope skill directory.",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: { name: { type: "string" }, relativePath: { type: "string" } },
|
|
38
|
+
required: ["name", "relativePath"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
42
|
+
};
|
|
43
|
+
function textResult(value) {
|
|
44
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value };
|
|
45
|
+
}
|
|
46
|
+
function errorResult(message) {
|
|
47
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
function frontmatter(content) {
|
|
50
|
+
if (!content.startsWith("---"))
|
|
51
|
+
return {};
|
|
52
|
+
const end = content.indexOf("\n---", 3);
|
|
53
|
+
if (end < 0)
|
|
54
|
+
return {};
|
|
55
|
+
try {
|
|
56
|
+
const parsed = parseYaml(content.slice(3, end));
|
|
57
|
+
return {
|
|
58
|
+
name: typeof parsed?.name === "string" ? parsed.name : undefined,
|
|
59
|
+
description: typeof parsed?.description === "string" ? parsed.description : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function fingerprint(content) {
|
|
67
|
+
return createHash("sha256").update(content).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
function isSecretLikePath(path) {
|
|
70
|
+
const normalized = path.toLowerCase();
|
|
71
|
+
return normalized.includes(".env") || normalized.includes("secret") || normalized.endsWith("servers.local.env");
|
|
72
|
+
}
|
|
73
|
+
function skillFor(canonical, agentId, name) {
|
|
74
|
+
const skill = canonical.skills.find((candidate) => candidate.name === name);
|
|
75
|
+
if (!skill || !hasSkillDocument(skill) || !isInScope(agentId, skill.scope, canonical.managedAgents))
|
|
76
|
+
return undefined;
|
|
77
|
+
return skill;
|
|
78
|
+
}
|
|
79
|
+
function hasSkillDocument(skill) {
|
|
80
|
+
try {
|
|
81
|
+
return statSync(resolve(skill.dir, "SKILL.md")).isFile();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function skillUri(name) {
|
|
88
|
+
return `${SKILL_RESOURCE_PREFIX}${encodeURIComponent(name)}/SKILL.md`;
|
|
89
|
+
}
|
|
90
|
+
function skillMetadata(skill, canonical, agentId, content) {
|
|
91
|
+
const metadata = frontmatter(content);
|
|
92
|
+
return {
|
|
93
|
+
name: skill.name,
|
|
94
|
+
description: metadata.description ?? "",
|
|
95
|
+
fingerprint: fingerprint(content),
|
|
96
|
+
uri: skillUri(skill.name),
|
|
97
|
+
scope: resolveScope(skill.scope, canonical.managedAgents).includes(agentId) ? "in-scope" : "out-of-scope",
|
|
98
|
+
source: "canonical",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export class SkillProvider {
|
|
102
|
+
id = "skills";
|
|
103
|
+
listTools() {
|
|
104
|
+
return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
|
|
105
|
+
}
|
|
106
|
+
async callTool(name, args, context) {
|
|
107
|
+
const canonical = loadCanonicalSource(context.homeDir);
|
|
108
|
+
const input = (args ?? {});
|
|
109
|
+
if (name === SEARCH_TOOL.name)
|
|
110
|
+
return this.search(canonical, context.agentId, input);
|
|
111
|
+
if (name === READ_TOOL.name)
|
|
112
|
+
return this.readSkill(canonical, context.agentId, input);
|
|
113
|
+
if (name === READ_FILE_TOOL.name)
|
|
114
|
+
return this.readFile(canonical, context.agentId, input);
|
|
115
|
+
return errorResult(`unknown skill provider tool "${name}"`);
|
|
116
|
+
}
|
|
117
|
+
listResources(context) {
|
|
118
|
+
const canonical = loadCanonicalSource(context.homeDir);
|
|
119
|
+
return canonical.skills
|
|
120
|
+
.filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
|
|
121
|
+
.map((skill) => ({
|
|
122
|
+
uri: skillUri(skill.name),
|
|
123
|
+
name: `${skill.name}/SKILL.md`,
|
|
124
|
+
description: `Instructions for the ${skill.name} skill`,
|
|
125
|
+
mimeType: "text/markdown",
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
readResource(uri, context) {
|
|
129
|
+
if (uri.protocol !== "trellis:" || uri.hostname !== "skills") {
|
|
130
|
+
throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
|
|
131
|
+
}
|
|
132
|
+
const segments = uri.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
|
|
133
|
+
if (segments.length !== 2 || segments[1] !== "SKILL.md") {
|
|
134
|
+
throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
|
|
135
|
+
}
|
|
136
|
+
const canonical = loadCanonicalSource(context.homeDir);
|
|
137
|
+
const skill = skillFor(canonical, context.agentId, segments[0]);
|
|
138
|
+
if (!skill)
|
|
139
|
+
throw new Error(`skill "${segments[0]}" is not available to ${context.agentId}`);
|
|
140
|
+
const content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
|
|
141
|
+
return { contents: [{ uri: uri.toString(), mimeType: "text/markdown", text: content }] };
|
|
142
|
+
}
|
|
143
|
+
search(canonical, agentId, input) {
|
|
144
|
+
const query = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
|
|
145
|
+
const limit = typeof input.limit === "number" ? Math.min(50, Math.max(1, Math.floor(input.limit))) : 20;
|
|
146
|
+
const results = canonical.skills
|
|
147
|
+
.filter((skill) => isInScope(agentId, skill.scope, canonical.managedAgents))
|
|
148
|
+
.map((skill) => {
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return { ...skillMetadata(skill, canonical, agentId, content), dir: skill.dir };
|
|
157
|
+
})
|
|
158
|
+
.filter((skill) => skill !== undefined)
|
|
159
|
+
.filter((skill) => !query || `${skill.name} ${skill.description}`.toLowerCase().includes(query))
|
|
160
|
+
.slice(0, limit)
|
|
161
|
+
.map(({ dir: _dir, ...result }) => result);
|
|
162
|
+
return textResult({ skills: results });
|
|
163
|
+
}
|
|
164
|
+
readSkill(canonical, agentId, input) {
|
|
165
|
+
if (typeof input.name !== "string" || input.name.length === 0)
|
|
166
|
+
return errorResult('"name" is required');
|
|
167
|
+
const skill = skillFor(canonical, agentId, input.name);
|
|
168
|
+
if (!skill)
|
|
169
|
+
return errorResult(`skill "${input.name}" is not available to ${agentId}`);
|
|
170
|
+
const path = resolve(skill.dir, "SKILL.md");
|
|
171
|
+
const content = this.readBoundedFile(path, skill.dir);
|
|
172
|
+
return textResult({ ...skillMetadata(skill, canonical, agentId, content), content });
|
|
173
|
+
}
|
|
174
|
+
readFile(canonical, agentId, input) {
|
|
175
|
+
if (typeof input.name !== "string" || typeof input.relativePath !== "string")
|
|
176
|
+
return errorResult('"name" and "relativePath" are required');
|
|
177
|
+
const skill = skillFor(canonical, agentId, input.name);
|
|
178
|
+
if (!skill)
|
|
179
|
+
return errorResult(`skill "${input.name}" is not available to ${agentId}`);
|
|
180
|
+
if (input.relativePath.length === 0 || input.relativePath.includes("\0") || isSecretLikePath(input.relativePath)) {
|
|
181
|
+
return errorResult("supporting file path is not allowed");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const path = resolve(skill.dir, input.relativePath);
|
|
185
|
+
const root = realpathSync(skill.dir);
|
|
186
|
+
const realPath = realpathSync(path);
|
|
187
|
+
const rel = relative(root, realPath);
|
|
188
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || !existsSync(realPath) || !statSync(realPath).isFile()) {
|
|
189
|
+
return errorResult("supporting file is outside the skill root");
|
|
190
|
+
}
|
|
191
|
+
return textResult({ name: skill.name, relativePath: rel, source: "canonical-skill-supporting-file", content: this.readBoundedFile(realPath, root) });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return errorResult(`could not read supporting file: ${err instanceof Error ? err.message : String(err)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
readBoundedFile(path, root) {
|
|
198
|
+
if (isSecretLikePath(path))
|
|
199
|
+
throw new Error("secret-like files are not readable through the skill provider");
|
|
200
|
+
const rootPath = realpathSync(root);
|
|
201
|
+
const realPath = realpathSync(path);
|
|
202
|
+
const rel = relative(rootPath, realPath);
|
|
203
|
+
if (rel.startsWith(`..${sep}`) || rel === "..")
|
|
204
|
+
throw new Error("file is outside the skill root");
|
|
205
|
+
const stat = statSync(realPath);
|
|
206
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
207
|
+
throw new Error(`file exceeds the ${MAX_FILE_BYTES}-byte provider limit`);
|
|
208
|
+
return readFileSync(realPath, "utf8");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
3
|
+
export declare class TaskProvider implements TrellisProvider {
|
|
4
|
+
readonly id = "tasks";
|
|
5
|
+
listTools(): Tool[];
|
|
6
|
+
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { claimTask, createTask, handoffTask, listTasks, readTask, updateTask } from "./taskStore.js";
|
|
2
|
+
const LIST = { name: "trellis.tasks.list", description: "List safe Trellis handoff tasks.", inputSchema: { type: "object", properties: { status: { type: "string" }, toAgent: { type: "string" } }, additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } };
|
|
3
|
+
const READ = { name: "trellis.tasks.read", description: "Read one Trellis handoff task and its audit history.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } };
|
|
4
|
+
const CREATE = { name: "trellis.tasks.create", description: "Create a safe durable handoff task. Requires explicit confirm=true and never launches another Agent.", inputSchema: { type: "object", properties: { objective: { type: "string" }, toAgent: { type: "string" }, context: { type: "object" }, constraints: { type: "array", items: { type: "string" } }, confirm: { type: "boolean" } }, required: ["objective", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
|
|
5
|
+
const CLAIM = { name: "trellis.tasks.claim", description: "Claim one pending handoff task with an exclusive lease. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, leaseMs: { type: "number" }, confirm: { type: "boolean" } }, required: ["id", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
|
|
6
|
+
const UPDATE = { name: "trellis.tasks.update", description: "Update a claimed task status without launching another Agent. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, status: { type: "string" }, note: { type: "string" }, result: { type: "string" }, confirm: { type: "boolean" } }, required: ["id", "status", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
|
|
7
|
+
const HANDOFF = { name: "trellis.tasks.handoff", description: "Hand a task to another managed Agent as data only. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, toAgent: { type: "string" }, note: { type: "string" }, confirm: { type: "boolean" } }, required: ["id", "toAgent", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
|
|
8
|
+
function result(value) {
|
|
9
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value };
|
|
10
|
+
}
|
|
11
|
+
function failure(error) {
|
|
12
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
13
|
+
}
|
|
14
|
+
export class TaskProvider {
|
|
15
|
+
id = "tasks";
|
|
16
|
+
listTools() { return [LIST, READ, CREATE, CLAIM, UPDATE, HANDOFF]; }
|
|
17
|
+
async callTool(name, args, context) {
|
|
18
|
+
const input = (args ?? {});
|
|
19
|
+
try {
|
|
20
|
+
if (name === LIST.name) {
|
|
21
|
+
const status = input.status;
|
|
22
|
+
const toAgent = input.toAgent;
|
|
23
|
+
return result({ tasks: listTasks(context.homeDir, { status, toAgent }) });
|
|
24
|
+
}
|
|
25
|
+
if (name === READ.name)
|
|
26
|
+
return result(readTask(context.homeDir, String(input.id ?? "")));
|
|
27
|
+
if (input.confirm !== true)
|
|
28
|
+
return failure(`task mutation "${name}" requires confirm=true; no Agent is launched automatically`);
|
|
29
|
+
if (name === CREATE.name)
|
|
30
|
+
return result(createTask(context.homeDir, { objective: String(input.objective ?? ""), fromAgent: context.agentId, ...(input.toAgent ? { toAgent: input.toAgent } : {}), ...(input.context ? { context: input.context } : {}), ...(input.constraints ? { constraints: input.constraints } : {}) }));
|
|
31
|
+
if (name === CLAIM.name)
|
|
32
|
+
return result(claimTask(context.homeDir, String(input.id ?? ""), context.agentId, input.leaseMs));
|
|
33
|
+
if (name === UPDATE.name)
|
|
34
|
+
return result(updateTask(context.homeDir, String(input.id ?? ""), context.agentId, input.status, input.note, input.result));
|
|
35
|
+
if (name === HANDOFF.name)
|
|
36
|
+
return result(handoffTask(context.homeDir, String(input.id ?? ""), context.agentId, input.toAgent, input.note));
|
|
37
|
+
return failure(`unknown task tool "${name}"`);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return failure(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentId } from "../core/types.js";
|
|
2
|
+
export type TaskStatus = "pending" | "claimed" | "in_progress" | "blocked" | "completed";
|
|
3
|
+
export interface TaskContext {
|
|
4
|
+
workspace?: string;
|
|
5
|
+
commit?: string;
|
|
6
|
+
references?: string[];
|
|
7
|
+
note?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TaskHistoryEntry {
|
|
10
|
+
at: string;
|
|
11
|
+
agent: AgentId;
|
|
12
|
+
action: string;
|
|
13
|
+
status: TaskStatus;
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface TaskRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
objective: string;
|
|
19
|
+
fromAgent: AgentId;
|
|
20
|
+
toAgent?: AgentId;
|
|
21
|
+
status: TaskStatus;
|
|
22
|
+
context?: TaskContext;
|
|
23
|
+
constraints?: string[];
|
|
24
|
+
result?: string;
|
|
25
|
+
claimedBy?: AgentId;
|
|
26
|
+
leaseUntil?: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
history: TaskHistoryEntry[];
|
|
30
|
+
}
|
|
31
|
+
export declare function taskStorePath(homeDir: string): string;
|
|
32
|
+
export declare function listTasks(homeDir: string, filter?: {
|
|
33
|
+
status?: TaskStatus;
|
|
34
|
+
toAgent?: AgentId;
|
|
35
|
+
}): TaskRecord[];
|
|
36
|
+
export declare function readTask(homeDir: string, id: string): TaskRecord;
|
|
37
|
+
export declare function createTask(homeDir: string, input: {
|
|
38
|
+
objective: string;
|
|
39
|
+
fromAgent: AgentId;
|
|
40
|
+
toAgent?: AgentId;
|
|
41
|
+
context?: TaskContext;
|
|
42
|
+
constraints?: string[];
|
|
43
|
+
}): TaskRecord;
|
|
44
|
+
export declare function claimTask(homeDir: string, id: string, agent: AgentId, leaseMs?: number): TaskRecord;
|
|
45
|
+
export declare function updateTask(homeDir: string, id: string, agent: AgentId, status: TaskStatus, note?: string, result?: string): TaskRecord;
|
|
46
|
+
export declare function handoffTask(homeDir: string, id: string, agent: AgentId, toAgent: AgentId, note?: string): TaskRecord;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const SECRET_LIKE = /(sk-|ghp_|glpat-|mcpr_|token\s*=|password\s*=|secret\s*=|authorization\s*:)/i;
|
|
5
|
+
export function taskStorePath(homeDir) {
|
|
6
|
+
return join(homeDir, ".trellis", "tasks", "tasks.json");
|
|
7
|
+
}
|
|
8
|
+
function readFile(path) {
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return { tasks: [] };
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
return { tasks: Array.isArray(value.tasks) ? value.tasks : [] };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`could not parse task store ${path}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function writeFile(path, value) {
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
21
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
22
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
23
|
+
renameSync(temporary, path);
|
|
24
|
+
}
|
|
25
|
+
function assertSafeText(label, value) {
|
|
26
|
+
if (!value.trim())
|
|
27
|
+
throw new Error(`${label} must not be empty`);
|
|
28
|
+
if (SECRET_LIKE.test(value))
|
|
29
|
+
throw new Error(`${label} looks like it contains a credential or secret-like value`);
|
|
30
|
+
}
|
|
31
|
+
function assertSafeContext(context) {
|
|
32
|
+
if (!context)
|
|
33
|
+
return;
|
|
34
|
+
for (const [key, value] of Object.entries(context)) {
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
assertSafeText(`context.${key}`, value);
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
value.forEach((item) => assertSafeText(`context.${key}`, item));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function now() {
|
|
42
|
+
return new Date().toISOString();
|
|
43
|
+
}
|
|
44
|
+
function assertAgent(value, label) {
|
|
45
|
+
if (value !== "claude-code" && value !== "codex" && value !== "kiro" && value !== "pi" && value !== "kimi-code") {
|
|
46
|
+
throw new Error(`${label} must be a supported Agent id`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function taskOrThrow(file, id) {
|
|
50
|
+
const task = file.tasks.find((candidate) => candidate.id === id);
|
|
51
|
+
if (!task)
|
|
52
|
+
throw new Error(`task "${id}" was not found`);
|
|
53
|
+
return task;
|
|
54
|
+
}
|
|
55
|
+
function addHistory(task, agent, action, status, note) {
|
|
56
|
+
task.status = status;
|
|
57
|
+
task.updatedAt = now();
|
|
58
|
+
task.history.push({ at: task.updatedAt, agent, action, status, ...(note ? { note } : {}) });
|
|
59
|
+
}
|
|
60
|
+
export function listTasks(homeDir, filter) {
|
|
61
|
+
const tasks = readFile(taskStorePath(homeDir)).tasks;
|
|
62
|
+
return tasks
|
|
63
|
+
.filter((task) => !filter?.status || task.status === filter.status)
|
|
64
|
+
.filter((task) => !filter?.toAgent || task.toAgent === filter.toAgent)
|
|
65
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
66
|
+
}
|
|
67
|
+
export function readTask(homeDir, id) {
|
|
68
|
+
return taskOrThrow(readFile(taskStorePath(homeDir)), id);
|
|
69
|
+
}
|
|
70
|
+
export function createTask(homeDir, input) {
|
|
71
|
+
assertSafeText("objective", input.objective);
|
|
72
|
+
assertAgent(input.fromAgent, "fromAgent");
|
|
73
|
+
if (input.toAgent)
|
|
74
|
+
assertAgent(input.toAgent, "toAgent");
|
|
75
|
+
assertSafeContext(input.context);
|
|
76
|
+
input.constraints?.forEach((value) => assertSafeText("constraint", value));
|
|
77
|
+
const timestamp = now();
|
|
78
|
+
const task = {
|
|
79
|
+
id: `task-${randomUUID()}`,
|
|
80
|
+
objective: input.objective,
|
|
81
|
+
fromAgent: input.fromAgent,
|
|
82
|
+
...(input.toAgent ? { toAgent: input.toAgent } : {}),
|
|
83
|
+
status: "pending",
|
|
84
|
+
...(input.context ? { context: input.context } : {}),
|
|
85
|
+
...(input.constraints?.length ? { constraints: input.constraints } : {}),
|
|
86
|
+
createdAt: timestamp,
|
|
87
|
+
updatedAt: timestamp,
|
|
88
|
+
history: [{ at: timestamp, agent: input.fromAgent, action: "created", status: "pending" }],
|
|
89
|
+
};
|
|
90
|
+
const file = readFile(taskStorePath(homeDir));
|
|
91
|
+
file.tasks.push(task);
|
|
92
|
+
writeFile(taskStorePath(homeDir), file);
|
|
93
|
+
return task;
|
|
94
|
+
}
|
|
95
|
+
export function claimTask(homeDir, id, agent, leaseMs = 30 * 60 * 1000) {
|
|
96
|
+
assertAgent(agent, "agent");
|
|
97
|
+
if (!Number.isFinite(leaseMs) || leaseMs < 1_000 || leaseMs > 24 * 60 * 60 * 1000)
|
|
98
|
+
throw new Error("leaseMs must be between 1000 and 86400000");
|
|
99
|
+
const path = taskStorePath(homeDir);
|
|
100
|
+
const file = readFile(path);
|
|
101
|
+
const task = taskOrThrow(file, id);
|
|
102
|
+
const timestamp = Date.now();
|
|
103
|
+
if (task.leaseUntil && new Date(task.leaseUntil).getTime() <= timestamp) {
|
|
104
|
+
task.claimedBy = undefined;
|
|
105
|
+
task.leaseUntil = undefined;
|
|
106
|
+
if (task.status === "claimed" || task.status === "in_progress")
|
|
107
|
+
task.status = "pending";
|
|
108
|
+
}
|
|
109
|
+
if (task.status !== "pending" || task.claimedBy)
|
|
110
|
+
throw new Error(`task "${id}" is already claimed or is not pending`);
|
|
111
|
+
task.claimedBy = agent;
|
|
112
|
+
task.leaseUntil = new Date(timestamp + leaseMs).toISOString();
|
|
113
|
+
addHistory(task, agent, "claimed", "claimed");
|
|
114
|
+
writeFile(path, file);
|
|
115
|
+
return task;
|
|
116
|
+
}
|
|
117
|
+
export function updateTask(homeDir, id, agent, status, note, result) {
|
|
118
|
+
assertAgent(agent, "agent");
|
|
119
|
+
if (!["pending", "claimed", "in_progress", "blocked", "completed"].includes(status))
|
|
120
|
+
throw new Error(`invalid task status "${status}"`);
|
|
121
|
+
if (note)
|
|
122
|
+
assertSafeText("note", note);
|
|
123
|
+
if (result)
|
|
124
|
+
assertSafeText("result", result);
|
|
125
|
+
const path = taskStorePath(homeDir);
|
|
126
|
+
const file = readFile(path);
|
|
127
|
+
const task = taskOrThrow(file, id);
|
|
128
|
+
if (task.claimedBy && task.claimedBy !== agent)
|
|
129
|
+
throw new Error(`task "${id}" is claimed by another Agent`);
|
|
130
|
+
if (status === "completed" && result)
|
|
131
|
+
task.result = result;
|
|
132
|
+
addHistory(task, agent, status === "completed" ? "completed" : "updated", status, note);
|
|
133
|
+
if (status === "completed") {
|
|
134
|
+
task.claimedBy = undefined;
|
|
135
|
+
task.leaseUntil = undefined;
|
|
136
|
+
}
|
|
137
|
+
writeFile(path, file);
|
|
138
|
+
return task;
|
|
139
|
+
}
|
|
140
|
+
export function handoffTask(homeDir, id, agent, toAgent, note) {
|
|
141
|
+
assertAgent(agent, "agent");
|
|
142
|
+
assertAgent(toAgent, "toAgent");
|
|
143
|
+
if (note)
|
|
144
|
+
assertSafeText("note", note);
|
|
145
|
+
const path = taskStorePath(homeDir);
|
|
146
|
+
const file = readFile(path);
|
|
147
|
+
const task = taskOrThrow(file, id);
|
|
148
|
+
if (task.claimedBy && task.claimedBy !== agent)
|
|
149
|
+
throw new Error(`task "${id}" is claimed by another Agent`);
|
|
150
|
+
task.toAgent = toAgent;
|
|
151
|
+
task.claimedBy = undefined;
|
|
152
|
+
task.leaseUntil = undefined;
|
|
153
|
+
addHistory(task, agent, "handed-off", "pending", note);
|
|
154
|
+
writeFile(path, file);
|
|
155
|
+
return task;
|
|
156
|
+
}
|
|
@@ -1,45 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (trellis-onboard-interactive-picker design.md D1) — the interaction
|
|
4
|
-
* surface `trellis onboard`'s two prompts need (at most four rows, no
|
|
5
|
-
* search, no pagination) doesn't need a full prompt library; this
|
|
6
|
-
* hand-rolls just enough raw-mode key handling and ANSI rendering for
|
|
7
|
-
* that bounded case, matching the project's existing narrow-dependency
|
|
8
|
-
* precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
|
|
2
|
+
* Styled interactive prompts used by `trellis onboard`.
|
|
9
3
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
4
|
+
* The onboarding orchestration depends on semantic values (an index or a set
|
|
5
|
+
* of indices), not on a particular terminal renderer. Clack owns raw-mode
|
|
6
|
+
* handling, color, wrapping, cursor cleanup, and cancellation; the injected
|
|
7
|
+
* streams keep the prompt loop testable without a real terminal.
|
|
14
8
|
*/
|
|
15
9
|
import type { Readable, Writable } from "node:stream";
|
|
16
10
|
export interface PickerStreams {
|
|
17
11
|
input: NodeJS.ReadStream | Readable;
|
|
18
12
|
output: NodeJS.WriteStream | Writable;
|
|
19
13
|
}
|
|
14
|
+
export interface PickerOption {
|
|
15
|
+
label: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* (design.md D2). A stream lacking `setRawMode` (piped input, some
|
|
24
|
-
* minimal TTYs) always falls back, never hangs or guesses.
|
|
19
|
+
* Clack is only started for a real interactive terminal. Piped input keeps
|
|
20
|
+
* the existing numbered fallback in onboard instead of hanging in raw mode.
|
|
25
21
|
*/
|
|
26
22
|
export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
|
|
27
|
-
/** Wrapping index navigation
|
|
23
|
+
/** Wrapping index navigation retained as a small public utility for callers/tests. */
|
|
28
24
|
export declare function nextIndex(current: number, delta: number, length: number): number;
|
|
29
|
-
/** Pure single-index toggle
|
|
25
|
+
/** Pure single-index toggle retained for callers/tests. */
|
|
30
26
|
export declare function toggled(checked: readonly boolean[], index: number): boolean[];
|
|
31
27
|
/**
|
|
32
|
-
* Single-select
|
|
33
|
-
*
|
|
34
|
-
* must have already confirmed `canUseInteractivePicker()` — this function
|
|
35
|
-
* does not re-check, and assumes `streams.input` genuinely supports raw
|
|
36
|
-
* mode.
|
|
28
|
+
* Single-select prompt. The returned number is the original item index, so
|
|
29
|
+
* callers do not depend on display labels or prompt-library values.
|
|
37
30
|
*/
|
|
38
|
-
export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
|
|
31
|
+
export declare function runSingleSelectPicker(items: readonly (string | PickerOption)[], streams?: PickerStreams, message?: string): Promise<number | null>;
|
|
39
32
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* confirm time, or `null` on Ctrl+C cancel. Same precondition as
|
|
43
|
-
* `runSingleSelectPicker`.
|
|
33
|
+
* Checkbox prompt. The returned indices preserve the existing onboard
|
|
34
|
+
* contract, while Clack handles checked state, wrapping, colors, and cleanup.
|
|
44
35
|
*/
|
|
45
|
-
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;
|
|
36
|
+
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams, message?: string): Promise<number[] | null>;
|
|
37
|
+
/** Searchable multiselect for large skill/MCP inventories. It returns the
|
|
38
|
+
* selected labels as stable names, preserving the semantic adapter contract
|
|
39
|
+
* while avoiding a wall of dozens of rows in the onboarding terminal. */
|
|
40
|
+
export declare function runSearchMultiSelectPicker(items: readonly string[], streams?: PickerStreams, message?: string): Promise<string[] | null>;
|