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
@@ -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",