privateer-agent 0.6.9 → 0.7.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 (41) hide show
  1. package/README.md +20 -20
  2. package/SECURITY.md +1 -1
  3. package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
  4. package/bin/privateer-launch.mjs +7 -6
  5. package/bin/privateer-subagent.mjs +1 -1
  6. package/extensions/privateer-brand.ts +1 -1
  7. package/extensions/privateer-connect.ts +14 -3
  8. package/extensions/privateer-tools.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/auth/privateer.ts +2 -2
  11. package/src/channels/run.ts +5 -5
  12. package/src/channels/status.ts +7 -7
  13. package/src/cli/chat.ts +29 -3
  14. package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
  15. package/src/config/hosted.ts +5 -5
  16. package/src/crypto/accountTrust.ts +2 -2
  17. package/src/crypto/accountVerify.ts +1 -1
  18. package/src/{daemon → harbor}/index.ts +37 -37
  19. package/src/{daemon → harbor}/ipc.ts +19 -19
  20. package/src/{daemon → harbor}/service.ts +61 -28
  21. package/src/main.ts +1 -1
  22. package/src/providers/account.ts +1 -1
  23. package/src/providers/defaultModel.ts +1 -1
  24. package/src/remote/channelsControl.ts +8 -8
  25. package/src/remote/controlAuth.ts +1 -1
  26. package/src/remote/liveTaskSession.ts +4 -4
  27. package/src/remote/mcpControl.ts +2 -2
  28. package/src/remote/relayClient.ts +41 -21
  29. package/src/remote/remoteBridge.ts +17 -7
  30. package/src/remote/routinesControl.ts +7 -7
  31. package/src/remote/workflowsControl.ts +6 -6
  32. package/src/routines/delivery.ts +6 -6
  33. package/src/routines/schema.ts +3 -3
  34. package/src/routines/store.ts +3 -3
  35. package/src/routines/trigger.ts +1 -1
  36. package/src/tools/routine.ts +8 -8
  37. package/src/util/fileMentions.ts +232 -0
  38. package/src/workflows/expr.ts +1 -1
  39. package/src/workflows/runner.ts +3 -3
  40. package/src/workflows/schema.ts +1 -1
  41. package/src/workflows/store.ts +1 -1
