privateer-agent 0.6.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) 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 +2 -2
  8. package/extensions/privateer-tools.ts +1 -1
  9. package/package.json +4 -2
  10. package/src/auth/privateer.ts +2 -2
  11. package/src/channels/run.ts +16 -7
  12. package/src/channels/status.ts +7 -7
  13. package/src/cli/chat.ts +39 -5
  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 +81 -44
  19. package/src/harbor/ipc.ts +175 -0
  20. package/src/{daemon → harbor}/service.ts +61 -28
  21. package/src/main.ts +1 -1
  22. package/src/mcp/catalog.ts +106 -0
  23. package/src/providers/account.ts +58 -11
  24. package/src/providers/catalog.ts +8 -6
  25. package/src/providers/defaultModel.ts +1 -1
  26. package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
  27. package/src/providers/phala/aci-verifier/crypto.ts +95 -0
  28. package/src/providers/phala/aci-verifier/digest.ts +116 -0
  29. package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
  30. package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
  31. package/src/providers/phala/aci-verifier/errors.ts +41 -0
  32. package/src/providers/phala/aci-verifier/index.ts +87 -0
  33. package/src/providers/phala/aci-verifier/jcs.ts +69 -0
  34. package/src/providers/phala/aci-verifier/receipt.ts +139 -0
  35. package/src/providers/phala/aci-verifier/report.ts +126 -0
  36. package/src/providers/phala/aci-verifier/types.ts +139 -0
  37. package/src/providers/phala/sse.ts +43 -0
  38. package/src/providers/phala/webcrypto-globals.d.ts +17 -0
  39. package/src/providers/phalaSeal.ts +200 -0
  40. package/src/providers/sealedShim.ts +295 -0
  41. package/src/remote/channelsControl.ts +8 -8
  42. package/src/remote/controlAuth.ts +1 -1
  43. package/src/remote/liveTaskSession.ts +15 -7
  44. package/src/remote/mcpControl.ts +2 -2
  45. package/src/remote/relayClient.ts +41 -21
  46. package/src/remote/remoteBridge.ts +17 -7
  47. package/src/remote/routinesControl.ts +7 -7
  48. package/src/remote/workflowsControl.ts +6 -6
  49. package/src/routines/delivery.ts +6 -6
  50. package/src/routines/schema.ts +3 -3
  51. package/src/routines/store.ts +3 -3
  52. package/src/routines/trigger.ts +1 -1
  53. package/src/tools/routine.ts +8 -8
  54. package/src/util/fileMentions.ts +232 -0
  55. package/src/workflows/expr.ts +1 -1
  56. package/src/workflows/runner.ts +3 -3
  57. package/src/workflows/schema.ts +1 -1
  58. package/src/workflows/store.ts +1 -1
  59. package/src/daemon/ipc.ts +0 -127
@@ -27,6 +27,7 @@ export interface RelayLike {
27
27
  sendCommands(commands: { name: string; description?: string }[]): void;
28
28
  requestSelect(id: string, req: SelectRequest): void;
29
29
  requestInput(id: string, req: InputRequest): void;
30
+ sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void;
30
31
  sendExtensions(payload: ExtensionsPayload): void;
31
32
  sendSkills(payload: SkillsPayload): void;
32
33
  }
