@spencerboucher/switchyard 0.3.0 → 0.5.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 +6 -5
- package/dist/bridge/switchyard-terminal.js +527 -0
- package/dist/switchyard.mjs +7154 -5028
- package/dist/terminal/holder.js +305 -0
- package/package.json +6 -3
- package/web-dist/assets/{AutomationView-Bt_v8JDx.js → AutomationView-CFohp0SQ.js} +2 -2
- package/web-dist/assets/{AutomationView-Bt_v8JDx.js.map → AutomationView-CFohp0SQ.js.map} +1 -1
- package/web-dist/assets/{InteractionCard-BRI9KgrO.js → InteractionCard-B-xmpTNG.js} +2 -2
- package/web-dist/assets/{InteractionCard-BRI9KgrO.js.map → InteractionCard-B-xmpTNG.js.map} +1 -1
- package/web-dist/assets/Menu-C4GUY_ym.js +2 -0
- package/web-dist/assets/Menu-C4GUY_ym.js.map +1 -0
- package/web-dist/assets/{Overlay-B9h5oAe6.js → Overlay--v0ur2lj.js} +2 -2
- package/web-dist/assets/{Overlay-B9h5oAe6.js.map → Overlay--v0ur2lj.js.map} +1 -1
- package/web-dist/assets/{attention-VkuGuoSf.js → attention-q6_xlnT4.js} +2 -2
- package/web-dist/assets/{attention-VkuGuoSf.js.map → attention-q6_xlnT4.js.map} +1 -1
- package/web-dist/assets/{automations-BY88rXFv.js → automations-CyA83OAD.js} +2 -2
- package/web-dist/assets/{automations-BY88rXFv.js.map → automations-CyA83OAD.js.map} +1 -1
- package/web-dist/assets/{board-OzxtnPgh.js → board-BFvNcFhk.js} +2 -2
- package/web-dist/assets/{board-OzxtnPgh.js.map → board-BFvNcFhk.js.map} +1 -1
- package/web-dist/assets/code-DavnmlQN.js +8 -0
- package/web-dist/assets/code-DavnmlQN.js.map +1 -0
- package/web-dist/assets/{describe-CRXLn3O6.js → describe-BirsjlUl.js} +2 -2
- package/web-dist/assets/{describe-CRXLn3O6.js.map → describe-BirsjlUl.js.map} +1 -1
- package/web-dist/assets/dist-C-h8FC-n.js +56 -0
- package/web-dist/assets/dist-C-h8FC-n.js.map +1 -0
- package/web-dist/assets/{enroll-C9-fBIKO.js → enroll-C7UBC20s.js} +2 -2
- package/web-dist/assets/{enroll-C9-fBIKO.js.map → enroll-C7UBC20s.js.map} +1 -1
- package/web-dist/assets/index-ByiuDoG0.css +2 -0
- package/web-dist/assets/{index-B11DJFHW.js → index-IdzOP9GE.js} +3 -3
- package/web-dist/assets/{index-B11DJFHW.js.map → index-IdzOP9GE.js.map} +1 -1
- package/web-dist/assets/{more-BUvq82hI.js → more-syTnSpW3.js} +2 -2
- package/web-dist/assets/{more-BUvq82hI.js.map → more-syTnSpW3.js.map} +1 -1
- package/web-dist/assets/{now-DR2wt1DG.js → now-BqsLS-tK.js} +2 -2
- package/web-dist/assets/{now-DR2wt1DG.js.map → now-BqsLS-tK.js.map} +1 -1
- package/web-dist/assets/settings-DRj7UPtP.js +8 -0
- package/web-dist/assets/settings-DRj7UPtP.js.map +1 -0
- package/web-dist/assets/{send-CFwekPNi.js → sliders-horizontal-S3tmwxsP.js} +2 -2
- package/web-dist/assets/sliders-horizontal-S3tmwxsP.js.map +1 -0
- package/web-dist/assets/{useNow-ChB9z-N6.js → useNow-BQpDYijR.js} +2 -2
- package/web-dist/assets/{useNow-ChB9z-N6.js.map → useNow-BQpDYijR.js.map} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/sw.js +1 -1
- package/web-dist/sw.js.map +1 -1
- package/web-dist/assets/Menu-Cqil5qVF.js +0 -2
- package/web-dist/assets/Menu-Cqil5qVF.js.map +0 -1
- package/web-dist/assets/code-BR4PKt8F.js +0 -8
- package/web-dist/assets/code-BR4PKt8F.js.map +0 -1
- package/web-dist/assets/dist-C4YmDHPG.js +0 -56
- package/web-dist/assets/dist-C4YmDHPG.js.map +0 -1
- package/web-dist/assets/index-DGGOgcLZ.css +0 -2
- package/web-dist/assets/send-CFwekPNi.js.map +0 -1
- package/web-dist/assets/settings-C4tDpSCU.js +0 -7
- package/web-dist/assets/settings-C4tDpSCU.js.map +0 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ flowchart TB
|
|
|
13
13
|
subgraph clients["Clients: windows onto the same work"]
|
|
14
14
|
phone["Phone<br/>(installable web app)"]
|
|
15
15
|
browser["Desktop browser"]
|
|
16
|
-
term["
|
|
16
|
+
term["Pi terminal and CLI"]
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
coord["<b>Coordinator</b><br/>tasks, schedules, notifications,<br/>sign-in, sync<br/><i>never runs code</i>"]
|
|
@@ -37,7 +37,7 @@ There are three kinds of parts:
|
|
|
37
37
|
|
|
38
38
|
- **The coordinator** is the one always-on service. It keeps tasks, schedules, notifications, devices and the shared view of every session, and it decides what runs where. It never runs an agent and never holds your provider credentials, so it can live anywhere: a container, a small server, or your laptop.
|
|
39
39
|
- **Hosts** are your machines. A host agent runs Pi as your OS user, with your repositories, worktrees, Pi packages and model logins, and connects *outbound* to the coordinator. A host needs no open ports, and a laptop can come and go: its sessions keep running while the coordinator is down, and the events catch up when it returns.
|
|
40
|
-
- **Clients** are views. The web app works on desktop and as an installed app on a phone;
|
|
40
|
+
- **Clients** are views. The web app works on desktop and as an installed app on a phone; in a terminal you get Pi's own TUI for any session (`switchyard tui`, or a whole herdr session mirroring Switchyard), and the scriptable CLI works over the same API. Any client can watch, answer and steer a session; none of them owns it.
|
|
41
41
|
|
|
42
42
|
For a single machine, `switchyard up` runs the coordinator and a host in one process. For more, run the coordinator as a server and enroll each machine as a host. The protocol is the same either way.
|
|
43
43
|
|
|
@@ -77,7 +77,7 @@ sequenceDiagram
|
|
|
77
77
|
| Area | What you get |
|
|
78
78
|
|---|---|
|
|
79
79
|
| **Sessions** | Persistent Pi conversations on any host; the same live session in every client; steer or queue follow-ups; take over and return to automation; resume after crashes with the same conversation (never a silent new one); import existing Pi sessions |
|
|
80
|
-
| **Workspaces** | A git worktree per task or session inside the repository, discovery of worktrees you made yourself, setup commands,
|
|
80
|
+
| **Workspaces** | A git worktree per task or session inside the repository, discovery of worktrees you made yourself, setup commands, removal that refuses to lose uncommitted or unmerged work, and project folders per host whose repositories are added automatically |
|
|
81
81
|
| **Tasks and board** | Backlog, Ready, In progress, Review, Done; dependencies with cycle detection; priorities; per-project concurrency limits; a Now view explaining why queued work waits |
|
|
82
82
|
| **Review** | Changed files and diffs on desktop and phone, line comments sent as one follow-up, request changes, accept |
|
|
83
83
|
| **Bounded continuation** | Optional: keep the agent working toward an objective, within a maximum number of continuations, a time limit and token or cost caps, ending in your checks and your review |
|
|
@@ -116,7 +116,7 @@ switchyard up
|
|
|
116
116
|
```sh
|
|
117
117
|
docker run -d --name switchyard -p 127.0.0.1:7400:7400 -v switchyard-data:/data \
|
|
118
118
|
-e SWITCHYARD_PUBLIC_URL=https://switchyard.example.com \
|
|
119
|
-
ghcr.io/justmytwospence/switchyard:0.
|
|
119
|
+
ghcr.io/justmytwospence/switchyard:0.5.0
|
|
120
120
|
docker exec switchyard switchyard init # prints a single-use sign-in link for your first device
|
|
121
121
|
```
|
|
122
122
|
|
|
@@ -154,7 +154,8 @@ Switchyard is young and moving quickly. It is tested against Pi `>=1.4.0 <1.6.0`
|
|
|
154
154
|
|---|---|
|
|
155
155
|
| [Deployment](docs/deployment.md) | Single machine, server, reverse proxies, VPNs, hosts, environment reference, privilege model |
|
|
156
156
|
| [Sign-in and devices](docs/auth.md) | The owner model, pairing, passkeys, single sign-on, host enrollment, revocation |
|
|
157
|
-
| [CLI
|
|
157
|
+
| [CLI](docs/cli.md) | Every `switchyard` command and credentials |
|
|
158
|
+
| [Terminal sessions](docs/terminal-sessions.md) | Pi's own TUI for every session, from any terminal, and the herdr integration |
|
|
158
159
|
| [Operations](docs/operations.md) | Backup, restore, migrations, retention, logs, health, diagnostics |
|
|
159
160
|
| [Adapters](docs/adapters.md) | Rendering a Pi plugin's UI in every client; shipping an adapter with your Pi package |
|
|
160
161
|
| [Architecture](docs/architecture.md) | Components, data ownership, the host and client protocols, packages |
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switchyard terminal extension, loaded with `-e` into an interactive Pi that runs inside a
|
|
3
|
+
* Switchyard terminal holder (a pty). It is the control channel the host uses instead of
|
|
4
|
+
* `pi --mode rpc`: it answers the same JSONL commands (`prompt`, `steer`, `follow_up`, `abort`,
|
|
5
|
+
* `get_state`, ...), forwards session events in RPC shape, and lets remote clients answer extension
|
|
6
|
+
* dialogs while they are also shown in the terminal (whichever answer comes first wins).
|
|
7
|
+
*
|
|
8
|
+
* It connects to the holder's socket named by `SWITCHYARD_CONTROL_SOCKET`, imports only Node
|
|
9
|
+
* built-ins, and never throws: without the variable it does nothing.
|
|
10
|
+
*
|
|
11
|
+
* Dialogs: every extension's `ctx.ui` is the same object, so replacing `select`, `confirm`,
|
|
12
|
+
* `input` and `editor` on it routes all dialogs through the race below. When that object cannot be
|
|
13
|
+
* patched, `remoteDialogs` is false and the host tells remote clients to answer in the terminal.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { createConnection } from "node:net";
|
|
17
|
+
const PATCHED = Symbol.for("switchyard.terminal");
|
|
18
|
+
const WRAPPED = Symbol.for("switchyard.terminal.wrapped");
|
|
19
|
+
function messageText(message) {
|
|
20
|
+
const content = message?.content;
|
|
21
|
+
if (typeof content === "string")
|
|
22
|
+
return content;
|
|
23
|
+
if (!Array.isArray(content))
|
|
24
|
+
return "";
|
|
25
|
+
return content
|
|
26
|
+
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
|
27
|
+
.map((c) => c.text)
|
|
28
|
+
.join("\n");
|
|
29
|
+
}
|
|
30
|
+
/** Pi's `toJsonEvent` for `message_update` (`dist/modes/json-event.js`). */
|
|
31
|
+
export function jsonMessageUpdate(event) {
|
|
32
|
+
const aev = event?.assistantMessageEvent ?? {};
|
|
33
|
+
let out;
|
|
34
|
+
if (aev.type === "toolcall_start") {
|
|
35
|
+
const { partial, ...rest } = aev;
|
|
36
|
+
const call = partial?.content?.[aev.contentIndex];
|
|
37
|
+
out = { ...rest, id: call?.id, toolName: call?.name };
|
|
38
|
+
}
|
|
39
|
+
else if (aev && typeof aev === "object" && "partial" in aev) {
|
|
40
|
+
const { partial: _p, ...rest } = aev;
|
|
41
|
+
out = rest;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out = aev;
|
|
45
|
+
}
|
|
46
|
+
return { type: "message_update", usage: event?.message?.usage, assistantMessageEvent: out };
|
|
47
|
+
}
|
|
48
|
+
function addUsage(totals, usage) {
|
|
49
|
+
if (!usage || typeof usage !== "object")
|
|
50
|
+
return;
|
|
51
|
+
if (typeof usage.totalTokens === "number")
|
|
52
|
+
totals.tokens += usage.totalTokens;
|
|
53
|
+
if (typeof usage.cost?.total === "number")
|
|
54
|
+
totals.cost += usage.cost.total;
|
|
55
|
+
}
|
|
56
|
+
/** Token and cost totals over session entries, like Pi's `getSessionStats()`. */
|
|
57
|
+
export function sessionTotals(entries) {
|
|
58
|
+
const totals = { tokens: 0, cost: 0 };
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e?.type === "usage")
|
|
61
|
+
addUsage(totals, e.usage);
|
|
62
|
+
else if ((e?.type === "branch_summary" || e?.type === "compaction") && e.usage) {
|
|
63
|
+
addUsage(totals, e.usage);
|
|
64
|
+
}
|
|
65
|
+
else if (e?.type === "message") {
|
|
66
|
+
const m = e.message;
|
|
67
|
+
if (m?.role === "assistant" || m?.role === "toolResult")
|
|
68
|
+
addUsage(totals, m.usage);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return totals;
|
|
72
|
+
}
|
|
73
|
+
export default function switchyardTerminal(pi) {
|
|
74
|
+
const socketPath = process.env.SWITCHYARD_CONTROL_SOCKET;
|
|
75
|
+
if (!socketPath)
|
|
76
|
+
return;
|
|
77
|
+
let ctx = null;
|
|
78
|
+
let remoteDialogs = false;
|
|
79
|
+
let entryCount = 0;
|
|
80
|
+
const pending = new Map();
|
|
81
|
+
const early = [];
|
|
82
|
+
const queue = { steering: [], followUp: [] };
|
|
83
|
+
let socket = null;
|
|
84
|
+
const send = (obj) => {
|
|
85
|
+
try {
|
|
86
|
+
if (!socket || socket.destroyed)
|
|
87
|
+
return;
|
|
88
|
+
socket.write(`${JSON.stringify(obj)}\n`);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// never throw into Pi
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const sendQueue = () => send({ type: "queue_update", ...queue });
|
|
95
|
+
const flushEntries = () => {
|
|
96
|
+
try {
|
|
97
|
+
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
|
|
98
|
+
for (const entry of entries.slice(entryCount))
|
|
99
|
+
send({ type: "entry_appended", entry });
|
|
100
|
+
entryCount = entries.length;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
// ---- dialogs ----
|
|
107
|
+
const parseBody = (method, body) => {
|
|
108
|
+
if (method === "confirm")
|
|
109
|
+
return body.cancelled ? false : body.confirmed === true;
|
|
110
|
+
return body.cancelled ? undefined : body.value;
|
|
111
|
+
};
|
|
112
|
+
const race = (ui, method, orig, title, second, opts) => {
|
|
113
|
+
if (opts?.signal?.aborted)
|
|
114
|
+
return orig.call(ui, title, second, opts);
|
|
115
|
+
const id = randomUUID();
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
const signals = [ac.signal, opts?.signal].filter(Boolean);
|
|
118
|
+
const signal = signals.length > 1 ? AbortSignal.any(signals) : ac.signal;
|
|
119
|
+
const extra = method === "select"
|
|
120
|
+
? { options: second }
|
|
121
|
+
: method === "confirm"
|
|
122
|
+
? { message: second }
|
|
123
|
+
: { placeholder: second };
|
|
124
|
+
const request = {
|
|
125
|
+
type: "extension_ui_request",
|
|
126
|
+
id,
|
|
127
|
+
method,
|
|
128
|
+
title,
|
|
129
|
+
...extra,
|
|
130
|
+
...(opts?.timeout ? { timeout: opts.timeout } : {}),
|
|
131
|
+
};
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let done = false;
|
|
134
|
+
const finish = (value) => {
|
|
135
|
+
if (done)
|
|
136
|
+
return;
|
|
137
|
+
done = true;
|
|
138
|
+
pending.delete(id);
|
|
139
|
+
resolve(value);
|
|
140
|
+
};
|
|
141
|
+
pending.set(id, {
|
|
142
|
+
request,
|
|
143
|
+
onRemote: (body) => {
|
|
144
|
+
const value = parseBody(method, body);
|
|
145
|
+
ac.abort();
|
|
146
|
+
finish(value);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
send(request);
|
|
150
|
+
Promise.resolve()
|
|
151
|
+
.then(() => orig.call(ui, title, second, { ...(opts ?? {}), signal }))
|
|
152
|
+
.then((value) => {
|
|
153
|
+
if (ac.signal.aborted)
|
|
154
|
+
return;
|
|
155
|
+
send({
|
|
156
|
+
type: "extension_ui_closed",
|
|
157
|
+
id,
|
|
158
|
+
by: opts?.signal?.aborted ? "pi" : "terminal",
|
|
159
|
+
});
|
|
160
|
+
finish(value);
|
|
161
|
+
}, () => {
|
|
162
|
+
if (ac.signal.aborted)
|
|
163
|
+
return;
|
|
164
|
+
send({ type: "extension_ui_closed", id, by: "terminal" });
|
|
165
|
+
finish(method === "confirm" ? false : undefined);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
const raceEditor = (ui, orig, title, prefill) => {
|
|
170
|
+
const id = randomUUID();
|
|
171
|
+
const request = {
|
|
172
|
+
type: "extension_ui_request",
|
|
173
|
+
id,
|
|
174
|
+
method: "editor",
|
|
175
|
+
title,
|
|
176
|
+
...(prefill !== undefined ? { prefill } : {}),
|
|
177
|
+
};
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let done = false;
|
|
180
|
+
let remote = false;
|
|
181
|
+
const finish = (value) => {
|
|
182
|
+
if (done)
|
|
183
|
+
return;
|
|
184
|
+
done = true;
|
|
185
|
+
pending.delete(id);
|
|
186
|
+
resolve(value);
|
|
187
|
+
};
|
|
188
|
+
pending.set(id, {
|
|
189
|
+
request,
|
|
190
|
+
onRemote: (body) => {
|
|
191
|
+
remote = true;
|
|
192
|
+
// The terminal editor takes no abort signal: dismiss it with Esc.
|
|
193
|
+
send({ type: "holder_keys", data: "\u001b" });
|
|
194
|
+
finish(parseBody("editor", body));
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
send(request);
|
|
198
|
+
Promise.resolve()
|
|
199
|
+
.then(() => orig.call(ui, title, prefill))
|
|
200
|
+
.then((value) => {
|
|
201
|
+
if (remote)
|
|
202
|
+
return;
|
|
203
|
+
send({ type: "extension_ui_closed", id, by: "terminal" });
|
|
204
|
+
finish(value);
|
|
205
|
+
}, () => {
|
|
206
|
+
if (remote)
|
|
207
|
+
return;
|
|
208
|
+
send({ type: "extension_ui_closed", id, by: "terminal" });
|
|
209
|
+
finish(undefined);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const fire = (method, fields) => send({ type: "extension_ui_request", id: randomUUID(), method, ...fields });
|
|
214
|
+
const patchUi = (c) => {
|
|
215
|
+
try {
|
|
216
|
+
const ui = c.ui;
|
|
217
|
+
if (!ui || typeof ui !== "object") {
|
|
218
|
+
remoteDialogs = false;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (ui[PATCHED]) {
|
|
222
|
+
remoteDialogs = c.ui.select === ui.select && ui.select?.[WRAPPED] === true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const wrap = (fn) => {
|
|
226
|
+
fn[WRAPPED] = true;
|
|
227
|
+
return fn;
|
|
228
|
+
};
|
|
229
|
+
for (const method of ["select", "confirm", "input"]) {
|
|
230
|
+
const orig = ui[method];
|
|
231
|
+
if (typeof orig !== "function")
|
|
232
|
+
continue;
|
|
233
|
+
ui[method] = wrap((title, second, opts) => race(ui, method, orig, title, second, opts));
|
|
234
|
+
}
|
|
235
|
+
const origEditor = ui.editor;
|
|
236
|
+
if (typeof origEditor === "function") {
|
|
237
|
+
ui.editor = wrap((title, prefill) => raceEditor(ui, origEditor, title, prefill));
|
|
238
|
+
}
|
|
239
|
+
const origNotify = ui.notify;
|
|
240
|
+
if (typeof origNotify === "function") {
|
|
241
|
+
ui.notify = (message, type) => {
|
|
242
|
+
const r = origNotify.call(ui, message, type);
|
|
243
|
+
fire("notify", { message, notifyType: type });
|
|
244
|
+
return r;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const origStatus = ui.setStatus;
|
|
248
|
+
if (typeof origStatus === "function") {
|
|
249
|
+
ui.setStatus = (key, text) => {
|
|
250
|
+
const r = origStatus.call(ui, key, text);
|
|
251
|
+
fire("setStatus", { statusKey: key, statusText: text });
|
|
252
|
+
return r;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const origWidget = ui.setWidget;
|
|
256
|
+
if (typeof origWidget === "function") {
|
|
257
|
+
ui.setWidget = (key, content, options) => {
|
|
258
|
+
const r = origWidget.call(ui, key, content, options);
|
|
259
|
+
if (content === undefined || Array.isArray(content)) {
|
|
260
|
+
fire("setWidget", {
|
|
261
|
+
widgetKey: key,
|
|
262
|
+
widgetLines: content,
|
|
263
|
+
widgetPlacement: options?.placement,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return r;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const origTitle = ui.setTitle;
|
|
270
|
+
if (typeof origTitle === "function") {
|
|
271
|
+
ui.setTitle = (title) => {
|
|
272
|
+
const r = origTitle.call(ui, title);
|
|
273
|
+
fire("setTitle", { title });
|
|
274
|
+
return r;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
Object.defineProperty(ui, PATCHED, { value: true });
|
|
278
|
+
remoteDialogs = c.ui.select === ui.select && ui.select?.[WRAPPED] === true;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
remoteDialogs = false;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
// ---- commands ----
|
|
285
|
+
const content = (message, images) => {
|
|
286
|
+
if (!Array.isArray(images) || images.length === 0)
|
|
287
|
+
return message;
|
|
288
|
+
return [
|
|
289
|
+
{ type: "text", text: message },
|
|
290
|
+
...images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
|
|
291
|
+
];
|
|
292
|
+
};
|
|
293
|
+
const respond = (id, command, data, error) => {
|
|
294
|
+
if (error !== undefined)
|
|
295
|
+
send({ type: "response", id, command, success: false, error });
|
|
296
|
+
else if (data === undefined)
|
|
297
|
+
send({ type: "response", id, command, success: true });
|
|
298
|
+
else
|
|
299
|
+
send({ type: "response", id, command, success: true, data });
|
|
300
|
+
};
|
|
301
|
+
const handleCommand = (cmd) => {
|
|
302
|
+
const id = cmd.id;
|
|
303
|
+
const type = String(cmd.type);
|
|
304
|
+
if (!ctx) {
|
|
305
|
+
// Commands that arrive before the session started run once it has.
|
|
306
|
+
early.push(cmd);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
switch (type) {
|
|
311
|
+
case "get_state":
|
|
312
|
+
respond(id, type, {
|
|
313
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
314
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? null,
|
|
315
|
+
sessionName: pi.getSessionName?.() ?? null,
|
|
316
|
+
isStreaming: !ctx.isIdle(),
|
|
317
|
+
pendingMessageCount: ctx.hasPendingMessages() ? 1 : 0,
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
case "prompt": {
|
|
321
|
+
const message = String(cmd.message ?? "");
|
|
322
|
+
if (message.startsWith("/")) {
|
|
323
|
+
respond(id, type, undefined, "Slash commands from Switchyard need the terminal in terminal sessions: type it there");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!ctx.isIdle()) {
|
|
327
|
+
respond(id, type, undefined, "Session is running: choose Steer or Queue follow-up");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
pi.sendUserMessage(content(message, cmd.images));
|
|
331
|
+
respond(id, type, { disposition: "started" });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case "steer":
|
|
335
|
+
case "follow_up": {
|
|
336
|
+
const message = String(cmd.message ?? "");
|
|
337
|
+
const idle = ctx.isIdle();
|
|
338
|
+
pi.sendUserMessage(content(message, cmd.images), {
|
|
339
|
+
deliverAs: type === "steer" ? "steer" : "followUp",
|
|
340
|
+
});
|
|
341
|
+
if (!idle) {
|
|
342
|
+
(type === "steer" ? queue.steering : queue.followUp).push(message);
|
|
343
|
+
sendQueue();
|
|
344
|
+
}
|
|
345
|
+
respond(id, type, { disposition: idle ? "started" : "queued" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
case "abort":
|
|
349
|
+
ctx.abort();
|
|
350
|
+
respond(id, type);
|
|
351
|
+
return;
|
|
352
|
+
case "clear_queue":
|
|
353
|
+
respond(id, type, { steering: [], followUp: [] });
|
|
354
|
+
return;
|
|
355
|
+
case "set_session_name": {
|
|
356
|
+
const name = String(cmd.name ?? "").trim();
|
|
357
|
+
if (!name) {
|
|
358
|
+
respond(id, type, undefined, "Session name cannot be empty");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
pi.setSessionName(name);
|
|
362
|
+
respond(id, type);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
case "get_session_stats": {
|
|
366
|
+
const totals = sessionTotals(ctx.sessionManager.getEntries());
|
|
367
|
+
respond(id, type, { tokens: { total: totals.tokens }, cost: totals.cost });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
case "shutdown":
|
|
371
|
+
respond(id, type);
|
|
372
|
+
setImmediate(() => {
|
|
373
|
+
try {
|
|
374
|
+
ctx.shutdown();
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// ignore
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
default:
|
|
382
|
+
respond(id, type, undefined, `Unknown command: ${type}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
respond(id, type, undefined, err instanceof Error ? err.message : String(err));
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const onLine = (line) => {
|
|
390
|
+
let msg;
|
|
391
|
+
try {
|
|
392
|
+
msg = JSON.parse(line);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!msg || typeof msg !== "object")
|
|
398
|
+
return;
|
|
399
|
+
if (msg.type === "extension_ui_response") {
|
|
400
|
+
pending.get(String(msg.id))?.onRemote(msg);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (msg.type === "control_attached") {
|
|
404
|
+
for (const p of pending.values())
|
|
405
|
+
send(p.request);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (typeof msg.type === "string" && msg.id !== undefined)
|
|
409
|
+
handleCommand(msg);
|
|
410
|
+
};
|
|
411
|
+
// ---- socket ----
|
|
412
|
+
try {
|
|
413
|
+
const s = createConnection(socketPath);
|
|
414
|
+
socket = s;
|
|
415
|
+
let buf = "";
|
|
416
|
+
s.setEncoding("utf8");
|
|
417
|
+
s.on("data", (chunk) => {
|
|
418
|
+
buf += chunk;
|
|
419
|
+
let nl = buf.indexOf("\n");
|
|
420
|
+
while (nl >= 0) {
|
|
421
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
422
|
+
buf = buf.slice(nl + 1);
|
|
423
|
+
if (line)
|
|
424
|
+
onLine(line);
|
|
425
|
+
nl = buf.indexOf("\n");
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
s.on("error", () => {
|
|
429
|
+
socket = null;
|
|
430
|
+
});
|
|
431
|
+
s.on("close", () => {
|
|
432
|
+
if (socket === s)
|
|
433
|
+
socket = null;
|
|
434
|
+
});
|
|
435
|
+
s.unref();
|
|
436
|
+
s.write(`${JSON.stringify({ role: "extension" })}\n`);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
socket = null;
|
|
440
|
+
}
|
|
441
|
+
// ---- events ----
|
|
442
|
+
const on = (name, fn) => {
|
|
443
|
+
try {
|
|
444
|
+
pi.on(name, (event, c) => {
|
|
445
|
+
try {
|
|
446
|
+
fn(event, c);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// never throw into Pi
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
on("session_start", (event, c) => {
|
|
458
|
+
ctx = c;
|
|
459
|
+
patchUi(c);
|
|
460
|
+
entryCount = c.sessionManager.getEntries().length;
|
|
461
|
+
const sessionId = c.sessionManager.getSessionId();
|
|
462
|
+
const sessionFile = c.sessionManager.getSessionFile() ?? null;
|
|
463
|
+
send({ type: "extension_hello", pid: process.pid, remoteDialogs, sessionId, sessionFile });
|
|
464
|
+
if (event?.reason && event.reason !== "startup") {
|
|
465
|
+
send({ type: "switchyard_native", sessionId, sessionFile });
|
|
466
|
+
}
|
|
467
|
+
for (const cmd of early.splice(0))
|
|
468
|
+
handleCommand(cmd);
|
|
469
|
+
});
|
|
470
|
+
for (const name of [
|
|
471
|
+
"agent_start",
|
|
472
|
+
"message_start",
|
|
473
|
+
"tool_execution_start",
|
|
474
|
+
"tool_execution_update",
|
|
475
|
+
"tool_execution_end",
|
|
476
|
+
"session_info_changed",
|
|
477
|
+
]) {
|
|
478
|
+
on(name, (event) => send(event));
|
|
479
|
+
}
|
|
480
|
+
on("message_start", (event) => {
|
|
481
|
+
if (event?.message?.role !== "user")
|
|
482
|
+
return;
|
|
483
|
+
const text = messageText(event.message);
|
|
484
|
+
for (const list of [queue.steering, queue.followUp]) {
|
|
485
|
+
const i = list.indexOf(text);
|
|
486
|
+
if (i >= 0) {
|
|
487
|
+
list.splice(i, 1);
|
|
488
|
+
sendQueue();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
on("message_update", (event) => send(jsonMessageUpdate(event)));
|
|
494
|
+
on("message_end", (event) => {
|
|
495
|
+
send(event);
|
|
496
|
+
flushEntries();
|
|
497
|
+
});
|
|
498
|
+
on("turn_end", () => flushEntries());
|
|
499
|
+
on("agent_settled", (event) => {
|
|
500
|
+
send({ type: "agent_settled", aborted: event?.aborted === true });
|
|
501
|
+
flushEntries();
|
|
502
|
+
if (queue.steering.length > 0 || queue.followUp.length > 0) {
|
|
503
|
+
queue.steering = [];
|
|
504
|
+
queue.followUp = [];
|
|
505
|
+
sendQueue();
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
on("session_compact", () => {
|
|
509
|
+
send({ type: "compaction_end", result: true });
|
|
510
|
+
flushEntries();
|
|
511
|
+
});
|
|
512
|
+
on("session_compact_failed", (event) => send({
|
|
513
|
+
type: "compaction_end",
|
|
514
|
+
result: null,
|
|
515
|
+
aborted: event?.aborted === true,
|
|
516
|
+
errorMessage: event?.errorMessage ?? "unknown error",
|
|
517
|
+
}));
|
|
518
|
+
on("ui_prompt_start", (event) => send({ type: "ui_prompt", event: event.type, kind: event.kind, title: event.title ?? null }));
|
|
519
|
+
on("ui_prompt_end", (event) => send({ type: "ui_prompt", event: event.type, kind: event.kind, title: event.title ?? null }));
|
|
520
|
+
on("input", (event) => {
|
|
521
|
+
if (event?.source !== "interactive" || !event.streamingBehavior)
|
|
522
|
+
return;
|
|
523
|
+
(event.streamingBehavior === "steer" ? queue.steering : queue.followUp).push(String(event.text ?? ""));
|
|
524
|
+
sendQueue();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
//# sourceMappingURL=switchyard-terminal.js.map
|