privateer-agent 0.3.6 → 0.4.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/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +61 -20
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +383 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +389 -30
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +511 -46
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,261 @@
1
+ // Child → parent approval/prompt relay for subagents.
2
+ //
3
+ // pi-subagents runs each subagent as a headless child `pi` process with stdin
4
+ // IGNORED (`-p --mode json`), so the child's permission gate has no interactive
5
+ // way to reach a human. When the child is guarded by privateer's moat (the
6
+ // discovered gate shim) a gated action would otherwise just fail-closed (deny).
7
+ // This module gives that child a way OUT: it writes its approval request to a
8
+ // per-session directory on disk; the PARENT privateer session (which owns the
9
+ // RemoteBridge / relay to the app) watches that directory, relays the request to
10
+ // the phone for Allow/Deny, and writes the answer back as a reply file the child
11
+ // is polling for.
12
+ //
13
+ // Why our own channel and not pi-subagents' supervisor channel: we own BOTH
14
+ // processes, and the gate ask is a Pi permission decision — not a
15
+ // `contact_supervisor` tool call — so piggy-backing on pi-subagents' internal
16
+ // channel would couple us to its provisioning. A privateer-owned dir, addressed
17
+ // by an env var we set (inherited through pi-subagents' `{...process.env}` spawn),
18
+ // is fully under our control and unit-testable without a live Pi.
19
+ //
20
+ // Transport shape (mirrors pi-subagents' own request/reply-file dance so the
21
+ // semantics are familiar): `<dir>/requests/<id>.json` written atomically by the
22
+ // child; `<dir>/replies/<id>.json` written atomically by the parent; the parent
23
+ // deletes the request once answered. Everything is fail-closed: no parent, a gone
24
+ // controller, a timeout, or an abort all resolve the child to "deny"/null.
25
+
26
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+ import { randomUUID } from "node:crypto";
30
+
31
+ // The env var carrying the channel dir to a subagent child. Set by the parent into
32
+ // its OWN process.env before subagents spawn, so pi-subagents' `{...process.env}`
33
+ // spawn inherits it into every (possibly nested) child.
34
+ export const SUBAGENT_CHANNEL_ENV = "PRIVATEER_SUBAGENT_CHANNEL";
35
+
36
+ // A relayed permission approval. `kind`/`title`/`detail` mirror PermissionRequest
37
+ // so the parent can hand it straight to the bridge's requestApproval.
38
+ export interface ApprovalAsk {
39
+ type: "approval";
40
+ kind?: string;
41
+ title: string;
42
+ detail: string;
43
+ }
44
+
45
+ // A relayed selection prompt (an extension's ctx.ui.select in the child).
46
+ export interface SelectAsk {
47
+ type: "select";
48
+ title: string;
49
+ options: { value: string; label: string; hint?: string }[];
50
+ current?: string;
51
+ }
52
+
53
+ // A relayed free-form text prompt (an extension's ctx.ui.input in the child).
54
+ export interface InputAsk {
55
+ type: "input";
56
+ title: string;
57
+ placeholder?: string;
58
+ }
59
+
60
+ export type SubagentAsk = ApprovalAsk | SelectAsk | InputAsk;
61
+
62
+ // The parent's answer. `decision` for approvals ("allow"/"deny"); `value` for
63
+ // select/input (the chosen value / typed line, or null for a dismiss/deny).
64
+ export interface SubagentReply {
65
+ decision?: "allow" | "deny";
66
+ value?: string | null;
67
+ }
68
+
69
+ interface RequestEnvelope {
70
+ id: string;
71
+ ask: SubagentAsk;
72
+ // The subagent that raised it, for display/audit (best-effort; from env).
73
+ agent?: string;
74
+ }
75
+
76
+ interface ReplyEnvelope {
77
+ id: string;
78
+ reply: SubagentReply;
79
+ }
80
+
81
+ // Deterministic per-session channel dir. Keyed by the PARENT session id so a parent
82
+ // watches exactly the children it spawned, and two concurrent parents never cross
83
+ // wires. Under the OS temp dir (world-unreadable 0700), never the repo/agent dir.
84
+ export function channelDirForSession(sessionId: string): string {
85
+ const safe = sessionId.replace(/[^\w.-]+/g, "_") || "session";
86
+ return join(tmpdir(), "privateer-subagent-channels", safe);
87
+ }
88
+
89
+ function requestsDir(dir: string): string {
90
+ return join(dir, "requests");
91
+ }
92
+ function repliesDir(dir: string): string {
93
+ return join(dir, "replies");
94
+ }
95
+
96
+ // Create the channel dir tree (idempotent). Parent calls this before advertising the
97
+ // env var; child tolerates a missing tree by treating it as "no parent" (deny).
98
+ export function ensureChannelDir(dir: string): void {
99
+ mkdirSync(requestsDir(dir), { recursive: true, mode: 0o700 });
100
+ mkdirSync(repliesDir(dir), { recursive: true, mode: 0o700 });
101
+ }
102
+
103
+ // Atomic JSON write: write a sibling temp file then rename, so a reader never sees a
104
+ // half-written file (rename is atomic within a dir on POSIX).
105
+ function writeJsonAtomic(path: string, value: unknown): void {
106
+ const tmp = `${path}.${randomUUID()}.tmp`;
107
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
108
+ renameSync(tmp, path);
109
+ }
110
+
111
+ function readJson<T>(path: string): T | undefined {
112
+ try {
113
+ return JSON.parse(readFileSync(path, "utf8")) as T;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ function safeUnlink(path: string): void {
120
+ try {
121
+ rmSync(path, { force: true });
122
+ } catch {
123
+ /* already gone */
124
+ }
125
+ }
126
+
127
+ // ── child side ───────────────────────────────────────────────────────────────
128
+
129
+ export interface AskOptions {
130
+ timeoutMs?: number;
131
+ pollMs?: number;
132
+ signal?: AbortSignal;
133
+ agent?: string;
134
+ }
135
+
136
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000; // match pi-subagents' 10-min ask ceiling
137
+ const DEFAULT_POLL_MS = 200;
138
+
139
+ // Forward an ask to the parent and await its reply. Resolves to the reply, or null
140
+ // on timeout / abort / no channel — every non-answer is a fail-closed null so the
141
+ // caller (the gate) denies. Safe to call from a headless child with no stdio.
142
+ export async function askParent(dir: string, ask: SubagentAsk, opts: AskOptions = {}): Promise<SubagentReply | null> {
143
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
145
+ const id = randomUUID();
146
+ const reqPath = join(requestsDir(dir), `${id}.json`);
147
+ const replyPath = join(repliesDir(dir), `${id}.json`);
148
+ try {
149
+ ensureChannelDir(dir);
150
+ writeJsonAtomic(reqPath, { id, ask, agent: opts.agent } satisfies RequestEnvelope);
151
+ } catch {
152
+ return null; // no channel / unwritable → fail closed
153
+ }
154
+
155
+ const deadline = Date.now() + timeoutMs;
156
+ try {
157
+ for (;;) {
158
+ if (opts.signal?.aborted) return null;
159
+ const reply = readJson<ReplyEnvelope>(replyPath);
160
+ if (reply && reply.id === id) return reply.reply ?? null;
161
+ if (Date.now() >= deadline) return null;
162
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())), opts.signal);
163
+ }
164
+ } finally {
165
+ // Best-effort cleanup so a timed-out/aborted request doesn't linger and a stale
166
+ // reply doesn't confuse a later run (ids are unique, but keep the dir tidy).
167
+ safeUnlink(reqPath);
168
+ safeUnlink(replyPath);
169
+ }
170
+ }
171
+
172
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
173
+ return new Promise((resolve) => {
174
+ if (ms <= 0) return resolve();
175
+ const t = setTimeout(done, ms);
176
+ const onAbort = () => done();
177
+ function done() {
178
+ clearTimeout(t);
179
+ signal?.removeEventListener("abort", onAbort);
180
+ resolve();
181
+ }
182
+ signal?.addEventListener("abort", onAbort, { once: true });
183
+ });
184
+ }
185
+
186
+ // ── parent side ──────────────────────────────────────────────────────────────
187
+
188
+ export interface WatcherHandle {
189
+ stop: () => void;
190
+ }
191
+
192
+ // The parent's answerer: given a child's ask, return the reply (relaying to the app
193
+ // and awaiting the human, in the real wiring). Returning a rejecting/denying reply
194
+ // or throwing both fail closed — the watcher writes a deny/null reply on a throw.
195
+ export type AskHandler = (ask: SubagentAsk, meta: { id: string; agent?: string }) => Promise<SubagentReply>;
196
+
197
+ export interface WatchOptions {
198
+ pollMs?: number;
199
+ onError?: (err: unknown) => void;
200
+ }
201
+
202
+ // Watch the channel for child requests, answer each via `handler`, and write the
203
+ // reply back (then delete the request). Requests are handled at most once — an id is
204
+ // marked in-flight before the async handler runs so a fast poll can't double-serve.
205
+ // A handler that throws yields a fail-closed reply (deny / null value). Returns a
206
+ // handle whose stop() ends the poll loop.
207
+ export function watchSubagentChannel(dir: string, handler: AskHandler, opts: WatchOptions = {}): WatcherHandle {
208
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
209
+ const inFlight = new Set<string>();
210
+ let stopped = false;
211
+ let timer: ReturnType<typeof setTimeout> | undefined;
212
+
213
+ const failClosed = (ask: SubagentAsk): SubagentReply =>
214
+ ask.type === "approval" ? { decision: "deny" } : { value: null };
215
+
216
+ const serve = (id: string, env: RequestEnvelope): void => {
217
+ inFlight.add(id);
218
+ void Promise.resolve()
219
+ .then(() => handler(env.ask, { id, agent: env.agent }))
220
+ .catch((err) => {
221
+ opts.onError?.(err);
222
+ return failClosed(env.ask);
223
+ })
224
+ .then((reply) => {
225
+ try {
226
+ writeJsonAtomic(join(repliesDir(dir), `${id}.json`), { id, reply } satisfies ReplyEnvelope);
227
+ } catch (err) {
228
+ opts.onError?.(err);
229
+ } finally {
230
+ safeUnlink(join(requestsDir(dir), `${id}.json`));
231
+ inFlight.delete(id);
232
+ }
233
+ });
234
+ };
235
+
236
+ const tick = (): void => {
237
+ if (stopped) return;
238
+ try {
239
+ ensureChannelDir(dir);
240
+ for (const name of readdirSync(requestsDir(dir))) {
241
+ if (!name.endsWith(".json")) continue;
242
+ const id = name.slice(0, -5);
243
+ if (inFlight.has(id)) continue;
244
+ const env = readJson<RequestEnvelope>(join(requestsDir(dir), name));
245
+ if (env && env.id === id && env.ask) serve(id, env);
246
+ }
247
+ } catch (err) {
248
+ opts.onError?.(err);
249
+ }
250
+ if (!stopped) timer = setTimeout(tick, pollMs);
251
+ };
252
+
253
+ tick();
254
+
255
+ return {
256
+ stop: () => {
257
+ stopped = true;
258
+ if (timer) clearTimeout(timer);
259
+ },
260
+ };
261
+ }
@@ -0,0 +1,126 @@
1
+ // Adapters that connect the subagent approval channel (src/remote/subagentChannel.ts)
2
+ // to the two ends that use it:
3
+ //
4
+ // • CHILD side — makeChildGateAsk(): an `Asker` the discovered permission-gate uses
5
+ // as its `localAsk` when running inside a subagent child. Instead of denying a
6
+ // gated action headlessly, it forwards the approval to the parent and maps the
7
+ // reply to an AskOutcome. Fail-closed: no channel / timeout / deny → "deny".
8
+ //
9
+ // • PARENT side — startParentApprovalRelay(): a top-level privateer session ensures
10
+ // the channel dir, advertises it to descendants via env, and watches it — relaying
11
+ // each child ask to the app over its RemoteBridge (remoteAsk / selectRemote /
12
+ // inputRemote) and writing the answer back. When no controller is attached the
13
+ // bridge's own fail-closed posture denies, so an undriven terminal never
14
+ // auto-approves a subagent's gated action.
15
+ //
16
+ // The channel dir is per ROOT parent and inherited by every (nested) descendant, so
17
+ // an approval raised at any subagent depth reaches the one session that holds the app
18
+ // relay. A subagent child never starts its own watcher — it only forwards.
19
+
20
+ import type { PermissionRequest } from "../permissions/gate.ts";
21
+ import type { AskOutcome, Asker } from "../permissions/modeGate.ts";
22
+ import type { SelectRequest, InputRequest } from "./remoteBridge.ts";
23
+ import {
24
+ askParent,
25
+ watchSubagentChannel,
26
+ ensureChannelDir,
27
+ channelDirForSession,
28
+ SUBAGENT_CHANNEL_ENV,
29
+ type SubagentAsk,
30
+ type SubagentReply,
31
+ type WatcherHandle,
32
+ } from "./subagentChannel.ts";
33
+ import { randomUUID } from "node:crypto";
34
+
35
+ // True when this process IS a subagent child (pi-subagents sets PI_SUBAGENT_CHILD=1).
36
+ export function isSubagentChild(): boolean {
37
+ return process.env.PI_SUBAGENT_CHILD === "1";
38
+ }
39
+
40
+ // The channel dir advertised to this process (set by the root parent, inherited by
41
+ // every child through the environment). Undefined → no relay wired → fail closed.
42
+ export function inheritedChannelDir(): string | undefined {
43
+ const d = process.env[SUBAGENT_CHANNEL_ENV]?.trim();
44
+ return d || undefined;
45
+ }
46
+
47
+ // This subagent's role name (for display/audit on the parent), best-effort from env.
48
+ function childAgentName(): string | undefined {
49
+ return process.env.PI_SUBAGENT_CHILD_AGENT?.trim() || undefined;
50
+ }
51
+
52
+ // ── child side ───────────────────────────────────────────────────────────────
53
+
54
+ // Build the `localAsk` a subagent child's gate should use: forward the approval to
55
+ // the parent over the inherited channel. A child NEVER returns "always" (it must not
56
+ // mutate the human's allowlist/mode); only "allow"/"deny". If no channel is wired,
57
+ // or the parent doesn't answer, it denies — the same fail-closed stance as a headless
58
+ // gate with no UI.
59
+ export function makeChildGateAsk(dir: string): Asker {
60
+ const agent = childAgentName();
61
+ return async (req: PermissionRequest): Promise<AskOutcome> => {
62
+ const ask: SubagentAsk = { type: "approval", kind: req.kind, title: req.title, detail: req.detail };
63
+ const reply = await askParent(dir, ask, { agent });
64
+ return reply?.decision === "allow" ? "allow" : "deny";
65
+ };
66
+ }
67
+
68
+ // ── parent side ──────────────────────────────────────────────────────────────
69
+
70
+ // The subset of RemoteBridge the parent relay needs. RemoteBridge implements it.
71
+ export interface ApprovalRelayBridge {
72
+ isConnected(): boolean;
73
+ remoteAsk(req: PermissionRequest, signal?: AbortSignal): Promise<AskOutcome>;
74
+ selectRemote(req: SelectRequest, signal?: AbortSignal): Promise<string | null>;
75
+ inputRemote(req: InputRequest, signal?: AbortSignal): Promise<string | null>;
76
+ }
77
+
78
+ // Map one child ask to the app over the bridge and return the reply. When no
79
+ // controller is attached the bridge fails closed (remoteAsk→"deny", select/input→
80
+ // null), so an undriven terminal denies a subagent's gated action rather than
81
+ // auto-approving it.
82
+ export async function relayAskToApp(bridge: ApprovalRelayBridge, ask: SubagentAsk): Promise<SubagentReply> {
83
+ switch (ask.type) {
84
+ case "approval": {
85
+ const outcome = await bridge.remoteAsk({
86
+ tool: "subagent",
87
+ kind: (ask.kind as PermissionRequest["kind"]) ?? "bash",
88
+ title: ask.title,
89
+ detail: ask.detail,
90
+ });
91
+ return { decision: outcome === "deny" ? "deny" : "allow" };
92
+ }
93
+ case "select": {
94
+ const value = await bridge.selectRemote({ title: ask.title, options: ask.options, current: ask.current });
95
+ return { value };
96
+ }
97
+ case "input": {
98
+ const value = await bridge.inputRemote({ title: ask.title, placeholder: ask.placeholder });
99
+ return { value };
100
+ }
101
+ }
102
+ }
103
+
104
+ export interface ParentRelayHandle extends WatcherHandle {
105
+ dir: string;
106
+ }
107
+
108
+ // Start the parent-side approval relay for a top-level session. Ensures the channel
109
+ // dir, advertises it to descendants via SUBAGENT_CHANNEL_ENV (only if not already
110
+ // set — a nested privateer session, which shouldn't happen, must not clobber the
111
+ // root's), and watches it, relaying each ask to the app over `bridge`. Returns a
112
+ // handle whose stop() ends the watcher (call on session teardown).
113
+ //
114
+ // No-op-ish for a subagent child: a child must never watch (it has no app relay) — it
115
+ // only forwards. Callers are top-level sessions, but this guards anyway.
116
+ export function startParentApprovalRelay(
117
+ bridge: ApprovalRelayBridge,
118
+ opts: { onError?: (err: unknown) => void } = {},
119
+ ): ParentRelayHandle | null {
120
+ if (isSubagentChild()) return null;
121
+ const dir = inheritedChannelDir() ?? channelDirForSession(`${process.pid}-${randomUUID()}`);
122
+ process.env[SUBAGENT_CHANNEL_ENV] = dir; // advertise to children spawned after this
123
+ ensureChannelDir(dir);
124
+ const watcher = watchSubagentChannel(dir, (ask) => relayAskToApp(bridge, ask), { onError: opts.onError });
125
+ return { dir, stop: watcher.stop };
126
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Workflow management for the app (§8.3).
3
+ *
4
+ * The sibling of routinesControl.ts / channelsControl.ts, for declarative workflow
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.
9
+ *
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 /
12
+ * remove / run) via guardControl BEFORE calling save/remove/run here — a forged save
13
+ * would plant a script step, a forged run would execute one. `list`/`get` are read-only.
14
+ *
15
+ * Scheduling is NOT here: a workflow is triggered by a routines.json entry naming it
16
+ * (§8.3), so enabled/cron/bookkeeping live in the routines layer. This control only
17
+ * creates/edits/removes the graph and runs one on demand.
18
+ */
19
+ import {
20
+ loadWorkflows,
21
+ loadWorkflow,
22
+ findWorkflow,
23
+ saveWorkflow,
24
+ removeWorkflow,
25
+ } from "../workflows/store.ts";
26
+ import { Workflow, validateWorkflow, newWorkflowId } from "../workflows/schema.ts";
27
+
28
+ // A compact summary for the app's list screen — cheap to render, no full graph. The
29
+ // editor fetches the whole Workflow separately via `get`.
30
+ export interface WorkflowSummary {
31
+ id: string;
32
+ name: string;
33
+ description?: string;
34
+ entryPoint: string;
35
+ // Counts across the flat graph, so the card can show "5 steps · 2 gates" at a glance.
36
+ stepCount: number;
37
+ gateCount: number;
38
+ scriptCount: number;
39
+ }
40
+
41
+ export interface WorkflowsControl {
42
+ // All saved workflows as summaries, sorted by name.
43
+ list(): WorkflowSummary[];
44
+ // The full graph for one workflow (the app's editor), or undefined if absent.
45
+ get(idOrName: string): Workflow | undefined;
46
+ // Create (no workflow.id) or overwrite (existing id) a workflow. Validates the strict
47
+ // schema AND the route graph; rejects a name that collides with a different workflow.
48
+ save(draft: unknown): { ok: boolean; message?: string; id?: string };
49
+ // Remove a workflow by id or name. ok:false when nothing matched.
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
52
+ // runner isn't wired.
53
+ run(idOrName: string): { ok: boolean; message?: string };
54
+ }
55
+
56
+ function summarize(wf: Workflow): WorkflowSummary {
57
+ return {
58
+ id: wf.workflow.id,
59
+ name: wf.workflow.name,
60
+ description: wf.workflow.description,
61
+ entryPoint: wf.workflow.entry_point,
62
+ stepCount: wf.steps.length + wf.parallel.length + wf.for_each.length,
63
+ gateCount: wf.steps.filter((s) => s.type === "human_gate").length,
64
+ scriptCount: wf.steps.filter((s) => s.type === "script").length,
65
+ };
66
+ }
67
+
68
+ // Pull an id out of an untrusted draft's header without trusting its shape.
69
+ function draftId(draft: unknown): string | undefined {
70
+ const header = (draft as { workflow?: { id?: unknown } } | null)?.workflow;
71
+ return typeof header?.id === "string" ? header.id : undefined;
72
+ }
73
+
74
+ export function makeWorkflowsControl(opts: {
75
+ // Fire a workflow now — injected by the daemon (it owns the runner + its seams).
76
+ // Absent → run is reported unavailable rather than silently dropped (mirrors routines).
77
+ runNow?: (wf: Workflow) => void;
78
+ }): WorkflowsControl {
79
+ return {
80
+ list(): WorkflowSummary[] {
81
+ return loadWorkflows().map(summarize);
82
+ },
83
+
84
+ get(idOrName: string): Workflow | undefined {
85
+ return loadWorkflow((idOrName ?? "").trim());
86
+ },
87
+
88
+ save(draft: unknown): { ok: boolean; message?: string; id?: string } {
89
+ // An edit keeps the draft's id; a create mints a fresh one. We inject the id into
90
+ // the header BEFORE parsing so the required `workflow.id` is always present and the
91
+ // client can't smuggle a malformed one (the schema + workflowFilePath re-check shape).
92
+ const existingId = draftId(draft);
93
+ const id = existingId ?? newWorkflowId();
94
+ const header = (draft as { workflow?: Record<string, unknown> } | null)?.workflow ?? {};
95
+ const candidate = { ...(draft as object), workflow: { ...header, id } };
96
+
97
+ const parsed = Workflow.safeParse(candidate);
98
+ if (!parsed.success) {
99
+ const first = parsed.error.issues[0];
100
+ return { ok: false, message: `Invalid workflow: ${first ? `${first.path.join(".")} — ${first.message}` : "schema error"}.` };
101
+ }
102
+ const wf = parsed.data;
103
+
104
+ const graphErrors = validateWorkflow(wf);
105
+ if (graphErrors.length > 0) return { ok: false, message: `Invalid graph: ${graphErrors[0]}.` };
106
+
107
+ // A rename must not collide with a *different* workflow's name (mirrors routines).
108
+ const clash = loadWorkflows().find(
109
+ (w) => w.workflow.name.toLowerCase() === wf.workflow.name.toLowerCase() && w.workflow.id !== id,
110
+ );
111
+ if (clash) return { ok: false, message: `A workflow named "${wf.workflow.name}" already exists.` };
112
+
113
+ saveWorkflow(wf);
114
+ return { ok: true, id, message: existingId ? `Updated "${wf.workflow.name}".` : `Created "${wf.workflow.name}".` };
115
+ },
116
+
117
+ remove(idOrName: string): { ok: boolean; message?: string } {
118
+ const removed = removeWorkflow((idOrName ?? "").trim());
119
+ return removed
120
+ ? { ok: true, message: `Removed "${removed.workflow.name}".` }
121
+ : { ok: false, message: "Not found." };
122
+ },
123
+
124
+ run(idOrName: string): { ok: boolean; message?: string } {
125
+ const wf = findWorkflow(loadWorkflows(), (idOrName ?? "").trim());
126
+ if (!wf) return { ok: false, message: "Not found." };
127
+ if (!opts.runNow) return { ok: false, message: "The runner can't run this right now." };
128
+ opts.runNow(wf);
129
+ return { ok: true, message: `Running "${wf.workflow.name}" now.` };
130
+ },
131
+ };
132
+ }
@@ -209,10 +209,14 @@ export function drainPendingRelay(): PendingRelay[] {
209
209
  // disk until a later flush succeeds. Unlike PendingRelay this carries `status`, so
210
210
  // the sealed envelope the app opens can render ok/error without re-parsing markdown.
211
211
  export interface PendingCloud {
212
- routine: string;
212
+ routine: string; // the routine name OR ad-hoc task title (see `kind`)
213
213
  at: string; // ISO timestamp
214
214
  status: "ok" | "error";
215
215
  content: string;
216
+ // What produced this — a scheduled routine (default, for back-compat with items
217
+ // written before ad-hoc tasks existed) or an app-submitted one-shot task. Preserved
218
+ // so the flush re-seals with the right `kind` and the app labels it correctly.
219
+ kind?: "routine" | "task";
216
220
  }
217
221
 
218
222
  function pendingCloudPath(): string {
@@ -0,0 +1,4 @@
1
+ // The confined workflow expression/template engine now lives in the standalone
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.
4
+ export * from "privateer-workflow/expr";
@@ -0,0 +1,8 @@
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
3
+ // `../workflows/runner.ts` import paths keep working unchanged.
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
7
+ // — that seam is unchanged; only the engine's source moved out to the shared package.
8
+ export * from "privateer-workflow/runner";
@@ -0,0 +1,5 @@
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
3
+ // `../workflows/schema.ts` import paths (schema.ts is also the store's dependency) keep
4
+ // working unchanged.
5
+ export * from "privateer-workflow/schema";
@@ -0,0 +1,108 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { globalDir } from "../config/paths.ts";
4
+ import { Workflow, validateWorkflow } from "./schema.ts";
5
+
6
+ /**
7
+ * Workflow persistence — the sibling of routines/store.ts, but ONE FILE PER WORKFLOW.
8
+ *
9
+ * Routines are many small records in a single routines.json; a workflow is a larger,
10
+ * authored graph, so each lives in its own file under ~/.privateer/workflows/ (§8.3).
11
+ * The canonical on-disk format the app writes is `w-<id>.json`. Hand-authored `*.yaml`
12
+ * is a deliberate follow-up (needs a YAML parser + an async loader) — this skeleton
13
+ * stays dependency-free by reading/writing JSON only.
14
+ *
15
+ * A workflow file is an EXECUTABLE control artifact (it can run `script` steps), so —
16
+ * exactly like routines.json — every file is written owner-only (0600) inside the
17
+ * owner-only (0700) global dir. The signed-frame gate (workflowsControl + authorizeControl)
18
+ * is what stops the untrusted relay from writing one; these fs perms stop other local
19
+ * users from reading/altering it (the §6.5 machine-local trust root).
20
+ */
21
+
22
+ export function workflowsDir(): string {
23
+ return join(globalDir(), "workflows");
24
+ }
25
+
26
+ // The canonical path for a workflow id. Ids are `w-<ts>` (see newWorkflowId) — already
27
+ // filesystem-safe — but we re-assert the shape so a hostile id can never escape the dir.
28
+ export function workflowFilePath(id: string): string {
29
+ if (!/^w-[a-zA-Z0-9]+$/.test(id)) throw new Error(`unsafe workflow id "${id}"`);
30
+ return join(workflowsDir(), `${id}.json`);
31
+ }
32
+
33
+ function tryChmod(path: string, mode: number): void {
34
+ try {
35
+ chmodSync(path, mode);
36
+ } catch {
37
+ /* non-POSIX filesystem or insufficient perms — nothing we can do */
38
+ }
39
+ }
40
+
41
+ // Parse + fully validate one file's contents into a Workflow, or null if it's corrupt,
42
+ // fails the strict schema, or has a broken route graph. Per-file so ONE bad file never
43
+ // hides the rest (unlike routines' single-file all-or-nothing load).
44
+ function parseWorkflow(raw: string): Workflow | null {
45
+ try {
46
+ const parsed = Workflow.parse(JSON.parse(raw));
47
+ if (validateWorkflow(parsed).length > 0) return null;
48
+ return parsed;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
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.
56
+ export function loadWorkflows(): Workflow[] {
57
+ const dir = workflowsDir();
58
+ if (!existsSync(dir)) return [];
59
+ const out: Workflow[] = [];
60
+ for (const entry of readdirSync(dir)) {
61
+ if (!entry.endsWith(".json")) continue; // .yaml authoring is a follow-up
62
+ try {
63
+ const wf = parseWorkflow(readFileSync(join(dir, entry), "utf8"));
64
+ if (wf) out.push(wf);
65
+ } catch {
66
+ /* unreadable file — skip */
67
+ }
68
+ }
69
+ return out.sort((a, b) => a.workflow.name.localeCompare(b.workflow.name));
70
+ }
71
+
72
+ // Look up by id first, then by (case-insensitive) name — mirrors findRoutine.
73
+ export function findWorkflow(workflows: Workflow[], idOrName: string): Workflow | undefined {
74
+ const needle = idOrName.trim().toLowerCase();
75
+ return (
76
+ workflows.find((w) => w.workflow.id === idOrName) ??
77
+ workflows.find((w) => w.workflow.name.toLowerCase() === needle)
78
+ );
79
+ }
80
+
81
+ // Load a single workflow by id or name (the app's editor fetches the full graph).
82
+ export function loadWorkflow(idOrName: string): Workflow | undefined {
83
+ return findWorkflow(loadWorkflows(), idOrName);
84
+ }
85
+
86
+ // Write a workflow to its own file (create or overwrite by id). Caller has already
87
+ // schema-validated it (workflowsControl.save) — we re-assert nothing here beyond the
88
+ // id-shape guard in workflowFilePath.
89
+ export function saveWorkflow(wf: Workflow): void {
90
+ const dir = workflowsDir();
91
+ mkdirSync(dir, { recursive: true });
92
+ tryChmod(dir, 0o700);
93
+ const path = workflowFilePath(wf.workflow.id);
94
+ writeFileSync(path, JSON.stringify(wf, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
95
+ tryChmod(path, 0o600);
96
+ }
97
+
98
+ // Remove a workflow by id or name. Returns the removed workflow, or null if absent.
99
+ export function removeWorkflow(idOrName: string): Workflow | null {
100
+ const target = findWorkflow(loadWorkflows(), idOrName);
101
+ if (!target) return null;
102
+ try {
103
+ unlinkSync(workflowFilePath(target.workflow.id));
104
+ } catch {
105
+ return null; // already gone / unwritable
106
+ }
107
+ return target;
108
+ }