@spencerboucher/switchyard 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +6 -6
  2. package/dist/bridge/switchyard-terminal.js +158 -2
  3. package/dist/switchyard.mjs +1332 -769
  4. package/package.json +1 -1
  5. package/web-dist/assets/AutomationView-GCthHkym.js +2 -0
  6. package/web-dist/assets/AutomationView-GCthHkym.js.map +1 -0
  7. package/web-dist/assets/Badge-CEMI3YyI.js +2 -0
  8. package/web-dist/assets/Badge-CEMI3YyI.js.map +1 -0
  9. package/web-dist/assets/InteractionCard-DaLqafmA.js +6 -0
  10. package/web-dist/assets/InteractionCard-DaLqafmA.js.map +1 -0
  11. package/web-dist/assets/{Menu-GymfuHGP.js → Menu-BcKiEc6X.js} +2 -2
  12. package/web-dist/assets/{Menu-GymfuHGP.js.map → Menu-BcKiEc6X.js.map} +1 -1
  13. package/web-dist/assets/{SegmentedControl-ChdVilXb.js → SegmentedControl-BZPzNU08.js} +2 -2
  14. package/web-dist/assets/{SegmentedControl-ChdVilXb.js.map → SegmentedControl-BZPzNU08.js.map} +1 -1
  15. package/web-dist/assets/Tabs-COH5EMql.js +184 -0
  16. package/web-dist/assets/Tabs-COH5EMql.js.map +1 -0
  17. package/web-dist/assets/TextArea-CeMEPZn4.js +2 -0
  18. package/web-dist/assets/TextArea-CeMEPZn4.js.map +1 -0
  19. package/web-dist/assets/attention-BAdea2kA.js +2 -0
  20. package/web-dist/assets/attention-BAdea2kA.js.map +1 -0
  21. package/web-dist/assets/automations-DSFUzB0w.js +2 -0
  22. package/web-dist/assets/automations-DSFUzB0w.js.map +1 -0
  23. package/web-dist/assets/board-eQ3H8yCp.js +10 -0
  24. package/web-dist/assets/board-eQ3H8yCp.js.map +1 -0
  25. package/web-dist/assets/c-Cz4nF6n_.js +1 -0
  26. package/web-dist/assets/c-SSSlmE39.js +2 -0
  27. package/web-dist/assets/c-SSSlmE39.js.map +1 -0
  28. package/web-dist/assets/code-D7JmYMKe.js +13 -0
  29. package/web-dist/assets/code-D7JmYMKe.js.map +1 -0
  30. package/web-dist/assets/cpp-Cts1PyIS.js +2 -0
  31. package/web-dist/assets/cpp-Cts1PyIS.js.map +1 -0
  32. package/web-dist/assets/cpp-DzjeI0uH.js +1 -0
  33. package/web-dist/assets/css-BGIxQoYs.js +2 -0
  34. package/web-dist/assets/css-BGIxQoYs.js.map +1 -0
  35. package/web-dist/assets/css-DgaLhXQH.js +1 -0
  36. package/web-dist/assets/describe-C1YimBXJ.js +5 -0
  37. package/web-dist/assets/describe-C1YimBXJ.js.map +1 -0
  38. package/web-dist/assets/diff-woXpYk--.js +2 -0
  39. package/web-dist/assets/diff-woXpYk--.js.map +1 -0
  40. package/web-dist/assets/dist-B_RpNOcK.js +56 -0
  41. package/web-dist/assets/dist-B_RpNOcK.js.map +1 -0
  42. package/web-dist/assets/dockerfile-IyjqRm3v.js +2 -0
  43. package/web-dist/assets/dockerfile-IyjqRm3v.js.map +1 -0
  44. package/web-dist/assets/enroll-1OCy5r50.js +2 -0
  45. package/web-dist/assets/enroll-1OCy5r50.js.map +1 -0
  46. package/web-dist/assets/github-dark-C-LZuMrd.js +2 -0
  47. package/web-dist/assets/github-dark-C-LZuMrd.js.map +1 -0
  48. package/web-dist/assets/github-light-EUqPIrTm.js +2 -0
  49. package/web-dist/assets/github-light-EUqPIrTm.js.map +1 -0
  50. package/web-dist/assets/go-rLFTqkRN.js +2 -0
  51. package/web-dist/assets/go-rLFTqkRN.js.map +1 -0
  52. package/web-dist/assets/html-BMn1ogTm.js +2 -0
  53. package/web-dist/assets/html-BMn1ogTm.js.map +1 -0
  54. package/web-dist/assets/html-BWDvXlHg.js +1 -0
  55. package/web-dist/assets/index-BhpEXLav.js +11 -0
  56. package/web-dist/assets/index-BhpEXLav.js.map +1 -0
  57. package/web-dist/assets/index-DUeOwDZ6.css +2 -0
  58. package/web-dist/assets/java-BtIQNnN0.js +2 -0
  59. package/web-dist/assets/java-BtIQNnN0.js.map +1 -0
  60. package/web-dist/assets/java-_ATy9zCa.js +1 -0
  61. package/web-dist/assets/javascript-BzbQS41l.js +2 -0
  62. package/web-dist/assets/javascript-BzbQS41l.js.map +1 -0
  63. package/web-dist/assets/javascript-CpIGwZ_Y.js +1 -0
  64. package/web-dist/assets/json-qhed-kSA.js +2 -0
  65. package/web-dist/assets/json-qhed-kSA.js.map +1 -0
  66. package/web-dist/assets/jsonc-CYpm1nAK.js +2 -0
  67. package/web-dist/assets/jsonc-CYpm1nAK.js.map +1 -0
  68. package/web-dist/assets/jsx-C4yDKi0A.js +1 -0
  69. package/web-dist/assets/jsx-D3KKoC5L.js +2 -0
  70. package/web-dist/assets/jsx-D3KKoC5L.js.map +1 -0
  71. package/web-dist/assets/logic-CBvFOyIo.js +2 -0
  72. package/web-dist/assets/logic-CBvFOyIo.js.map +1 -0
  73. package/web-dist/assets/lua-DjuecEsk.js +2 -0
  74. package/web-dist/assets/lua-DjuecEsk.js.map +1 -0
  75. package/web-dist/assets/lua-fPRBWD-V.js +1 -0
  76. package/web-dist/assets/{more-CRGJsz6E.js → more-RjVyxPaz.js} +2 -2
  77. package/web-dist/assets/{more-CRGJsz6E.js.map → more-RjVyxPaz.js.map} +1 -1
  78. package/web-dist/assets/now-CDjeFSLD.js +2 -0
  79. package/web-dist/assets/now-CDjeFSLD.js.map +1 -0
  80. package/web-dist/assets/python-gzcpVVnB.js +2 -0
  81. package/web-dist/assets/python-gzcpVVnB.js.map +1 -0
  82. package/web-dist/assets/ruby-Bx-U2qjP.js +2 -0
  83. package/web-dist/assets/ruby-Bx-U2qjP.js.map +1 -0
  84. package/web-dist/assets/rust-DpiIf5JO.js +2 -0
  85. package/web-dist/assets/rust-DpiIf5JO.js.map +1 -0
  86. package/web-dist/assets/scss-8bKmll0B.js +2 -0
  87. package/web-dist/assets/scss-8bKmll0B.js.map +1 -0
  88. package/web-dist/assets/send-BKjswHxB.js +2 -0
  89. package/web-dist/assets/send-BKjswHxB.js.map +1 -0
  90. package/web-dist/assets/settings-tH4UuIfJ.js +7 -0
  91. package/web-dist/assets/settings-tH4UuIfJ.js.map +1 -0
  92. package/web-dist/assets/shared-CRX6KSBm.js +3 -0
  93. package/web-dist/assets/shared-CRX6KSBm.js.map +1 -0
  94. package/web-dist/assets/shellscript-CVTB3cr6.js +1 -0
  95. package/web-dist/assets/shellscript-CqHdAlCp.js +2 -0
  96. package/web-dist/assets/shellscript-CqHdAlCp.js.map +1 -0
  97. package/web-dist/assets/sliders-horizontal-DAoirila.js +2 -0
  98. package/web-dist/assets/sliders-horizontal-DAoirila.js.map +1 -0
  99. package/web-dist/assets/sql-CNu3Or2W.js +2 -0
  100. package/web-dist/assets/sql-CNu3Or2W.js.map +1 -0
  101. package/web-dist/assets/sql-CfM2IJm3.js +1 -0
  102. package/web-dist/assets/states-DXozulGZ.js +2 -0
  103. package/web-dist/assets/states-DXozulGZ.js.map +1 -0
  104. package/web-dist/assets/swift-CyEgAFGc.js +2 -0
  105. package/web-dist/assets/swift-CyEgAFGc.js.map +1 -0
  106. package/web-dist/assets/terminal-login-CW2rE50W.js +2 -0
  107. package/web-dist/assets/terminal-login-CW2rE50W.js.map +1 -0
  108. package/web-dist/assets/toml-CcmNWLt0.js +2 -0
  109. package/web-dist/assets/toml-CcmNWLt0.js.map +1 -0
  110. package/web-dist/assets/tsx-BOvsFOI6.js +2 -0
  111. package/web-dist/assets/tsx-BOvsFOI6.js.map +1 -0
  112. package/web-dist/assets/tsx-CGosfAUg.js +1 -0
  113. package/web-dist/assets/typescript-Bb1J8qyy.js +1 -0
  114. package/web-dist/assets/typescript-j_1H8WHN.js +2 -0
  115. package/web-dist/assets/typescript-j_1H8WHN.js.map +1 -0
  116. package/web-dist/assets/useNow-C_B7JgMM.js +2 -0
  117. package/web-dist/assets/{useNow-Bc_NlDY3.js.map → useNow-C_B7JgMM.js.map} +1 -1
  118. package/web-dist/assets/xml-DuKfbPI6.js +1 -0
  119. package/web-dist/assets/xml-WY0zc4PG.js +2 -0
  120. package/web-dist/assets/xml-WY0zc4PG.js.map +1 -0
  121. package/web-dist/assets/yaml-DDA_XMNu.js +1 -0
  122. package/web-dist/assets/yaml-DijMHRDA.js +2 -0
  123. package/web-dist/assets/yaml-DijMHRDA.js.map +1 -0
  124. package/web-dist/index.html +4 -3
  125. package/web-dist/sw.js +1 -1
  126. package/web-dist/sw.js.map +1 -1
  127. package/web-dist/assets/AutomationView-DgZn3ltn.js +0 -2
  128. package/web-dist/assets/AutomationView-DgZn3ltn.js.map +0 -1
  129. package/web-dist/assets/InteractionCard-cvPWKSu1.js +0 -4
  130. package/web-dist/assets/InteractionCard-cvPWKSu1.js.map +0 -1
  131. package/web-dist/assets/Markdown-jXkvZeRp.js +0 -31
  132. package/web-dist/assets/Markdown-jXkvZeRp.js.map +0 -1
  133. package/web-dist/assets/Overlay-oYnnwvFM.js +0 -2
  134. package/web-dist/assets/Overlay-oYnnwvFM.js.map +0 -1
  135. package/web-dist/assets/TextArea-BsQHOQMe.js +0 -2
  136. package/web-dist/assets/TextArea-BsQHOQMe.js.map +0 -1
  137. package/web-dist/assets/attention-Bxg9W15n.js +0 -2
  138. package/web-dist/assets/attention-Bxg9W15n.js.map +0 -1
  139. package/web-dist/assets/automations-DdB97dnL.js +0 -2
  140. package/web-dist/assets/automations-DdB97dnL.js.map +0 -1
  141. package/web-dist/assets/board-s4LuDPcg.js +0 -10
  142. package/web-dist/assets/board-s4LuDPcg.js.map +0 -1
  143. package/web-dist/assets/code-B0KXn7lD.js +0 -8
  144. package/web-dist/assets/code-B0KXn7lD.js.map +0 -1
  145. package/web-dist/assets/describe-C_3FJuSs.js +0 -5
  146. package/web-dist/assets/describe-C_3FJuSs.js.map +0 -1
  147. package/web-dist/assets/dist-Bgkh2hc5.js +0 -56
  148. package/web-dist/assets/dist-Bgkh2hc5.js.map +0 -1
  149. package/web-dist/assets/enroll-DijmoolY.js +0 -2
  150. package/web-dist/assets/enroll-DijmoolY.js.map +0 -1
  151. package/web-dist/assets/index-C5n2QB-Y.css +0 -2
  152. package/web-dist/assets/index-Cf6ZUCfK.js +0 -11
  153. package/web-dist/assets/index-Cf6ZUCfK.js.map +0 -1
  154. package/web-dist/assets/logic-D_Eq9DMW.js +0 -2
  155. package/web-dist/assets/logic-D_Eq9DMW.js.map +0 -1
  156. package/web-dist/assets/now-D6lDofYy.js +0 -2
  157. package/web-dist/assets/now-D6lDofYy.js.map +0 -1
  158. package/web-dist/assets/settings-Csal1F-W.js +0 -8
  159. package/web-dist/assets/settings-Csal1F-W.js.map +0 -1
  160. package/web-dist/assets/sliders-horizontal-S3tmwxsP.js +0 -2
  161. package/web-dist/assets/sliders-horizontal-S3tmwxsP.js.map +0 -1
  162. package/web-dist/assets/states-DrTQuYXw.js +0 -2
  163. package/web-dist/assets/states-DrTQuYXw.js.map +0 -1
  164. package/web-dist/assets/useNow-Bc_NlDY3.js +0 -2