@@ -93,6 +94,9 @@ export interface RemoteBridgeConfig {
93
94
  onSkillCreate?: (skill: { name: string; description: string; instructions: string }, sig?: string, ts?: number) => void;
94
95
  onSkillDelete?: (name: string, sig?: string, ts?: number) => void;
95
96
  onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
97
+ // The app is autocompleting an `@file` mention in its composer — the owner should
98
+ // list the cwd files matching `query` and reply via sendFileMatches(id, …).
99
+ onFilesSearch?: (id: string, query: string) => void;
96
100
  // A controller (re)attached — the owner should push a transcript snapshot.
97
101
  onControllerAttached?: () => void;
98
102
  onStatus?: (text: string) => void;
@@ -139,7 +143,7 @@ export class RemoteBridge {
139
143
  onSkillCreate: (skill, sig, ts) => this.cfg.onSkillCreate?.(skill, sig, ts),
140
144
  onSkillDelete: (name, sig, ts) => this.cfg.onSkillDelete?.(name, sig, ts),
141
145
  onSkillSetEnabled: (name, enabled, sig, ts) => this.cfg.onSkillSetEnabled?.(name, enabled, sig, ts),
142
- // Routines are owned by the daemon, not an interactive session, so its own relay
146
+ // Routines are owned by the harbor, not an interactive session, so its own relay
143
147
  // (not this bridge) handles routines_*. These no-ops just satisfy Required — an
144
148
  // interactive terminal never surfaces the routines manager in the app.
145
149
  onRoutinesList: () => {},
@@ -147,25 +151,25 @@ export class RemoteBridge {
147
151
  onRoutinesDelete: () => {},
148
152
  onRoutinesSetEnabled: () => {},
149
153
  onRoutinesRun: () => {},
150
- // Ad-hoc task spawns are daemon-owned too (they run on / are stood up by the daemon,
154
+ // Ad-hoc task spawns are harbor-owned too (they run on / are stood up by the harbor,
151
155
  // not an interactive session), so its own relay handles task_submit/task_spawn. These
152
156
  // no-ops just satisfy Required — an interactive terminal never receives them.
153
157
  onTaskSubmit: () => {},
154
158
  onTaskSpawn: () => {},
155
- // Channels, like routines, are owned by the daemon (its channels/run.ts config),
156
- // not an interactive session — the daemon's own relay handles channels_*. These
159
+ // Channels, like routines, are owned by the harbor (its channels/run.ts config),
160
+ // not an interactive session — the harbor's own relay handles channels_*. These
157
161
  // no-ops just satisfy Required; an interactive terminal never surfaces channels.
158
162
  onChannelsList: () => {},
159
163
  onChannelsSave: () => {},
160
164
  onChannelsRemove: () => {},
161
- // MCP connectors, like channels, are managed on the daemon (the host that runs the
162
- // adapter) — the daemon's own relay handles mcp_*. These no-ops just satisfy Required;
165
+ // MCP connectors, like channels, are managed on the harbor (the host that runs the
166
+ // adapter) — the harbor's own relay handles mcp_*. These no-ops just satisfy Required;
163
167
  // an interactive terminal manages MCP over IPC (desktop), never over this relay.
164
168
  onMcpList: () => {},
165
169
  onMcpSave: () => {},
166
170
  onMcpSetEnabled: () => {},
167
171
  onMcpRemove: () => {},
168
- // Workflows, like routines/channels, are daemon-owned — the daemon's own relay handles
172
+ // Workflows, like routines/channels, are harbor-owned — the harbor's own relay handles
169
173
  // workflows_*. These no-ops just satisfy Required; an interactive terminal never
170
174
  // surfaces workflows.
171
175
  onWorkflowsList: () => {},
@@ -185,6 +189,7 @@ export class RemoteBridge {
185
189
  const resolve = this.pendingInputs.get(id);
186
190
  if (resolve) resolve(value);
187
191
  },
192
+ onFilesSearch: (id, query) => this.cfg.onFilesSearch?.(id, query),
188
193
  onNoQuarter: (on) => {
189
194
  this.noQuarter = on;
190
195
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
@@ -239,6 +244,11 @@ export class RemoteBridge {
239
244
  this.relay?.sendCommands(commands);
240
245
  }
241
246
 
247
+ // Reply to an app `@file` autocomplete query with the matching cwd entries.
248
+ sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void {
249
+ this.relay?.sendFileMatches(id, matches);
250
+ }
251
+
242
252
  // Push the installed-extensions snapshot to the app's extensions manager.
243
253
  sendExtensions(payload: ExtensionsPayload): void {
244
254
  this.relay?.sendExtensions(payload);
@@ -2,15 +2,15 @@
2
2
  * Routine management for the app.
3
3
  *
4
4
  * A UI-agnostic wrapper over the routines store so the app (over the relay) can
5
- * see the daemon's saved routines and create / edit / delete / pause / run them —
5
+ * see the harbor's saved routines and create / edit / delete / pause / run them —
6
6
  * the sibling of extensionsControl.ts and skillsControl.ts, but for scheduled
7
7
  * tasks rather than Pi packages/skills.
8
8
  *
9
- * Unlike those two, routines are owned by the DAEMON (not an interactive Pi
9
+ * Unlike those two, routines are owned by the HARBOR (not an interactive Pi
10
10
  * session): they live in routines.json (see routines/store.ts) and fire from the
11
- * resident scheduler. So this control is wired into the daemon's own relay
11
+ * resident scheduler. So this control is wired into the harbor's own relay
12
12
  * connection (the "Privateer Routines" terminal), not the REPL/TUI. Running a
13
- * routine now is the one action that needs the daemon itself, so it's injected as
13
+ * routine now is the one action that needs the harbor itself, so it's injected as
14
14
  * `runNow` rather than reaching back into the store.
15
15
  *
16
16
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
@@ -75,7 +75,7 @@ export interface RoutinesControl {
75
75
  remove(idOrName: string): { ok: boolean; message?: string };
76
76
  // Pause/resume a routine. Resuming reschedules nextRun; pausing clears it.
77
77
  setEnabled(idOrName: string, enabled: boolean): { ok: boolean; message?: string };
78
- // Run a routine now (fire-and-forget on the daemon). ok:false when not found.
78
+ // Run a routine now (fire-and-forget on the harbor). ok:false when not found.
79
79
  run(idOrName: string): { ok: boolean; message?: string };
80
80
  }
81
81
 
@@ -122,11 +122,11 @@ function toRemote(r: Routine): RemoteRoutine {
122
122
  }
123
123
 
124
124
  export function makeRoutinesControl(opts: {
125
- // Working directory for a new routine when the draft omits `cwd` (the daemon's).
125
+ // Working directory for a new routine when the draft omits `cwd` (the harbor's).
126
126
  defaultCwd: () => string;
127
127
  // Is a webhook name declared in config? Guards "webhook:<name>" delivery entries.
128
128
  webhookExists?: (name: string) => boolean;
129
- // Fire a routine now — injected by the daemon (its runRoutine). Absent → run is
129
+ // Fire a routine now — injected by the harbor (its runRoutine). Absent → run is
130
130
  // reported unavailable rather than silently dropped.
131
131
  runNow?: (routine: Routine) => void;
132
132
  }): RoutinesControl {
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * The sibling of routinesControl.ts / channelsControl.ts, for declarative workflow
5
5
  * graphs. UI-agnostic: nothing here imports React or the relay — the caller (the
6
- * daemon) owns the frame plumbing, the signed-frame gate (authorizeControl), and the
7
- * run seam. Like routines, workflows are owned by the DAEMON (they run on its resident
8
- * scheduler / on-demand), so this control is wired into the daemon's own relay.
6
+ * harbor) owns the frame plumbing, the signed-frame gate (authorizeControl), and the
7
+ * run seam. Like routines, workflows are owned by the HARBOR (they run on its resident
8
+ * scheduler / on-demand), so this control is wired into the harbor's own relay.
9
9
  *
10
10
  * A workflow file is an EXECUTABLE artifact (it can carry `script` steps), so the
11
- * daemon MUST verify the account signature on every mutating frame (workflows_save /
11
+ * harbor MUST verify the account signature on every mutating frame (workflows_save /
12
12
  * remove / run) via guardControl BEFORE calling save/remove/run here — a forged save
13
13
  * would plant a script step, a forged run would execute one. `list`/`get` are read-only.
14
14
  *
@@ -48,7 +48,7 @@ export interface WorkflowsControl {
48
48
  save(draft: unknown): { ok: boolean; message?: string; id?: string };
49
49
  // Remove a workflow by id or name. ok:false when nothing matched.
50
50
  remove(idOrName: string): { ok: boolean; message?: string };
51
- // Run a workflow now (fire-and-forget on the daemon). ok:false when not found or the
51
+ // Run a workflow now (fire-and-forget on the harbor). ok:false when not found or the
52
52
  // runner isn't wired.
53
53
  run(idOrName: string): { ok: boolean; message?: string };
54
54
  }
@@ -72,7 +72,7 @@ function draftId(draft: unknown): string | undefined {
72
72
  }
73
73
 
74
74
  export function makeWorkflowsControl(opts: {
75
- // Fire a workflow now — injected by the daemon (it owns the runner + its seams).
75
+ // Fire a workflow now — injected by the harbor (it owns the runner + its seams).
76
76
  // Absent → run is reported unavailable rather than silently dropped (mirrors routines).
77
77
  runNow?: (wf: Workflow) => void;
78
78
  }): WorkflowsControl {
@@ -2,13 +2,13 @@ import type { Routine } from "./schema.ts";
2
2
  import { webhookName } from "./schema.ts";
3
3
  import { writeRoutineOutput, addNotice } from "./store.ts";
4
4
 
5
- // A relay pusher, injected by the daemon. Given the finished result it either
5
+ // A relay pusher, injected by the harbor. Given the finished result it either
6
6
  // forwards it to an attached controller immediately ("live") or persists it to the
7
7
  // pending-relay queue to flush when the app next attaches ("queued"). Either way the
8
8
  // result is durably accounted for, so delivery doesn't add a notice backstop for it.
9
9
  export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
10
10
 
11
- // A cloud-outbox pusher, injected by the daemon. Seals the result to the account's
11
+ // A cloud-outbox pusher, injected by the harbor. Seals the result to the account's
12
12
  // outbox public key and POSTs the ciphertext to the server (E2EE store-and-forward),
13
13
  // or buffers it durably on failure. Returns "sent" when the server accepted it,
14
14
  // "queued" when it was persisted for a later flush — either way the result is
@@ -31,7 +31,7 @@ export interface DeliveryContext {
31
31
  // Named endpoints for "webhook:<name>" delivery entries; results POST here.
32
32
  webhooks?: Record<string, WebhookTarget>;
33
33
  // Scrubs secrets from anything leaving the machine. Webhook bodies always pass
34
- // through this when provided (the daemon wires in redactText).
34
+ // through this when provided (the harbor wires in redactText).
35
35
  redact?: (text: string) => string;
36
36
  // Injectable for tests; defaults to global fetch.
37
37
  fetchImpl?: typeof fetch;
@@ -39,7 +39,7 @@ export interface DeliveryContext {
39
39
 
40
40
  export interface DeliveryReport {
41
41
  // Channels that actually delivered (email is handled inside the agent run, so it
42
- // never appears here — see the daemon). Failed webhooks show as
42
+ // never appears here — see the harbor). Failed webhooks show as
43
43
  // "webhook:<name>(failed)" and leave a notice so the result isn't silently lost.
44
44
  delivered: string[];
45
45
  // Absolute path to latest.md when file delivery ran.
@@ -101,7 +101,7 @@ async function postWebhook(
101
101
  // result is never silently lost. `webhook:<name>` POSTs the (redacted) result to a
102
102
  // named endpoint from config; a failed or unconfigured webhook leaves a notice.
103
103
  // `email` is intentionally not handled here: it is fulfilled inside the agent turn
104
- // (the daemon adds the Gmail tool + an instruction to the prompt) so plaintext
104
+ // (the harbor adds the Gmail tool + an instruction to the prompt) so plaintext
105
105
  // egress stays an explicit, gated action.
106
106
  export async function deliver(
107
107
  routine: Routine,
@@ -127,7 +127,7 @@ export async function deliver(
127
127
  }
128
128
 
129
129
  // Relay: pushed live to an attached controller, or queued to flush when the app
130
- // next attaches (the daemon persists that queue, so it's durable either way). Only
130
+ // next attaches (the harbor persists that queue, so it's durable either way). Only
131
131
  // when no pusher is wired at all do we fall back to a notice so it isn't lost.
132
132
  if (wants.has("relay")) {
133
133
  const pushed = ctx.pushRelay?.(routine, content);
@@ -27,7 +27,7 @@ export function webhookName(entry: string): string | null {
27
27
  }
28
28
 
29
29
  // A saved, unattended agent task. Persisted in routines.json and executed by the
30
- // daemon when its trigger comes due. A routine's trigger is EITHER recurring (a
30
+ // harbor when its trigger comes due. A routine's trigger is EITHER recurring (a
31
31
  // cron expression) or one-off (`at`, a specific datetime) — exactly one is set.
32
32
  export const Routine = z
33
33
  .object({
@@ -48,14 +48,14 @@ export const Routine = z
48
48
  model: z.string().optional(),
49
49
  // Where to deliver the result. Defaults to on-box file output.
50
50
  delivery: z.array(DeliveryEntry).default(["file"]),
51
- // Optional tool allow-subset. Unset → the safe read/web set (see daemon). Entries
51
+ // Optional tool allow-subset. Unset → the safe read/web set (see harbor). Entries
52
52
  // may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
53
53
  // "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
54
54
  // tools run unattended under the auto-approve gate, so grant the minimum needed.
55
55
  tools: z.array(z.string()).optional(),
56
56
  // Paused routines stay in the file but never fire.
57
57
  enabled: z.boolean().default(true),
58
- // Bookkeeping, updated by the daemon after each run.
58
+ // Bookkeeping, updated by the harbor after each run.
59
59
  lastRun: z.string().optional(),
60
60
  lastStatus: z.enum(["ok", "error"]).optional(),
61
61
  lastError: z.string().optional(),
@@ -20,7 +20,7 @@ function slug(name: string): string {
20
20
  return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "routine";
21
21
  }
22
22
 
23
- // A stable relay terminal id for the daemon, persisted so it reappears as the same
23
+ // A stable relay terminal id for the harbor, persisted so it reappears as the same
24
24
  // "Privateer Routines" terminal in the app across restarts (rather than a fresh
25
25
  // random terminal each boot). Random on first use so it stays unique per install —
26
26
  // the relay routes on this id with no user namespacing, so a shared constant could
@@ -53,7 +53,7 @@ export function loadRoutines(): Routine[] {
53
53
  try {
54
54
  return RoutineFile.parse(JSON.parse(readFileSync(path, "utf8"))).routines;
55
55
  } catch {
56
- // A corrupt or hand-edited file shouldn't crash the daemon; treat as empty.
56
+ // A corrupt or hand-edited file shouldn't crash the harbor; treat as empty.
57
57
  return [];
58
58
  }
59
59
  }
@@ -160,7 +160,7 @@ export function drainNotices(): RoutineNotice[] {
160
160
  }
161
161
 
162
162
  // A relay result produced while no controller was attached, held until the app
163
- // next connects. Persisted (not just in-memory) so it survives a daemon restart.
163
+ // next connects. Persisted (not just in-memory) so it survives a harbor restart.
164
164
  export interface PendingRelay {
165
165
  routine: string;
166
166
  at: string; // ISO timestamp
@@ -16,7 +16,7 @@ export function triggerError(t: Trigger): string | null {
16
16
 
17
17
  // The fire time to store as `nextRun`. For cron: the next match strictly after
18
18
  // `from`. For a one-off: the fixed `at` time as-is (even if already past, so a
19
- // missed one-off still fires once when the daemon comes back). Null if unparseable.
19
+ // missed one-off still fires once when the harbor comes back). Null if unparseable.
20
20
  export function computeNextRun(t: Trigger, from: Date = new Date()): Date | null {
21
21
  if (t.cron) return cronNext(t.cron, from);
22
22
  if (t.at) {
@@ -1,6 +1,6 @@
1
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
2
+ // or "remind me at 3pm tomorrow" into a saved routine the scheduler harbor runs
3
+ // unattended (see src/harbor). Ported from tree-cli/src/tools/routine.ts, adapted to
4
4
  // Pi's registerTool (TypeBox schema) with the in-tool ctx.gate.request removed: in
5
5
  // the Pi model our permission-gate extension gates the tool_call itself (classify.ts
6
6
  // gives it a "Create routine" prompt + flags email/webhook egress).
@@ -11,7 +11,7 @@ import { configPath } from "../config/paths.ts";
11
11
  import { newRoutineId, webhookName, type Routine } from "../routines/schema.ts";
12
12
  import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
13
13
  import { upsertRoutine } from "../routines/store.ts";
14
- import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
14
+ import { sendToHarbor, HarborNotRunningError } from "../harbor/ipc.ts";
15
15
 
16
16
  const KNOWN_CHANNELS = new Set(["file", "relay", "notice", "cloud", "email"]);
17
17
 
@@ -106,20 +106,20 @@ export const routineToolDefinition = {
106
106
  nextRun: next?.toISOString(),
107
107
  };
108
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.
109
+ // Hand to the running harbor (it validates + schedules); fall back to writing the
110
+ // file so the routine persists until the harbor starts.
111
111
  try {
112
- const res = await sendToDaemon({ cmd: "add", routine });
112
+ const res = await sendToHarbor({ cmd: "add", routine });
113
113
  if (!res.ok) return text(`Error saving routine: ${res.message ?? "unknown"}`);
114
114
  return text(
115
115
  `Created routine "${name}" (${describeTrigger({ cron, at })}). ` +
116
116
  `Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`,
117
117
  );
118
118
  } catch (e) {
119
- if (e instanceof DaemonNotRunningError) {
119
+ if (e instanceof HarborNotRunningError) {
120
120
  upsertRoutine(routine);
121
121
  return text(
122
- `Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler daemon isn't ` +
122
+ `Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler harbor isn't ` +
123
123
  `running yet, so it won't fire until you start it.`,
124
124
  );
125
125
  }
@@ -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 [];