@kevin5251984/guild 0.2.12
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/LICENSE +21 -0
- package/bin/guildd.mjs +20 -0
- package/cordis.yml +24 -0
- package/package.json +52 -0
- package/src/agent-file.ts +125 -0
- package/src/browser.ts +668 -0
- package/src/catalog/default-bots.ts +263 -0
- package/src/catalog/skills.ts +128 -0
- package/src/catalog/subagents.ts +70 -0
- package/src/chat-parts.ts +71 -0
- package/src/cli-args.ts +75 -0
- package/src/cli.ts +60 -0
- package/src/compact.ts +355 -0
- package/src/cordis.d.ts +40 -0
- package/src/db.ts +653 -0
- package/src/generate.ts +673 -0
- package/src/handlers.ts +1623 -0
- package/src/harness.ts +326 -0
- package/src/host-agents.ts +137 -0
- package/src/host-browse.ts +199 -0
- package/src/host-skills.ts +150 -0
- package/src/image-gen.ts +270 -0
- package/src/index.ts +12 -0
- package/src/llm.ts +993 -0
- package/src/mcp.ts +563 -0
- package/src/memory.ts +159 -0
- package/src/mention.ts +176 -0
- package/src/oauth.ts +1474 -0
- package/src/plugins/api.ts +8 -0
- package/src/plugins/chat.ts +31 -0
- package/src/plugins/harness.ts +77 -0
- package/src/plugins/llm.ts +50 -0
- package/src/plugins/mcp.ts +58 -0
- package/src/plugins/memory.ts +42 -0
- package/src/plugins/oauth.ts +47 -0
- package/src/plugins/server.ts +126 -0
- package/src/plugins/store.ts +29 -0
- package/src/plugins/tools.ts +79 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +3045 -0
- package/src/public/chat.html +5834 -0
- package/src/public/favicon-16.png +0 -0
- package/src/public/favicon-16.svg +10 -0
- package/src/public/favicon-32.png +0 -0
- package/src/public/favicon.ico +0 -0
- package/src/public/favicon.svg +13 -0
- package/src/public/i18n.js +663 -0
- package/src/public/index.html +143 -0
- package/src/public/library.html +678 -0
- package/src/public/mcp-add.html +126 -0
- package/src/public/md.js +332 -0
- package/src/public/rpg/inn-street.jpg +0 -0
- package/src/public/settings.html +795 -0
- package/src/public/skills-add.html +212 -0
- package/src/public/studio.html +1181 -0
- package/src/public/style.css +1678 -0
- package/src/public/subagents-add.html +152 -0
- package/src/router.ts +978 -0
- package/src/send-budget.ts +52 -0
- package/src/server.ts +1 -0
- package/src/skill-import.ts +250 -0
- package/src/slash.ts +15 -0
- package/src/start.ts +103 -0
- package/src/store.ts +1208 -0
- package/src/subagent.ts +355 -0
- package/src/tools.ts +818 -0
- package/src/trajectory.ts +339 -0
- package/src/usage.ts +111 -0
- package/vendor/protocol/package.json +19 -0
- package/vendor/protocol/src/index.ts +159 -0
package/src/harness.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { ToolContext, ToolOutcome, ToolTrace } from "./tools.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codex-shaped sandbox names. Default is full_access (today's unsandboxed tools).
|
|
9
|
+
* This is the first Harness cut: a gate around executeTool, not Codex app-server.
|
|
10
|
+
*/
|
|
11
|
+
export const SANDBOX_MODES = [
|
|
12
|
+
"read_only",
|
|
13
|
+
"workspace_write",
|
|
14
|
+
"full_access",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type Sandbox = (typeof SANDBOX_MODES)[number];
|
|
18
|
+
|
|
19
|
+
export type HarnessPolicy = {
|
|
20
|
+
sandbox: Sandbox;
|
|
21
|
+
workspace: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SandboxRefusal = { text: string; isError: true };
|
|
25
|
+
|
|
26
|
+
const HOME = homedir();
|
|
27
|
+
/** `packages/daemon` (this file lives in `src/`). */
|
|
28
|
+
const DAEMON_DIR = fileURLToPath(new URL("..", import.meta.url));
|
|
29
|
+
|
|
30
|
+
/** Guild checkout (parent of `packages/`). */
|
|
31
|
+
export function defaultWorkspace(): string {
|
|
32
|
+
return resolve(DAEMON_DIR, "..", "..");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseSandbox(raw: unknown): Sandbox {
|
|
36
|
+
if (raw === "read_only" || raw === "workspace_write" || raw === "full_access") {
|
|
37
|
+
return raw;
|
|
38
|
+
}
|
|
39
|
+
return "full_access";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Set `GUILD_SANDBOX` wins. Unset is undefined, not full_access. */
|
|
43
|
+
export function envSandbox(env: NodeJS.ProcessEnv = process.env): Sandbox | undefined {
|
|
44
|
+
const raw = env.GUILD_SANDBOX;
|
|
45
|
+
if (raw === "read_only" || raw === "workspace_write" || raw === "full_access") {
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sandboxFromEnv(env: NodeJS.ProcessEnv = process.env): Sandbox {
|
|
52
|
+
return envSandbox(env) ?? "full_access";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Optional `sandbox: read_only|workspace_write|full_access` line in POSITION.md.
|
|
57
|
+
* Env still wins over this.
|
|
58
|
+
*/
|
|
59
|
+
export function sandboxFromPosition(body: string): Sandbox | undefined {
|
|
60
|
+
const match = body.match(
|
|
61
|
+
/(?:^|\n)\s*(?:[-*]\s*)?sandbox:\s*(read_only|workspace_write|full_access)\b/i,
|
|
62
|
+
);
|
|
63
|
+
if (!match) return undefined;
|
|
64
|
+
const value = match[1].toLowerCase();
|
|
65
|
+
if (value === "read_only" || value === "workspace_write" || value === "full_access") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveToolPath(input: string, base = HOME): string {
|
|
72
|
+
const trimmed = input.trim();
|
|
73
|
+
if (trimmed === "~") return HOME;
|
|
74
|
+
if (trimmed.startsWith("~/")) return resolve(HOME, trimmed.slice(2));
|
|
75
|
+
if (trimmed.startsWith("/")) return resolve(trimmed);
|
|
76
|
+
return resolve(base, trimmed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function workspaceFromEnv(
|
|
80
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
81
|
+
fallback?: string,
|
|
82
|
+
): string {
|
|
83
|
+
const raw = env.GUILD_WORKSPACE?.trim();
|
|
84
|
+
if (raw) return resolveToolPath(raw);
|
|
85
|
+
if (fallback?.trim()) return resolveToolPath(fallback);
|
|
86
|
+
return defaultWorkspace();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function policyFor(
|
|
90
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
91
|
+
input: {
|
|
92
|
+
sandbox?: Sandbox;
|
|
93
|
+
workspace?: string;
|
|
94
|
+
position?: string;
|
|
95
|
+
} = {},
|
|
96
|
+
): HarnessPolicy {
|
|
97
|
+
const sandbox =
|
|
98
|
+
envSandbox(env) ??
|
|
99
|
+
input.sandbox ??
|
|
100
|
+
sandboxFromPosition(input.position ?? "") ??
|
|
101
|
+
"full_access";
|
|
102
|
+
return {
|
|
103
|
+
sandbox,
|
|
104
|
+
workspace: workspaceFromEnv(env, input.workspace),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function policyFromEnv(
|
|
109
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
110
|
+
fallbackWorkspace?: string,
|
|
111
|
+
): HarnessPolicy {
|
|
112
|
+
return policyFor(env, { workspace: fallbackWorkspace });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function canonicalize(path: string): string {
|
|
116
|
+
let abs = resolve(path);
|
|
117
|
+
const tail: string[] = [];
|
|
118
|
+
for (;;) {
|
|
119
|
+
try {
|
|
120
|
+
const real = realpathSync(abs);
|
|
121
|
+
return tail.length ? resolve(real, ...tail) : real;
|
|
122
|
+
} catch {
|
|
123
|
+
const parent = dirname(abs);
|
|
124
|
+
if (parent === abs) return tail.length ? resolve(abs, ...tail) : abs;
|
|
125
|
+
tail.unshift(basename(abs));
|
|
126
|
+
abs = parent;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function pathInsideWorkspace(target: string, workspace: string): boolean {
|
|
132
|
+
const rel = relative(canonicalize(workspace), canonicalize(target));
|
|
133
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mutatingTool(name: string): boolean {
|
|
137
|
+
return (
|
|
138
|
+
name === "run" ||
|
|
139
|
+
name === "write" ||
|
|
140
|
+
name === "spawn" ||
|
|
141
|
+
name === "image_gen" ||
|
|
142
|
+
name === "browser" ||
|
|
143
|
+
name.startsWith("mcp__")
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function gateTool(
|
|
148
|
+
name: string,
|
|
149
|
+
args: Record<string, unknown>,
|
|
150
|
+
input: {
|
|
151
|
+
sandbox?: Sandbox;
|
|
152
|
+
workspace?: string;
|
|
153
|
+
} = {},
|
|
154
|
+
): SandboxRefusal | null {
|
|
155
|
+
const sandbox = parseSandbox(input.sandbox);
|
|
156
|
+
if (sandbox === "full_access") return null;
|
|
157
|
+
|
|
158
|
+
if (sandbox === "read_only") {
|
|
159
|
+
if (
|
|
160
|
+
name === "read" ||
|
|
161
|
+
name === "list" ||
|
|
162
|
+
name === "skill" ||
|
|
163
|
+
name === "spawn" ||
|
|
164
|
+
name === "read_spawn"
|
|
165
|
+
) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
text: `sandbox=read_only refused ${name}`,
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const workspace = input.workspace?.trim()
|
|
175
|
+
? resolveToolPath(input.workspace)
|
|
176
|
+
: HOME;
|
|
177
|
+
|
|
178
|
+
if (name.startsWith("mcp__")) {
|
|
179
|
+
return {
|
|
180
|
+
text: "sandbox=workspace_write refused mcp (unsandboxed child process); use full_access",
|
|
181
|
+
isError: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
name === "read" ||
|
|
187
|
+
name === "list" ||
|
|
188
|
+
name === "skill" ||
|
|
189
|
+
name === "spawn" ||
|
|
190
|
+
name === "read_spawn"
|
|
191
|
+
) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (name === "write") {
|
|
196
|
+
const raw = typeof args.path === "string" ? args.path : "";
|
|
197
|
+
if (!raw.trim()) return null;
|
|
198
|
+
const target = resolveToolPath(raw, workspace);
|
|
199
|
+
if (!pathInsideWorkspace(target, workspace)) {
|
|
200
|
+
return {
|
|
201
|
+
text: `sandbox=workspace_write refused write outside workspace: ${target}`,
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (name === "run") {
|
|
209
|
+
const workdir = typeof args.workdir === "string" ? args.workdir.trim() : "";
|
|
210
|
+
const cwd = workdir ? resolveToolPath(workdir, workspace) : workspace;
|
|
211
|
+
if (!pathInsideWorkspace(cwd, workspace)) {
|
|
212
|
+
return {
|
|
213
|
+
text: `sandbox=workspace_write refused run cwd outside workspace: ${cwd}`,
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (name === "image_gen") {
|
|
221
|
+
return {
|
|
222
|
+
text: "sandbox=workspace_write refused image_gen (writes under GUILD_HOME); use full_access",
|
|
223
|
+
isError: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type LoopCall = {
|
|
231
|
+
id: string;
|
|
232
|
+
name: string;
|
|
233
|
+
args: Record<string, unknown>;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export type LoopAsk = {
|
|
237
|
+
calls: LoopCall[];
|
|
238
|
+
text: string;
|
|
239
|
+
thinking?: string;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export type AgentLoopResult = {
|
|
243
|
+
text: string;
|
|
244
|
+
traces: ToolTrace[];
|
|
245
|
+
thinking: string;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const EMPTY_AFTER_TOOLS = "(工具跑完了,但模型沒寫最終回覆)";
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Shared tool loop (DSH-style). Providers only implement `ask`.
|
|
252
|
+
* Tool execution always goes through executeToolTraced → ctx.tools when dispatched.
|
|
253
|
+
*/
|
|
254
|
+
export async function runAgentLoop(input: {
|
|
255
|
+
toolCtx: ToolContext;
|
|
256
|
+
traces?: ToolTrace[];
|
|
257
|
+
thinkingChunks?: string[];
|
|
258
|
+
ask: (state: {
|
|
259
|
+
round: number;
|
|
260
|
+
wrap: boolean;
|
|
261
|
+
steer: string | null;
|
|
262
|
+
}) => Promise<LoopAsk | null>;
|
|
263
|
+
onRetry?: (lateSteer: string) => void;
|
|
264
|
+
onTools?: (calls: LoopCall[], outcomes: ToolOutcome[]) => void;
|
|
265
|
+
exhausted?: string;
|
|
266
|
+
emptyAfterTools?: string;
|
|
267
|
+
nullIfNoTraces?: boolean;
|
|
268
|
+
}): Promise<AgentLoopResult | null> {
|
|
269
|
+
const {
|
|
270
|
+
executeToolTraced,
|
|
271
|
+
nextToolRound,
|
|
272
|
+
takeSteers,
|
|
273
|
+
throwIfAborted,
|
|
274
|
+
emitProgress,
|
|
275
|
+
TOOL_LOOP_EXHAUSTED,
|
|
276
|
+
} = await import("./tools.ts");
|
|
277
|
+
const traces = input.traces ?? [];
|
|
278
|
+
const thinkingChunks = input.thinkingChunks ?? [];
|
|
279
|
+
const exhausted = input.exhausted ?? TOOL_LOOP_EXHAUSTED;
|
|
280
|
+
const emptyAfterTools = input.emptyAfterTools ?? EMPTY_AFTER_TOOLS;
|
|
281
|
+
const thinkingOf = () => thinkingChunks.join("\n\n");
|
|
282
|
+
|
|
283
|
+
for (let round = 0; ; round++) {
|
|
284
|
+
throwIfAborted(input.toolCtx);
|
|
285
|
+
const phase = nextToolRound(round);
|
|
286
|
+
if (phase === "stop") {
|
|
287
|
+
if (!traces.length && input.nullIfNoTraces) return null;
|
|
288
|
+
return { text: exhausted, traces, thinking: thinkingOf() };
|
|
289
|
+
}
|
|
290
|
+
emitProgress(input.toolCtx, traces, thinkingOf());
|
|
291
|
+
const asked = await input.ask({
|
|
292
|
+
round,
|
|
293
|
+
wrap: phase === "wrap",
|
|
294
|
+
steer: takeSteers(input.toolCtx),
|
|
295
|
+
});
|
|
296
|
+
if (!asked) return null;
|
|
297
|
+
if (asked.thinking?.trim()) {
|
|
298
|
+
thinkingChunks.push(asked.thinking.trim());
|
|
299
|
+
emitProgress(input.toolCtx, traces, thinkingOf());
|
|
300
|
+
}
|
|
301
|
+
const thinking = thinkingOf();
|
|
302
|
+
if (!asked.calls.length) {
|
|
303
|
+
const late = takeSteers(input.toolCtx);
|
|
304
|
+
if (late) {
|
|
305
|
+
input.onRetry?.(late);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const text = asked.text.trim();
|
|
309
|
+
if (text) return { text, traces, thinking };
|
|
310
|
+
if (traces.length) return { text: emptyAfterTools, traces, thinking };
|
|
311
|
+
return input.nullIfNoTraces ? null : { text: emptyAfterTools, traces, thinking };
|
|
312
|
+
}
|
|
313
|
+
const outcomes = await Promise.all(
|
|
314
|
+
asked.calls.map((call) =>
|
|
315
|
+
executeToolTraced(
|
|
316
|
+
call.name,
|
|
317
|
+
call.args,
|
|
318
|
+
input.toolCtx,
|
|
319
|
+
traces,
|
|
320
|
+
thinking,
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
input.onTools?.(asked.calls, outcomes);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { parseAgentFile } from "./agent-file.ts";
|
|
5
|
+
|
|
6
|
+
export type HostAgent = {
|
|
7
|
+
id: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
body: string;
|
|
12
|
+
instructions: string;
|
|
13
|
+
readOnly: boolean;
|
|
14
|
+
model?: string;
|
|
15
|
+
reasoning?: string;
|
|
16
|
+
source: "host";
|
|
17
|
+
host: string;
|
|
18
|
+
hostName: string;
|
|
19
|
+
path: string;
|
|
20
|
+
tags: string[];
|
|
21
|
+
createdAt: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type HostTool = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
dirs: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const HOST_TOOLS: HostTool[] = [
|
|
31
|
+
{ id: "codex", name: "Codex", dirs: [".codex/agents"] },
|
|
32
|
+
{ id: "grok", name: "Grok", dirs: [".grok/agents", ".grok/bundled/agents"] },
|
|
33
|
+
{ id: "claude", name: "Claude", dirs: [".claude/agents"] },
|
|
34
|
+
{ id: "cursor", name: "Cursor", dirs: [".cursor/agents"] },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const BODY_CAP = 80_000;
|
|
38
|
+
const LIST_CAP = 400;
|
|
39
|
+
const AGENT_FILE = /\.(toml|md)$/i;
|
|
40
|
+
|
|
41
|
+
function agentFilesIn(dir: string): string[] {
|
|
42
|
+
if (!existsSync(dir)) return [];
|
|
43
|
+
try {
|
|
44
|
+
if (!statSync(dir).isDirectory()) return [];
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
let entries: { name: string; isFile: () => boolean; isDirectory: () => boolean }[] =
|
|
49
|
+
[];
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const found: string[] = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.name.startsWith(".")) continue;
|
|
58
|
+
if (entry.isDirectory()) continue;
|
|
59
|
+
if (entry.isFile() && AGENT_FILE.test(entry.name)) {
|
|
60
|
+
found.push(join(dir, entry.name));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readAgent(file: string, tool: HostTool, home: string): HostAgent | null {
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(file, "utf8");
|
|
69
|
+
const slug = basename(file).replace(/\.(toml|md)$/i, "");
|
|
70
|
+
const parsed = parseAgentFile(raw, slug);
|
|
71
|
+
if (!parsed.instructions.trim()) return null;
|
|
72
|
+
const st = statSync(file);
|
|
73
|
+
const rel = file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
74
|
+
const body = raw.length > BODY_CAP ? raw.slice(0, BODY_CAP) : raw;
|
|
75
|
+
return {
|
|
76
|
+
id: `host:${tool.id}:${slug}`,
|
|
77
|
+
slug,
|
|
78
|
+
name: parsed.name || slug,
|
|
79
|
+
description: parsed.description || "",
|
|
80
|
+
body,
|
|
81
|
+
instructions: parsed.instructions,
|
|
82
|
+
readOnly: parsed.readOnly,
|
|
83
|
+
model: parsed.model,
|
|
84
|
+
reasoning: parsed.reasoning,
|
|
85
|
+
source: "host",
|
|
86
|
+
host: tool.id,
|
|
87
|
+
hostName: tool.name,
|
|
88
|
+
path: rel,
|
|
89
|
+
tags: [tool.id, ...(parsed.readOnly ? ["read-only"] : [])],
|
|
90
|
+
createdAt: st.mtime.toISOString(),
|
|
91
|
+
};
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listHostAgents(opts?: {
|
|
98
|
+
home?: string;
|
|
99
|
+
cwd?: string;
|
|
100
|
+
includeBody?: boolean;
|
|
101
|
+
}): HostAgent[] {
|
|
102
|
+
const home = opts?.home || homedir();
|
|
103
|
+
const cwd = opts?.cwd || process.cwd();
|
|
104
|
+
const includeBody = opts?.includeBody !== false;
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
const out: HostAgent[] = [];
|
|
107
|
+
for (const tool of HOST_TOOLS) {
|
|
108
|
+
const dirs = tool.dirs.flatMap((rel) => [join(home, rel), join(cwd, rel)]);
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
for (const file of agentFilesIn(dir)) {
|
|
111
|
+
let key = file;
|
|
112
|
+
try {
|
|
113
|
+
key = realpathSync(file);
|
|
114
|
+
} catch {
|
|
115
|
+
/* keep file */
|
|
116
|
+
}
|
|
117
|
+
if (seen.has(key)) continue;
|
|
118
|
+
const item = readAgent(file, tool, home);
|
|
119
|
+
if (!item) continue;
|
|
120
|
+
if (!includeBody) item.body = "";
|
|
121
|
+
seen.add(key);
|
|
122
|
+
const clash = out.some((row) => row.id === item.id);
|
|
123
|
+
if (clash) item.id = `${item.id}:${out.length}`;
|
|
124
|
+
out.push(item);
|
|
125
|
+
if (out.length >= LIST_CAP) return out;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out.sort(
|
|
130
|
+
(a, b) =>
|
|
131
|
+
a.hostName.localeCompare(b.hostName) || a.name.localeCompare(b.name),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function hostAgentTools(): { id: string; name: string }[] {
|
|
136
|
+
return HOST_TOOLS.map((tool) => ({ id: tool.id, name: tool.name }));
|
|
137
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { StoreError } from "./store.ts";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const HOME = homedir();
|
|
15
|
+
const LS_CAP = 200;
|
|
16
|
+
const READ_CAP = 48_000;
|
|
17
|
+
const GIT_CAP = 12_000;
|
|
18
|
+
const TREE_CAP = 8_000;
|
|
19
|
+
|
|
20
|
+
export type HostEntry = {
|
|
21
|
+
name: string;
|
|
22
|
+
kind: "file" | "dir" | "link";
|
|
23
|
+
size?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function resolveUserPath(input: string): string {
|
|
27
|
+
const trimmed = String(input || "~").trim() || "~";
|
|
28
|
+
if (trimmed === "~") return HOME;
|
|
29
|
+
if (trimmed.startsWith("~/")) return resolve(HOME, trimmed.slice(2));
|
|
30
|
+
if (trimmed.startsWith("/")) return resolve(trimmed);
|
|
31
|
+
return resolve(HOME, trimmed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parentOf(path: string): string | null {
|
|
35
|
+
const parent = dirname(path);
|
|
36
|
+
if (parent === path) return null;
|
|
37
|
+
return parent;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asHostError(error: unknown, fallback: string): never {
|
|
41
|
+
if (error instanceof StoreError) throw error;
|
|
42
|
+
const err = error as { code?: string; message?: string };
|
|
43
|
+
if (err.code === "ENOENT") throw new StoreError(404, "path not found");
|
|
44
|
+
if (err.code === "EACCES") throw new StoreError(403, "permission denied");
|
|
45
|
+
throw new StoreError(400, err.message || fallback);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hostList(rawPath: string): {
|
|
49
|
+
path: string;
|
|
50
|
+
parent: string | null;
|
|
51
|
+
entries: HostEntry[];
|
|
52
|
+
} {
|
|
53
|
+
try {
|
|
54
|
+
const target = resolveUserPath(rawPath);
|
|
55
|
+
const st = statSync(target);
|
|
56
|
+
if (!st.isDirectory()) throw new StoreError(400, "not a directory");
|
|
57
|
+
const entries = readdirSync(target, { withFileTypes: true })
|
|
58
|
+
.slice(0, LS_CAP)
|
|
59
|
+
.map((entry) => {
|
|
60
|
+
const item: HostEntry = {
|
|
61
|
+
name: entry.name,
|
|
62
|
+
kind: entry.isDirectory()
|
|
63
|
+
? "dir"
|
|
64
|
+
: entry.isSymbolicLink()
|
|
65
|
+
? "link"
|
|
66
|
+
: "file",
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
if (entry.isFile()) {
|
|
70
|
+
item.size = statSync(join(target, entry.name)).size;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
return item;
|
|
76
|
+
})
|
|
77
|
+
.sort((a, b) => {
|
|
78
|
+
if (a.kind === "dir" && b.kind !== "dir") return -1;
|
|
79
|
+
if (a.kind !== "dir" && b.kind === "dir") return 1;
|
|
80
|
+
return a.name.localeCompare(b.name);
|
|
81
|
+
});
|
|
82
|
+
return { path: target, parent: parentOf(target), entries };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
asHostError(error, "list failed");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hostRead(rawPath: string): {
|
|
89
|
+
path: string;
|
|
90
|
+
name: string;
|
|
91
|
+
text: string;
|
|
92
|
+
truncated: boolean;
|
|
93
|
+
bytes: number;
|
|
94
|
+
} {
|
|
95
|
+
try {
|
|
96
|
+
const target = resolveUserPath(rawPath);
|
|
97
|
+
const st = statSync(target);
|
|
98
|
+
if (!st.isFile()) throw new StoreError(400, "not a file");
|
|
99
|
+
const raw = readFileSync(target);
|
|
100
|
+
if (raw.includes(0)) throw new StoreError(400, "binary file");
|
|
101
|
+
const truncated = raw.length > READ_CAP;
|
|
102
|
+
const text = raw.subarray(0, READ_CAP).toString("utf8");
|
|
103
|
+
return {
|
|
104
|
+
path: target,
|
|
105
|
+
name: target.split("/").pop() || target,
|
|
106
|
+
text,
|
|
107
|
+
truncated,
|
|
108
|
+
bytes: raw.length,
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
asHostError(error, "read failed");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function walkTree(
|
|
116
|
+
root: string,
|
|
117
|
+
depth: number,
|
|
118
|
+
prefix: string,
|
|
119
|
+
lines: string[],
|
|
120
|
+
budget: { left: number },
|
|
121
|
+
): void {
|
|
122
|
+
if (budget.left <= 0 || depth < 0) return;
|
|
123
|
+
let entries: ReturnType<typeof readdirSync>;
|
|
124
|
+
try {
|
|
125
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
126
|
+
} catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const visible = entries
|
|
130
|
+
.filter((entry) => entry.name !== "node_modules" && entry.name !== ".git")
|
|
131
|
+
.slice(0, 80);
|
|
132
|
+
for (const entry of visible) {
|
|
133
|
+
if (budget.left <= 0) {
|
|
134
|
+
lines.push(`${prefix}…`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
budget.left -= 1;
|
|
138
|
+
const mark = entry.isDirectory() ? "/" : "";
|
|
139
|
+
lines.push(`${prefix}${entry.name}${mark}`);
|
|
140
|
+
if (entry.isDirectory() && depth > 0) {
|
|
141
|
+
walkTree(join(root, entry.name), depth - 1, `${prefix} `, lines, budget);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function hostTree(rawPath: string): { path: string; text: string } {
|
|
147
|
+
try {
|
|
148
|
+
const target = resolveUserPath(rawPath);
|
|
149
|
+
const st = statSync(target);
|
|
150
|
+
if (!st.isDirectory()) throw new StoreError(400, "not a directory");
|
|
151
|
+
const lines = [target];
|
|
152
|
+
walkTree(target, 2, "", lines, { left: 120 });
|
|
153
|
+
let text = lines.join("\n");
|
|
154
|
+
if (text.length > TREE_CAP) text = `${text.slice(0, TREE_CAP)}\n…`;
|
|
155
|
+
return { path: target, text };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
asHostError(error, "tree failed");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findGitRoot(start: string): string | null {
|
|
162
|
+
let dir = start;
|
|
163
|
+
for (let i = 0; i < 12; i += 1) {
|
|
164
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
165
|
+
const parent = dirname(dir);
|
|
166
|
+
if (parent === dir) return null;
|
|
167
|
+
dir = parent;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function hostGit(rawPath: string): Promise<{
|
|
173
|
+
path: string;
|
|
174
|
+
root: string;
|
|
175
|
+
text: string;
|
|
176
|
+
}> {
|
|
177
|
+
try {
|
|
178
|
+
const start = resolveUserPath(rawPath);
|
|
179
|
+
const base = statSync(start).isDirectory() ? start : dirname(start);
|
|
180
|
+
const root = findGitRoot(base);
|
|
181
|
+
if (!root) throw new StoreError(404, "not a git repository");
|
|
182
|
+
const opts = { cwd: root, timeout: 8_000, maxBuffer: GIT_CAP * 2 };
|
|
183
|
+
const status = await execFileAsync("git", ["status", "-sb"], opts);
|
|
184
|
+
let diff = "";
|
|
185
|
+
try {
|
|
186
|
+
const out = await execFileAsync("git", ["diff", "--stat", "HEAD"], opts);
|
|
187
|
+
diff = String(out.stdout || "");
|
|
188
|
+
} catch {
|
|
189
|
+
diff = "";
|
|
190
|
+
}
|
|
191
|
+
const text = [`repo: ${root}`, status.stdout.trim(), diff.trim()]
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join("\n")
|
|
194
|
+
.slice(0, GIT_CAP);
|
|
195
|
+
return { path: start, root, text: text || "(clean)" };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
asHostError(error, "git failed");
|
|
198
|
+
}
|
|
199
|
+
}
|