package/README.md CHANGED
@@ -111,15 +111,15 @@ npm install -g @spencerboucher/switchyard
111
111
  switchyard up
112
112
  ```
113
113
 
114
- `up` runs the coordinator and a host in one process at `http://localhost:7400`, sets the server up on first start and opens a single-use sign-in link in your browser. Add a project in Settings › Projects (a path to a git repository), then start a session or create a task.
114
+ `up` runs the coordinator and a host in one process at `http://localhost:7400`, prints and opens a single-use sign-in link in your browser on first start. Add a project in Settings › Projects (a path to a git repository), then start a session or create a task.
115
115
 
116
116
  **Run it as a server** with the container image (it runs only the coordinator):
117
117
 
118
118
  ```sh
119
119
  docker run -d --name switchyard -p 127.0.0.1:7400:7400 -v switchyard-data:/data \
120
120
  -e SWITCHYARD_PUBLIC_URL=https://switchyard.example.com \
121
- ghcr.io/justmytwospence/switchyard:0.6.0
122
- docker exec switchyard switchyard init # prints a single-use sign-in link for your first device
121
+ ghcr.io/justmytwospence/switchyard:0.8.0
122
+ # Single sign-on is required off localhost: see docs/auth.md
123
123
  ```
124
124
 
125
125
  Put HTTPS in front of it (a reverse proxy, or a VPN or tailnet; the installed phone app, notifications and passkeys need HTTPS). Then, on each machine that should run agents, create a host token in Settings › Hosts › Add host and run:
@@ -136,8 +136,8 @@ Settings › Hosts › Add host shows these three commands with your token fille
136
136
 
137
137
  ## Security model
138
138
 
139
- - **A server is owned before it serves anything.** Ownership comes from `switchyard init` on the server, or from an owner declared in an OpenID Connect configuration. There is never an "unclaimed" web page.
140
- - **Only you add devices.** A new device gets in by a pairing link or QR code made from a device you already use, by a passkey, or by single sign-on that matches the configured owner. Each device has its own credential that you can revoke; signing out of an identity provider never breaks an installed app.
139
+ - **A server is owned before it serves anything.** Off localhost, the owner is declared in an OpenID Connect configuration (required). There is never an "unclaimed" web page.
140
+ - **Only you add devices.** A browser gets in by single sign-on that matches the configured owner or by a passkey; a terminal is approved from a browser that is already signed in. Each device has its own credential that you can revoke; signing out of an identity provider never breaks an installed app.
141
141
  - **Hosts dial out** with their own revocable credentials; the coordinator holds no provider credentials and needs no access to your home directory or Docker socket.
142
142
  - **Agents run with the host user's privileges.** Running the coordinator in a container does not sandbox the agents; run hosts as a user whose access you are comfortable giving an agent.
143
143
 
@@ -155,7 +155,7 @@ Switchyard is young and moving quickly. It is tested against Pi `>=1.4.0 <1.6.0`
155
155
  | Guide | Covers |
