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.
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -34,6 +34,36 @@ function terminalLabel(): string {
|
|
|
34
34
|
// bounds size, this bounds obvious secret leakage (bearer tokens, API keys, env
|
|
35
35
|
// secrets, PEM private keys). The "output may contain secrets" warning still
|
|
36
36
|
// stands; a determined leak (unusual formats) can slip through.
|
|
37
|
+
// Pull the account signature / freshness ts off a control frame, if well-formed.
|
|
38
|
+
// Undefined when absent or the wrong type → the terminal's authorizeControl fails
|
|
39
|
+
// closed (an unsigned mutation is refused).
|
|
40
|
+
function sig(frame: { sig?: unknown }): string | undefined {
|
|
41
|
+
return typeof frame.sig === "string" ? frame.sig : undefined;
|
|
42
|
+
}
|
|
43
|
+
function tsOf(frame: { ts?: unknown }): number | undefined {
|
|
44
|
+
return typeof frame.ts === "number" ? frame.ts : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse a task_submit/task_spawn frame into a TaskSpec, keeping ONLY well-typed,
|
|
48
|
+
// present fields (absent → left undefined). The daemon re-derives the canonical signed
|
|
49
|
+
// args from this (taskControlArgs, undefined → null), so it must not invent fields.
|
|
50
|
+
export function parseTaskSpec(frame: {
|
|
51
|
+
prompt?: string;
|
|
52
|
+
cwd?: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
title?: string;
|
|
55
|
+
tools?: unknown;
|
|
56
|
+
}): TaskSpec {
|
|
57
|
+
const spec: TaskSpec = { prompt: typeof frame.prompt === "string" ? frame.prompt : "" };
|
|
58
|
+
if (typeof frame.cwd === "string") spec.cwd = frame.cwd;
|
|
59
|
+
if (typeof frame.model === "string") spec.model = frame.model;
|
|
60
|
+
if (typeof frame.title === "string") spec.title = frame.title;
|
|
61
|
+
if (Array.isArray(frame.tools) && frame.tools.every((t) => typeof t === "string")) {
|
|
62
|
+
spec.tools = frame.tools as string[];
|
|
63
|
+
}
|
|
64
|
+
return spec;
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
function redactSecrets(s: string): string {
|
|
38
68
|
if (!s) return s;
|
|
39
69
|
return s
|
|
@@ -49,6 +79,19 @@ function safe(s: string, max: number): string {
|
|
|
49
79
|
return clip(redactSecrets(s), max);
|
|
50
80
|
}
|
|
51
81
|
|
|
82
|
+
// An ad-hoc task the app asks the daemon to run headlessly (task_submit) or to spawn
|
|
83
|
+
// as a live-drivable session (task_spawn). Only `prompt` is required; the rest fall back
|
|
84
|
+
// to daemon defaults. The SAME field set is what the app canonical-signs into the control
|
|
85
|
+
// envelope (client/services/accountSign.ts) and the daemon re-derives to verify (index.ts
|
|
86
|
+
// taskControlArgs) — keep the two in sync, byte for byte, like the other signed frames.
|
|
87
|
+
export interface TaskSpec {
|
|
88
|
+
prompt: string;
|
|
89
|
+
cwd?: string;
|
|
90
|
+
model?: string;
|
|
91
|
+
tools?: string[];
|
|
92
|
+
title?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
export interface RelayCallbacks {
|
|
53
96
|
// A prompt arrived from the app — feed it into the turn loop (tagged remote).
|
|
54
97
|
onPrompt: (text: string) => void;
|
|
@@ -59,6 +102,13 @@ export interface RelayCallbacks {
|
|
|
59
102
|
// client and not reconnect. Optional: the routines daemon handles it too, but
|
|
60
103
|
// callbacks that predate it keep compiling.
|
|
61
104
|
onTerminate?: () => void;
|
|
105
|
+
// The account signed this terminal out server-side (revoked from the app's
|
|
106
|
+
// Linked Devices). Distinct from onTerminate: that only ends remote-access and
|
|
107
|
+
// leaves the login intact, whereas this wipes the machine login too. The owner
|
|
108
|
+
// should tear down the session (clear credentials, announce it) AND stop the
|
|
109
|
+
// relay. Optional so callbacks that predate the frame keep compiling; an older
|
|
110
|
+
// CLI that ignores it still gets signed out by the ≤25s heartbeat kill.
|
|
111
|
+
onRevoked?: () => void;
|
|
62
112
|
// The app answered a relayed approval request.
|
|
63
113
|
onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
|
|
64
114
|
// The app toggled no-quarter (unattended) mode: remote turns auto-approve like
|
|
@@ -67,6 +117,86 @@ export interface RelayCallbacks {
|
|
|
67
117
|
onNoQuarter?: (on: boolean) => void;
|
|
68
118
|
// A controller attached — push a transcript snapshot so it can catch up.
|
|
69
119
|
onControllerAttached: () => void;
|
|
120
|
+
// The app ran a slash command from its composer (e.g. "/model provider/id").
|
|
121
|
+
// Routed to the same command dispatcher the local REPL uses. Optional so
|
|
122
|
+
// callbacks that predate the app command UI keep compiling.
|
|
123
|
+
onCommand?: (text: string) => void;
|
|
124
|
+
// The app answered a CLI-initiated selection prompt (the id from requestSelect).
|
|
125
|
+
// A null value means the app dismissed the picker without choosing.
|
|
126
|
+
onSelectResponse?: (id: string, value: string | null) => void;
|
|
127
|
+
// The app answered a CLI-initiated text-input prompt (the id from requestInput).
|
|
128
|
+
// A null value means the app dismissed the prompt without submitting.
|
|
129
|
+
onInputResponse?: (id: string, value: string | null) => void;
|
|
130
|
+
// The app opened the extensions manager — reply with the current installed list
|
|
131
|
+
// (a sendExtensions frame). Optional so pre-extensions callbacks keep compiling.
|
|
132
|
+
onExtensionsList?: () => void;
|
|
133
|
+
// The app asked to install a Pi extension by source spec (npm:/git:/path).
|
|
134
|
+
// `sig`+`ts` authenticate the mutation with the account key (H2, verified via
|
|
135
|
+
// controlAuth) — a forged extensions_add would install attacker code, so it's signed.
|
|
136
|
+
onExtensionsAdd?: (source: string, sig?: string, ts?: number) => void;
|
|
137
|
+
// The app asked to remove a previously-installed extension by source spec.
|
|
138
|
+
onExtensionsRemove?: (source: string, sig?: string, ts?: number) => void;
|
|
139
|
+
// The app opened the skills manager — reply with the current skills list
|
|
140
|
+
// (a sendSkills frame). Optional so pre-skills callbacks keep compiling.
|
|
141
|
+
onSkillsList?: () => void;
|
|
142
|
+
// The app asked to create/overwrite a user skill (name + description + body). Signed
|
|
143
|
+
// (H2) — a forged skill would inject an auto-invoked system-prompt instruction.
|
|
144
|
+
onSkillCreate?: (skill: { name: string; description: string; instructions: string }, sig?: string, ts?: number) => void;
|
|
145
|
+
// The app asked to delete a user skill by name.
|
|
146
|
+
onSkillDelete?: (name: string, sig?: string, ts?: number) => void;
|
|
147
|
+
// The app toggled a user skill's model-invocation availability.
|
|
148
|
+
onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
|
|
149
|
+
// The app opened the routines manager — reply with the current routines list
|
|
150
|
+
// (a sendRoutines frame). Owned by the daemon, so these only fire on its relay.
|
|
151
|
+
onRoutinesList?: () => void;
|
|
152
|
+
// The app asked to create (no id) or edit (id) a routine. The raw draft object is
|
|
153
|
+
// handed through untyped; the daemon's routinesControl validates it. Signed (H2) —
|
|
154
|
+
// a forged routine runs a headless bypass-mode session (RCE), so it MUST be verified.
|
|
155
|
+
onRoutinesSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
|
|
156
|
+
// The app asked to delete a routine by id or name.
|
|
157
|
+
onRoutinesDelete?: (idOrName: string, sig?: string, ts?: number) => void;
|
|
158
|
+
// The app paused/resumed a routine by id or name.
|
|
159
|
+
onRoutinesSetEnabled?: (idOrName: string, enabled: boolean, sig?: string, ts?: number) => void;
|
|
160
|
+
// The app asked to run a routine now, by id or name.
|
|
161
|
+
onRoutinesRun?: (idOrName: string, sig?: string, ts?: number) => void;
|
|
162
|
+
// The app submitted an AD-HOC one-shot task (not a stored routine) to run headlessly
|
|
163
|
+
// right now — a fresh restricted-tool bypass session whose result is sealed to the
|
|
164
|
+
// account outbox. Signed (H2) — a forged task_submit runs an arbitrary headless
|
|
165
|
+
// session (RCE), identical in blast radius to a forged routines_run, so it MUST be
|
|
166
|
+
// verified via controlAuth before it runs. Daemon-owned (fires only on its relay).
|
|
167
|
+
onTaskSubmit?: (spec: TaskSpec, sig?: string, ts?: number) => void;
|
|
168
|
+
// The app asked to SPAWN a fresh interactive session it can drive live (mode:"live").
|
|
169
|
+
// The daemon stands up a new RemoteBridge terminal and replies (sendTaskSpawned) with
|
|
170
|
+
// its termId so the app can attach. Same signed-RCE gate as task_submit.
|
|
171
|
+
onTaskSpawn?: (spec: TaskSpec, sig?: string, ts?: number) => void;
|
|
172
|
+
// The app opened the channels manager — reply with the current channel config
|
|
173
|
+
// (a sendChannels frame). Owned by the daemon, so these only fire on its relay.
|
|
174
|
+
onChannelsList?: () => void;
|
|
175
|
+
// The app asked to create/edit a platform's channel config. `draft` carries only
|
|
176
|
+
// NON-secret fields (roles/posture/tools/model). `sealedSecrets`, when present, is
|
|
177
|
+
// a base64 sealed-box the app sealed to THIS terminal's pinned pubkey (opened by the
|
|
178
|
+
// owner). `sig`+`ts` authenticate the WHOLE save with the account key the terminal
|
|
179
|
+
// pinned at link (accountVerify) — the owner rejects an unsigned/forged/stale save,
|
|
180
|
+
// so a hostile relay can neither forge a token nor inject an admin.
|
|
181
|
+
onChannelsSave?: (draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number) => void;
|
|
182
|
+
// The app asked to delete a platform's channel config, by platform name. Signed
|
|
183
|
+
// (H2) — a forged removal is a DoS (the bot stops until re-added).
|
|
184
|
+
onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
|
|
185
|
+
// The app opened the workflows manager — reply with the current workflow summaries
|
|
186
|
+
// (a sendWorkflows frame). Owned by the daemon, so these only fire on its relay.
|
|
187
|
+
onWorkflowsList?: () => void;
|
|
188
|
+
// The app opened one workflow in its editor — reply with the full graph (sendWorkflow).
|
|
189
|
+
onWorkflowsGet?: (idOrName: string) => void;
|
|
190
|
+
// The app asked to create (no workflow.id) or edit (id) a workflow. The raw graph is
|
|
191
|
+
// handed through untyped; the daemon's workflowsControl strict-validates it. Signed
|
|
192
|
+
// (H2) — a forged save plants a `script` step that BYPASSES the permission gate (RCE),
|
|
193
|
+
// exactly like a forged routine, so it MUST be verified before it persists.
|
|
194
|
+
onWorkflowsSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
|
|
195
|
+
// The app asked to delete a workflow by id or name. Signed (H2).
|
|
196
|
+
onWorkflowsRemove?: (idOrName: string, sig?: string, ts?: number) => void;
|
|
197
|
+
// The app asked to run a workflow now, by id or name. Signed (H2) and verified in
|
|
198
|
+
// STRICT mode (it executes a graph — non-idempotent, like task_spawn).
|
|
199
|
+
onWorkflowsRun?: (idOrName: string, sig?: string, ts?: number) => void;
|
|
70
200
|
// A file finished transferring from the app (reassembled from chunks). Held to
|
|
71
201
|
// ride along with the next remote prompt.
|
|
72
202
|
onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
|
|
@@ -160,6 +290,13 @@ export class RelayClient {
|
|
|
160
290
|
this.label = opts?.label ?? terminalLabel();
|
|
161
291
|
}
|
|
162
292
|
|
|
293
|
+
// This terminal's relay id — the value the app signs into a control envelope's
|
|
294
|
+
// `termId` and the terminal verifies against (authorizeControl). Exposed so the
|
|
295
|
+
// interactive handlers (extensions_*/skills_*) can bind their own id.
|
|
296
|
+
get id(): string {
|
|
297
|
+
return this.termId;
|
|
298
|
+
}
|
|
299
|
+
|
|
163
300
|
async start(): Promise<void> {
|
|
164
301
|
this.closed = false;
|
|
165
302
|
await this.connect();
|
|
@@ -255,6 +392,24 @@ export class RelayClient {
|
|
|
255
392
|
seq?: number;
|
|
256
393
|
data?: string;
|
|
257
394
|
on?: boolean;
|
|
395
|
+
value?: string;
|
|
396
|
+
source?: string;
|
|
397
|
+
description?: string;
|
|
398
|
+
instructions?: string;
|
|
399
|
+
enabled?: boolean;
|
|
400
|
+
idOrName?: string;
|
|
401
|
+
routine?: Record<string, unknown>;
|
|
402
|
+
platform?: string;
|
|
403
|
+
draft?: Record<string, unknown>;
|
|
404
|
+
sealedSecrets?: string;
|
|
405
|
+
prompt?: string;
|
|
406
|
+
cwd?: string;
|
|
407
|
+
model?: string;
|
|
408
|
+
title?: string;
|
|
409
|
+
tools?: unknown;
|
|
410
|
+
mode?: string;
|
|
411
|
+
sig?: string;
|
|
412
|
+
ts?: number;
|
|
258
413
|
};
|
|
259
414
|
try {
|
|
260
415
|
frame = JSON.parse(data.toString());
|
|
@@ -275,6 +430,9 @@ export class RelayClient {
|
|
|
275
430
|
case "terminate":
|
|
276
431
|
this.cb.onTerminate?.();
|
|
277
432
|
break;
|
|
433
|
+
case "session_revoked":
|
|
434
|
+
this.cb.onRevoked?.();
|
|
435
|
+
break;
|
|
278
436
|
case "approval_response":
|
|
279
437
|
if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
|
|
280
438
|
break;
|
|
@@ -284,6 +442,100 @@ export class RelayClient {
|
|
|
284
442
|
case "controller_attached":
|
|
285
443
|
this.cb.onControllerAttached();
|
|
286
444
|
break;
|
|
445
|
+
case "command":
|
|
446
|
+
if (typeof frame.text === "string") this.cb.onCommand?.(frame.text);
|
|
447
|
+
break;
|
|
448
|
+
case "select_response":
|
|
449
|
+
if (frame.id) this.cb.onSelectResponse?.(frame.id, typeof frame.value === "string" ? frame.value : null);
|
|
450
|
+
break;
|
|
451
|
+
case "input_response":
|
|
452
|
+
if (frame.id) this.cb.onInputResponse?.(frame.id, typeof frame.value === "string" ? frame.value : null);
|
|
453
|
+
break;
|
|
454
|
+
case "extensions_list":
|
|
455
|
+
this.cb.onExtensionsList?.();
|
|
456
|
+
break;
|
|
457
|
+
case "extensions_add":
|
|
458
|
+
if (typeof frame.source === "string") this.cb.onExtensionsAdd?.(frame.source, sig(frame), tsOf(frame));
|
|
459
|
+
break;
|
|
460
|
+
case "extensions_remove":
|
|
461
|
+
if (typeof frame.source === "string") this.cb.onExtensionsRemove?.(frame.source, sig(frame), tsOf(frame));
|
|
462
|
+
break;
|
|
463
|
+
case "skills_list":
|
|
464
|
+
this.cb.onSkillsList?.();
|
|
465
|
+
break;
|
|
466
|
+
case "skills_create":
|
|
467
|
+
if (typeof frame.name === "string") {
|
|
468
|
+
this.cb.onSkillCreate?.(
|
|
469
|
+
{
|
|
470
|
+
name: frame.name,
|
|
471
|
+
description: typeof frame.description === "string" ? frame.description : "",
|
|
472
|
+
instructions: typeof frame.instructions === "string" ? frame.instructions : "",
|
|
473
|
+
},
|
|
474
|
+
sig(frame),
|
|
475
|
+
tsOf(frame),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
case "skills_delete":
|
|
480
|
+
if (typeof frame.name === "string") this.cb.onSkillDelete?.(frame.name, sig(frame), tsOf(frame));
|
|
481
|
+
break;
|
|
482
|
+
case "skills_set_enabled":
|
|
483
|
+
if (typeof frame.name === "string") this.cb.onSkillSetEnabled?.(frame.name, frame.enabled === true, sig(frame), tsOf(frame));
|
|
484
|
+
break;
|
|
485
|
+
case "routines_list":
|
|
486
|
+
this.cb.onRoutinesList?.();
|
|
487
|
+
break;
|
|
488
|
+
case "routines_save":
|
|
489
|
+
if (frame.routine && typeof frame.routine === "object") this.cb.onRoutinesSave?.(frame.routine, sig(frame), tsOf(frame));
|
|
490
|
+
break;
|
|
491
|
+
case "routines_delete":
|
|
492
|
+
if (typeof frame.idOrName === "string") this.cb.onRoutinesDelete?.(frame.idOrName, sig(frame), tsOf(frame));
|
|
493
|
+
break;
|
|
494
|
+
case "routines_set_enabled":
|
|
495
|
+
if (typeof frame.idOrName === "string") this.cb.onRoutinesSetEnabled?.(frame.idOrName, frame.enabled === true, sig(frame), tsOf(frame));
|
|
496
|
+
break;
|
|
497
|
+
case "routines_run":
|
|
498
|
+
if (typeof frame.idOrName === "string") this.cb.onRoutinesRun?.(frame.idOrName, sig(frame), tsOf(frame));
|
|
499
|
+
break;
|
|
500
|
+
case "task_submit":
|
|
501
|
+
if (typeof frame.prompt === "string") this.cb.onTaskSubmit?.(parseTaskSpec(frame), sig(frame), tsOf(frame));
|
|
502
|
+
break;
|
|
503
|
+
case "task_spawn":
|
|
504
|
+
if (typeof frame.prompt === "string") this.cb.onTaskSpawn?.(parseTaskSpec(frame), sig(frame), tsOf(frame));
|
|
505
|
+
break;
|
|
506
|
+
case "channels_list":
|
|
507
|
+
this.cb.onChannelsList?.();
|
|
508
|
+
break;
|
|
509
|
+
case "channels_save":
|
|
510
|
+
if (frame.draft && typeof frame.draft === "object") {
|
|
511
|
+
this.cb.onChannelsSave?.(
|
|
512
|
+
frame.draft,
|
|
513
|
+
typeof frame.sealedSecrets === "string" ? frame.sealedSecrets : undefined,
|
|
514
|
+
sig(frame),
|
|
515
|
+
tsOf(frame),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
519
|
+
case "channels_remove":
|
|
520
|
+
if (typeof frame.platform === "string") this.cb.onChannelsRemove?.(frame.platform, sig(frame), tsOf(frame));
|
|
521
|
+
break;
|
|
522
|
+
case "workflows_list":
|
|
523
|
+
this.cb.onWorkflowsList?.();
|
|
524
|
+
break;
|
|
525
|
+
case "workflows_get":
|
|
526
|
+
if (typeof frame.idOrName === "string") this.cb.onWorkflowsGet?.(frame.idOrName);
|
|
527
|
+
break;
|
|
528
|
+
case "workflows_save":
|
|
529
|
+
// The graph rides in `draft` (same slot as channels_save), untyped — the daemon
|
|
530
|
+
// strict-validates it via workflowsControl.save after the signature check.
|
|
531
|
+
if (frame.draft && typeof frame.draft === "object") this.cb.onWorkflowsSave?.(frame.draft, sig(frame), tsOf(frame));
|
|
532
|
+
break;
|
|
533
|
+
case "workflows_remove":
|
|
534
|
+
if (typeof frame.idOrName === "string") this.cb.onWorkflowsRemove?.(frame.idOrName, sig(frame), tsOf(frame));
|
|
535
|
+
break;
|
|
536
|
+
case "workflows_run":
|
|
537
|
+
if (typeof frame.idOrName === "string") this.cb.onWorkflowsRun?.(frame.idOrName, sig(frame), tsOf(frame));
|
|
538
|
+
break;
|
|
287
539
|
case "attach_begin":
|
|
288
540
|
this.beginAttachment(frame);
|
|
289
541
|
break;
|
|
@@ -368,6 +620,32 @@ export class RelayClient {
|
|
|
368
620
|
return true;
|
|
369
621
|
}
|
|
370
622
|
|
|
623
|
+
// Push a finished ad-hoc task result to any attached controller as a text event (the
|
|
624
|
+
// durable copy still goes to the outbox). Same shape as sendRoutineResult.
|
|
625
|
+
sendTaskResult(title: string, content: string): boolean {
|
|
626
|
+
if (!this.isConnected()) return false;
|
|
627
|
+
this.flushDeltas();
|
|
628
|
+
this.rawSend({ type: "event", event: { type: "text", text: safe(`⏺ Task "${title}"\n\n${content}`, 8000) } });
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Push a workflow's live step text / final result to any attached controller as a text
|
|
633
|
+
// event, so it renders in the daemon terminal's feed. Same durable-copy caveat as
|
|
634
|
+
// sendRoutineResult (the outbox is the source of truth).
|
|
635
|
+
sendWorkflowResult(name: string, content: string): boolean {
|
|
636
|
+
if (!this.isConnected()) return false;
|
|
637
|
+
this.flushDeltas();
|
|
638
|
+
this.rawSend({ type: "event", event: { type: "text", text: safe(`⏺ Workflow "${name}"\n\n${content}`, 8000) } });
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Tell the app that a live task session was stood up on `termId` (label for display),
|
|
643
|
+
// so it can open a controller connection to that terminal and drive it. Fire-and-forget
|
|
644
|
+
// over the daemon's management relay.
|
|
645
|
+
sendTaskSpawned(termId: string, label: string): void {
|
|
646
|
+
this.rawSend({ type: "task_spawned", termId, label });
|
|
647
|
+
}
|
|
648
|
+
|
|
371
649
|
sendEvent(ev: EngineEvent): void {
|
|
372
650
|
if (ev.type === "text") return this.bufferDelta("text", ev.text);
|
|
373
651
|
if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
|
|
@@ -428,13 +706,246 @@ export class RelayClient {
|
|
|
428
706
|
// by design: deliberately NO cwd / hostname / username, matching terminalLabel's
|
|
429
707
|
// stance (the server/controller learns as little as possible about the machine).
|
|
430
708
|
// Empty/absent fields are omitted so the app renders less rather than blank.
|
|
431
|
-
sendContext(ctx: { model?: string; version?: string }): void {
|
|
709
|
+
sendContext(ctx: { model?: string; version?: string; terminalPub?: string }): void {
|
|
432
710
|
const frame: Record<string, unknown> = { type: "context" };
|
|
433
711
|
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|
|
434
712
|
if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
|
|
713
|
+
// The terminal's identity public key (base64). NOT PII — it's a public key, and
|
|
714
|
+
// the app uses it to confirm this terminal is the one it PINNED at link time
|
|
715
|
+
// before sealing any secret to it (channel tokens). A malicious relay can swap
|
|
716
|
+
// it, which is exactly why the app checks it against the link-time pin.
|
|
717
|
+
if (typeof ctx.terminalPub === "string" && ctx.terminalPub) frame.terminalPub = ctx.terminalPub;
|
|
435
718
|
this.rawSend(frame);
|
|
436
719
|
}
|
|
437
720
|
|
|
721
|
+
// A one-line notice for the app's feed (e.g. "model → …", "unknown command").
|
|
722
|
+
// The generic command-feedback channel back to the driver.
|
|
723
|
+
sendNotice(text: string): void {
|
|
724
|
+
this.rawSend({ type: "notice", text: safe(text, 500) });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Advertise the terminal's available slash commands (this CLI's built-ins PLUS
|
|
728
|
+
// whatever Pi extensions have registered) so the app's composer can autocomplete
|
|
729
|
+
// them. Pushed on controller attach. NON-PII: command names + descriptions only.
|
|
730
|
+
sendCommands(commands: { name: string; description?: string }[]): void {
|
|
731
|
+
this.rawSend({
|
|
732
|
+
type: "commands",
|
|
733
|
+
commands: commands.slice(0, 200).map((c) => ({ name: c.name, description: c.description ? safe(c.description, 200) : undefined })),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Push the terminal's installed Pi extensions (the user's own packages; the moat
|
|
738
|
+
// is excluded upstream) to the app's extensions manager. Sent on request and after
|
|
739
|
+
// each add/remove. `busy` drives a progress indicator; `needsRestart` tells the app
|
|
740
|
+
// the change only takes effect on the next terminal launch. NON-PII: package
|
|
741
|
+
// sources + scope only. Installed list bounded like sendCommands.
|
|
742
|
+
sendExtensions(payload: {
|
|
743
|
+
installed: { source: string; scope: string; filtered?: boolean; installed?: boolean }[];
|
|
744
|
+
busy?: boolean;
|
|
745
|
+
message?: string;
|
|
746
|
+
needsRestart?: boolean;
|
|
747
|
+
}): void {
|
|
748
|
+
this.rawSend({
|
|
749
|
+
type: "extensions",
|
|
750
|
+
installed: payload.installed.slice(0, 200).map((e) => ({
|
|
751
|
+
source: safe(e.source, 200),
|
|
752
|
+
scope: e.scope === "project" ? "project" : "user",
|
|
753
|
+
filtered: !!e.filtered,
|
|
754
|
+
installed: !!e.installed,
|
|
755
|
+
})),
|
|
756
|
+
busy: !!payload.busy,
|
|
757
|
+
message: payload.message ? safe(payload.message, 500) : undefined,
|
|
758
|
+
needsRestart: !!payload.needsRestart,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Push the terminal's skills (user-authored + read-only package/project ones) to
|
|
763
|
+
// the app's skills manager. Sent on request and after each create/delete/toggle.
|
|
764
|
+
// `busy` drives a progress indicator; `needsRestart` tells the app a change only
|
|
765
|
+
// reaches the model (the <available_skills> prompt block) on the next launch —
|
|
766
|
+
// Run-now via /skill:name works immediately. NON-PII: names + descriptions +
|
|
767
|
+
// coarse source only. Items bounded like sendExtensions.
|
|
768
|
+
sendSkills(payload: {
|
|
769
|
+
items: { name: string; description: string; source: string; editable: boolean; disabled: boolean }[];
|
|
770
|
+
busy?: boolean;
|
|
771
|
+
message?: string;
|
|
772
|
+
needsRestart?: boolean;
|
|
773
|
+
}): void {
|
|
774
|
+
this.rawSend({
|
|
775
|
+
type: "skills",
|
|
776
|
+
items: payload.items.slice(0, 200).map((s) => ({
|
|
777
|
+
name: safe(s.name, 200),
|
|
778
|
+
description: safe(s.description, 1024),
|
|
779
|
+
source: safe(s.source, 200),
|
|
780
|
+
editable: !!s.editable,
|
|
781
|
+
disabled: !!s.disabled,
|
|
782
|
+
})),
|
|
783
|
+
busy: !!payload.busy,
|
|
784
|
+
message: payload.message ? safe(payload.message, 500) : undefined,
|
|
785
|
+
needsRestart: !!payload.needsRestart,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Push the daemon's saved routines to the app's routines manager. Sent on request
|
|
790
|
+
// and after each save/delete/pause/run. `busy` drives a progress indicator;
|
|
791
|
+
// `message` carries a one-line result/error. Unlike the feed/webhook paths this is
|
|
792
|
+
// the user's OWN config echoed back to their OWN app, so fields are size-clipped
|
|
793
|
+
// (clip, NOT redactSecrets) to keep an edit round-tripping faithfully — a prompt
|
|
794
|
+
// containing a literal "KEY=…" example must survive intact. List bounded like the
|
|
795
|
+
// other managers.
|
|
796
|
+
sendRoutines(payload: {
|
|
797
|
+
items: {
|
|
798
|
+
id: string;
|
|
799
|
+
name: string;
|
|
800
|
+
cron?: string;
|
|
801
|
+
at?: string;
|
|
802
|
+
prompt: string;
|
|
803
|
+
cwd: string;
|
|
804
|
+
model?: string;
|
|
805
|
+
delivery: string[];
|
|
806
|
+
tools?: string[];
|
|
807
|
+
enabled: boolean;
|
|
808
|
+
lastRun?: string;
|
|
809
|
+
lastStatus?: "ok" | "error";
|
|
810
|
+
lastError?: string;
|
|
811
|
+
nextRun?: string;
|
|
812
|
+
}[];
|
|
813
|
+
busy?: boolean;
|
|
814
|
+
message?: string;
|
|
815
|
+
}): void {
|
|
816
|
+
this.rawSend({
|
|
817
|
+
type: "routines",
|
|
818
|
+
items: payload.items.slice(0, 200).map((r) => ({
|
|
819
|
+
id: r.id,
|
|
820
|
+
name: clip(r.name, 200),
|
|
821
|
+
cron: r.cron ? clip(r.cron, 200) : undefined,
|
|
822
|
+
at: r.at ? clip(r.at, 64) : undefined,
|
|
823
|
+
prompt: clip(r.prompt, 8000),
|
|
824
|
+
cwd: clip(r.cwd, 1024),
|
|
825
|
+
model: r.model ? clip(r.model, 200) : undefined,
|
|
826
|
+
delivery: (r.delivery ?? []).slice(0, 20).map((d) => clip(String(d), 128)),
|
|
827
|
+
tools: r.tools ? r.tools.slice(0, 100).map((t) => clip(String(t), 128)) : undefined,
|
|
828
|
+
enabled: !!r.enabled,
|
|
829
|
+
lastRun: r.lastRun,
|
|
830
|
+
lastStatus: r.lastStatus,
|
|
831
|
+
lastError: r.lastError ? clip(r.lastError, 1000) : undefined,
|
|
832
|
+
nextRun: r.nextRun,
|
|
833
|
+
})),
|
|
834
|
+
busy: !!payload.busy,
|
|
835
|
+
message: payload.message ? clip(payload.message, 500) : undefined,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Push the daemon's channel config to the app's channels manager. Sent on request
|
|
840
|
+
// and after each save/remove. Like sendRoutines this is the user's OWN config
|
|
841
|
+
// echoed to their OWN app — but a bot token NEVER crosses this wire: only
|
|
842
|
+
// `secretsSet` (which secret fields are present, by NAME) is sent, so a relay /
|
|
843
|
+
// server compromise can't lift a token from this frame. List bounded like the
|
|
844
|
+
// other managers.
|
|
845
|
+
sendChannels(payload: {
|
|
846
|
+
items: {
|
|
847
|
+
platform: string;
|
|
848
|
+
configured: boolean;
|
|
849
|
+
running: boolean;
|
|
850
|
+
adminCount: number;
|
|
851
|
+
memberCount: number;
|
|
852
|
+
posture: string;
|
|
853
|
+
tools: string[];
|
|
854
|
+
model?: string;
|
|
855
|
+
secretsSet: string[];
|
|
856
|
+
}[];
|
|
857
|
+
busy?: boolean;
|
|
858
|
+
message?: string;
|
|
859
|
+
}): void {
|
|
860
|
+
this.rawSend({
|
|
861
|
+
type: "channels",
|
|
862
|
+
items: payload.items.slice(0, 20).map((c) => ({
|
|
863
|
+
platform: clip(String(c.platform), 32),
|
|
864
|
+
configured: !!c.configured,
|
|
865
|
+
running: !!c.running,
|
|
866
|
+
adminCount: Math.max(0, Math.floor(c.adminCount) || 0),
|
|
867
|
+
memberCount: Math.max(0, Math.floor(c.memberCount) || 0),
|
|
868
|
+
posture: clip(String(c.posture), 32),
|
|
869
|
+
tools: (c.tools ?? []).slice(0, 100).map((t) => clip(String(t), 128)),
|
|
870
|
+
model: c.model ? clip(c.model, 200) : undefined,
|
|
871
|
+
secretsSet: (c.secretsSet ?? []).slice(0, 20).map((s) => clip(String(s), 64)),
|
|
872
|
+
})),
|
|
873
|
+
busy: !!payload.busy,
|
|
874
|
+
message: payload.message ? clip(payload.message, 500) : undefined,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Push the daemon's saved workflows to the app's workflows manager as SUMMARIES (not
|
|
879
|
+
// the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
|
|
880
|
+
// and after each save/remove/run. The user's OWN config echoed to their OWN app, so
|
|
881
|
+
// fields are clipped (not redacted). List bounded like the other managers.
|
|
882
|
+
sendWorkflows(payload: {
|
|
883
|
+
items: {
|
|
884
|
+
id: string;
|
|
885
|
+
name: string;
|
|
886
|
+
description?: string;
|
|
887
|
+
entryPoint: string;
|
|
888
|
+
stepCount: number;
|
|
889
|
+
gateCount: number;
|
|
890
|
+
scriptCount: number;
|
|
891
|
+
}[];
|
|
892
|
+
busy?: boolean;
|
|
893
|
+
message?: string;
|
|
894
|
+
}): void {
|
|
895
|
+
this.rawSend({
|
|
896
|
+
type: "workflows",
|
|
897
|
+
items: payload.items.slice(0, 200).map((w) => ({
|
|
898
|
+
id: w.id,
|
|
899
|
+
name: clip(w.name, 200),
|
|
900
|
+
description: w.description ? clip(w.description, 1000) : undefined,
|
|
901
|
+
entryPoint: clip(w.entryPoint, 64),
|
|
902
|
+
stepCount: Math.max(0, Math.floor(w.stepCount) || 0),
|
|
903
|
+
gateCount: Math.max(0, Math.floor(w.gateCount) || 0),
|
|
904
|
+
scriptCount: Math.max(0, Math.floor(w.scriptCount) || 0),
|
|
905
|
+
})),
|
|
906
|
+
busy: !!payload.busy,
|
|
907
|
+
message: payload.message ? clip(payload.message, 500) : undefined,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Push ONE full workflow graph to the app's editor (reply to workflows_get). The graph
|
|
912
|
+
// is the user's own authored config, so it's sent whole (clipped only by the relay's
|
|
913
|
+
// per-frame cap); `null` means the requested workflow wasn't found.
|
|
914
|
+
sendWorkflow(workflow: unknown | null): void {
|
|
915
|
+
this.rawSend({ type: "workflow", workflow: workflow ?? null });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Ask the app to pick from a set of options — a CLI-initiated selection prompt.
|
|
919
|
+
// The app renders the same picker as /model and replies with a select_response
|
|
920
|
+
// (resolved by the bridge). Generic so any future prompt reuses one UI.
|
|
921
|
+
requestSelect(
|
|
922
|
+
id: string,
|
|
923
|
+
req: { title: string; options: { value: string; label: string; hint?: string }[]; current?: string },
|
|
924
|
+
): void {
|
|
925
|
+
this.rawSend({
|
|
926
|
+
type: "select_request",
|
|
927
|
+
id,
|
|
928
|
+
title: safe(req.title, 200),
|
|
929
|
+
options: req.options.slice(0, 500).map((o) => ({ value: o.value, label: safe(o.label, 200), hint: o.hint ? safe(o.hint, 200) : undefined })),
|
|
930
|
+
current: req.current,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Ask the app for a line of free-form text — a CLI-initiated input prompt. The
|
|
935
|
+
// app renders a text field and replies with an input_response (resolved by the
|
|
936
|
+
// bridge). Companion to requestSelect for prompts that aren't a fixed choice.
|
|
937
|
+
requestInput(
|
|
938
|
+
id: string,
|
|
939
|
+
req: { title: string; placeholder?: string },
|
|
940
|
+
): void {
|
|
941
|
+
this.rawSend({
|
|
942
|
+
type: "input_request",
|
|
943
|
+
id,
|
|
944
|
+
title: safe(req.title, 200),
|
|
945
|
+
placeholder: req.placeholder ? safe(req.placeholder, 200) : undefined,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
438
949
|
requestApproval(id: string, req: PermissionRequest): void {
|
|
439
950
|
this.rawSend({
|
|
440
951
|
type: "approval_request",
|