@xingular/harness 0.1.6 → 2.0.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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -6,10 +6,11 @@ import { createConnection } from "node:net";
6
6
  var SOCKET_ENV = "XINGULAR_HARNESS_SOCKET";
7
7
  var TOKEN_ENV = "XINGULAR_HARNESS_TOKEN";
8
8
  var SocketError = class extends Error {
9
- constructor(code, message) {
10
- super(message);
9
+ constructor(code, message2) {
10
+ super(message2);
11
11
  this.code = code;
12
12
  }
13
+ code;
13
14
  };
14
15
  function connectionFromEnv(env = process.env) {
15
16
  const socket = env[SOCKET_ENV];
@@ -60,6 +61,322 @@ function terminalCall(event, data = {}, timeoutMs = 6e5) {
60
61
  return request(connectionFromEnv(), { event, data }, event, timeoutMs);
61
62
  }
62
63
 
64
+ // src/terminal/accountsMenu.ts
65
+ var HEALTH = {
66
+ ok: "Ready",
67
+ limited: "Limit reached",
68
+ auth_expired: "Sign in again",
69
+ signed_out: "Signed out",
70
+ error: "Needs attention",
71
+ unknown: "Not checked"
72
+ };
73
+ var ADD = "Add account";
74
+ var POOLS = "Pools";
75
+ var CHECK_ALL = "Check all accounts";
76
+ var DONE = "Done";
77
+ var BACK = "Back";
78
+ var USE_HERE = "Use for this session (restarts it)";
79
+ var MAKE_DEFAULT = "Make default for new sessions";
80
+ var RENAME = "Rename";
81
+ var CHECK = "Check";
82
+ var DETAILS = "Details";
83
+ var ACTIVITY = "Activity";
84
+ var SIGN_IN = "Sign in";
85
+ var REMOVE_PROVIDER = "Remove a provider";
86
+ var SIGN_OUT = "Sign out";
87
+ var REMOVE = "Remove account";
88
+ var NEW_POOL = "New pool";
89
+ var EDIT = "Edit";
90
+ var DELETE = "Delete";
91
+ var SAVE_POOL = "Save pool";
92
+ var CANCEL = "Cancel";
93
+ var START_OVER = "Start over";
94
+ var REMOVE_LAST = "Remove the last one";
95
+ var CANCEL_SIGN_IN = "Cancel sign-in";
96
+ var CHECK_STATUS = "Check status";
97
+ var LOGIN_TIMEOUT_MS = 10 * 6e4;
98
+ var POLL_MS = 1e3;
99
+ var message = (error) => error instanceof Error ? error.message : String(error);
100
+ async function attempt(ui, action) {
101
+ try {
102
+ await action();
103
+ return true;
104
+ } catch (error) {
105
+ ui.notify(message(error), "error");
106
+ return false;
107
+ }
108
+ }
109
+ function resetIn(resetsAt, now2 = Date.now()) {
110
+ const minutes = Math.round((Date.parse(resetsAt) - now2) / 6e4);
111
+ if (Number.isNaN(minutes)) return resetsAt;
112
+ if (minutes <= 0) return "now";
113
+ if (minutes < 60) return `in ${minutes}m`;
114
+ if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
115
+ return `in ${Math.floor(minutes / (60 * 24))}d`;
116
+ }
117
+ function accountRows(overview, session) {
118
+ return overview.accounts.map((account, index) => {
119
+ const mark2 = account.isDefault ? "\u25CF" : "\u25CB";
120
+ const here2 = session?.accountId === account.id ? " \xB7 this session" : "";
121
+ return { account, label: `${index + 1}. ${mark2} ${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}${here2}` };
122
+ });
123
+ }
124
+ var limitLine = (limit) => `${limit.label}: ${limit.usedPercent ?? "?"}% used${limit.resetsAt ? `, resets ${resetIn(limit.resetsAt)}` : ""}`;
125
+ function accountDetails(account) {
126
+ const state = account.state;
127
+ const lines2 = [
128
+ account.label,
129
+ `Health: ${HEALTH[state.health] ?? HEALTH.unknown}`,
130
+ `Default for new sessions: ${account.isDefault ? "yes" : "no"}`,
131
+ `Signed in: ${state.signedIn === null ? "not checked" : state.signedIn ? "yes" : "no"}`,
132
+ `Providers: ${state.piProviders.length ? state.piProviders.join(", ") : "none"}`,
133
+ state.plan ? `Plan: ${state.plan}` : null,
134
+ state.identity ? `Identity: ${state.identity}` : null,
135
+ `Last checked: ${state.lastCheckedAt ? new Date(state.lastCheckedAt).toLocaleString() : "never"}`,
136
+ state.lastError ? `Last error: ${state.lastError.message}${state.lastError.resetsAt ? ` (resets ${resetIn(state.lastError.resetsAt)})` : ""}` : null,
137
+ ...state.limits.map(limitLine),
138
+ state.limits.length ? null : "Usage limits: Pi providers do not report them.",
139
+ state.capabilities.length ? `Detected: ${state.capabilities.join(", ")}` : null
140
+ ];
141
+ return lines2.filter((line) => line !== null).join("\n");
142
+ }
143
+ function loginStep(step) {
144
+ if (step.type === "auth_url" && typeof step.url === "string") return `Open this link to sign in:
145
+ ${step.url}${typeof step.instructions === "string" ? `
146
+ ${step.instructions}` : ""}`;
147
+ if (step.type === "device_code" && typeof step.userCode === "string") return `Enter the code ${step.userCode} at ${String(step.verificationUri ?? "")}`;
148
+ return null;
149
+ }
150
+ async function runAccountsMenu(ui, call, session) {
151
+ for (; ; ) {
152
+ const overview = await call("accounts.list");
153
+ const rows = accountRows(overview, session);
154
+ const options = [...rows.map((row2) => row2.label), ADD, POOLS, CHECK_ALL, DONE];
155
+ const picked = await ui.select("Accounts", options);
156
+ if (!picked || picked === DONE) return;
157
+ if (picked === ADD) {
158
+ await addAccount(ui, call);
159
+ continue;
160
+ }
161
+ if (picked === POOLS) {
162
+ await poolsMenu(ui, call);
163
+ continue;
164
+ }
165
+ if (picked === CHECK_ALL) {
166
+ await attempt(ui, () => call("accounts.refresh"));
167
+ continue;
168
+ }
169
+ const row = rows[options.indexOf(picked)];
170
+ if (!row) continue;
171
+ if (await accountMenu(ui, call, session, row.account.id) === "relaunch") return "relaunch";
172
+ }
173
+ }
174
+ async function addAccount(ui, call) {
175
+ const label = await ui.input("Name for the new account", "Work, Personal, \u2026");
176
+ if (!label?.trim()) return;
177
+ if (await attempt(ui, () => call("accounts.create", { label: label.trim(), authKind: "subscription" }))) ui.notify("Account added. Sign in to use it.", "info");
178
+ }
179
+ async function accountMenu(ui, call, session, accountId) {
180
+ const isSession = session.accountId === accountId;
181
+ for (; ; ) {
182
+ const overview = await call("accounts.list");
183
+ const account = overview.accounts.find((candidate) => candidate.id === accountId);
184
+ if (!account) return;
185
+ const actions = [
186
+ ...isSession ? [] : [USE_HERE],
187
+ ...account.isDefault ? [] : [MAKE_DEFAULT],
188
+ RENAME,
189
+ CHECK,
190
+ DETAILS,
191
+ ACTIVITY,
192
+ SIGN_IN,
193
+ ...account.state.piProviders.length ? [REMOVE_PROVIDER] : [],
194
+ SIGN_OUT,
195
+ // The session's own account is in use, so it cannot be removed from inside the session.
196
+ ...isSession ? [] : [REMOVE],
197
+ BACK
198
+ ];
199
+ const action = await ui.select(`${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}`, actions);
200
+ if (!action || action === BACK) return;
201
+ if (action === USE_HERE) {
202
+ if (!await ui.confirm("Switch this session?", `This ends the terminal's Pi process and resumes the conversation on ${account.label}. Work in progress stops.`)) continue;
203
+ if (await attempt(ui, () => call("accounts.relaunch", { accountId }))) {
204
+ ui.notify(`Switching to ${account.label}.`, "info");
205
+ return "relaunch";
206
+ }
207
+ } else if (action === MAKE_DEFAULT) {
208
+ if (await attempt(ui, () => call("accounts.setDefault", { accountId }))) ui.notify(`${account.label} is the default for new sessions.`, "info");
209
+ } else if (action === RENAME) {
210
+ const label = await ui.input("New name", account.label);
211
+ if (label?.trim()) await attempt(ui, () => call("accounts.rename", { accountId, label: label.trim() }));
212
+ } else if (action === CHECK) {
213
+ if (await attempt(ui, () => call("accounts.refresh", { accountId }))) ui.notify("Checked.", "info");
214
+ } else if (action === DETAILS) {
215
+ await ui.select(accountDetails(account), [BACK]);
216
+ } else if (action === ACTIVITY) {
217
+ await showActivity(ui, call, accountId, account.label);
218
+ } else if (action === SIGN_IN) {
219
+ await signIn(ui, call, account);
220
+ } else if (action === REMOVE_PROVIDER) {
221
+ const provider = await ui.select("Remove which provider?", [...account.state.piProviders, BACK]);
222
+ if (!provider || provider === BACK) continue;
223
+ if (await ui.confirm(`Remove ${provider}?`, `The stored ${provider} sign-in for ${account.label} is deleted from this device.`)) await attempt(ui, () => call("accounts.removeProvider", { accountId, provider }));
224
+ } else if (action === SIGN_OUT) {
225
+ if (await ui.confirm(`Sign out of ${account.label}?`, "Stored sign-ins are deleted. The account stays, so you can sign in again.")) await attempt(ui, () => call("accounts.signOut", { accountId }));
226
+ } else if (action === REMOVE) {
227
+ const last = overview.accounts.length === 1;
228
+ const consequence = last ? "This is the only account. New sessions will ask you to add one." : account.isDefault ? "New sessions will use another account." : "Sessions pinned to it will ask for another account next time they start.";
229
+ if (!await ui.confirm(`Remove ${account.label}?`, `Its stored sign-in and its runtime folder are deleted. ${consequence}`)) continue;
230
+ if (await attempt(ui, () => call("accounts.remove", { accountId }))) ui.notify(`${account.label} was removed.`, "info");
231
+ return;
232
+ }
233
+ }
234
+ }
235
+ async function showActivity(ui, call, accountId, label) {
236
+ const entries = await call("accounts.audit", { accountId, limit: 30 });
237
+ const lines2 = entries.map((entry) => `${new Date(entry.at).toLocaleString()} \xB7 ${entry.action} \xB7 ${entry.detail}`);
238
+ await ui.select(`${label}: activity
239
+ ${lines2.join("\n") || "Nothing has happened here yet."}`, [BACK]);
240
+ }
241
+ async function signIn(ui, call, account) {
242
+ const providers = await call("accounts.providers");
243
+ if (!providers.length) {
244
+ ui.notify("No provider here can be signed in to.", "warning");
245
+ return;
246
+ }
247
+ const labels = providers.map((provider2, index2) => `${index2 + 1}. ${provider2.name}${account.state.piProviders.includes(provider2.id) ? " \xB7 signed in" : ""}`);
248
+ const pickedLabel = await ui.select(`Sign in ${account.label} to which provider?`, [...labels, BACK]);
249
+ const index = labels.indexOf(pickedLabel ?? "");
250
+ const provider = providers[index];
251
+ if (!provider) return;
252
+ const methods = [...provider.oauth ? [provider.oauthLabel ?? "Sign in with the browser"] : [], ...provider.apiKey ? ["Enter an API key"] : []];
253
+ if (!methods.length) {
254
+ ui.notify(`${provider.name} has no sign-in from a terminal.`, "warning");
255
+ return;
256
+ }
257
+ const method = methods.length === 1 ? methods[0] : await ui.select(`${provider.name}: how to sign in`, [...methods, BACK]);
258
+ if (!method || method === BACK) return;
259
+ if (method === "Enter an API key") {
260
+ const key = await ui.input(`API key for ${provider.name}`, "Paste the key");
261
+ if (!key?.trim()) return;
262
+ if (await attempt(ui, () => call("accounts.setApiKey", { accountId: account.id, provider: provider.id, key: key.trim() }))) ui.notify(`Key saved for ${provider.name}.`, "info");
263
+ return;
264
+ }
265
+ await runLogin(ui, call, account.id, provider.id);
266
+ }
267
+ async function runLogin(ui, call, accountId, provider) {
268
+ let loginId;
269
+ try {
270
+ loginId = (await call("accounts.loginStart", { accountId, provider })).loginId;
271
+ } catch (error) {
272
+ ui.notify(message(error), "error");
273
+ return;
274
+ }
275
+ return followLogin(ui, call, accountId, loginId);
276
+ }
277
+ async function followLogin(ui, call, accountId, loginId) {
278
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
279
+ let shownRevision = -1;
280
+ for (; ; ) {
281
+ const snapshot = await call("accounts.loginStatus", { accountId });
282
+ if (!snapshot || snapshot.loginId !== loginId) return;
283
+ if (snapshot.status !== "running") return reportLogin(ui, snapshot);
284
+ if (snapshot.prompt) {
285
+ await answerPrompt(ui, call, loginId, snapshot.prompt);
286
+ continue;
287
+ }
288
+ const step = [...snapshot.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
+ if (step && snapshot.revision !== shownRevision) {
290
+ shownRevision = snapshot.revision;
291
+ const choice = await ui.select(`${step}
292
+
293
+ Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN]);
294
+ if (choice === CANCEL_SIGN_IN) {
295
+ await call("accounts.loginCancel", { accountId, loginId });
296
+ ui.notify("Sign-in cancelled.", "warning");
297
+ return;
298
+ }
299
+ continue;
300
+ }
301
+ if (Date.now() > deadline) {
302
+ await call("accounts.loginCancel", { accountId, loginId });
303
+ ui.notify("Sign-in timed out and was cancelled.", "error");
304
+ return;
305
+ }
306
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
307
+ }
308
+ }
309
+ function reportLogin(ui, snapshot) {
310
+ if (snapshot.status === "completed") ui.notify("Signed in.", "info");
311
+ else if (snapshot.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
+ else ui.notify(snapshot.error ?? "Sign-in failed.", "error");
313
+ }
314
+ async function answerPrompt(ui, call, loginId, prompt) {
315
+ const promptId = String(prompt.promptId ?? "");
316
+ const detail = prompt.prompt ?? {};
317
+ const text3 = detail.message ?? "Sign-in needs an answer.";
318
+ let value;
319
+ if (detail.type === "select" && Array.isArray(detail.options)) {
320
+ if (detail.options.every((option) => typeof option === "string")) {
321
+ value = await ui.select(text3, detail.options);
322
+ } else if (detail.options.every((option) => typeof option === "object" && option !== null && "id" in option && typeof option.id === "string" && "label" in option && typeof option.label === "string")) {
323
+ const options = detail.options;
324
+ const selected = await ui.select(text3, options.map((option) => option.label));
325
+ value = options.find((option) => option.label === selected)?.id;
326
+ }
327
+ } else {
328
+ value = await ui.input(text3, detail.placeholder ?? void 0);
329
+ }
330
+ await call("accounts.loginRespond", { loginId, promptId, value: value ?? null });
331
+ }
332
+ async function poolsMenu(ui, call) {
333
+ for (; ; ) {
334
+ const overview = await call("accounts.list");
335
+ const names = new Map(overview.accounts.map((account) => [account.id, account.label]));
336
+ const labels = overview.pools.map((pool2, index) => `${index + 1}. ${pool2.label} \xB7 ${pool2.accountIds.map((id) => names.get(id) ?? "removed account").join(" \u2192 ")}`);
337
+ const options = [NEW_POOL, ...labels, BACK];
338
+ const picked = await ui.select("Pools: new sessions use the first ready account in a pool, in this order", options);
339
+ if (!picked || picked === BACK) return;
340
+ if (picked === NEW_POOL) {
341
+ await editPool(ui, call, overview, null);
342
+ continue;
343
+ }
344
+ const pool = overview.pools[options.indexOf(picked) - 1];
345
+ if (!pool) continue;
346
+ const action = await ui.select(pool.label, [EDIT, DELETE, BACK]);
347
+ if (action === EDIT) await editPool(ui, call, overview, pool);
348
+ else if (action === DELETE && await ui.confirm(`Delete ${pool.label}?`, "The accounts stay. Only the pool is removed.")) await attempt(ui, () => call("accounts.removePool", { poolId: pool.id }));
349
+ }
350
+ }
351
+ async function editPool(ui, call, overview, pool) {
352
+ const label = await ui.input("Pool name", pool?.label);
353
+ if (!label?.trim()) return;
354
+ const names = new Map(overview.accounts.map((account) => [account.id, account.label]));
355
+ const order = [...pool?.accountIds ?? []];
356
+ for (; ; ) {
357
+ const ordered = order.map((id, index) => `${index + 1}. ${names.get(id) ?? "removed account"}`).join(" ");
358
+ const free = overview.accounts.filter((account) => !order.includes(account.id));
359
+ const addOptions = free.map((account) => `Add ${account.label}`);
360
+ const options = [...addOptions, ...order.length ? [REMOVE_LAST, START_OVER] : [], SAVE_POOL, CANCEL];
361
+ const picked = await ui.select(`${label.trim()}: ${ordered || "no accounts yet"}`, options);
362
+ if (!picked || picked === CANCEL) return;
363
+ if (picked === SAVE_POOL) {
364
+ if (!order.length) {
365
+ ui.notify("A pool needs at least one account.", "error");
366
+ continue;
367
+ }
368
+ if (await attempt(ui, () => call("accounts.savePool", { pool: { id: pool?.id, label: label.trim(), accountIds: order } }))) ui.notify("Pool saved.", "info");
369
+ return;
370
+ }
371
+ if (picked === START_OVER) order.length = 0;
372
+ else if (picked === REMOVE_LAST) order.pop();
373
+ else {
374
+ const account = free[addOptions.indexOf(picked)];
375
+ if (account) order.push(account.id);
376
+ }
377
+ }
378
+ }
379
+
63
380
  // src/terminal/pluginMenu.ts
64
381
  var here = (attachment, projectId) => {
65
382
  if (projectId && attachment.on.projectIds.includes(projectId)) return true;
@@ -90,8 +407,8 @@ var SWITCH_OFF_EVERYWHERE = "Switch off everywhere";
90
407
  var SWITCH_ON_EVERYWHERE = "Switch on everywhere";
91
408
  var TRUST = "Trust its code";
92
409
  var UNTRUST = "Stop trusting its code";
93
- var BACK = "Back";
94
- var DONE = "Done";
410
+ var BACK2 = "Back";
411
+ var DONE2 = "Done";
95
412
  function rowActions(row, projectId) {
96
413
  const switchable = row.whole ? row.plugin.attachment !== null : true;
97
414
  const actions = [];
@@ -100,7 +417,7 @@ function rowActions(row, projectId) {
100
417
  actions.push(row.on ? SWITCH_OFF_EVERYWHERE : SWITCH_ON_EVERYWHERE);
101
418
  }
102
419
  if (row.whole && row.plugin.code) actions.push(row.plugin.trust === "untrusted" ? TRUST : UNTRUST);
103
- return [...actions, BACK];
420
+ return [...actions, BACK2];
104
421
  }
105
422
  async function runPluginsMenu(ui, call, projectId) {
106
423
  let changed = false;
@@ -110,11 +427,11 @@ async function runPluginsMenu(ui, call, projectId) {
110
427
  ui.notify("There is nothing to switch or trust here yet.", "info");
111
428
  return;
112
429
  }
113
- const picked = await ui.select(`Plugins${changed ? " (changes apply when this session restarts)" : ""}`, [...rows.map((row2) => row2.label), DONE]);
430
+ const picked = await ui.select(`Plugins${changed ? " (changes apply when this session restarts)" : ""}`, [...rows.map((row2) => row2.label), DONE2]);
114
431
  const row = rows.find((candidate) => candidate.label === picked);
115
432
  if (!row) break;
116
433
  const action = await ui.select(`${row.plugin.title}: ${row.plugin.description || "No description."}`, rowActions(row, projectId));
117
- if (!action || action === BACK) continue;
434
+ if (!action || action === BACK2) continue;
118
435
  try {
119
436
  if (action === TRUST || action === UNTRUST) {
120
437
  const code = row.plugin.code;
@@ -137,15 +454,414 @@ Fingerprint ${code.hash.slice(0, 12)}. It runs inside the session and can do any
137
454
  if (changed) ui.notify("Saved. It applies when this session restarts.", "info");
138
455
  }
139
456
 
457
+ // src/terminal/hubMenu.ts
458
+ async function runHubMenu(ui, call) {
459
+ for (; ; ) {
460
+ const catalog = await call("hub.list");
461
+ const labels = catalog.entries.map((entry2) => `${entry2.title} \xB7 ${entry2.category} \xB7 ${entry2.id}@${entry2.version}`);
462
+ const picked = await ui.select("Hub", [...labels, "Refresh catalog", "Done"]);
463
+ if (picked === "Refresh catalog") {
464
+ const refreshed = await call("hub.list", { refresh: true });
465
+ if (refreshed.error) ui.notify(`${refreshed.error} Using ${refreshed.origin} content.`, "warning");
466
+ continue;
467
+ }
468
+ const entry = catalog.entries[labels.indexOf(picked ?? "")];
469
+ if (!entry) return;
470
+ const summary = `${entry.description}
471
+
472
+ Publisher: ${entry.publisher}
473
+ License: ${entry.license}
474
+ Source: ${entry.sourceUrl}
475
+
476
+ ${entry.delivery.kind === "document" ? "Saved as data for a compatible application. Nothing is created or enabled." : "Installed switched off. Code remains untrusted and approvals remain unchanged."}`;
477
+ if (!await ui.confirm(`Install ${entry.title} ${entry.version}?`, summary)) continue;
478
+ try {
479
+ const receipt = await call("hub.install", { id: entry.id, version: entry.version });
480
+ ui.notify(receipt.document ? "Saved in this library for a compatible application." : "Installed, switched off. Use /plugins to review and attach it. Changes apply after restarting the session.", "info");
481
+ } catch (error) {
482
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
483
+ }
484
+ }
485
+ }
486
+
487
+ // src/terminal/control.ts
488
+ var claims = /* @__PURE__ */ new Map();
489
+ function claimTerminalControl(connection = connectionFromEnv()) {
490
+ const identity = JSON.stringify([connection.socket, connection.token]);
491
+ let claim = claims.get(identity);
492
+ if (!claim) {
493
+ claim = socketCall(connection, "control.claim", {}, 15e3).then(({ key }) => {
494
+ if (!key) throw new Error("The terminal controller returned no control key.");
495
+ return key;
496
+ });
497
+ claims.set(identity, claim);
498
+ }
499
+ return claim;
500
+ }
501
+ async function controlCall(call, params = {}, timeoutMs = 12e4) {
502
+ const connection = connectionFromEnv();
503
+ const control = await claimTerminalControl(connection);
504
+ return socketCall(connection, call, params, timeoutMs, control);
505
+ }
506
+
507
+ // src/sessions/contracts.ts
508
+ import { z } from "zod";
509
+ var runStatusSchema = z.enum(["queued", "running", "waiting_for_approval", "waiting_for_input", "interrupted", "completed", "failed"]);
510
+ var runEventTypeSchema = z.enum([
511
+ "started",
512
+ "context.snapshot",
513
+ "message.delta",
514
+ "message.completed",
515
+ "message.steered",
516
+ "tool.started",
517
+ "tool.completed",
518
+ "retry.started",
519
+ "retry.ended",
520
+ "compaction.started",
521
+ "compaction.ended",
522
+ "approval.requested",
523
+ "approval.resolved",
524
+ "question.requested",
525
+ "question.answered",
526
+ "extension.notice",
527
+ "reaction.added",
528
+ "agent.message",
529
+ "artifact.added",
530
+ "file.changed",
531
+ "usage.updated",
532
+ "account.status",
533
+ "status.changed",
534
+ "failed",
535
+ "completed"
536
+ ]);
537
+ var messageDeltaPayload = z.object({ messageId: z.string(), text: z.string() });
538
+ var messageCompletedPayload = z.object({ messageId: z.string(), role: z.enum(["user", "assistant"]), text: z.string() });
539
+ var messageSteeredPayload = z.object({ text: z.string() });
540
+ var contextSnapshotPayload = z.object({
541
+ systemPrompt: z.string().max(1e5),
542
+ contextUpdate: z.string().max(1e5).optional(),
543
+ tools: z.array(z.object({ name: z.string(), description: z.string().max(2e3), parameters: z.unknown() })).max(120),
544
+ systemPromptTruncated: z.boolean().optional(),
545
+ contextUpdateTruncated: z.boolean().optional(),
546
+ toolsTruncated: z.boolean().optional()
547
+ });
548
+ var toolAttachmentRefSchema = z.object({
549
+ id: z.string().min(1).max(300),
550
+ name: z.string().min(1).max(500),
551
+ mimeType: z.string().min(1).max(100)
552
+ }).strict();
553
+ var toolAttachmentRefsSchema = z.array(toolAttachmentRefSchema).max(16);
554
+ var toolPayload = z.object({
555
+ toolCallId: z.string(),
556
+ name: z.string(),
557
+ summary: z.string().max(200).optional(),
558
+ input: z.string().max(24e3).optional(),
559
+ inputTruncated: z.boolean().optional(),
560
+ output: z.string().max(24e3).optional(),
561
+ outputTruncated: z.boolean().optional(),
562
+ ok: z.boolean().optional(),
563
+ /** On `tool.completed`: how long the call ran, and the size of what it returned to the model. */
564
+ durationMs: z.number().optional(),
565
+ resultChars: z.number().optional(),
566
+ /** Opaque attachment references. Content stays outside the run event stream. */
567
+ attachments: toolAttachmentRefsSchema.optional()
568
+ });
569
+ var retryStartedPayload = z.object({ attempt: z.number(), maxAttempts: z.number(), delayMs: z.number(), error: z.string().max(1e3).optional() });
570
+ var retryEndedPayload = z.object({ attempt: z.number(), success: z.boolean(), error: z.string().max(1e3).optional() });
571
+ var compactionStartedPayload = z.object({ reason: z.string() });
572
+ var compactionEndedPayload = z.object({
573
+ reason: z.string(),
574
+ aborted: z.boolean(),
575
+ willRetry: z.boolean().optional(),
576
+ error: z.string().max(1e3).optional(),
577
+ tokensBefore: z.number().optional(),
578
+ tokensAfter: z.number().optional(),
579
+ summary: z.string().max(24e3).optional(),
580
+ summaryTruncated: z.boolean().optional()
581
+ });
582
+ var approvalAutomaticSchema = z.enum(["always", "remembered", "scope"]);
583
+ var approvalRequestedPayload = z.object({
584
+ approvalId: z.string(),
585
+ target: z.string(),
586
+ action: z.string(),
587
+ screenshotRef: z.string().optional(),
588
+ operation: z.object({ tool: z.string(), args: z.unknown() }).optional(),
589
+ /** A tool the user may choose to always allow for this session. */
590
+ rememberable: z.boolean().optional(),
591
+ /** Set when the request was granted as it was made, so nobody is waiting on it. */
592
+ automatic: approvalAutomaticSchema.optional()
593
+ });
594
+ var approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(["approved", "denied", "native"]), automatic: approvalAutomaticSchema.optional() });
595
+ var questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() });
596
+ var questionBatchFields = {
597
+ batchId: z.string().min(1).optional(),
598
+ batchIndex: z.number().int().min(1).max(5).optional(),
599
+ batchCount: z.number().int().min(1).max(5).optional()
600
+ };
601
+ var questionBatchRefinement = (value, context) => {
602
+ const fields = [value.batchId, value.batchIndex, value.batchCount];
603
+ if (fields.some((field) => field !== void 0) && fields.some((field) => field === void 0)) {
604
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Question batch fields must be supplied together." });
605
+ }
606
+ if (value.batchIndex !== void 0 && value.batchCount !== void 0 && value.batchIndex > value.batchCount) {
607
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Question batch index cannot exceed its count." });
608
+ }
609
+ };
610
+ var questionRequestedPayload = z.object({
611
+ questionId: z.string(),
612
+ question: z.string().min(1),
613
+ summary: z.string().min(1).max(100).optional(),
614
+ options: z.array(questionOptionSchema),
615
+ multiSelect: z.boolean(),
616
+ allowText: z.boolean(),
617
+ userOnly: z.boolean().optional(),
618
+ ...questionBatchFields
619
+ }).superRefine(questionBatchRefinement);
620
+ var questionAnsweredPayload = z.object({
621
+ questionId: z.string(),
622
+ selected: z.array(z.string()),
623
+ text: z.string().optional(),
624
+ notes: z.record(z.string(), z.string().max(1e4)).optional(),
625
+ ...questionBatchFields
626
+ }).superRefine(questionBatchRefinement);
627
+ var reactionAddedPayload = z.object({ emoji: z.string().min(1).refine((text3) => Buffer.byteLength(text3) <= 16 && !/[A-Za-z0-9\s]/.test(text3)) });
628
+ var agentMessagePayload = z.object({ direction: z.enum(["to", "from"]), agentId: z.string(), agentName: z.string(), text: z.string() });
629
+ var extensionNoticePayload = z.object({
630
+ kind: z.enum(["notify", "status", "widget", "title", "error"]),
631
+ level: z.enum(["info", "warning", "error"]).optional(),
632
+ key: z.string().optional(),
633
+ text: z.string(),
634
+ extension: z.string().optional()
635
+ });
636
+ var artifactPayload = z.object({ artifactId: z.string(), name: z.string(), file: z.unknown().optional() });
637
+ var fileChangedPayload = z.object({ path: z.string(), change: z.enum(["added", "modified", "deleted"]) });
638
+ var costSourceSchema = z.enum(["harness", "estimate", "mixed"]);
639
+ var usageRequestSchema = z.object({
640
+ /** Position in the run, from 1. */
641
+ index: z.number(),
642
+ responseId: z.string().optional(),
643
+ model: z.string().optional(),
644
+ provider: z.string().optional(),
645
+ inputTokens: z.number().optional(),
646
+ cacheReadTokens: z.number().optional(),
647
+ cacheWriteTokens: z.number().optional(),
648
+ outputTokens: z.number().optional(),
649
+ reasoningTokens: z.number().optional(),
650
+ costUsd: z.number().optional(),
651
+ costSource: z.enum(["harness", "estimate"]).optional(),
652
+ /** Separate provider-supplied summary; never generic private thinking content. */
653
+ reasoningSummary: z.string().max(4e3).optional(),
654
+ /** From sending the request to the end of the response, and to its first output. */
655
+ durationMs: z.number().optional(),
656
+ ttftMs: z.number().optional(),
657
+ stopReason: z.string().optional()
658
+ });
659
+ var usagePayloadSchema = z.object({
660
+ /** Input tokens that were not read from the provider's cache. */
661
+ inputTokens: z.number().optional(),
662
+ /** Includes reasoning tokens. */
663
+ outputTokens: z.number().optional(),
664
+ cacheReadTokens: z.number().optional(),
665
+ cacheWriteTokens: z.number().optional(),
666
+ reasoningTokens: z.number().optional(),
667
+ costUsd: z.number().optional(),
668
+ costSource: costSourceSchema.optional(),
669
+ /** Model requests made so far. */
670
+ requests: z.number().optional(),
671
+ /** Size of the latest request (not cumulative), and the model's limit. */
672
+ contextTokens: z.number().optional(),
673
+ contextWindow: z.number().optional(),
674
+ model: z.string().optional(),
675
+ provider: z.string().optional(),
676
+ /** The request this update reports. */
677
+ request: usageRequestSchema.optional()
678
+ });
679
+ var accountIssueCategorySchema = z.enum(["auth_expired", "rate_limited", "quota", "network", "unknown"]);
680
+ var accountLimitSchema = z.object({
681
+ id: z.string(),
682
+ label: z.string(),
683
+ usedPercent: z.number().nullable().optional(),
684
+ resetsAt: z.string().nullable().optional(),
685
+ windowMinutes: z.number().nullable().optional(),
686
+ status: z.string().nullable().optional()
687
+ });
688
+ var accountStatusPayload = z.object({
689
+ accountId: z.string(),
690
+ category: accountIssueCategorySchema.optional(),
691
+ message: z.string().optional(),
692
+ resetsAt: z.string().optional(),
693
+ plan: z.string().optional(),
694
+ limits: z.array(accountLimitSchema).optional()
695
+ });
696
+ var statusPayload = z.object({ status: runStatusSchema, reason: z.string().optional() });
697
+ var failedPayload = z.object({ code: z.string(), message: z.string() });
698
+ var emptyPayload = z.object({}).passthrough();
699
+ var runEventPayloadSchemas = {
700
+ started: emptyPayload,
701
+ "context.snapshot": contextSnapshotPayload,
702
+ "message.delta": messageDeltaPayload,
703
+ "message.completed": messageCompletedPayload,
704
+ "message.steered": messageSteeredPayload,
705
+ "tool.started": toolPayload,
706
+ "tool.completed": toolPayload,
707
+ "retry.started": retryStartedPayload,
708
+ "retry.ended": retryEndedPayload,
709
+ "compaction.started": compactionStartedPayload,
710
+ "compaction.ended": compactionEndedPayload,
711
+ "approval.requested": approvalRequestedPayload,
712
+ "approval.resolved": approvalResolvedPayload,
713
+ "question.requested": questionRequestedPayload,
714
+ "question.answered": questionAnsweredPayload,
715
+ "extension.notice": extensionNoticePayload,
716
+ "reaction.added": reactionAddedPayload,
717
+ "agent.message": agentMessagePayload,
718
+ "artifact.added": artifactPayload,
719
+ "file.changed": fileChangedPayload,
720
+ "usage.updated": usagePayloadSchema,
721
+ "account.status": accountStatusPayload,
722
+ "status.changed": statusPayload,
723
+ failed: failedPayload,
724
+ completed: emptyPayload
725
+ };
726
+ var runEventSchema = z.object({
727
+ id: z.string(),
728
+ runId: z.string(),
729
+ sessionId: z.string(),
730
+ sequence: z.number().int().nonnegative(),
731
+ timestamp: z.string(),
732
+ type: runEventTypeSchema,
733
+ payload: z.unknown(),
734
+ /** The native event this one was derived from, kept as evidence. */
735
+ native: z.unknown().optional(),
736
+ /** Set when consecutive text deltas were merged for relay; covers sequences coalescedFrom..sequence. */
737
+ coalescedFrom: z.number().int().nonnegative().optional()
738
+ }).superRefine((event, context) => {
739
+ if (!runEventPayloadSchemas[event.type].safeParse(event.payload).success) {
740
+ context.addIssue({ code: "custom", path: ["payload"], message: `Invalid payload for ${event.type}` });
741
+ }
742
+ });
743
+ var text = z.string().nullish().transform((value) => value ?? null);
744
+ var runRecordSchema = z.object({
745
+ resumedFromRunId: text,
746
+ parentRunId: text,
747
+ relation: text,
748
+ terminalReason: text,
749
+ id: z.string(),
750
+ projectId: text,
751
+ mode: z.string(),
752
+ harness: z.string(),
753
+ sessionType: z.string(),
754
+ sessionId: z.string(),
755
+ nativeSessionId: text,
756
+ workingDirectory: text,
757
+ worktreeId: text,
758
+ goal: text,
759
+ attachmentCount: z.number().int().nonnegative().default(0),
760
+ startedAt: z.string(),
761
+ endedAt: text,
762
+ status: z.string(),
763
+ exitCode: z.number().int().nullish().transform((value) => value ?? null),
764
+ handoffId: text,
765
+ accountId: text,
766
+ agentId: text,
767
+ trigger: text
768
+ });
769
+
770
+ // src/core/index.ts
771
+ import { DatabaseSync } from "node:sqlite";
772
+
773
+ // src/sessions/subagents.ts
774
+ var SUBAGENT_RECEIPT = "xingularSubagents";
775
+ var SUBAGENT_OWNER = "xingular-subagent-owner";
776
+ var SUBAGENT_RESULTS = "xingular-subagent-results";
777
+ function subagentHistory(entries) {
778
+ const owners = [];
779
+ const receipts = [];
780
+ for (const raw of entries) {
781
+ if (!raw || typeof raw !== "object") continue;
782
+ const entry = raw;
783
+ if (entry.type === "custom" && entry.customType === SUBAGENT_OWNER && typeof entry.data?.runId === "string") owners.push(entry.data.runId);
784
+ const message2 = entry.type === "message" ? entry.message : entry;
785
+ const receipt = message2?.details?.[SUBAGENT_RECEIPT];
786
+ if (!receipt || receipt.version !== 1 || typeof receipt.ownerRunId !== "string") continue;
787
+ const strings = (value) => Array.isArray(value) && value.every((id) => typeof id === "string");
788
+ if (receipt.spawned !== void 0 && !strings(receipt.spawned) || receipt.delivered !== void 0 && !strings(receipt.delivered)) continue;
789
+ receipts.push(receipt);
790
+ }
791
+ return { owners, receipts };
792
+ }
793
+
794
+ // src/tools/askUser.ts
795
+ var ASK_USER = "ask_user";
796
+ var MAX_OPTIONS = 6;
797
+ var text2 = (value) => typeof value === "string" ? value.trim() : "";
798
+ function describeAnswer(answer) {
799
+ const parts = [];
800
+ if (answer.selected.length) parts.push(`The user chose: ${answer.selected.join(", ")}.`);
801
+ if (answer.text) parts.push(answer.selected.length ? `They added: ${answer.text}` : `The user answered: ${answer.text}`);
802
+ for (const [choice, note] of Object.entries(answer.notes ?? {})) parts.push(`Their note for ${choice}: ${note}`);
803
+ return parts.join(" ") || "The user did not answer.";
804
+ }
805
+ function askUserTool(run2) {
806
+ return {
807
+ name: ASK_USER,
808
+ label: "Ask the user",
809
+ description: "Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 2\u20133 word summary and aim to keep each question to 25 words or fewer. Offer up to 6 short options when the choices are clear; every option must include a helpful description of 25 words or fewer. Every question includes a free-text \u201CSomething else\u201D answer. Set multiSelect when several can apply. Use it when you need a decision, not for small talk.",
810
+ parameters: (Type2) => Type2.Object({
811
+ questions: Type2.Array(Type2.Object({
812
+ summary: Type2.String({ description: "A concise 2\u20133 word summary label" }),
813
+ question: Type2.String({ description: "A clear, standalone question; aim for 25 words or fewer" }),
814
+ options: Type2.Optional(
815
+ Type2.Array(Type2.Object({
816
+ label: Type2.String({ description: "A short choice" }),
817
+ description: Type2.String({ description: "Required. Briefly explain this choice in 25 words or fewer." })
818
+ }), { maxItems: MAX_OPTIONS })
819
+ ),
820
+ multiSelect: Type2.Optional(Type2.Boolean())
821
+ }), { minItems: 1, maxItems: 5 })
822
+ }),
823
+ execute: async (input) => {
824
+ if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > 5) return "Ask between one and five questions.";
825
+ const questions = input.questions.map((raw) => {
826
+ const item = raw;
827
+ const summary = text2(item.summary);
828
+ const question = text2(item.question);
829
+ const options = (Array.isArray(item.options) ? item.options : []).map((option) => ({ label: text2(option.label), description: text2(option.description) })).filter((option) => option.label).slice(0, MAX_OPTIONS);
830
+ return { summary, question, options, multiSelect: options.length > 1 && item.multiSelect === true, allowText: true };
831
+ });
832
+ if (questions.some((question) => !question.summary || question.summary.length > 100)) return "Each question needs a short summary of no more than 100 characters.";
833
+ if (questions.some((question) => !question.question)) return "Every question needs a question sentence.";
834
+ if (questions.some((question) => question.options.some((option) => !option.description))) return "Every choice needs a short description. Aim for 25 words or fewer.";
835
+ const answers = await run2.requestAnswers(questions);
836
+ return questions.map((question, index) => `${question.summary}: ${describeAnswer(answers[index])}`).join("\n");
837
+ }
838
+ };
839
+ }
840
+
140
841
  // src/terminal/piExtension.ts
141
842
  function resultSize(result) {
142
843
  const content = result?.content;
143
844
  if (!Array.isArray(content)) return typeof result === "string" ? result.length : null;
144
845
  return content.reduce((total, part) => total + (part?.type === "text" && typeof part.text === "string" ? part.text.length : 0), 0);
145
846
  }
847
+ async function withParentCancellation(signal, work) {
848
+ const stop = () => {
849
+ void controlCall("terminal.interrupt").catch(() => {
850
+ });
851
+ };
852
+ if (signal?.aborted) stop();
853
+ signal?.addEventListener("abort", stop, { once: true });
854
+ try {
855
+ return await work();
856
+ } finally {
857
+ signal?.removeEventListener("abort", stop);
858
+ }
859
+ }
146
860
  async function piExtension_default(pi) {
147
861
  await terminalCall("startup", { phase: "Pi extension loaded" });
148
862
  let context = null;
863
+ let nativeSubagents = false;
864
+ let subagentParent = false;
149
865
  let timer = null;
150
866
  let polling = false;
151
867
  let failure;
@@ -160,19 +876,48 @@ async function piExtension_default(pi) {
160
876
  try {
161
877
  await terminalCall("startup", { phase: stage });
162
878
  const connection = connectionFromEnv();
163
- const control = await socketCall(connection, "control.claim", {}, 15e3).then((claimed) => claimed.key).catch(() => null);
879
+ const control = await claimTerminalControl(connection).catch(() => null);
880
+ pi.registerCommand("hub", {
881
+ description: "Browse and install reviewed skills, plugins, and reusable documents",
882
+ handler: async (_args, ctx) => {
883
+ if (!control) return ctx.ui.notify("This session cannot install hub items.", "warning");
884
+ await runHubMenu(
885
+ { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message2) => ctx.ui.confirm(title, message2), notify: (message2, level) => ctx.ui.notify(message2, level) },
886
+ (method, params) => socketCall(connection, method, params ?? {}, 3e5, control)
887
+ );
888
+ }
889
+ });
164
890
  pi.registerCommand("plugins", {
165
891
  description: "See what this session loads, and switch a plugin on or off or trust its code",
166
892
  handler: async (_args, ctx) => {
167
893
  if (!control) return ctx.ui.notify("This session cannot change plugins.", "warning");
168
894
  const { projectId } = await socketCall(connection, "plugins.session", {}, 15e3, control).catch(() => ({ projectId: null }));
169
895
  await runPluginsMenu(
170
- { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message) => ctx.ui.confirm(title, message), notify: (message, level) => ctx.ui.notify(message, level) },
896
+ { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message2) => ctx.ui.confirm(title, message2), notify: (message2, level) => ctx.ui.notify(message2, level) },
171
897
  (method, params) => socketCall(connection, method, params ?? {}, 6e4, control),
172
898
  projectId
173
899
  );
174
900
  }
175
901
  });
902
+ pi.registerCommand("accounts", {
903
+ description: "Add, sign in to, check, switch, or remove the Pi accounts this harness uses",
904
+ handler: async (_args, ctx) => {
905
+ if (!control) return ctx.ui.notify("This session cannot manage accounts.", "warning");
906
+ const session = await socketCall(connection, "accounts.session", {}, 15e3, control).catch(() => null);
907
+ if (!session) return ctx.ui.notify("This session has no account to manage.", "warning");
908
+ const outcome = await runAccountsMenu(
909
+ {
910
+ select: (title, options) => ctx.ui.select(title, options),
911
+ confirm: (title, message2) => ctx.ui.confirm(title, message2),
912
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
913
+ notify: (message2, level) => ctx.ui.notify(message2, level)
914
+ },
915
+ (method, params) => socketCall(connection, method, params ?? {}, 12e4, control),
916
+ session
917
+ );
918
+ if (outcome === "relaunch") ctx.shutdown();
919
+ }
920
+ });
176
921
  const { tools } = await socketCall(connection, "tools.list", {}, 15e3);
177
922
  stage = "Tool registration";
178
923
  for (const tool of tools) {
@@ -187,6 +932,18 @@ async function piExtension_default(pi) {
187
932
  }
188
933
  });
189
934
  }
935
+ const native = await controlCall("terminal.subagents.tools");
936
+ nativeSubagents = native.tools.length > 0;
937
+ for (const tool of native.tools) pi.registerTool({
938
+ name: tool.name,
939
+ label: tool.label,
940
+ description: tool.description,
941
+ parameters: Type.Unsafe(tool.parameters),
942
+ execute: async (_id, input, signal) => {
943
+ const result = await withParentCancellation(signal, () => controlCall("terminal.subagents.call", { name: tool.name, input }, 2147483647));
944
+ return { content: [{ type: "text", text: result.text }], details: result.details ?? {} };
945
+ }
946
+ });
190
947
  } catch (error) {
191
948
  await terminalCall("startupFailed", { reason: `${stage} failed: ${error instanceof Error ? error.message : "Unavailable"}` }).catch(() => {
192
949
  });
@@ -194,6 +951,7 @@ async function piExtension_default(pi) {
194
951
  }
195
952
  pi.on("session_start", async (_event, ctx) => {
196
953
  context = ctx;
954
+ if (timer) clearInterval(timer);
197
955
  await terminalCall("ready", { nativeSessionId: ctx.sessionManager.getSessionFile() });
198
956
  timer = setInterval(() => {
199
957
  if (polling || !context) return;
@@ -218,13 +976,26 @@ async function piExtension_default(pi) {
218
976
  usage = emptyUsage();
219
977
  reasoningTokens = void 0;
220
978
  costReported = false;
221
- await terminalCall("begin", { text: event.messageType === "user" ? event.prompt : "" });
979
+ const begun = await terminalCall("begin", { text: event.messageType === "user" ? event.prompt : "" });
980
+ if (nativeSubagents) {
981
+ pi.appendEntry(SUBAGENT_OWNER, { runId: begun.runId });
982
+ await controlCall("terminal.subagents.register", { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) });
983
+ subagentParent = true;
984
+ }
222
985
  });
986
+ const deliverChildren = async (event, ctx, settle) => {
987
+ if (!nativeSubagents || settle && event.outcome !== "completed" || ctx.signal?.aborted) return;
988
+ const result = await withParentCancellation(ctx.signal, () => controlCall("terminal.subagents.delivery", { history: subagentHistory([...ctx.sessionManager.getBranch(), ...event.entries]), settle }, 2147483647));
989
+ if (!result) return;
990
+ return { entries: [...event.entries, { type: "custom_message", customType: SUBAGENT_RESULTS, content: result.text, display: false, details: { [SUBAGENT_RECEIPT]: result.receipt } }], continue: true };
991
+ };
992
+ pi.on("turn_end", (event, ctx) => deliverChildren(event, ctx, false));
993
+ pi.on("agent_before_settle", (event, ctx) => deliverChildren(event, ctx, true));
223
994
  pi.on("message_end", async (event) => {
224
995
  if (event.message.role !== "assistant") return;
225
996
  if (event.message.stopReason === "error" || event.message.stopReason === "aborted") failure = event.message.errorMessage ?? `Pi stopped: ${event.message.stopReason}`;
226
- const text = event.message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
227
- if (text) await terminalCall("message", { text });
997
+ const text3 = event.message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
998
+ if (text3) await terminalCall("message", { text: text3 });
228
999
  if (event.message.usage) {
229
1000
  const reported = event.message.usage;
230
1001
  usage.inputTokens += reported.input ?? 0;
@@ -269,9 +1040,9 @@ async function piExtension_default(pi) {
269
1040
  });
270
1041
  pi.on("tool_call", async (event) => {
271
1042
  try {
272
- const policy = await terminalCall("policy", { tool: event.toolName, args: event.input });
1043
+ const policy = await controlCall("terminal.policy", { tool: event.toolName, args: event.input });
273
1044
  if (policy.requiresApproval) {
274
- const approval = await terminalCall("approval", { requestId: event.toolCallId, target: event.toolName, action: JSON.stringify(event.input), tool: event.toolName, args: event.input });
1045
+ const approval = await controlCall("terminal.approval", { requestId: event.toolCallId, tool: event.toolName, args: event.input });
275
1046
  if (approval.decision === "approved") return;
276
1047
  return { block: true, reason: "The user denied this action. Ask how they would like to proceed." };
277
1048
  }
@@ -294,17 +1065,38 @@ async function piExtension_default(pi) {
294
1065
  fileTools.delete(event.toolCallId);
295
1066
  if (!event.isError && path) await terminalCall("file", { path, change: "modified" });
296
1067
  });
297
- pi.on("agent_settled", async () => {
298
- await terminalCall("settled", { error: failure });
1068
+ pi.on("agent_settled", async (event, ctx) => {
1069
+ if (subagentParent && !event.aborted && !failure) {
1070
+ try {
1071
+ await controlCall("terminal.subagents.verify", { history: subagentHistory(ctx.sessionManager.getBranch()) }, 2147483647);
1072
+ } catch (error) {
1073
+ failure = error instanceof Error ? error.message : String(error);
1074
+ }
1075
+ }
1076
+ subagentParent = false;
1077
+ await terminalCall("settled", { error: failure, aborted: event.aborted });
299
1078
  });
300
1079
  pi.on("user_bash", async () => {
301
1080
  throw new Error("Use the bash tool so this action can be checked.");
302
1081
  });
303
1082
  pi.on("session_shutdown", async () => {
304
1083
  if (timer) clearInterval(timer);
1084
+ timer = null;
1085
+ context = null;
305
1086
  await terminalCall("shutdown").catch(() => {
306
1087
  });
307
1088
  });
1089
+ const ask = askUserTool({ requestAnswers: (questions) => terminalCall("questions", { questions }, 123e4) });
1090
+ pi.registerTool({
1091
+ name: ask.name,
1092
+ label: ask.label,
1093
+ description: ask.description,
1094
+ parameters: Type.Unsafe(ask.parameters(Type)),
1095
+ async execute(_id, input) {
1096
+ const result = await ask.execute(input);
1097
+ return { content: [{ type: "text", text: typeof result === "string" ? result : result.text }], details: void 0 };
1098
+ }
1099
+ });
308
1100
  pi.registerTool({
309
1101
  name: "ask_user_question",
310
1102
  label: "Ask a question",