156
156
  |---|---|
157
157
  | [Deployment](docs/deployment.md) | Single machine, server, reverse proxies, VPNs, hosts, environment reference, privilege model |
158
- | [Sign-in and devices](docs/auth.md) | The owner model, pairing, passkeys, single sign-on, host enrollment, revocation |
158
+ | [Sign-in and devices](docs/auth.md) | The owner model, single sign-on, passkeys, terminal approval, single sign-on, host enrollment, revocation |
159
159
  | [CLI](docs/cli.md) | Every `switchyard` command and credentials |
160
160
  | [Terminal sessions](docs/terminal-sessions.md) | Pi's own TUI for every session, from any terminal, and the herdr integration |
161
161
  | [Operations](docs/operations.md) | Backup, restore, migrations, retention, logs, health, diagnostics |
@@ -11,12 +11,16 @@
11
11
  * Dialogs: every extension's `ctx.ui` is the same object, so replacing `select`, `confirm`,
12
12
  * `input` and `editor` on it routes all dialogs through the race below. When that object cannot be
13
13
  * patched, `remoteDialogs` is false and the host tells remote clients to answer in the terminal.
14
+ *
15
+ * Other extensions that draw a custom terminal dialog can also ask the same question as a
16
+ * web-only dialog through `switchyard.remote-ui.v1` on `pi.events`; the first answer wins.
14
17
  */