@@ -0,0 +1,232 @@
1
+ // @file mentions — let a prompt reference files on the terminal's machine by typing
2
+ // `@path`. Used by BOTH surfaces:
3
+ // • the local REPL (readline tab-completion + resolution at submit)
4
+ // • the app composer, driven over the relay (a files_search palette; the SAME
5
+ // resolution runs on the terminal when the prompt lands)
6
+ //
7
+ // The mention token stays INLINE in the prompt (so the model sees the reference in
8
+ // context) and each referenced file's content is appended after it as a
9
+ // <file name="…">…</file> block — text inline, images as real attachments. This
10
+ // mirrors Pi's own @file CLI-arg expander (cli/file-processor) but is a library, not
11
+ // a process: it never exits on a bad path, and it is CWD-CONSTRAINED.
12
+ //
13
+ // SECURITY: resolution is a client-side text expansion that bypasses the permission
14
+ // gate (unlike the Read tool). A remote driver is the account owner, but a
15
+ // gate-bypassing arbitrary read (`@/etc/shadow`, `@../secrets`) is exactly what we
16
+ // must not grant. So every token MUST resolve inside cwd — anything that escapes the
17
+ // cwd subtree (absolute paths, `..`, symlink targets outside) is skipped, not read.
18
+ // The same rule bounds the relay file-search so filenames outside the project never
19
+ // leak to the controller.
20
+
21
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
22
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
+
24
+ /** An image attachment, shaped for AgentSession.prompt()'s `images` option (Pi's ImageContent). */
25
+ export interface MentionImage {
26
+ type: "image";
27
+ data: string; // base64
28
+ mimeType: string;
29
+ }
30
+
31
+ export interface ResolvedMentions {
32
+ /** The prompt with each referenced file's content appended as a <file> block. */
33
+ text: string;
34
+ /** Image attachments to pass via prompt options.images. */
35
+ images: MentionImage[];
36
+ /** cwd-relative paths that were successfully attached. */
37
+ resolved: string[];
38
+ /** Raw tokens that couldn't be attached (missing / outside cwd / a dir / too big). */
39
+ skipped: string[];
40
+ }
41
+
42
+ // Inline text stays reasonable; a giant file would blow the context and the relay.
43
+ const MAX_TEXT_BYTES = 256 * 1024; // 256 KB per text file inlined
44
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image before base64
45
+
46
+ const IMAGE_EXT: Record<string, string> = {
47
+ png: "image/png",
48
+ jpg: "image/jpeg",
49
+ jpeg: "image/jpeg",
50
+ gif: "image/gif",
51
+ webp: "image/webp",
52
+ };
53
+
54
+ const extOf = (name: string): string => {
55
+ const dot = name.lastIndexOf(".");
56
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
57
+ };
58
+
59
+ // Trailing characters that are almost always sentence punctuation, not part of a
60
+ // filename — trimmed from a token if the trimmed form resolves and the raw doesn't.
61
+ const TRAIL_PUNCT = /[.,;:!?)\]}>]+$/;
62
+
63
+ // A mention is `@` at start-of-string or after whitespace, then either a "quoted path"
64
+ // (allows spaces) or a run of non-whitespace path characters. Capturing group 2 is the
65
+ // path (quoted contents via group 3, else the bare run).
66
+ const MENTION_RE = /(^|\s)@("([^"]+)"|[^\s@]+)/g;
67
+
68
+ /** Pull the raw path tokens out of a prompt (order-preserving, de-duplicated). */
69
+ export function parseMentions(text: string): string[] {
70
+ const out: string[] = [];
71
+ const seen = new Set<string>();
72
+ for (const m of text.matchAll(MENTION_RE)) {
73
+ const raw = m[3] ?? m[2]; // quoted contents, else the bare run
74
+ if (raw && !seen.has(raw)) {
75
+ seen.add(raw);
76
+ out.push(raw);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // Resolve a raw token to an absolute path inside cwd, or null if it escapes / doesn't
83
+ // exist. Follows the real (symlink-resolved) path and re-checks containment so a
84
+ // symlink inside cwd pointing outside can't be used to read out.
85
+ async function resolveInsideCwd(raw: string, cwd: string): Promise<string | null> {
86
+ // A relative path only — an absolute token is an escape attempt by definition.
87
+ if (isAbsolute(raw)) return null;
88
+ const abs = resolve(cwd, raw);
89
+ const within = (p: string): boolean => p === cwd || p.startsWith(cwd + sep);
90
+ if (!within(abs)) return null; // `..` climbed out
91
+ try {
92
+ const real = await realpath(abs);
93
+ // realpath the cwd too, so a symlinked project root still matches.
94
+ const realCwd = await realpath(cwd).catch(() => cwd);
95
+ if (real !== realCwd && !real.startsWith(realCwd + sep)) return null;
96
+ return real;
97
+ } catch {
98
+ return null; // doesn't exist
99
+ }
100
+ }
101
+
102
+ // Try the token as-is, then progressively trimmed of trailing punctuation, returning
103
+ // the first form that resolves to a readable file inside cwd.
104
+ async function resolveToken(raw: string, cwd: string): Promise<string | null> {
105
+ const candidates = [raw];
106
+ const trimmed = raw.replace(TRAIL_PUNCT, "");
107
+ if (trimmed && trimmed !== raw) candidates.push(trimmed);
108
+ for (const c of candidates) {
109
+ const abs = await resolveInsideCwd(c, cwd);
110
+ if (abs) return abs;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Expand every `@path` mention in `text` into appended <file> blocks (text) plus image
117
+ * attachments. Unresolved mentions are left inline verbatim and reported in `skipped`.
118
+ * The returned `text` equals the input when there are no resolvable mentions.
119
+ */
120
+ export async function resolveMentions(text: string, cwd: string): Promise<ResolvedMentions> {
121
+ const tokens = parseMentions(text);
122
+ const images: MentionImage[] = [];
123
+ const resolved: string[] = [];
124
+ const skipped: string[] = [];
125
+ const blocks: string[] = [];
126
+ // resolveToken returns the symlink-resolved (real) absolute path, so relative paths
127
+ // must be computed against the real cwd — otherwise a symlinked cwd (e.g. /var →
128
+ // /private/var on macOS) yields a spurious `../../…` prefix.
129
+ const realCwd = await realpath(cwd).catch(() => cwd);
130
+
131
+ for (const raw of tokens) {
132
+ const abs = await resolveToken(raw, cwd);
133
+ if (!abs) { skipped.push(raw); continue; }
134
+ let st;
135
+ try { st = await stat(abs); } catch { skipped.push(raw); continue; }
136
+ if (!st.isFile() || st.size === 0) { skipped.push(raw); continue; }
137
+ const rel = relative(realCwd, abs) || basename(abs);
138
+ const mime = IMAGE_EXT[extOf(abs)];
139
+ try {
140
+ if (mime) {
141
+ if (st.size > MAX_IMAGE_BYTES) { skipped.push(raw); continue; }
142
+ const buf = await readFile(abs);
143
+ images.push({ type: "image", data: buf.toString("base64"), mimeType: mime });
144
+ // A bare reference so the model ties the image to the path it saw inline.
145
+ blocks.push(`<file name="${rel}"></file>`);
146
+ } else {
147
+ if (st.size > MAX_TEXT_BYTES) { skipped.push(raw); continue; }
148
+ const content = await readFile(abs, "utf-8");
149
+ blocks.push(`<file name="${rel}">\n${content}\n</file>`);
150
+ }
151
+ resolved.push(rel);
152
+ } catch {
153
+ skipped.push(raw);
154
+ }
155
+ }
156
+
157
+ const out = blocks.length ? `${text}\n\n${blocks.join("\n")}` : text;
158
+ return { text: out, images, resolved, skipped };
159
+ }
160
+
161
+ // ── autocomplete ──────────────────────────────────────────────────────────────────
162
+
163
+ export interface FileMatch {
164
+ /** cwd-relative path (directories carry a trailing "/"). */
165
+ path: string;
166
+ isDir: boolean;
167
+ }
168
+
169
+ // Directory entries we never surface as suggestions (noise / not project files).
170
+ const IGNORE_DIRS = new Set([".git", "node_modules", ".DS_Store"]);
171
+
172
+ /**
173
+ * List up to `limit` files/dirs inside cwd whose path matches `query` — the text the
174
+ * user typed after `@`. A query with a trailing "/" (or ending at a real dir) lists
175
+ * that directory's contents; otherwise it prefix-matches the basename within the
176
+ * query's parent dir. Case-insensitive. CWD-constrained: a query that escapes cwd
177
+ * returns nothing.
178
+ */
179
+ export async function searchFiles(query: string, cwd: string, limit = 50): Promise<FileMatch[]> {
180
+ const q = query ?? "";
181
+ if (isAbsolute(q)) return [];
182
+ // Split into the directory to scan and the basename prefix to filter by. A trailing
183
+ // slash means "list this dir", so the prefix is empty.
184
+ const endsWithSlash = q.endsWith("/");
185
+ const dirPart = endsWithSlash ? q : dirname(q);
186
+ const prefix = endsWithSlash ? "" : basename(q);
187
+ const scanRel = dirPart === "." ? "" : dirPart;
188
+ const scanAbs = resolve(cwd, scanRel);
189
+ // Containment check (mirror resolveInsideCwd, sync form — no realpath needed for a listing).
190
+ if (scanAbs !== cwd && !scanAbs.startsWith(cwd + sep)) return [];
191
+
192
+ let entries: import("node:fs").Dirent[];
193
+ try {
194
+ entries = await readdir(scanAbs, { withFileTypes: true });
195
+ } catch {
196
+ return [];
197
+ }
198
+ const pfx = prefix.toLowerCase();
199
+ const matches: FileMatch[] = [];
200
+ for (const e of entries) {
201
+ if (IGNORE_DIRS.has(e.name)) continue;
202
+ if (pfx && !e.name.toLowerCase().startsWith(pfx)) continue;
203
+ if (e.name.startsWith(".") && !pfx.startsWith(".")) continue; // hide dotfiles unless asked
204
+ const isDir = e.isDirectory();
205
+ const rel = scanRel ? join(scanRel, e.name) : e.name;
206
+ matches.push({ path: isDir ? `${rel}/` : rel, isDir });
207
+ if (matches.length >= limit) break;
208
+ }
209
+ // Directories first, then alphabetical — the natural drill-down order.
210
+ matches.sort((a, b) => (a.isDir === b.isDir ? a.path.localeCompare(b.path) : a.isDir ? -1 : 1));
211
+ return matches;
212
+ }
213
+
214
+ /**
215
+ * A Node readline completer for `@`-mentions. Given the line up to the cursor, if it
216
+ * ends in an `@token`, returns full-line completions (readline replaces the whole
217
+ * line) so the mention drills into the cwd tree on Tab. Returns [[], line] when the
218
+ * cursor isn't in a mention, leaving other completion untouched.
219
+ */
220
+ export async function completeMention(line: string, cwd: string): Promise<[string[], string]> {
221
+ // Find the last unquoted-ish `@token` that runs to the end of the line.
222
+ const m = /(^|\s)@([^\s@]*)$/.exec(line);
223
+ if (!m) return [[], line];
224
+ const token = m[2];
225
+ const tokenStart = m.index + m[1].length; // index of the '@'
226
+ const head = line.slice(0, tokenStart); // everything before '@'
227
+ const matches = await searchFiles(token, cwd, 100);
228
+ // Rebuild each as a full line: head + "@" + path. A single dir match keeps the
229
+ // trailing slash so the next Tab drills in.
230
+ const hits = matches.map((mm) => `${head}@${mm.path}`);
231
+ return [hits, line];
232
+ }
@@ -1,4 +1,4 @@
1
1
  // The confined workflow expression/template engine now lives in the standalone
2
2
  // `privateer-workflow` package (its canonical home). This module re-exports it so the
3
- // daemon's existing `../workflows/expr.ts` import paths keep working unchanged.
3
+ // harbor's existing `../workflows/expr.ts` import paths keep working unchanged.
4
4
  export * from "privateer-workflow/expr";
@@ -1,8 +1,8 @@
1
1
  // The workflow runner now lives in the standalone `privateer-workflow` package (its
2
- // canonical home). This module re-exports it so the daemon's existing
2
+ // canonical home). This module re-exports it so the harbor's existing
3
3
  // `../workflows/runner.ts` import paths keep working unchanged.
4
4
  //
5
- // The daemon wires the runner's injected RunnerDeps to its own capabilities (headless
6
- // runSession, relay approvals, gated child processes, the cloud outbox) in daemon/index.ts
5
+ // The harbor wires the runner's injected RunnerDeps to its own capabilities (headless
6
+ // runSession, relay approvals, gated child processes, the cloud outbox) in harbor/index.ts
7
7
  // — that seam is unchanged; only the engine's source moved out to the shared package.
8
8
  export * from "privateer-workflow/runner";
@@ -1,5 +1,5 @@
1
1
  // The declarative workflow schema now lives in the standalone `privateer-workflow` package
2
- // (its canonical home). This module re-exports it so the daemon's existing
2
+ // (its canonical home). This module re-exports it so the harbor's existing
3
3
  // `../workflows/schema.ts` import paths (schema.ts is also the store's dependency) keep
4
4
  // working unchanged.
5
5
  export * from "privateer-workflow/schema";
@@ -52,7 +52,7 @@ function parseWorkflow(raw: string): Workflow | null {
52
52
  }
53
53
 
54
54
  // All valid workflows on disk, sorted by name. Corrupt/invalid files are skipped, not
55
- // thrown — a hand-mangled file shouldn't take down the daemon or the app's list.
55
+ // thrown — a hand-mangled file shouldn't take down the harbor or the app's list.
56
56
  export function loadWorkflows(): Workflow[] {
57
57
  const dir = workflowsDir();
58
58
  if (!existsSync(dir)) return [];