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.
- package/README.md +20 -20
- package/SECURITY.md +1 -1
- package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
- package/bin/privateer-launch.mjs +7 -6
- package/bin/privateer-subagent.mjs +1 -1
- package/extensions/privateer-brand.ts +1 -1
- package/extensions/privateer-connect.ts +14 -3
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +1 -1
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +5 -5
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +29 -3
- package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
- package/src/config/hosted.ts +5 -5
- package/src/crypto/accountTrust.ts +2 -2
- package/src/crypto/accountVerify.ts +1 -1
- package/src/{daemon → harbor}/index.ts +37 -37
- package/src/{daemon → harbor}/ipc.ts +19 -19
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/providers/account.ts +1 -1
- package/src/providers/defaultModel.ts +1 -1
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +4 -4
- package/src/remote/mcpControl.ts +2 -2
- package/src/remote/relayClient.ts +41 -21
- package/src/remote/remoteBridge.ts +17 -7
- package/src/remote/routinesControl.ts +7 -7
- package/src/remote/workflowsControl.ts +6 -6
- package/src/routines/delivery.ts +6 -6
- package/src/routines/schema.ts +3 -3
- package/src/routines/store.ts +3 -3
- package/src/routines/trigger.ts +1 -1
- package/src/tools/routine.ts +8 -8
- package/src/util/fileMentions.ts +232 -0
- package/src/workflows/expr.ts +1 -1
- package/src/workflows/runner.ts +3 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// A live, app-drivable agent session spawned on demand by the
|
|
2
|
-
// mode:"live"). Unlike a headless task (
|
|
1
|
+
// A live, app-drivable agent session spawned on demand by the harbor (task_spawn,
|
|
2
|
+
// mode:"live"). Unlike a headless task (harbor/index.ts runTask → restricted tools +
|
|
3
3
|
// bypass gate + outbox), this stands up a FULL interactive session behind its own relay
|
|
4
4
|
// terminal so the app can attach and drive it in real time: stream tokens, approve each
|
|
5
5
|
// tool, interrupt. It is the same wiring cli/chat.ts uses for `/remote-access`, factored
|
|
6
|
-
// so the
|
|
6
|
+
// so the harbor can create one without a TTY.
|
|
7
7
|
//
|
|
8
8
|
// SAFETY: the gate runs in "default" mode, so on a driven turn every gated tool relays to
|
|
9
9
|
// the app for allow/deny (bridge.remoteAsk) and fail-closes if the controller is gone —
|
|
@@ -70,7 +70,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
70
70
|
if (lifeTimer) clearTimeout(lifeTimer);
|
|
71
71
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
72
72
|
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
73
|
-
// app's Linked Devices; the
|
|
73
|
+
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
74
74
|
if (spawnedAccount) {
|
|
75
75
|
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
76
76
|
try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
|
package/src/remote/mcpControl.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* MCP connector management for the app — the sibling of channelsControl.ts, but for
|
|
3
3
|
* MCP server config rather than messaging channels. It is what lets the phone/web
|
|
4
4
|
* client add, toggle, and remove MCP connectors on a Node HOST it drives over the
|
|
5
|
-
* relay (the
|
|
5
|
+
* relay (the harbor today; an interactive terminal by the same shape).
|
|
6
6
|
*
|
|
7
7
|
* The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
|
|
8
8
|
* stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* returns an env VALUE — only which env keys exist (`envKeys`) and which are non-empty
|
|
21
21
|
* (`secretsSet`), by name. save() persists whatever env VALUES it is handed in
|
|
22
22
|
* `draft.env`; the seal/open of those values in transit is the caller's job (the
|
|
23
|
-
*
|
|
23
|
+
* harbor opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
|
|
24
24
|
* this module only ever deals in the plaintext files it already owns.
|
|
25
25
|
*
|
|
26
26
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
@@ -45,7 +45,7 @@ function tsOf(frame: { ts?: unknown }): number | undefined {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Parse a task_submit/task_spawn frame into a TaskSpec, keeping ONLY well-typed,
|
|
48
|
-
// present fields (absent → left undefined). The
|
|
48
|
+
// present fields (absent → left undefined). The harbor re-derives the canonical signed
|
|
49
49
|
// args from this (taskControlArgs, undefined → null), so it must not invent fields.
|
|
50
50
|
export function parseTaskSpec(frame: {
|
|
51
51
|
prompt?: string;
|
|
@@ -79,10 +79,10 @@ function safe(s: string, max: number): string {
|
|
|
79
79
|
return clip(redactSecrets(s), max);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// An ad-hoc task the app asks the
|
|
82
|
+
// An ad-hoc task the app asks the harbor to run headlessly (task_submit) or to spawn
|
|
83
83
|
// as a live-drivable session (task_spawn). Only `prompt` is required; the rest fall back
|
|
84
|
-
// to
|
|
85
|
-
// envelope (client/services/accountSign.ts) and the
|
|
84
|
+
// to harbor defaults. The SAME field set is what the app canonical-signs into the control
|
|
85
|
+
// envelope (client/services/accountSign.ts) and the harbor re-derives to verify (index.ts
|
|
86
86
|
// taskControlArgs) — keep the two in sync, byte for byte, like the other signed frames.
|
|
87
87
|
export interface TaskSpec {
|
|
88
88
|
prompt: string;
|
|
@@ -99,7 +99,7 @@ export interface RelayCallbacks {
|
|
|
99
99
|
onInterrupt: () => void;
|
|
100
100
|
// The app asked to turn remote access OFF entirely (the in-app "End remote
|
|
101
101
|
// access" action). The owner should disable /remote-access — i.e. stop this
|
|
102
|
-
// client and not reconnect. Optional: the routines
|
|
102
|
+
// client and not reconnect. Optional: the routines harbor handles it too, but
|
|
103
103
|
// callbacks that predate it keep compiling.
|
|
104
104
|
onTerminate?: () => void;
|
|
105
105
|
// The account signed this terminal out server-side (revoked from the app's
|
|
@@ -127,6 +127,10 @@ export interface RelayCallbacks {
|
|
|
127
127
|
// The app answered a CLI-initiated text-input prompt (the id from requestInput).
|
|
128
128
|
// A null value means the app dismissed the prompt without submitting.
|
|
129
129
|
onInputResponse?: (id: string, value: string | null) => void;
|
|
130
|
+
// The app's composer is autocompleting an `@file` mention — reply with the cwd
|
|
131
|
+
// files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
|
|
132
|
+
// Read-only; resolution of the picked path still happens on the prompt turn.
|
|
133
|
+
onFilesSearch?: (id: string, query: string) => void;
|
|
130
134
|
// The app opened the extensions manager — reply with the current installed list
|
|
131
135
|
// (a sendExtensions frame). Optional so pre-extensions callbacks keep compiling.
|
|
132
136
|
onExtensionsList?: () => void;
|
|
@@ -147,10 +151,10 @@ export interface RelayCallbacks {
|
|
|
147
151
|
// The app toggled a user skill's model-invocation availability.
|
|
148
152
|
onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
|
|
149
153
|
// The app opened the routines manager — reply with the current routines list
|
|
150
|
-
// (a sendRoutines frame). Owned by the
|
|
154
|
+
// (a sendRoutines frame). Owned by the harbor, so these only fire on its relay.
|
|
151
155
|
onRoutinesList?: () => void;
|
|
152
156
|
// The app asked to create (no id) or edit (id) a routine. The raw draft object is
|
|
153
|
-
// handed through untyped; the
|
|
157
|
+
// handed through untyped; the harbor's routinesControl validates it. Signed (H2) —
|
|
154
158
|
// a forged routine runs a headless bypass-mode session (RCE), so it MUST be verified.
|
|
155
159
|
onRoutinesSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
|
|
156
160
|
// The app asked to delete a routine by id or name.
|
|
@@ -163,14 +167,14 @@ export interface RelayCallbacks {
|
|
|
163
167
|
// right now — a fresh restricted-tool bypass session whose result is sealed to the
|
|
164
168
|
// account outbox. Signed (H2) — a forged task_submit runs an arbitrary headless
|
|
165
169
|
// session (RCE), identical in blast radius to a forged routines_run, so it MUST be
|
|
166
|
-
// verified via controlAuth before it runs.
|
|
170
|
+
// verified via controlAuth before it runs. Harbor-owned (fires only on its relay).
|
|
167
171
|
onTaskSubmit?: (spec: TaskSpec, sig?: string, ts?: number) => void;
|
|
168
172
|
// The app asked to SPAWN a fresh interactive session it can drive live (mode:"live").
|
|
169
|
-
// The
|
|
173
|
+
// The harbor stands up a new RemoteBridge terminal and replies (sendTaskSpawned) with
|
|
170
174
|
// its termId so the app can attach. Same signed-RCE gate as task_submit.
|
|
171
175
|
onTaskSpawn?: (spec: TaskSpec, sig?: string, ts?: number) => void;
|
|
172
176
|
// The app opened the channels manager — reply with the current channel config
|
|
173
|
-
// (a sendChannels frame). Owned by the
|
|
177
|
+
// (a sendChannels frame). Owned by the harbor, so these only fire on its relay.
|
|
174
178
|
onChannelsList?: () => void;
|
|
175
179
|
// The app asked to create/edit a platform's channel config. `draft` carries only
|
|
176
180
|
// NON-secret fields (roles/posture/tools/model). `sealedSecrets`, when present, is
|
|
@@ -183,7 +187,7 @@ export interface RelayCallbacks {
|
|
|
183
187
|
// (H2) — a forged removal is a DoS (the bot stops until re-added).
|
|
184
188
|
onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
|
|
185
189
|
// The app opened the MCP connectors manager — reply with the current MCP config
|
|
186
|
-
// (a sendMcp frame). Owned by the
|
|
190
|
+
// (a sendMcp frame). Owned by the harbor (the host that runs the adapter), so these
|
|
187
191
|
// only fire on its relay. Read-only, so unsigned.
|
|
188
192
|
onMcpList?: () => void;
|
|
189
193
|
// The app asked to create/edit an MCP connector. `draft` carries only NON-secret
|
|
@@ -199,12 +203,12 @@ export interface RelayCallbacks {
|
|
|
199
203
|
// The app asked to delete a connector by name. Signed (H2) — a forged removal is a DoS.
|
|
200
204
|
onMcpRemove?: (name: string, sig?: string, ts?: number) => void;
|
|
201
205
|
// The app opened the workflows manager — reply with the current workflow summaries
|
|
202
|
-
// (a sendWorkflows frame). Owned by the
|
|
206
|
+
// (a sendWorkflows frame). Owned by the harbor, so these only fire on its relay.
|
|
203
207
|
onWorkflowsList?: () => void;
|
|
204
208
|
// The app opened one workflow in its editor — reply with the full graph (sendWorkflow).
|
|
205
209
|
onWorkflowsGet?: (idOrName: string) => void;
|
|
206
210
|
// The app asked to create (no workflow.id) or edit (id) a workflow. The raw graph is
|
|
207
|
-
// handed through untyped; the
|
|
211
|
+
// handed through untyped; the harbor's workflowsControl strict-validates it. Signed
|
|
208
212
|
// (H2) — a forged save plants a `script` step that BYPASSES the permission gate (RCE),
|
|
209
213
|
// exactly like a forged routine, so it MUST be verified before it persists.
|
|
210
214
|
onWorkflowsSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
|
|
@@ -286,7 +290,7 @@ export class RelayClient {
|
|
|
286
290
|
private buf = "";
|
|
287
291
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
288
292
|
// Stable for this process so reconnects keep the same terminal identity. Callers
|
|
289
|
-
// may pass a persisted id/label (e.g. the routines
|
|
293
|
+
// may pass a persisted id/label (e.g. the routines harbor, so it shows up as one
|
|
290
294
|
// recognizable "Privateer Routines" terminal across restarts instead of a fresh
|
|
291
295
|
// random one each time).
|
|
292
296
|
private readonly termId: string;
|
|
@@ -424,6 +428,7 @@ export class RelayClient {
|
|
|
424
428
|
title?: string;
|
|
425
429
|
tools?: unknown;
|
|
426
430
|
mode?: string;
|
|
431
|
+
query?: string;
|
|
427
432
|
sig?: string;
|
|
428
433
|
ts?: number;
|
|
429
434
|
};
|
|
@@ -467,6 +472,9 @@ export class RelayClient {
|
|
|
467
472
|
case "input_response":
|
|
468
473
|
if (frame.id) this.cb.onInputResponse?.(frame.id, typeof frame.value === "string" ? frame.value : null);
|
|
469
474
|
break;
|
|
475
|
+
case "files_search":
|
|
476
|
+
if (typeof frame.id === "string") this.cb.onFilesSearch?.(frame.id, typeof frame.query === "string" ? frame.query : "");
|
|
477
|
+
break;
|
|
470
478
|
case "extensions_list":
|
|
471
479
|
this.cb.onExtensionsList?.();
|
|
472
480
|
break;
|
|
@@ -540,7 +548,7 @@ export class RelayClient {
|
|
|
540
548
|
break;
|
|
541
549
|
case "mcp_save":
|
|
542
550
|
// The connector rides in `draft` (same slot as channels_save), untyped — the
|
|
543
|
-
//
|
|
551
|
+
// harbor strict-validates it via mcpControl.save after the signature check.
|
|
544
552
|
if (frame.draft && typeof frame.draft === "object") {
|
|
545
553
|
this.cb.onMcpSave?.(
|
|
546
554
|
frame.draft,
|
|
@@ -563,7 +571,7 @@ export class RelayClient {
|
|
|
563
571
|
if (typeof frame.idOrName === "string") this.cb.onWorkflowsGet?.(frame.idOrName);
|
|
564
572
|
break;
|
|
565
573
|
case "workflows_save":
|
|
566
|
-
// The graph rides in `draft` (same slot as channels_save), untyped — the
|
|
574
|
+
// The graph rides in `draft` (same slot as channels_save), untyped — the harbor
|
|
567
575
|
// strict-validates it via workflowsControl.save after the signature check.
|
|
568
576
|
if (frame.draft && typeof frame.draft === "object") this.cb.onWorkflowsSave?.(frame.draft, sig(frame), tsOf(frame));
|
|
569
577
|
break;
|
|
@@ -667,7 +675,7 @@ export class RelayClient {
|
|
|
667
675
|
}
|
|
668
676
|
|
|
669
677
|
// Push a workflow's live step text / final result to any attached controller as a text
|
|
670
|
-
// event, so it renders in the
|
|
678
|
+
// event, so it renders in the harbor terminal's feed. Same durable-copy caveat as
|
|
671
679
|
// sendRoutineResult (the outbox is the source of truth).
|
|
672
680
|
sendWorkflowResult(name: string, content: string): boolean {
|
|
673
681
|
if (!this.isConnected()) return false;
|
|
@@ -678,7 +686,7 @@ export class RelayClient {
|
|
|
678
686
|
|
|
679
687
|
// Tell the app that a live task session was stood up on `termId` (label for display),
|
|
680
688
|
// so it can open a controller connection to that terminal and drive it. Fire-and-forget
|
|
681
|
-
// over the
|
|
689
|
+
// over the harbor's management relay.
|
|
682
690
|
sendTaskSpawned(termId: string, label: string): void {
|
|
683
691
|
this.rawSend({ type: "task_spawned", termId, label });
|
|
684
692
|
}
|
|
@@ -771,6 +779,18 @@ export class RelayClient {
|
|
|
771
779
|
});
|
|
772
780
|
}
|
|
773
781
|
|
|
782
|
+
// Reply to an `@file` autocomplete query with the matching cwd files/dirs. Keyed by
|
|
783
|
+
// the query's id so the app can match it to the in-flight request. Bounded; paths are
|
|
784
|
+
// cwd-relative (the caller — searchFiles — never emits a path outside the cwd subtree,
|
|
785
|
+
// so no machine location leaks beyond the project filenames the driver is browsing).
|
|
786
|
+
sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void {
|
|
787
|
+
this.rawSend({
|
|
788
|
+
type: "file_matches",
|
|
789
|
+
id,
|
|
790
|
+
matches: matches.slice(0, 50).map((m) => ({ path: safe(m.path, 300), isDir: !!m.isDir })),
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
774
794
|
// Push the terminal's installed Pi extensions (the user's own packages; the moat
|
|
775
795
|
// is excluded upstream) to the app's extensions manager. Sent on request and after
|
|
776
796
|
// each add/remove. `busy` drives a progress indicator; `needsRestart` tells the app
|
|
@@ -823,7 +843,7 @@ export class RelayClient {
|
|
|
823
843
|
});
|
|
824
844
|
}
|
|
825
845
|
|
|
826
|
-
// Push the
|
|
846
|
+
// Push the harbor's saved routines to the app's routines manager. Sent on request
|
|
827
847
|
// and after each save/delete/pause/run. `busy` drives a progress indicator;
|
|
828
848
|
// `message` carries a one-line result/error. Unlike the feed/webhook paths this is
|
|
829
849
|
// the user's OWN config echoed back to their OWN app, so fields are size-clipped
|
|
@@ -873,7 +893,7 @@ export class RelayClient {
|
|
|
873
893
|
});
|
|
874
894
|
}
|
|
875
895
|
|
|
876
|
-
// Push the
|
|
896
|
+
// Push the harbor's channel config to the app's channels manager. Sent on request
|
|
877
897
|
// and after each save/remove. Like sendRoutines this is the user's OWN config
|
|
878
898
|
// echoed to their OWN app — but a bot token NEVER crosses this wire: only
|
|
879
899
|
// `secretsSet` (which secret fields are present, by NAME) is sent, so a relay /
|
|
@@ -953,7 +973,7 @@ export class RelayClient {
|
|
|
953
973
|
});
|
|
954
974
|
}
|
|
955
975
|
|
|
956
|
-
// Push the
|
|
976
|
+
// Push the harbor's saved workflows to the app's workflows manager as SUMMARIES (not
|
|
957
977
|
// the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
|
|
958
978
|
// and after each save/remove/run. The user's OWN config echoed to their OWN app, so
|
|
959
979
|
// fields are clipped (not redacted). List bounded like the other managers.
|
|
@@ -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
|
|
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
|
|
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
|
|
156
|
-
// not an interactive session — the
|
|
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
|
|
162
|
-
// adapter) — the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
7
|
-
* run seam. Like routines, workflows are owned by the
|
|
8
|
-
* scheduler / on-demand), so this control is wired into the
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 {
|
package/src/routines/delivery.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/routines/schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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(),
|
package/src/routines/store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
package/src/routines/trigger.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/src/tools/routine.ts
CHANGED
|
@@ -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
|
|
3
|
-
// unattended (see src/
|
|
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 {
|
|
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
|
|
110
|
-
// file so the routine persists until the
|
|
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
|
|
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
|
|
119
|
+
if (e instanceof HarborNotRunningError) {
|
|
120
120
|
upsertRoutine(routine);
|
|
121
121
|
return text(
|
|
122
|
-
`Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler
|
|
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
|
}
|