15
18
  import { randomUUID } from "node:crypto";
16
19
  import { createConnection } from "node:net";
17
20
  import { modelInfo } from "./pi-model.js";
18
21
  const PATCHED = Symbol.for("switchyard.terminal");
19
22
  const WRAPPED = Symbol.for("switchyard.terminal.wrapped");
23
+ const REMOTE_UI_CHANNEL = "switchyard.remote-ui.v1";
20
24
  function messageText(message) {
21
25
  const content = message?.content;
22
26
  if (typeof content === "string")
@@ -76,12 +80,36 @@ export default function switchyardTerminal(pi) {
76
80
  if (!socketPath)
77
81
  return;
78
82
  let ctx = null;
83
+ let completer = null;
84
+ // Work that needs a command context (reload, plugin web views) runs inside a hidden command.
85
+ const internal = new Map();
86
+ const runInternal = (fn) => {
87
+ const token = randomUUID();
88
+ internal.set(token, fn);
89
+ // Without expandPromptTemplates Pi sends the text to the model instead of running the command.
90
+ pi.sendUserMessage(`/switchyard-internal ${token}`, { expandPromptTemplates: true });
91
+ };
92
+ try {
93
+ pi.registerCommand?.("switchyard-internal", {
94
+ description: "Used by Switchyard's web app",
95
+ handler: async (args, c) => {
96
+ const key = String(args ?? "").trim();
97
+ const run = internal.get(key);
98
+ internal.delete(key);
99
+ await run?.(c);
100
+ },
101
+ });
102
+ }
103
+ catch {
104
+ // an older Pi: no web views or reload
105
+ }
79
106
  let remoteDialogs = false;
80
107
  let entryCount = 0;
81
108
  const pending = new Map();
82
109
  const early = [];
83
110
  const queue = { steering: [], followUp: [] };
84
111
  let socket = null;
112
+ let remoteBacked = 0;
85
113
  const send = (obj) => {
86
114
  try {
87
115
  if (!socket || socket.destroyed)
@@ -219,6 +247,44 @@ export default function switchyardTerminal(pi) {
219
247
  });
220
248
  });
221
249
  };
