privateer-agent 0.1.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.
Files changed (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
package/src/session.ts CHANGED
@@ -1,264 +1,97 @@
1
- import type { LanguageModel, ToolSet } from "ai";
2
- import type { Config } from "./config/schema.ts";
3
- import { resolveModel, parseModelSpec } from "./providers/resolve.ts";
4
- import { modelSupports, modalitiesFor, suggestModelFor } from "./providers/capabilities.ts";
5
- import type { Route, RouteSet, Modality } from "./engine/router.ts";
6
- import { createTools, createReadOnlyTools, createToolSubset } from "./tools/index.ts";
7
- import { buildSystemPrompt, buildSubAgentPrompt, buildAgentPrompt } from "./context/systemPrompt.ts";
8
- import { findOutputStyle } from "./context/outputStyles.ts";
9
- import { QueryEngine } from "./engine/QueryEngine.ts";
10
- import { autoApproveGate, type PermissionGate } from "./permissions/gate.ts";
11
- import type { SubAgentRunner } from "./tools/context.ts";
12
- import { TodoStore } from "./tools/todoStore.ts";
13
- import type { CheckpointStore } from "./memory/checkpoints.ts";
14
- import type { ProcessRegistry } from "./tools/processRegistry.ts";
15
- import { AttachmentStore } from "./util/attachmentStore.ts";
16
- import { HookRunner, loadHooks, wrapToolsWithHooks } from "./hooks/engine.ts";
17
- import { createLimiter } from "./util/limit.ts";
18
-
19
- export interface SessionOptions {
20
- config: Config;
21
- modelSpec: string;
1
+ // Thin headless session wrapper (Phase 1).
2
+ //
3
+ // Wraps Pi's createAgentSession and exposes the ONE thing the preserved
4
+ // connection layer needs: a subscription that yields privateer EngineEvents
5
+ // (via the adapter) instead of raw Pi events. `createSession()` →
6
+ // `{ session, subscribeAsEngineEvents() }`, per docs/pi-migration-plan.md §2
7
+ // Phase 1. Mirrors the setup proven in ../../ pi-spike/spike-b.mjs.
8
+ //
9
+ // This module is Pi-touching: it is only ever loaded via a DYNAMIC import from
10
+ // an entrypoint that has already run ./boot.ts (env + dispatcher). Never import
11
+ // it statically from boot.ts or anything boot pulls in.
12
+
13
+ import {
14
+ createAgentSession,
15
+ DefaultResourceLoader,
16
+ SessionManager,
17
+ SettingsManager,
18
+ ModelRegistry,
19
+ AuthStorage,
20
+ } from "@earendil-works/pi-coding-agent";
21
+
22
+ import { agentDir as defaultAgentDir } from "./config/paths.ts";
23
+ import { createEngineEventAdapter } from "./bridge/engineAdapter.ts";
24
+ import type { EngineEvent } from "./engine/events.ts";
25
+
26
+ export interface CreateSessionOptions {
22
27
  cwd: string;
23
- gate?: PermissionGate;
24
- // Confine file tools to cwd (default true). False lets the agent read/edit anywhere.
25
- confineToCwd?: boolean;
26
- // Out-of-cwd directories approved this session; shared with the gate so an approved
27
- // location stops re-prompting. Pass the same array instance the gate holds.
28
- allowedOutsideRoots?: string[];
29
- // Active output style name (persona); resolved against .privateer/output-styles.
30
- outputStyle?: string;
31
- // When true, the system prompt instructs the model to plan, not implement.
32
- planMode?: boolean;
33
- // Session checkpoint store; write/edit record mutations into it for /rewind.
34
- checkpoints?: CheckpointStore;
35
- // Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
36
- extraTools?: ToolSet;
37
- // Background-shell registry for bash run_in_background / bash_output / kill_shell.
38
- processes?: ProcessRegistry;
39
- // Session attachment store, so dragged/pasted file bytes can be saved via the
40
- // save_attachment tool. Created here when the caller doesn't supply one.
41
- attachments?: AttachmentStore;
42
- // Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
43
- // tool-call id, so the TUI can render the grouped agents view. Best-effort.
44
- onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
45
- }
46
-
47
- export interface Session {
48
- engine: QueryEngine;
49
- modelSpec: string;
50
28
  provider: string;
51
29
  modelId: string;
52
- cwd: string;
53
- todos: TodoStore;
54
- attachments: AttachmentStore;
30
+ // Pi extension factories, e.g. [makePermissionGate({ decide })].
31
+ extensionFactories?: Array<(pi: any) => void>;
32
+ // Custom tools (defineTool) plus the enabled tool-name allowlist.
33
+ customTools?: unknown[];
34
+ tools?: string[];
35
+ // Override the Pi agent dir; defaults to $PRIVATEER_HOME/agent (pinned by boot).
36
+ agentDir?: string;
55
37
  }
56
38
 
57
- // Assemble a ready-to-run agent session: resolve the model, bind tools to the
58
- // cwd + permission gate, build the system prompt, and create the engine.
59
- export function createSession(opts: SessionOptions): Session {
60
- const resolved = resolveModel(opts.modelSpec, opts.config);
61
- const gate = opts.gate ?? autoApproveGate;
62
- const confineToCwd = opts.confineToCwd ?? true;
63
- const allowedOutsideRoots = opts.allowedOutsideRoots ?? [];
64
- const todos = new TodoStore();
65
- const attachments = opts.attachments ?? new AttachmentStore();
66
- const cache = isAnthropicFamily(resolved.provider, resolved.modelId);
67
-
68
- // Bound how many sub-agents run at once when the model fans `task` calls out.
69
- const subAgentLimit = createLimiter(opts.config.maxSubagents);
70
-
71
- // A `task` sub-agent: a fresh engine run to completion, returning the text it
72
- // produced. Without an agent definition it uses the read-only toolset under an
73
- // auto-approve gate; with one it uses that agent's tools (routed through the parent
74
- // gate, so any mutations are still user-approved), model override, and instructions.
75
- const runSubAgent: SubAgentRunner = ({ description, prompt, agent }) =>
76
- subAgentLimit(async () => {
77
- let model = resolved.model;
78
- let childCache = cache;
79
- if (agent?.model) {
80
- try {
81
- const r = resolveModel(agent.model, opts.config);
82
- model = r.model;
83
- childCache = isAnthropicFamily(r.provider, r.modelId);
84
- } catch {
85
- /* fall back to the parent model */
86
- }
87
- }
88
- const system = agent
89
- ? buildAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description, instructions: agent.prompt })
90
- : buildSubAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description });
91
- const tools = agent
92
- ? createToolSubset({ cwd: opts.cwd, gate, confineToCwd, allowedOutsideRoots }, agent.tools)
93
- : createReadOnlyTools({ cwd: opts.cwd, gate: autoApproveGate, confineToCwd, allowedOutsideRoots });
39
+ export interface PrivateerSession {
40
+ session: any;
41
+ // Subscribe to the turn stream as privateer EngineEvents. Returns an unsubscribe.
42
+ subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void;
43
+ adapter: ReturnType<typeof createEngineEventAdapter>;
44
+ }
94
45
 
95
- const child = new QueryEngine({
96
- routes: singleRouteSet(agent?.model ?? opts.modelSpec, model, childCache),
97
- system,
98
- tools,
99
- maxSteps: Math.min(opts.config.maxSteps, 20),
100
- });
101
- let out = "";
102
- let toolUses = 0;
103
- for await (const ev of child.send(prompt)) {
104
- if (ev.type === "text") out += ev.text;
105
- else if (ev.type === "tool-call") toolUses++;
106
- else if (ev.type === "error")
107
- return { text: `Sub-agent error: ${ev.error}`, toolUses, tokens: child.usage.totalTokens };
108
- }
109
- return {
110
- text: out.trim() || "(sub-agent returned no output)",
111
- toolUses,
112
- tokens: child.usage.totalTokens,
113
- };
114
- });
46
+ export async function createSession(opts: CreateSessionOptions): Promise<PrivateerSession> {
47
+ const AGENT_DIR = opts.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
48
+
49
+ const authStorage = AuthStorage.inMemory();
50
+ const modelRegistry = ModelRegistry.create(authStorage, `${AGENT_DIR}/models.json`);
51
+ if (typeof (modelRegistry as any).refresh === "function") await (modelRegistry as any).refresh();
52
+ else if (typeof (modelRegistry as any).loadModels === "function")
53
+ await (modelRegistry as any).loadModels();
54
+
55
+ const model = modelRegistry.find(opts.provider, opts.modelId);
56
+ if (!model) {
57
+ const ids = ((modelRegistry as any).getAll?.() ?? []).map(
58
+ (m: any) => `${m.provider}/${m.id}`,
59
+ );
60
+ throw new Error(
61
+ `model ${opts.provider}/${opts.modelId} not found. Registry has: ${ids.join(", ") || "(none)"}`,
62
+ );
63
+ }
115
64
 
116
- const hooks = new HookRunner(loadHooks((opts.config as Record<string, unknown>).hooks), opts.cwd);
117
- const tools = wrapToolsWithHooks(
118
- {
119
- ...createTools({
120
- cwd: opts.cwd,
121
- gate,
122
- confineToCwd,
123
- allowedOutsideRoots,
124
- todos,
125
- runSubAgent,
126
- onSubAgentMetrics: opts.onSubAgentMetrics,
127
- recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
128
- processes: opts.processes,
129
- attachments,
130
- }),
131
- ...(opts.extraTools ?? {}),
132
- },
133
- hooks,
134
- );
135
- const outputStyleBody = opts.outputStyle
136
- ? findOutputStyle(opts.outputStyle, opts.cwd)?.body
137
- : undefined;
138
- const system = buildSystemPrompt({
65
+ const settingsManager = SettingsManager.create(opts.cwd, AGENT_DIR);
66
+ const loader = new DefaultResourceLoader({
139
67
  cwd: opts.cwd,
140
- model: opts.modelSpec,
141
- outputStyleBody,
142
- planMode: opts.planMode,
68
+ agentDir: AGENT_DIR,
69
+ settingsManager,
70
+ extensionFactories: opts.extensionFactories ?? [],
143
71
  });
72
+ await loader.reload();
144
73
 
145
- const engine = new QueryEngine({
146
- routes: buildRouteSet(opts.config, opts.modelSpec, resolved.model, cache),
147
- system,
148
- tools,
149
- maxSteps: opts.config.maxSteps,
150
- contextBudget: opts.config.contextBudget,
151
- compactRatio: opts.config.compactRatio,
152
- });
153
-
154
- return {
155
- engine,
156
- modelSpec: opts.modelSpec,
157
- provider: resolved.provider,
158
- modelId: resolved.modelId,
74
+ const { session } = await createAgentSession({
159
75
  cwd: opts.cwd,
160
- todos,
161
- attachments,
162
- };
163
- }
164
-
165
- // Anthropic prompt caching only benefits Anthropic-family models: direct Anthropic,
166
- // or an OpenRouter route to an Anthropic model. For everything else the cache hints
167
- // are a harmless no-op, but we skip them to avoid sending unused providerOptions.
168
- function isAnthropicFamily(provider: string, modelId: string): boolean {
169
- if (provider === "anthropic") return true;
170
- if (provider === "openrouter") return modelId.startsWith("anthropic/");
171
- return false;
172
- }
173
-
174
- // Short display name for UI notices: drop any "vendor/" prefix from the model id.
175
- function shortLabel(spec: string): string {
176
- const modelId = spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
177
- return modelId.slice(modelId.lastIndexOf("/") + 1);
178
- }
179
-
180
- // Resolve a "provider:model" spec into a Route, deriving its per-model cache /
181
- // thinking flags and supported input modalities from the model family.
182
- function buildRoute(spec: string, config: Config): Route {
183
- const r = resolveModel(spec, config);
184
- const cache = isAnthropicFamily(r.provider, r.modelId);
185
- return {
186
- spec,
187
- model: r.model,
188
- cacheControl: cache,
189
- thinkingBudget: cache ? config.thinkingBudget : undefined,
190
- label: shortLabel(spec),
191
- supports: modalitiesFor(r.provider, r.modelId),
192
- };
193
- }
194
-
195
- // A trivial RouteSet with only the default route (sub-agents, which run one fixed
196
- // model). The high `longThreshold` / zero `fastMaxChars` keep the router on default.
197
- function singleRouteSet(spec: string, model: LanguageModel, cacheControl: boolean): RouteSet {
198
- const { provider, modelId } = parseModelSpec(spec);
199
- return {
200
- default: { spec, model, cacheControl, label: shortLabel(spec), supports: modalitiesFor(provider, modelId) },
201
- longThreshold: Number.POSITIVE_INFINITY,
202
- fastMaxChars: 0,
203
- };
204
- }
205
-
206
- // Pairs of (config key, RouteSet key, modality) for the modality routes.
207
- const MODALITY_ROUTE_KEYS: { cfg: "vision" | "document" | "audio" | "video"; modality: Modality }[] = [
208
- { cfg: "vision", modality: "image" },
209
- { cfg: "document", modality: "document" },
210
- { cfg: "audio", modality: "audio" },
211
- { cfg: "video", modality: "video" },
212
- ];
213
-
214
- // Assemble the session's RouteSet: the default route (the already-resolved session
215
- // model) plus any configured modality/long/fast routes, each tagged with the input
216
- // modalities its model accepts. Optional routes that fail to resolve are skipped
217
- // rather than failing the session. For each modality whose route is unset, hybrid
218
- // auto-detect picks a capable model when the default can't handle that modality.
219
- function buildRouteSet(
220
- config: Config,
221
- defaultSpec: string,
222
- defaultModel: LanguageModel,
223
- defaultCache: boolean,
224
- ): RouteSet {
225
- const router = config.router;
226
- const tryRoute = (spec?: string): Route | undefined => {
227
- if (!spec) return undefined;
228
- try {
229
- return buildRoute(spec, config);
230
- } catch {
231
- return undefined; // unconfigured/invalid optional route → ignored
232
- }
233
- };
234
-
235
- const { provider: defProvider, modelId: defModelId } = parseModelSpec(defaultSpec);
236
- const routes: RouteSet = {
237
- default: {
238
- spec: defaultSpec,
239
- model: defaultModel,
240
- cacheControl: defaultCache,
241
- thinkingBudget: defaultCache ? config.thinkingBudget : undefined,
242
- label: shortLabel(defaultSpec),
243
- supports: modalitiesFor(defProvider, defModelId),
244
- },
245
- vision: tryRoute(router?.vision),
246
- document: tryRoute(router?.document),
247
- audio: tryRoute(router?.audio),
248
- video: tryRoute(router?.video),
249
- long: tryRoute(router?.long),
250
- fast: tryRoute(router?.fast),
251
- longThreshold: router?.longThreshold ?? Math.floor((config.contextBudget ?? 120_000) / 2),
252
- fastMaxChars: router?.fastMaxChars ?? 280,
253
- };
254
-
255
- if (router?.auto ?? true) {
256
- for (const { cfg, modality } of MODALITY_ROUTE_KEYS) {
257
- if (routes[cfg]) continue; // explicitly configured → leave it
258
- if (modelSupports(modality, defProvider, defModelId)) continue; // default handles it
259
- const suggestion = suggestModelFor(modality, config);
260
- if (suggestion) routes[cfg] = tryRoute(suggestion);
261
- }
76
+ agentDir: AGENT_DIR,
77
+ model,
78
+ authStorage,
79
+ modelRegistry,
80
+ settingsManager,
81
+ sessionManager: SessionManager.inMemory(opts.cwd),
82
+ resourceLoader: loader,
83
+ ...(opts.customTools ? { customTools: opts.customTools } : {}),
84
+ ...(opts.tools ? { tools: opts.tools } : {}),
85
+ } as any);
86
+
87
+ const adapter = createEngineEventAdapter();
88
+
89
+ function subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void {
90
+ const unsub = session.subscribe((ev: any) => {
91
+ for (const ee of adapter.toEngineEvents(ev)) onEvent(ee);
92
+ });
93
+ return () => unsub?.();
262
94
  }
263
- return routes;
95
+
96
+ return { session, subscribeAsEngineEvents, adapter };
264
97
  }
@@ -0,0 +1,129 @@
1
+ // The `create_routine` tool — lets the agent turn "summarize the news every morning"
2
+ // or "remind me at 3pm tomorrow" into a saved routine the scheduler daemon runs
3
+ // unattended (see src/daemon). Ported from tree-cli/src/tools/routine.ts, adapted to
4
+ // Pi's registerTool (TypeBox schema) with the in-tool ctx.gate.request removed: in
5
+ // the Pi model our permission-gate extension gates the tool_call itself (classify.ts
6
+ // gives it a "Create routine" prompt + flags email/webhook egress).
7
+
8
+ import { Type } from "typebox";
9
+ import { readFileSync } from "node:fs";
10
+ import { configPath } from "../config/paths.ts";
11
+ import { newRoutineId, webhookName, type Routine } from "../routines/schema.ts";
12
+ import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
13
+ import { upsertRoutine } from "../routines/store.ts";
14
+ import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
15
+
16
+ const KNOWN_CHANNELS = new Set(["file", "relay", "notice", "cloud", "email"]);
17
+
18
+ function loadWebhooks(): Record<string, { url?: string }> {
19
+ try {
20
+ return JSON.parse(readFileSync(configPath(), "utf8")).webhooks ?? {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function text(t: string) {
27
+ return { content: [{ type: "text", text: t }], details: {} };
28
+ }
29
+
30
+ export const routineToolDefinition = {
31
+ name: "create_routine",
32
+ label: "Create Routine",
33
+ description:
34
+ "Create a routine: a saved task the scheduler runs unattended, either recurring (a cron " +
35
+ "expression) or one-off (a specific datetime), and delivers the result. Use when the user asks " +
36
+ "to be notified/updated on a cadence ('every morning', 'nightly') or at a future time ('at 3pm " +
37
+ "tomorrow'). Set exactly one of `cron` or `at`. Confirm timing + delivery with the user first; " +
38
+ "creating a routine prompts for approval. Runs use a safe read-only toolset unless `tools` grants more.",
39
+ parameters: Type.Object({
40
+ name: Type.String({ description: "Short unique label, e.g. 'morning-news'." }),
41
+ cron: Type.Optional(Type.String({ description: "Recurring: 5-field cron, e.g. '0 8 * * *' (08:00 daily). Omit for one-off." })),
42
+ at: Type.Optional(Type.String({ description: "One-off: ISO-8601 datetime, e.g. '2026-07-02T15:00:00'. Omit for recurring." })),
43
+ prompt: Type.String({ description: "Self-contained instruction the agent runs when it fires." }),
44
+ delivery: Type.Optional(
45
+ Type.Array(Type.String(), {
46
+ description:
47
+ "Where to deliver: 'file' (default), 'notice', 'relay', 'cloud' (E2E-encrypted to your Privateer " +
48
+ "account), 'email' (plaintext, opt-in), or 'webhook:<name>' (name must exist in config webhooks).",
49
+ }),
50
+ ),
51
+ cwd: Type.Optional(Type.String({ description: "Working directory for the run. Defaults to the current one." })),
52
+ model: Type.Optional(Type.String({ description: "Optional 'provider:model' or 'provider/model' override." })),
53
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Tool allow-list; omit for the default safe set." })),
54
+ }),
55
+ async execute(
56
+ _toolCallId: string,
57
+ params: {
58
+ name: string;
59
+ cron?: string;
60
+ at?: string;
61
+ prompt: string;
62
+ delivery?: string[];
63
+ cwd?: string;
64
+ model?: string;
65
+ tools?: string[];
66
+ },
67
+ _signal?: AbortSignal,
68
+ _onUpdate?: unknown,
69
+ ctx?: { cwd?: string },
70
+ ) {
71
+ const { name, cron, at, prompt, delivery, cwd, model, tools } = params;
72
+
73
+ const err = triggerError({ cron, at });
74
+ if (err) return text(`Error: ${err}`);
75
+
76
+ const chans = delivery && delivery.length > 0 ? delivery : ["file"];
77
+ // Validate channels: known names or webhook:<name>.
78
+ const bad = chans.filter((c) => !KNOWN_CHANNELS.has(c) && webhookName(c) === null);
79
+ if (bad.length > 0) return text(`Error: unknown delivery channel(s): ${bad.join(", ")}.`);
80
+
81
+ // Webhook targets must be pre-declared in config (the routine never carries a URL).
82
+ const hooks = chans.map(webhookName).filter((n): n is string => n !== null);
83
+ if (hooks.length > 0) {
84
+ const configured = loadWebhooks();
85
+ const unknown = hooks.filter((n) => !configured[n]);
86
+ if (unknown.length > 0) {
87
+ return text(
88
+ `Error: webhook${unknown.length > 1 ? "s" : ""} not configured: ${unknown.join(", ")}. ` +
89
+ `Declare them under "webhooks" in ~/.privateer/config.json first.`,
90
+ );
91
+ }
92
+ }
93
+
94
+ const next = computeNextRun({ cron, at });
95
+ const routine: Routine = {
96
+ id: newRoutineId(),
97
+ name,
98
+ cron,
99
+ at,
100
+ prompt,
101
+ cwd: cwd ?? ctx?.cwd ?? process.cwd(),
102
+ model,
103
+ delivery: chans as Routine["delivery"],
104
+ tools,
105
+ enabled: true,
106
+ nextRun: next?.toISOString(),
107
+ };
108
+
109
+ // Hand to the running daemon (it validates + schedules); fall back to writing the
110
+ // file so the routine persists until the daemon starts.
111
+ try {
112
+ const res = await sendToDaemon({ cmd: "add", routine });
113
+ if (!res.ok) return text(`Error saving routine: ${res.message ?? "unknown"}`);
114
+ return text(
115
+ `Created routine "${name}" (${describeTrigger({ cron, at })}). ` +
116
+ `Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`,
117
+ );
118
+ } catch (e) {
119
+ if (e instanceof DaemonNotRunningError) {
120
+ upsertRoutine(routine);
121
+ return text(
122
+ `Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler daemon isn't ` +
123
+ `running yet, so it won't fire until you start it.`,
124
+ );
125
+ }
126
+ return text(`Error contacting the scheduler: ${e instanceof Error ? e.message : String(e)}`);
127
+ }
128
+ },
129
+ };
@@ -1,53 +1,50 @@
1
+ // The `save_attachment` tool — persist a file the user attached from the app (shown
2
+ // in the prompt as "[Attachment #n] name") to a real path on disk. Ported from
3
+ // tree-cli to Pi registerTool; the in-tool ctx.gate.request is dropped (our gate
4
+ // extension gates the tool_call, classified as a write against the destination path).
5
+
6
+ import { Type } from "typebox";
1
7
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { tool } from "ai";
4
- import { z } from "zod";
5
- import type { ToolContext } from "./context.ts";
6
- import { resolveInCwd, displayPath, isOutsideScope } from "./context.ts";
7
- import { PermissionDeniedError } from "../permissions/gate.ts";
8
- import { isProtectedPath } from "../permissions/protected.ts";
8
+ import { dirname, isAbsolute, resolve } from "node:path";
9
+ import type { AttachmentStore } from "../util/attachmentStore.ts";
10
+
11
+ function text(t: string) {
12
+ return { content: [{ type: "text", text: t }], details: {} };
13
+ }
9
14
 
10
- export function saveAttachmentTool(ctx: ToolContext) {
11
- return tool({
15
+ export function makeSaveAttachmentTool(store: AttachmentStore) {
16
+ return {
17
+ name: "save_attachment",
18
+ label: "Save Attachment",
12
19
  description:
13
- "Save a user-attached file (image, PDF, audio, or video — shown in the prompt as a " +
14
- '"[Image #n]" / "[PDF #n]" / etc. chip) to a path on disk. Use this to persist a pasted ' +
15
- "or dragged-in attachment instead of trying to copy it from a temp or drop path, which is " +
16
- "unreliable and may be an empty placeholder. `ref` is the number n from the chip.",
17
- inputSchema: z.object({
18
- ref: z
19
- .number()
20
- .int()
21
- .describe("The attachment reference number n, taken from its [Kind #n] chip."),
22
- path: z.string().describe("Destination file path to write the attachment to."),
20
+ "Save a file the user attached from the Privateer app (shown in the prompt as a \"[Attachment #n] " +
21
+ "name\" chip) to a path on disk. Use this to persist a received attachment. `ref` is the number n.",
22
+ parameters: Type.Object({
23
+ ref: Type.Integer({ description: "The attachment reference number n from its [Attachment #n] chip." }),
24
+ path: Type.String({ description: "Destination file path to write the attachment to." }),
23
25
  }),
24
- execute: async ({ ref, path }) => {
25
- const store = ctx.attachments;
26
- const entry = store?.get(ref);
26
+ async execute(
27
+ _toolCallId: string,
28
+ params: { ref: number; path: string },
29
+ _signal?: AbortSignal,
30
+ _onUpdate?: unknown,
31
+ ctx?: { cwd?: string },
32
+ ) {
33
+ const entry = store.get(params.ref);
27
34
  if (!entry) {
28
- const avail = store?.refs() ?? [];
29
- return avail.length
30
- ? `No attachment #${ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
31
- : `No attachment #${ref}: nothing has been attached this session.`;
35
+ const avail = store.refs();
36
+ return text(
37
+ avail.length
38
+ ? `No attachment #${params.ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
39
+ : `No attachment #${params.ref}: nothing has been attached this session.`,
40
+ );
32
41
  }
33
- const abs = resolveInCwd(ctx, path);
34
- const outside = isOutsideScope(ctx, abs);
35
- const decision = await ctx.gate.request({
36
- tool: "save_attachment",
37
- kind: "write",
38
- title: outside ? "Save attachment outside working directory" : "Save attachment",
39
- detail: `[#${ref}] → ${outside ? abs : displayPath(ctx, abs)} (${entry.mediaType})`,
40
- protected: isProtectedPath(abs),
41
- outside,
42
- path: abs,
43
- });
44
- if (decision === "deny") throw new PermissionDeniedError("save_attachment");
45
-
46
- ctx.recordMutation?.(abs);
42
+ const cwd = ctx?.cwd ?? process.cwd();
43
+ const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
47
44
  mkdirSync(dirname(abs), { recursive: true });
48
45
  const bytes = readFileSync(entry.path);
49
46
  writeFileSync(abs, bytes);
50
- return `Saved attachment #${ref} to ${displayPath(ctx, abs)} (${bytes.length} bytes).`;
47
+ return text(`Saved attachment #${params.ref} (${entry.name}, ${entry.mediaType}) to ${params.path} (${bytes.length} bytes).`);
51
48
  },
52
- });
49
+ };
53
50
  }
@@ -0,0 +1,75 @@
1
+ // The `send_file_to_client` tool — stream a file from disk up to the connected
2
+ // Privateer app (the phone/web client driving this terminal via /remote-access) so
3
+ // the user can preview/save it on their device. Ported from tree-cli, adapted to Pi
4
+ // registerTool + the RemoteBridge (which owns the relay's chunked sendFile). Gated by
5
+ // our permission-gate extension like any tool.
6
+
7
+ import { Type } from "typebox";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { basename, isAbsolute, resolve } from "node:path";
10
+
11
+ const MAX_SEND_BYTES = 10 * 1024 * 1024; // mirrors the relay's per-file cap
12
+
13
+ const MEDIA: Record<string, string> = {
14
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
15
+ svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", md: "text/markdown",
16
+ json: "application/json", csv: "text/csv", html: "text/html", mp4: "video/mp4",
17
+ mov: "video/quicktime", mp3: "audio/mpeg", wav: "audio/wav", zip: "application/zip",
18
+ };
19
+ function mediaTypeForPath(p: string): string {
20
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
21
+ return MEDIA[ext] ?? "application/octet-stream";
22
+ }
23
+
24
+ function text(t: string) {
25
+ return { content: [{ type: "text", text: t }], details: {} };
26
+ }
27
+
28
+ export interface SendFileBridge {
29
+ isConnected(): boolean;
30
+ sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
31
+ }
32
+
33
+ export function makeSendFileTool(bridge: SendFileBridge) {
34
+ return {
35
+ name: "send_file_to_client",
36
+ label: "Send File to App",
37
+ description:
38
+ "Send a file from disk to the connected Privateer app (the phone/web client driving this terminal " +
39
+ "remotely), where the user can preview it and save/share it on their device. Use when the user asks " +
40
+ "to see, download, or receive a file on their phone or browser. Only works while remote access is on " +
41
+ "and the app is connected; max 10 MB per file.",
42
+ parameters: Type.Object({
43
+ path: Type.String({ description: "Path of the file to send, relative to cwd or absolute." }),
44
+ }),
45
+ async execute(
46
+ _toolCallId: string,
47
+ params: { path: string },
48
+ _signal?: AbortSignal,
49
+ _onUpdate?: unknown,
50
+ ctx?: { cwd?: string },
51
+ ) {
52
+ if (!bridge.isConnected()) {
53
+ return text(
54
+ "Cannot send: remote access is off or the app isn't connected right now. " +
55
+ "Turn it on with /remote-access and open the Privateer app, then try again.",
56
+ );
57
+ }
58
+ const cwd = ctx?.cwd ?? process.cwd();
59
+ const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
60
+ if (!existsSync(abs)) return text(`File not found: ${params.path}`);
61
+ const stat = statSync(abs);
62
+ if (stat.isDirectory()) return text(`${params.path} is a directory — send a single file.`);
63
+ if (stat.size === 0) return text(`${params.path} is empty — nothing to send.`);
64
+ if (stat.size > MAX_SEND_BYTES) {
65
+ return text(`${params.path} is ${(stat.size / 1048576).toFixed(1)} MB; the relay caps at ${MAX_SEND_BYTES / 1048576} MB per file.`);
66
+ }
67
+ const bytes = readFileSync(abs);
68
+ const mediaType = mediaTypeForPath(abs);
69
+ const res = await bridge.sendFile({ name: basename(abs), mediaType, base64: bytes.toString("base64"), size: bytes.length });
70
+ return res.ok
71
+ ? text(`Sent ${basename(abs)} (${bytes.length} bytes, ${mediaType}) to the connected app.`)
72
+ : text(`Couldn't send ${basename(abs)}: ${res.reason ?? "unknown error"}. Is the app currently connected?`);
73
+ },
74
+ };
75
+ }