privateer-agent 0.2.1 → 0.3.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 +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/hooks/engine.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import type { ToolSet } from "ai";
|
|
2
|
-
import { exec } from "../tools/exec.ts";
|
|
3
|
-
|
|
4
|
-
// A single hook: a shell command, optionally gated by a regex matched against the
|
|
5
|
-
// tool name (tool events only). Loaded from settings under the `hooks` key.
|
|
6
|
-
export interface HookDef {
|
|
7
|
-
matcher?: string;
|
|
8
|
-
command: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
|
|
12
|
-
export type HooksConfig = Partial<Record<HookEvent, HookDef[]>>;
|
|
13
|
-
|
|
14
|
-
// Aggregated result of firing an event's hooks.
|
|
15
|
-
export interface HookOutcome {
|
|
16
|
-
block?: boolean; // PreToolUse / UserPromptSubmit may veto
|
|
17
|
-
reason?: string;
|
|
18
|
-
additionalContext?: string; // UserPromptSubmit may inject extra context
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const HOOK_TIMEOUT_MS = 30_000;
|
|
22
|
-
const KNOWN_EVENTS: HookEvent[] = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"];
|
|
23
|
-
|
|
24
|
-
// Parse the loosely-typed `hooks` section of settings into a validated HooksConfig.
|
|
25
|
-
export function loadHooks(raw: unknown): HooksConfig {
|
|
26
|
-
if (typeof raw !== "object" || raw === null) return {};
|
|
27
|
-
const out: HooksConfig = {};
|
|
28
|
-
for (const event of KNOWN_EVENTS) {
|
|
29
|
-
const entries = (raw as Record<string, unknown>)[event];
|
|
30
|
-
if (!Array.isArray(entries)) continue;
|
|
31
|
-
const defs: HookDef[] = [];
|
|
32
|
-
for (const e of entries) {
|
|
33
|
-
if (e && typeof e === "object" && typeof (e as any).command === "string") {
|
|
34
|
-
defs.push({ matcher: (e as any).matcher, command: (e as any).command });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (defs.length) out[event] = defs;
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function matches(def: HookDef, toolName?: string): boolean {
|
|
43
|
-
if (!def.matcher) return true;
|
|
44
|
-
if (toolName === undefined) return true;
|
|
45
|
-
try {
|
|
46
|
-
return new RegExp(def.matcher).test(toolName);
|
|
47
|
-
} catch {
|
|
48
|
-
return false; // invalid regex → don't match
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Fire one hook with a JSON payload on stdin; interpret its result. A hook blocks by
|
|
53
|
-
// exiting with code 2 (reason from stderr) or printing JSON with decision "block"/"deny".
|
|
54
|
-
// It may inject context via { "additionalContext": "..." }.
|
|
55
|
-
async function fireHook(def: HookDef, payload: unknown, cwd: string): Promise<HookOutcome> {
|
|
56
|
-
const res = await exec(def.command, [], {
|
|
57
|
-
cwd,
|
|
58
|
-
timeoutMs: HOOK_TIMEOUT_MS,
|
|
59
|
-
shell: true,
|
|
60
|
-
input: JSON.stringify(payload),
|
|
61
|
-
});
|
|
62
|
-
if (res.code === 2) return { block: true, reason: res.stderr.trim() || "blocked by hook" };
|
|
63
|
-
const stdout = res.stdout.trim();
|
|
64
|
-
if (stdout.startsWith("{")) {
|
|
65
|
-
try {
|
|
66
|
-
const out = JSON.parse(stdout) as Record<string, unknown>;
|
|
67
|
-
const decision = (out.decision ?? out.permissionDecision) as string | undefined;
|
|
68
|
-
return {
|
|
69
|
-
block: decision === "block" || decision === "deny",
|
|
70
|
-
reason: (out.reason ?? out.permissionDecisionReason) as string | undefined,
|
|
71
|
-
additionalContext: out.additionalContext as string | undefined,
|
|
72
|
-
};
|
|
73
|
-
} catch {
|
|
74
|
-
/* not JSON → no control signal */
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return {};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Runs the configured hooks for each lifecycle event.
|
|
81
|
-
export class HookRunner {
|
|
82
|
-
constructor(
|
|
83
|
-
private readonly hooks: HooksConfig,
|
|
84
|
-
private readonly cwd: string,
|
|
85
|
-
) {}
|
|
86
|
-
|
|
87
|
-
has(event: HookEvent): boolean {
|
|
88
|
-
return (this.hooks[event]?.length ?? 0) > 0;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
config(): HooksConfig {
|
|
92
|
-
return this.hooks;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Fire tool-event hooks (Pre/PostToolUse). The first hook to block wins.
|
|
96
|
-
async tool(
|
|
97
|
-
event: "PreToolUse" | "PostToolUse",
|
|
98
|
-
toolName: string,
|
|
99
|
-
payload: { input: unknown; output?: unknown },
|
|
100
|
-
): Promise<HookOutcome> {
|
|
101
|
-
const defs = (this.hooks[event] ?? []).filter((d) => matches(d, toolName));
|
|
102
|
-
let merged: HookOutcome = {};
|
|
103
|
-
for (const def of defs) {
|
|
104
|
-
const outcome = await fireHook(def, { event, tool: toolName, ...payload, cwd: this.cwd }, this.cwd);
|
|
105
|
-
if (outcome.block) return outcome;
|
|
106
|
-
merged = { ...merged, ...outcome };
|
|
107
|
-
}
|
|
108
|
-
return merged;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Fire UserPromptSubmit hooks; a block vetoes the turn, additionalContext is merged.
|
|
112
|
-
async prompt(text: string): Promise<HookOutcome> {
|
|
113
|
-
const defs = this.hooks.UserPromptSubmit ?? [];
|
|
114
|
-
let context = "";
|
|
115
|
-
for (const def of defs) {
|
|
116
|
-
const outcome = await fireHook(def, { event: "UserPromptSubmit", prompt: text, cwd: this.cwd }, this.cwd);
|
|
117
|
-
if (outcome.block) return outcome;
|
|
118
|
-
if (outcome.additionalContext) context += (context ? "\n" : "") + outcome.additionalContext;
|
|
119
|
-
}
|
|
120
|
-
return context ? { additionalContext: context } : {};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Fire Stop hooks (turn finished); fire-and-report, no control flow.
|
|
124
|
-
async stop(): Promise<void> {
|
|
125
|
-
for (const def of this.hooks.Stop ?? []) {
|
|
126
|
-
await fireHook(def, { event: "Stop", cwd: this.cwd }, this.cwd);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Wrap each tool's execute with Pre/PostToolUse hooks. A blocking PreToolUse hook
|
|
132
|
-
// short-circuits the call, returning the reason so the model can adapt. No-op when no
|
|
133
|
-
// tool hooks are configured.
|
|
134
|
-
export function wrapToolsWithHooks(tools: ToolSet, runner: HookRunner): ToolSet {
|
|
135
|
-
if (!runner.has("PreToolUse") && !runner.has("PostToolUse")) return tools;
|
|
136
|
-
const wrapped: ToolSet = {};
|
|
137
|
-
for (const [name, t] of Object.entries(tools)) {
|
|
138
|
-
const orig = (t as any).execute;
|
|
139
|
-
if (typeof orig !== "function") {
|
|
140
|
-
wrapped[name] = t;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
wrapped[name] = {
|
|
144
|
-
...(t as any),
|
|
145
|
-
execute: async (input: unknown, options: unknown) => {
|
|
146
|
-
const pre = await runner.tool("PreToolUse", name, { input });
|
|
147
|
-
if (pre.block) return `Blocked by PreToolUse hook: ${pre.reason ?? "no reason given"}`;
|
|
148
|
-
const output = await orig(input, options);
|
|
149
|
-
await runner.tool("PostToolUse", name, { input, output });
|
|
150
|
-
return output;
|
|
151
|
-
},
|
|
152
|
-
} as ToolSet[string];
|
|
153
|
-
}
|
|
154
|
-
return wrapped;
|
|
155
|
-
}
|
package/src/main.tsx
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { render } from "ink";
|
|
3
|
-
import { Command } from "commander";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { openSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { Root } from "./components/Root.tsx";
|
|
8
|
-
import { NAME, VERSION, DESCRIPTION } from "./version.ts";
|
|
9
|
-
import { loadConfig, globalDir } from "./config/load.ts";
|
|
10
|
-
import { createSession } from "./session.ts";
|
|
11
|
-
import { loadLatest, loadSession } from "./memory/store.ts";
|
|
12
|
-
import { configuredProviders } from "./providers/resolve.ts";
|
|
13
|
-
import { describeError } from "./engine/errors.ts";
|
|
14
|
-
import { runDaemon } from "./daemon/index.ts";
|
|
15
|
-
import { revokeChildSession } from "./auth/privateer.ts";
|
|
16
|
-
|
|
17
|
-
// Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
|
|
18
|
-
// must NOT reach stdout/stderr — Node's default printer dumps the whole error
|
|
19
|
-
// object (the request body included) unredacted and scrambles the render. The
|
|
20
|
-
// underlying error has already surfaced cleanly via the engine's `error` event,
|
|
21
|
-
// so here we just swallow the duplicate.
|
|
22
|
-
let tuiActive = false;
|
|
23
|
-
|
|
24
|
-
process.on("unhandledRejection", (reason) => {
|
|
25
|
-
if (tuiActive) return;
|
|
26
|
-
const d = describeError(reason);
|
|
27
|
-
process.stderr.write(`\nError: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
|
|
28
|
-
process.exitCode = 1;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
interface CliOptions {
|
|
32
|
-
print?: boolean;
|
|
33
|
-
model?: string;
|
|
34
|
-
cwd?: string;
|
|
35
|
-
dangerouslySkipPermissions?: boolean;
|
|
36
|
-
// Commander treats `--no-quarter` as a negatable boolean: `quarter` is true by
|
|
37
|
-
// default and becomes false when the flag is passed.
|
|
38
|
-
quarter?: boolean;
|
|
39
|
-
continue?: boolean;
|
|
40
|
-
resume?: string;
|
|
41
|
-
onboard?: boolean;
|
|
42
|
-
// Commander negatable boolean: `confine` is true by default, false with --no-confine.
|
|
43
|
-
confine?: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const DEFAULT_MODEL = "anthropic:claude-opus-4-8";
|
|
47
|
-
|
|
48
|
-
async function main() {
|
|
49
|
-
const program = new Command();
|
|
50
|
-
|
|
51
|
-
program
|
|
52
|
-
.name(NAME)
|
|
53
|
-
.description(DESCRIPTION)
|
|
54
|
-
.version(VERSION, "-v, --version")
|
|
55
|
-
.argument("[prompt...]", "prompt to send (with -p, runs headless)")
|
|
56
|
-
.option("-p, --print", "print mode: run headless and write the answer to stdout")
|
|
57
|
-
.option("-m, --model <provider:model>", "model to use, e.g. openrouter:anthropic/claude-opus-4.8")
|
|
58
|
-
.option("-C, --cwd <dir>", "working directory")
|
|
59
|
-
.option("--no-confine", "let the agent read/edit outside the working directory without prompting")
|
|
60
|
-
.option("--dangerously-skip-permissions", "auto-approve all tool actions (bypass mode)")
|
|
61
|
-
.option("--no-quarter", "auto-approve all tool actions, taking no prisoners (bypass mode)")
|
|
62
|
-
.option("-c, --continue", "resume the most recent session in this directory")
|
|
63
|
-
.option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
|
|
64
|
-
.option("--onboard", "run the provider/key setup flow")
|
|
65
|
-
.action(async (promptParts: string[], options: CliOptions) => {
|
|
66
|
-
// Terminal-window close (SIGHUP) or a kill (SIGTERM) bypasses the normal
|
|
67
|
-
// exit path below — revoke this terminal's Privateer session first so it
|
|
68
|
-
// drops off the app's Linked Devices immediately instead of lingering
|
|
69
|
-
// until server-side expiry. Installing a handler replaces Node's default
|
|
70
|
-
// terminate-on-signal, so exit explicitly with the conventional code.
|
|
71
|
-
const revokeAndExit = (code: number) => () => {
|
|
72
|
-
void revokeChildSession().finally(() => process.exit(code));
|
|
73
|
-
};
|
|
74
|
-
process.on("SIGHUP", revokeAndExit(129));
|
|
75
|
-
process.on("SIGTERM", revokeAndExit(143));
|
|
76
|
-
try {
|
|
77
|
-
if (options.cwd) process.chdir(options.cwd);
|
|
78
|
-
const config = loadConfig();
|
|
79
|
-
if (options.dangerouslySkipPermissions || options.quarter === false)
|
|
80
|
-
config.permissionMode = "bypass";
|
|
81
|
-
// --no-confine turns off the working-directory boundary for this run.
|
|
82
|
-
if (options.confine === false) config.confineToCwd = false;
|
|
83
|
-
// --resume <id> loads a specific session (the id printed on a prior exit);
|
|
84
|
-
// --continue loads the most recent one.
|
|
85
|
-
const resume = options.resume
|
|
86
|
-
? loadSession(process.cwd(), options.resume)
|
|
87
|
-
: options.continue
|
|
88
|
-
? loadLatest(process.cwd())
|
|
89
|
-
: null;
|
|
90
|
-
if (options.resume && !resume) {
|
|
91
|
-
process.stderr.write(`No session "${options.resume}" found in this directory.\n`);
|
|
92
|
-
}
|
|
93
|
-
const modelSpec = options.model ?? resume?.modelSpec ?? config.defaultModel ?? DEFAULT_MODEL;
|
|
94
|
-
|
|
95
|
-
if (options.print) {
|
|
96
|
-
await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
|
|
97
|
-
await revokeChildSession();
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// First-run onboarding: no provider has credentials yet (and we're not
|
|
102
|
-
// resuming). Also forceable with --onboard.
|
|
103
|
-
const noProviderReady = !configuredProviders(config).some((p) => p.ready);
|
|
104
|
-
const startInOnboarding = Boolean(options.onboard) || (!resume && noProviderReady);
|
|
105
|
-
|
|
106
|
-
// Interactive TUI.
|
|
107
|
-
tuiActive = true;
|
|
108
|
-
const { waitUntilExit } = render(
|
|
109
|
-
<Root
|
|
110
|
-
config={config}
|
|
111
|
-
modelSpec={modelSpec}
|
|
112
|
-
cwd={process.cwd()}
|
|
113
|
-
resume={resume}
|
|
114
|
-
startInOnboarding={startInOnboarding}
|
|
115
|
-
/>,
|
|
116
|
-
);
|
|
117
|
-
await waitUntilExit();
|
|
118
|
-
tuiActive = false;
|
|
119
|
-
|
|
120
|
-
// This terminal is done — release its Privateer session so it leaves the
|
|
121
|
-
// app's Linked Devices right away. Started before the resume hint prints
|
|
122
|
-
// and awaited after, so it doesn't delay the output.
|
|
123
|
-
const revoked = revokeChildSession();
|
|
124
|
-
|
|
125
|
-
// On exit, print a hash that resumes this conversation later (à la Claude
|
|
126
|
-
// Code). The latest persisted session carries the id used this run; it only
|
|
127
|
-
// exists once at least one turn has been saved.
|
|
128
|
-
const last = loadLatest(process.cwd());
|
|
129
|
-
if (last && last.messages.length > 0) {
|
|
130
|
-
process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
|
|
131
|
-
}
|
|
132
|
-
await revoked;
|
|
133
|
-
} catch (err) {
|
|
134
|
-
// Configuration/resolution errors are expected and user-facing — print them
|
|
135
|
-
// cleanly without a stack trace.
|
|
136
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
137
|
-
process.exitCode = 1;
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// The scheduler daemon: a resident process that fires routines on their cron
|
|
142
|
-
// schedule. Runs in the foreground by default; --detach forks a background copy.
|
|
143
|
-
program
|
|
144
|
-
.command("daemon")
|
|
145
|
-
.description("run the scheduler that fires saved routines on their cron schedule")
|
|
146
|
-
.option("--detach", "start the daemon in the background and return")
|
|
147
|
-
.action((opts: { detach?: boolean }) => {
|
|
148
|
-
if (opts.detach) {
|
|
149
|
-
const logPath = join(globalDir(), "daemon.log");
|
|
150
|
-
const out = openSync(logPath, "a");
|
|
151
|
-
// Re-invoke this same runtime + script without --detach, fully detached.
|
|
152
|
-
const args = process.argv.slice(1).filter((a) => a !== "--detach");
|
|
153
|
-
const child = spawn(process.argv[0], args, { detached: true, stdio: ["ignore", out, out] });
|
|
154
|
-
child.unref();
|
|
155
|
-
process.stdout.write(`Daemon started in background (pid ${child.pid}).\nLogs: ${logPath}\n`);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
runDaemon();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
await program.parseAsync(process.argv);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Headless one-shot: stream the answer to stdout, surfacing tool activity on stderr.
|
|
165
|
-
async function runPrint(modelSpec: string, prompt: string, confineToCwd: boolean) {
|
|
166
|
-
if (!prompt) {
|
|
167
|
-
process.stderr.write("No prompt provided.\n");
|
|
168
|
-
process.exitCode = 1;
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const session = createSession({ config: loadConfig(), modelSpec, cwd: process.cwd(), confineToCwd });
|
|
172
|
-
for await (const ev of session.engine.send(prompt)) {
|
|
173
|
-
switch (ev.type) {
|
|
174
|
-
case "text":
|
|
175
|
-
process.stdout.write(ev.text);
|
|
176
|
-
break;
|
|
177
|
-
case "tool-call":
|
|
178
|
-
process.stderr.write(`\n· ${ev.name} ${JSON.stringify(ev.input)}\n`);
|
|
179
|
-
break;
|
|
180
|
-
case "tool-error":
|
|
181
|
-
process.stderr.write(`\n! ${ev.name}: ${ev.error}\n`);
|
|
182
|
-
break;
|
|
183
|
-
case "routed":
|
|
184
|
-
process.stderr.write(
|
|
185
|
-
ev.missing && ev.missing.length > 0
|
|
186
|
-
? `\n⚠ no model configured for ${ev.missing.join("/")} input\n`
|
|
187
|
-
: `\n↪ ${ev.label}${ev.reason ? ` · ${ev.reason}` : ""}\n`,
|
|
188
|
-
);
|
|
189
|
-
break;
|
|
190
|
-
case "error":
|
|
191
|
-
process.stderr.write(`\nError: ${ev.error}${ev.hint ? `\n${ev.hint}` : ""}\n`);
|
|
192
|
-
process.exitCode = 1;
|
|
193
|
-
break;
|
|
194
|
-
case "finish":
|
|
195
|
-
process.stdout.write(
|
|
196
|
-
`\n\n[${modelSpec} · ${ev.usage.totalTokens} tokens · ${ev.finishReason}]\n`,
|
|
197
|
-
);
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
main().catch((err) => {
|
|
204
|
-
// Redact + summarize rather than dumping the raw error object, which may carry
|
|
205
|
-
// request bodies or key material in provider-error fields.
|
|
206
|
-
const d = describeError(err);
|
|
207
|
-
process.stderr.write(`Error: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
|
|
208
|
-
process.exit(1);
|
|
209
|
-
});
|
package/src/mcp/client.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { tool, jsonSchema, type ToolSet } from "ai";
|
|
3
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
7
|
-
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
8
|
-
import { globalPaths, projectPaths } from "../config/paths.ts";
|
|
9
|
-
import { type PermissionGate, PermissionDeniedError } from "../permissions/gate.ts";
|
|
10
|
-
import { FileOAuthProvider, type AuthorizePrompt } from "./oauth.ts";
|
|
11
|
-
|
|
12
|
-
// A local stdio server (launched as a child process) or a remote HTTP server.
|
|
13
|
-
export type StdioServerConfig = { command: string; args?: string[]; env?: Record<string, string> };
|
|
14
|
-
export type HttpServerConfig = { url: string; headers?: Record<string, string>; transport?: "http" | "sse" };
|
|
15
|
-
export type McpServerConfig = StdioServerConfig | HttpServerConfig;
|
|
16
|
-
export type McpServers = Record<string, McpServerConfig>;
|
|
17
|
-
|
|
18
|
-
function isStdio(cfg: McpServerConfig): cfg is StdioServerConfig {
|
|
19
|
-
return typeof (cfg as StdioServerConfig).command === "string";
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface McpToolDef {
|
|
23
|
-
name: string;
|
|
24
|
-
description?: string;
|
|
25
|
-
inputSchema?: Record<string, unknown>;
|
|
26
|
-
// Standard MCP behavioral hints. We use `destructiveHint`/`readOnlyHint` to
|
|
27
|
-
// decide whether a tool may auto-approve: a tool that performs an irreversible
|
|
28
|
-
// external action marks itself destructive so it ALWAYS reaches the human.
|
|
29
|
-
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Read mcp.json from project then user scope (project overrides). Accepts either a
|
|
33
|
-
// top-level map or a { "mcpServers": {...} } wrapper. An entry is kept if it names a
|
|
34
|
-
// stdio `command` or a remote `url`; anything else is dropped.
|
|
35
|
-
export function loadMcpServers(cwd: string = process.cwd()): McpServers {
|
|
36
|
-
const merge = (path: string, into: McpServers) => {
|
|
37
|
-
if (!existsSync(path)) return;
|
|
38
|
-
try {
|
|
39
|
-
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
-
const map = (raw && typeof raw === "object" && raw.mcpServers) || raw;
|
|
41
|
-
if (map && typeof map === "object") {
|
|
42
|
-
for (const [name, cfg] of Object.entries(map as Record<string, unknown>)) {
|
|
43
|
-
if (cfg && typeof cfg === "object") {
|
|
44
|
-
const c = cfg as Partial<StdioServerConfig & HttpServerConfig>;
|
|
45
|
-
if (typeof c.command === "string" || typeof c.url === "string") {
|
|
46
|
-
into[name] = cfg as McpServerConfig;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
/* malformed mcp.json → skip */
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
const servers: McpServers = {};
|
|
56
|
-
merge(globalPaths().mcp, servers);
|
|
57
|
-
merge(projectPaths(cwd).mcp, servers);
|
|
58
|
-
return servers;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// process.env as a clean string map, so stdio servers inherit our environment
|
|
62
|
-
// (StdioClientTransport otherwise launches with a minimal default env).
|
|
63
|
-
function inheritedEnv(extra?: Record<string, string>): Record<string, string> {
|
|
64
|
-
const base: Record<string, string> = {};
|
|
65
|
-
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) base[k] = v;
|
|
66
|
-
return { ...base, ...extra };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
70
|
-
return new Promise<T>((resolve, reject) => {
|
|
71
|
-
const timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
|
72
|
-
p.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// An MCP client over the official SDK, transport chosen from config: a local child
|
|
77
|
-
// process (stdio) or a remote server (Streamable HTTP, falling back to legacy SSE).
|
|
78
|
-
// Exposes only what the agent needs: connect, tools/list, tools/call, close.
|
|
79
|
-
export class McpClient {
|
|
80
|
-
private client?: Client;
|
|
81
|
-
|
|
82
|
-
constructor(
|
|
83
|
-
private readonly name: string,
|
|
84
|
-
private readonly cfg: McpServerConfig,
|
|
85
|
-
private readonly cwd: string,
|
|
86
|
-
private readonly onAuthorize?: AuthorizePrompt,
|
|
87
|
-
) {}
|
|
88
|
-
|
|
89
|
-
async connect(timeoutMs = 10_000): Promise<void> {
|
|
90
|
-
const client = new Client({ name: "privateer", version: "0.1.0" }, { capabilities: {} });
|
|
91
|
-
if (isStdio(this.cfg)) {
|
|
92
|
-
const transport = new StdioClientTransport({
|
|
93
|
-
command: this.cfg.command,
|
|
94
|
-
args: this.cfg.args ?? [],
|
|
95
|
-
env: inheritedEnv(this.cfg.env),
|
|
96
|
-
cwd: this.cwd,
|
|
97
|
-
stderr: "ignore", // keep server logs out of the TUI
|
|
98
|
-
});
|
|
99
|
-
await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
|
|
100
|
-
this.client = client;
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const url = new URL(this.cfg.url);
|
|
105
|
-
const requestInit = this.cfg.headers ? { headers: this.cfg.headers } : undefined;
|
|
106
|
-
// Static header auth wins. Otherwise attach an interactive OAuth provider — it
|
|
107
|
-
// stays dormant unless the server actually answers 401.
|
|
108
|
-
const authProvider = this.cfg.headers
|
|
109
|
-
? undefined
|
|
110
|
-
: new FileOAuthProvider(this.name, this.cfg.url, this.onAuthorize);
|
|
111
|
-
const sse = () => new SSEClientTransport(url, { requestInit, authProvider });
|
|
112
|
-
|
|
113
|
-
if (this.cfg.transport === "sse") {
|
|
114
|
-
await this.tryConnect(client, sse, authProvider, timeoutMs);
|
|
115
|
-
} else {
|
|
116
|
-
const http = () => new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
117
|
-
try {
|
|
118
|
-
await this.tryConnect(client, http, authProvider, timeoutMs);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
// A server that only speaks the legacy HTTP+SSE transport rejects the
|
|
121
|
-
// Streamable-HTTP handshake; retry once over SSE before giving up.
|
|
122
|
-
if (this.cfg.transport !== "http") await this.tryConnect(client, sse, authProvider, timeoutMs);
|
|
123
|
-
else throw err;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
this.client = client;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Connect with one transport kind. On a 401 with an OAuth provider configured,
|
|
130
|
-
// run the interactive consent dance (browser → loopback redirect → code →
|
|
131
|
-
// token exchange) and reconnect with a fresh transport that picks up the tokens.
|
|
132
|
-
private async tryConnect(
|
|
133
|
-
client: Client,
|
|
134
|
-
make: () => StreamableHTTPClientTransport | SSEClientTransport,
|
|
135
|
-
provider: FileOAuthProvider | undefined,
|
|
136
|
-
timeoutMs: number,
|
|
137
|
-
): Promise<void> {
|
|
138
|
-
const transport = make();
|
|
139
|
-
try {
|
|
140
|
-
await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
|
|
141
|
-
} catch (err) {
|
|
142
|
-
if (err instanceof UnauthorizedError && provider) {
|
|
143
|
-
const code = await provider.waitForCode();
|
|
144
|
-
await transport.finishAuth(code);
|
|
145
|
-
await withTimeout(client.connect(make()), timeoutMs, `MCP server "${this.name}" reconnect`);
|
|
146
|
-
} else {
|
|
147
|
-
throw err;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async listTools(): Promise<McpToolDef[]> {
|
|
153
|
-
const res = await this.client!.listTools();
|
|
154
|
-
return Array.isArray(res?.tools)
|
|
155
|
-
? res.tools.map((t) => ({
|
|
156
|
-
name: t.name,
|
|
157
|
-
description: t.description,
|
|
158
|
-
inputSchema: t.inputSchema as Record<string, unknown>,
|
|
159
|
-
annotations: t.annotations as McpToolDef["annotations"],
|
|
160
|
-
}))
|
|
161
|
-
: [];
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async callTool(name: string, args: unknown): Promise<string> {
|
|
165
|
-
const res = await this.client!.callTool({ name, arguments: (args ?? {}) as Record<string, unknown> });
|
|
166
|
-
return formatContent(res);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
close(): void {
|
|
170
|
-
void this.client?.close();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function formatContent(result: any): string {
|
|
175
|
-
const content = result?.content;
|
|
176
|
-
if (Array.isArray(content)) {
|
|
177
|
-
const text = content
|
|
178
|
-
.map((c: any) => (c?.type === "text" ? c.text : `[${c?.type ?? "content"}]`))
|
|
179
|
-
.join("\n");
|
|
180
|
-
return result?.isError ? `Error: ${text}` : text || "(no output)";
|
|
181
|
-
}
|
|
182
|
-
return JSON.stringify(result ?? {});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Adapt one server's MCP tools into AI-SDK tools, namespaced as "<server>__<tool>" and
|
|
186
|
-
// routed through the permission gate (MCP calls are external, so they prompt by default).
|
|
187
|
-
export function adaptMcpTools(
|
|
188
|
-
server: string,
|
|
189
|
-
client: McpClient,
|
|
190
|
-
defs: McpToolDef[],
|
|
191
|
-
gate: PermissionGate,
|
|
192
|
-
): ToolSet {
|
|
193
|
-
const set: ToolSet = {};
|
|
194
|
-
for (const d of defs) {
|
|
195
|
-
const name = `${server}__${d.name}`;
|
|
196
|
-
// A mutating tool (e.g. send email, delete file) marks itself destructive. We
|
|
197
|
-
// map that to `alwaysAsk`, which the gate never auto-approves — so it always
|
|
198
|
-
// prompts the human (phone on remote turns, terminal otherwise) even under
|
|
199
|
-
// bypass mode or the allowlist. Read-only tools follow the normal policy.
|
|
200
|
-
const destructive = d.annotations?.destructiveHint === true && d.annotations?.readOnlyHint !== true;
|
|
201
|
-
set[name] = tool({
|
|
202
|
-
description: d.description ?? `${d.name} (MCP server: ${server})`,
|
|
203
|
-
inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
|
|
204
|
-
execute: async (args: unknown) => {
|
|
205
|
-
const decision = await gate.request({
|
|
206
|
-
tool: name,
|
|
207
|
-
kind: "fetch",
|
|
208
|
-
title: `MCP ${server}: ${d.name}`,
|
|
209
|
-
detail: JSON.stringify(args ?? {}).slice(0, 120),
|
|
210
|
-
alwaysAsk: destructive,
|
|
211
|
-
});
|
|
212
|
-
if (decision === "deny") throw new PermissionDeniedError(name);
|
|
213
|
-
return client.callTool(d.name, args);
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
return set;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export interface McpConnection {
|
|
221
|
-
tools: ToolSet;
|
|
222
|
-
clients: McpClient[];
|
|
223
|
-
status: { server: string; tools: number; error?: string }[];
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Connect every configured server, returning the merged toolset, the live clients (to
|
|
227
|
-
// close on teardown), and a per-server status. Failures are isolated per server.
|
|
228
|
-
export async function connectMcpServers(
|
|
229
|
-
servers: McpServers,
|
|
230
|
-
cwd: string,
|
|
231
|
-
gate: PermissionGate,
|
|
232
|
-
onAuthorize?: AuthorizePrompt,
|
|
233
|
-
): Promise<McpConnection> {
|
|
234
|
-
const tools: ToolSet = {};
|
|
235
|
-
const clients: McpClient[] = [];
|
|
236
|
-
const status: McpConnection["status"] = [];
|
|
237
|
-
for (const [name, cfg] of Object.entries(servers)) {
|
|
238
|
-
const client = new McpClient(name, cfg, cwd, onAuthorize);
|
|
239
|
-
try {
|
|
240
|
-
await client.connect();
|
|
241
|
-
const defs = await client.listTools();
|
|
242
|
-
Object.assign(tools, adaptMcpTools(name, client, defs, gate));
|
|
243
|
-
clients.push(client);
|
|
244
|
-
status.push({ server: name, tools: defs.length });
|
|
245
|
-
} catch (err) {
|
|
246
|
-
client.close();
|
|
247
|
-
status.push({ server: name, tools: 0, error: err instanceof Error ? err.message : String(err) });
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return { tools, clients, status };
|
|
251
|
-
}
|