250
+ /** A dialog shown only to remote clients (the caller shows its own in the terminal). */
251
+ const remoteOnly = (method, title, fields, signal) => {
252
+ const dflt = method === "confirm" ? false : undefined;
253
+ if (signal?.aborted)
254
+ return Promise.resolve(dflt);
255
+ const id = randomUUID();
256
+ const request = { type: "extension_ui_request", id, method, title, ...fields };
257
+ return new Promise((resolve) => {
258
+ let done = false;
259
+ const onAbort = () => {
260
+ send({
261
+ type: "extension_ui_closed",
262
+ id,
263
+ by: signal?.reason === "pi" ? "pi" : "terminal",
264
+ });
265
+ finish(dflt);
266
+ };
267
+ const finish = (value) => {
268
+ if (done)
269
+ return;
270
+ done = true;
271
+ remoteBacked--;
272
+ pending.delete(id);
273
+ signal?.removeEventListener("abort", onAbort);
274
+ resolve(value);
275
+ };
276
+ remoteBacked++;
277
+ pending.set(id, { request, onRemote: (body) => finish(parseBody(method, body)) });
278
+ signal?.addEventListener("abort", onAbort, { once: true });
279
+ send(request);
280
+ });
281
+ };
282
+ const remoteUi = {
283
+ select: (title, options, opts) => remoteOnly("select", title, { options }, opts?.signal),
284
+ confirm: (title, message, opts) => remoteOnly("confirm", title, { message }, opts?.signal),
285
+ input: (title, placeholder, opts) => remoteOnly("input", title, placeholder !== undefined ? { placeholder } : {}, opts?.signal),
286
+ editor: (title, prefill, opts) => remoteOnly("editor", title, prefill !== undefined ? { prefill } : {}, opts?.signal),
287
+ };
222
288
  const fire = (method, fields) => send({ type: "extension_ui_request", id: randomUUID(), method, ...fields });
223
289
  const patchUi = (c) => {
224
290
  try {
@@ -334,6 +400,33 @@ export default function switchyardTerminal(pi) {
334
400
  const name = message.slice(1).split(/\s/)[0];
335
401
  const known = (pi.getCommands?.() ?? []).find((c) => c.name === name);
336
402
  if (known?.source === "extension") {
403
+ const args = message.slice(1 + name.length).trim();
404
+ const probe = { version: 1, name, args, probe: true };
405
+ try {
406
+ pi.events?.emit?.("switchyard.web-command.v1", probe);
407
+ }
408
+ catch {
409
+ // no web view
410
+ }
411
+ if (probe.accept === true) {
412
+ runInternal(async (c) => {
413
+ const req = { version: 1, name, args, probe: false, ctx: c };
414
+ try {
415
+ pi.events.emit("switchyard.web-command.v1", req);
416
+ const view = await Promise.race([
417
+ Promise.resolve(req.view),
418
+ new Promise((_, rej) => setTimeout(() => rej(new Error("timed out")), 15000)),
419
+ ]);
420
+ if (!view)
421
+ throw new Error(`/${name} returned no view`);
422
+ respond(id, type, { disposition: "view", view });
423
+ }
424
+ catch (e) {
425
+ respond(id, type, undefined, `/${name} failed: ${e?.message ?? e}`);
426
+ }
427
+ });
428
+ return;
429
+ }
337
430
  pi.sendUserMessage(message, { expandPromptTemplates: true });
338
431
  respond(id, type, { disposition: "handled" });
339
432
  return;
@@ -403,10 +496,13 @@ export default function switchyardTerminal(pi) {
403
496
  return;
404
497
  case "get_commands":
405
498
  respond(id, type, {
406
- commands: (pi.getCommands?.() ?? []).map((c) => ({
499
+ commands: (pi.getCommands?.() ?? [])
500
+ .filter((c) => c.name !== "switchyard-internal")
501
+ .map((c) => ({
407
502
  name: c.name,
408
503
  description: c.description ?? null,
409
504
  source: c.source,
505
+ sourceInfo: c.sourceInfo ?? null,
410
506
  })),
411
507
  });
412
508
  return;
@@ -455,6 +551,38 @@ export default function switchyardTerminal(pi) {
455
551
  }
456
552
  });
457
553
  return;
554
+ case "reload":
555
+ if (!ctx) {
556
+ respond(id, type, undefined, "Session is not ready");
557
+ return;
558
+ }
559
+ respond(id, type);
560
+ runInternal((c) => c.reload());
561
+ return;
562
+ case "complete": {
563
+ const text = String(cmd.text ?? "");
564
+ if (!completer?.getSuggestions || !/^\/\S+ /.test(text) || text.includes("\n")) {
565
+ respond(id, type, { items: [], prefix: "" });
566
+ return;
567
+ }
568
+ const ac = new AbortController();
569
+ const timer = setTimeout(() => ac.abort(), 2000);
570
+ Promise.resolve(completer.getSuggestions([text], 0, text.length, { signal: ac.signal }))
571
+ .then((s) => respond(id, type, {
572
+ items: (Array.isArray(s?.items) ? s.items : [])
573
+ .filter((i) => typeof i?.value === "string")
574
+ .slice(0, 50)
575
+ .map((i) => ({
576
+ value: i.value,
577
+ label: typeof i.label === "string" ? i.label : i.value,
578
+ description: typeof i.description === "string" ? i.description : null,
579
+ })),
580
+ prefix: typeof s?.prefix === "string" ? s.prefix : "",
581
+ }))
582
+ .catch(() => respond(id, type, { items: [], prefix: "" }))
583
+ .finally(() => clearTimeout(timer));
584
+ return;
585
+ }
458
586
  default:
459
587
  respond(id, type, undefined, `Unknown command: ${type}`);
460
588
  }
@@ -515,6 +643,18 @@ export default function switchyardTerminal(pi) {
515
643
  catch {
516
644
  socket = null;
517
645
  }
646
+ try {
647
+ pi.events?.on?.(REMOTE_UI_CHANNEL, (data) => {
648
+ const r = data;
649
+ if (r?.version !== 1 || r.ui || !socket)
650
+ return;
651
+ r.ui = remoteUi;
652
+ r.provider = "switchyard";
653
+ });
654
+ }
655
+ catch {
656
+ // optional service
657
+ }
518
658
  // ---- events ----
519
659
  const on = (name, fn) => {
520
660
  try {
@@ -534,6 +674,16 @@ export default function switchyardTerminal(pi) {
534
674
  on("session_start", (event, c) => {
535
675
  ctx = c;
536
676
  patchUi(c);
677
+ completer = null;
678
+ try {
679
+ c.ui?.addAutocompleteProvider?.((current) => {
680
+ completer = current;
681
+ return current;
682
+ });
683
+ }
684
+ catch {
685
+ // RPC or an older Pi: no completions
686
+ }
537
687
  entryCount = c.sessionManager.getEntries().length;
538
688
  const sessionId = c.sessionManager.getSessionId();
539
689
  const sessionFile = c.sessionManager.getSessionFile() ?? null;
@@ -596,7 +746,13 @@ export default function switchyardTerminal(pi) {
596
746
  on("model_select", () => sendModelState());
597
747
  on("thinking_level_select", () => sendModelState());
598
748
  on("session_before_compact", (event) => send({ type: "compaction_start", reason: event?.reason ?? "manual" }));
599
- on("ui_prompt_start", (event) => send({ type: "ui_prompt", event: event.type, kind: event.kind, title: event.title ?? null }));
749
+ on("ui_prompt_start", (event) => send({
750
+ type: "ui_prompt",
751
+ event: event.type,
752
+ kind: event.kind,
753
+ title: event.title ?? null,
754
+ remoteBacked: event.kind === "custom" && remoteBacked > 0,
755
+ }));
600
756
  on("ui_prompt_end", (event) => send({ type: "ui_prompt", event: event.type, kind: event.kind, title: event.title ?? null }));
601
757
  on("input", (event) => {
602
758
  if (event?.source !== "interactive" || !event.streamingBehavior)