@xingular/harness 0.1.6 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -6,8 +6,8 @@ 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
13
  };
@@ -60,6 +60,322 @@ function terminalCall(event, data = {}, timeoutMs = 6e5) {
60
60
  return request(connectionFromEnv(), { event, data }, event, timeoutMs);
61
61
  }
62
62
 
63
+ // src/terminal/accountsMenu.ts
64
+ var HEALTH = {
65
+ ok: "Ready",
66
+ limited: "Limit reached",
67
+ auth_expired: "Sign in again",
68
+ signed_out: "Signed out",
69
+ error: "Needs attention",
70
+ unknown: "Not checked"
71
+ };
72
+ var ADD = "Add account";
73
+ var POOLS = "Pools";
74
+ var CHECK_ALL = "Check all accounts";
75
+ var DONE = "Done";
76
+ var BACK = "Back";
77
+ var USE_HERE = "Use for this session (restarts it)";
78
+ var MAKE_DEFAULT = "Make default for new sessions";
79
+ var RENAME = "Rename";
80
+ var CHECK = "Check";
81
+ var DETAILS = "Details";
82
+ var ACTIVITY = "Activity";
83
+ var SIGN_IN = "Sign in";
84
+ var REMOVE_PROVIDER = "Remove a provider";
85
+ var SIGN_OUT = "Sign out";
86
+ var REMOVE = "Remove account";
87
+ var NEW_POOL = "New pool";
88
+ var EDIT = "Edit";
89
+ var DELETE = "Delete";
90
+ var SAVE_POOL = "Save pool";
91
+ var CANCEL = "Cancel";
92
+ var START_OVER = "Start over";
93
+ var REMOVE_LAST = "Remove the last one";
94
+ var CANCEL_SIGN_IN = "Cancel sign-in";
95
+ var CHECK_STATUS = "Check status";
96
+ var LOGIN_TIMEOUT_MS = 10 * 6e4;
97
+ var POLL_MS = 1e3;
98
+ var message = (error) => error instanceof Error ? error.message : String(error);
99
+ async function attempt(ui, action) {
100
+ try {
101
+ await action();
102
+ return true;
103
+ } catch (error) {
104
+ ui.notify(message(error), "error");
105
+ return false;
106
+ }
107
+ }
108
+ function resetIn(resetsAt, now = Date.now()) {
109
+ const minutes = Math.round((Date.parse(resetsAt) - now) / 6e4);
110
+ if (Number.isNaN(minutes)) return resetsAt;
111
+ if (minutes <= 0) return "now";
112
+ if (minutes < 60) return `in ${minutes}m`;
113
+ if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
114
+ return `in ${Math.floor(minutes / (60 * 24))}d`;
115
+ }
116
+ function accountRows(overview, session) {
117
+ return overview.accounts.map((account, index) => {
118
+ const mark2 = account.isDefault ? "\u25CF" : "\u25CB";
119
+ const here2 = session?.accountId === account.id ? " \xB7 this session" : "";
120
+ return { account, label: `${index + 1}. ${mark2} ${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}${here2}` };
121
+ });
122
+ }
123
+ var limitLine = (limit) => `${limit.label}: ${limit.usedPercent ?? "?"}% used${limit.resetsAt ? `, resets ${resetIn(limit.resetsAt)}` : ""}`;
124
+ function accountDetails(account) {
125
+ const state = account.state;
126
+ const lines = [
127
+ account.label,
128
+ `Health: ${HEALTH[state.health] ?? HEALTH.unknown}`,
129
+ `Default for new sessions: ${account.isDefault ? "yes" : "no"}`,
130
+ `Signed in: ${state.signedIn === null ? "not checked" : state.signedIn ? "yes" : "no"}`,
131
+ `Providers: ${state.piProviders.length ? state.piProviders.join(", ") : "none"}`,
132
+ state.plan ? `Plan: ${state.plan}` : null,
133
+ state.identity ? `Identity: ${state.identity}` : null,
134
+ `Last checked: ${state.lastCheckedAt ? new Date(state.lastCheckedAt).toLocaleString() : "never"}`,
135
+ state.lastError ? `Last error: ${state.lastError.message}${state.lastError.resetsAt ? ` (resets ${resetIn(state.lastError.resetsAt)})` : ""}` : null,
136
+ ...state.limits.map(limitLine),
137
+ state.limits.length ? null : "Usage limits: Pi providers do not report them.",
138
+ state.capabilities.length ? `Detected: ${state.capabilities.join(", ")}` : null
139
+ ];
140
+ return lines.filter((line) => line !== null).join("\n");
141
+ }
142
+ function loginStep(step) {
143
+ if (step.type === "auth_url" && typeof step.url === "string") return `Open this link to sign in:
144
+ ${step.url}${typeof step.instructions === "string" ? `
145
+ ${step.instructions}` : ""}`;
146
+ if (step.type === "device_code" && typeof step.userCode === "string") return `Enter the code ${step.userCode} at ${String(step.verificationUri ?? "")}`;
147
+ return null;
148
+ }
149
+ async function runAccountsMenu(ui, call, session) {
150
+ for (; ; ) {
151
+ const overview = await call("accounts.list");
152
+ const rows = accountRows(overview, session);
153
+ const options = [...rows.map((row2) => row2.label), ADD, POOLS, CHECK_ALL, DONE];
154
+ const picked = await ui.select("Accounts", options);
155
+ if (!picked || picked === DONE) return;
156
+ if (picked === ADD) {
157
+ await addAccount(ui, call);
158
+ continue;
159
+ }
160
+ if (picked === POOLS) {
161
+ await poolsMenu(ui, call);
162
+ continue;
163
+ }
164
+ if (picked === CHECK_ALL) {
165
+ await attempt(ui, () => call("accounts.refresh"));
166
+ continue;
167
+ }
168
+ const row = rows[options.indexOf(picked)];
169
+ if (!row) continue;
170
+ if (await accountMenu(ui, call, session, row.account.id) === "relaunch") return "relaunch";
171
+ }
172
+ }
173
+ async function addAccount(ui, call) {
174
+ const label = await ui.input("Name for the new account", "Work, Personal, \u2026");
175
+ if (!label?.trim()) return;
176
+ if (await attempt(ui, () => call("accounts.create", { label: label.trim(), authKind: "subscription" }))) ui.notify("Account added. Sign in to use it.", "info");
177
+ }
178
+ async function accountMenu(ui, call, session, accountId) {
179
+ const isSession = session.accountId === accountId;
180
+ for (; ; ) {
181
+ const overview = await call("accounts.list");
182
+ const account = overview.accounts.find((candidate) => candidate.id === accountId);
183
+ if (!account) return;
184
+ const actions = [
185
+ ...isSession ? [] : [USE_HERE],
186
+ ...account.isDefault ? [] : [MAKE_DEFAULT],
187
+ RENAME,
188
+ CHECK,
189
+ DETAILS,
190
+ ACTIVITY,
191
+ SIGN_IN,
192
+ ...account.state.piProviders.length ? [REMOVE_PROVIDER] : [],
193
+ SIGN_OUT,
194
+ // The session's own account is in use, so it cannot be removed from inside the session.
195
+ ...isSession ? [] : [REMOVE],
196
+ BACK
197
+ ];
198
+ const action = await ui.select(`${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}`, actions);
199
+ if (!action || action === BACK) return;
200
+ if (action === USE_HERE) {
201
+ 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;
202
+ if (await attempt(ui, () => call("accounts.relaunch", { accountId }))) {
203
+ ui.notify(`Switching to ${account.label}.`, "info");
204
+ return "relaunch";
205
+ }
206
+ } else if (action === MAKE_DEFAULT) {
207
+ if (await attempt(ui, () => call("accounts.setDefault", { accountId }))) ui.notify(`${account.label} is the default for new sessions.`, "info");
208
+ } else if (action === RENAME) {
209
+ const label = await ui.input("New name", account.label);
210
+ if (label?.trim()) await attempt(ui, () => call("accounts.rename", { accountId, label: label.trim() }));
211
+ } else if (action === CHECK) {
212
+ if (await attempt(ui, () => call("accounts.refresh", { accountId }))) ui.notify("Checked.", "info");
213
+ } else if (action === DETAILS) {
214
+ await ui.select(accountDetails(account), [BACK]);
215
+ } else if (action === ACTIVITY) {
216
+ await showActivity(ui, call, accountId, account.label);
217
+ } else if (action === SIGN_IN) {
218
+ await signIn(ui, call, account);
219
+ } else if (action === REMOVE_PROVIDER) {
220
+ const provider = await ui.select("Remove which provider?", [...account.state.piProviders, BACK]);
221
+ if (!provider || provider === BACK) continue;
222
+ 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 }));
223
+ } else if (action === SIGN_OUT) {
224
+ 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 }));
225
+ } else if (action === REMOVE) {
226
+ const last = overview.accounts.length === 1;
227
+ 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.";
228
+ if (!await ui.confirm(`Remove ${account.label}?`, `Its stored sign-in and its runtime folder are deleted. ${consequence}`)) continue;
229
+ if (await attempt(ui, () => call("accounts.remove", { accountId }))) ui.notify(`${account.label} was removed.`, "info");
230
+ return;
231
+ }
232
+ }
233
+ }
234
+ async function showActivity(ui, call, accountId, label) {
235
+ const entries = await call("accounts.audit", { accountId, limit: 30 });
236
+ const lines = entries.map((entry) => `${new Date(entry.at).toLocaleString()} \xB7 ${entry.action} \xB7 ${entry.detail}`);
237
+ await ui.select(`${label}: activity
238
+ ${lines.join("\n") || "Nothing has happened here yet."}`, [BACK]);
239
+ }
240
+ async function signIn(ui, call, account) {
241
+ const providers = await call("accounts.providers");
242
+ if (!providers.length) {
243
+ ui.notify("No provider here can be signed in to.", "warning");
244
+ return;
245
+ }
246
+ const labels = providers.map((provider2, index2) => `${index2 + 1}. ${provider2.name}${account.state.piProviders.includes(provider2.id) ? " \xB7 signed in" : ""}`);
247
+ const pickedLabel = await ui.select(`Sign in ${account.label} to which provider?`, [...labels, BACK]);
248
+ const index = labels.indexOf(pickedLabel ?? "");
249
+ const provider = providers[index];
250
+ if (!provider) return;
251
+ const methods = [...provider.oauth ? [provider.oauthLabel ?? "Sign in with the browser"] : [], ...provider.apiKey ? ["Enter an API key"] : []];
252
+ if (!methods.length) {
253
+ ui.notify(`${provider.name} has no sign-in from a terminal.`, "warning");
254
+ return;
255
+ }
256
+ const method = methods.length === 1 ? methods[0] : await ui.select(`${provider.name}: how to sign in`, [...methods, BACK]);
257
+ if (!method || method === BACK) return;
258
+ if (method === "Enter an API key") {
259
+ const key = await ui.input(`API key for ${provider.name}`, "Paste the key");
260
+ if (!key?.trim()) return;
261
+ if (await attempt(ui, () => call("accounts.setApiKey", { accountId: account.id, provider: provider.id, key: key.trim() }))) ui.notify(`Key saved for ${provider.name}.`, "info");
262
+ return;
263
+ }
264
+ await runLogin(ui, call, account.id, provider.id);
265
+ }
266
+ async function runLogin(ui, call, accountId, provider) {
267
+ let loginId;
268
+ try {
269
+ loginId = (await call("accounts.loginStart", { accountId, provider })).loginId;
270
+ } catch (error) {
271
+ ui.notify(message(error), "error");
272
+ return;
273
+ }
274
+ return followLogin(ui, call, accountId, loginId);
275
+ }
276
+ async function followLogin(ui, call, accountId, loginId) {
277
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
278
+ let shownRevision = -1;
279
+ for (; ; ) {
280
+ const snapshot = await call("accounts.loginStatus", { accountId });
281
+ if (!snapshot || snapshot.loginId !== loginId) return;
282
+ if (snapshot.status !== "running") return reportLogin(ui, snapshot);
283
+ if (snapshot.prompt) {
284
+ await answerPrompt(ui, call, loginId, snapshot.prompt);
285
+ continue;
286
+ }
287
+ const step = [...snapshot.events].reverse().map(loginStep).find((text2) => text2 !== null);
288
+ if (step && snapshot.revision !== shownRevision) {
289
+ shownRevision = snapshot.revision;
290
+ const choice = await ui.select(`${step}
291
+
292
+ Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN]);
293
+ if (choice === CANCEL_SIGN_IN) {
294
+ await call("accounts.loginCancel", { accountId, loginId });
295
+ ui.notify("Sign-in cancelled.", "warning");
296
+ return;
297
+ }
298
+ continue;
299
+ }
300
+ if (Date.now() > deadline) {
301
+ await call("accounts.loginCancel", { accountId, loginId });
302
+ ui.notify("Sign-in timed out and was cancelled.", "error");
303
+ return;
304
+ }
305
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
306
+ }
307
+ }
308
+ function reportLogin(ui, snapshot) {
309
+ if (snapshot.status === "completed") ui.notify("Signed in.", "info");
310
+ else if (snapshot.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
311
+ else ui.notify(snapshot.error ?? "Sign-in failed.", "error");
312
+ }
313
+ async function answerPrompt(ui, call, loginId, prompt) {
314
+ const promptId = String(prompt.promptId ?? "");
315
+ const detail = prompt.prompt ?? {};
316
+ const text2 = detail.message ?? "Sign-in needs an answer.";
317
+ let value;
318
+ if (detail.type === "select" && Array.isArray(detail.options)) {
319
+ if (detail.options.every((option) => typeof option === "string")) {
320
+ value = await ui.select(text2, detail.options);
321
+ } 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")) {
322
+ const options = detail.options;
323
+ const selected = await ui.select(text2, options.map((option) => option.label));
324
+ value = options.find((option) => option.label === selected)?.id;
325
+ }
326
+ } else {
327
+ value = await ui.input(text2, detail.placeholder ?? void 0);
328
+ }
329
+ await call("accounts.loginRespond", { loginId, promptId, value: value ?? null });
330
+ }
331
+ async function poolsMenu(ui, call) {
332
+ for (; ; ) {
333
+ const overview = await call("accounts.list");
334
+ const names = new Map(overview.accounts.map((account) => [account.id, account.label]));
335
+ const labels = overview.pools.map((pool2, index) => `${index + 1}. ${pool2.label} \xB7 ${pool2.accountIds.map((id) => names.get(id) ?? "removed account").join(" \u2192 ")}`);
336
+ const options = [NEW_POOL, ...labels, BACK];
337
+ const picked = await ui.select("Pools: new sessions use the first ready account in a pool, in this order", options);
338
+ if (!picked || picked === BACK) return;
339
+ if (picked === NEW_POOL) {
340
+ await editPool(ui, call, overview, null);
341
+ continue;
342
+ }
343
+ const pool = overview.pools[options.indexOf(picked) - 1];
344
+ if (!pool) continue;
345
+ const action = await ui.select(pool.label, [EDIT, DELETE, BACK]);
346
+ if (action === EDIT) await editPool(ui, call, overview, pool);
347
+ 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 }));
348
+ }
349
+ }
350
+ async function editPool(ui, call, overview, pool) {
351
+ const label = await ui.input("Pool name", pool?.label);
352
+ if (!label?.trim()) return;
353
+ const names = new Map(overview.accounts.map((account) => [account.id, account.label]));
354
+ const order = [...pool?.accountIds ?? []];
355
+ for (; ; ) {
356
+ const ordered = order.map((id, index) => `${index + 1}. ${names.get(id) ?? "removed account"}`).join(" ");
357
+ const free = overview.accounts.filter((account) => !order.includes(account.id));
358
+ const addOptions = free.map((account) => `Add ${account.label}`);
359
+ const options = [...addOptions, ...order.length ? [REMOVE_LAST, START_OVER] : [], SAVE_POOL, CANCEL];
360
+ const picked = await ui.select(`${label.trim()}: ${ordered || "no accounts yet"}`, options);
361
+ if (!picked || picked === CANCEL) return;
362
+ if (picked === SAVE_POOL) {
363
+ if (!order.length) {
364
+ ui.notify("A pool needs at least one account.", "error");
365
+ continue;
366
+ }
367
+ if (await attempt(ui, () => call("accounts.savePool", { pool: { id: pool?.id, label: label.trim(), accountIds: order } }))) ui.notify("Pool saved.", "info");
368
+ return;
369
+ }
370
+ if (picked === START_OVER) order.length = 0;
371
+ else if (picked === REMOVE_LAST) order.pop();
372
+ else {
373
+ const account = free[addOptions.indexOf(picked)];
374
+ if (account) order.push(account.id);
375
+ }
376
+ }
377
+ }
378
+
63
379
  // src/terminal/pluginMenu.ts
64
380
  var here = (attachment, projectId) => {
65
381
  if (projectId && attachment.on.projectIds.includes(projectId)) return true;
@@ -90,8 +406,8 @@ var SWITCH_OFF_EVERYWHERE = "Switch off everywhere";
90
406
  var SWITCH_ON_EVERYWHERE = "Switch on everywhere";
91
407
  var TRUST = "Trust its code";
92
408
  var UNTRUST = "Stop trusting its code";
93
- var BACK = "Back";
94
- var DONE = "Done";
409
+ var BACK2 = "Back";
410
+ var DONE2 = "Done";
95
411
  function rowActions(row, projectId) {
96
412
  const switchable = row.whole ? row.plugin.attachment !== null : true;
97
413
  const actions = [];
@@ -100,7 +416,7 @@ function rowActions(row, projectId) {
100
416
  actions.push(row.on ? SWITCH_OFF_EVERYWHERE : SWITCH_ON_EVERYWHERE);
101
417
  }
102
418
  if (row.whole && row.plugin.code) actions.push(row.plugin.trust === "untrusted" ? TRUST : UNTRUST);
103
- return [...actions, BACK];
419
+ return [...actions, BACK2];
104
420
  }
105
421
  async function runPluginsMenu(ui, call, projectId) {
106
422
  let changed = false;
@@ -110,11 +426,11 @@ async function runPluginsMenu(ui, call, projectId) {
110
426
  ui.notify("There is nothing to switch or trust here yet.", "info");
111
427
  return;
112
428
  }
113
- const picked = await ui.select(`Plugins${changed ? " (changes apply when this session restarts)" : ""}`, [...rows.map((row2) => row2.label), DONE]);
429
+ const picked = await ui.select(`Plugins${changed ? " (changes apply when this session restarts)" : ""}`, [...rows.map((row2) => row2.label), DONE2]);
114
430
  const row = rows.find((candidate) => candidate.label === picked);
115
431
  if (!row) break;
116
432
  const action = await ui.select(`${row.plugin.title}: ${row.plugin.description || "No description."}`, rowActions(row, projectId));
117
- if (!action || action === BACK) continue;
433
+ if (!action || action === BACK2) continue;
118
434
  try {
119
435
  if (action === TRUST || action === UNTRUST) {
120
436
  const code = row.plugin.code;
@@ -137,6 +453,98 @@ Fingerprint ${code.hash.slice(0, 12)}. It runs inside the session and can do any
137
453
  if (changed) ui.notify("Saved. It applies when this session restarts.", "info");
138
454
  }
139
455
 
456
+ // src/terminal/hubMenu.ts
457
+ async function runHubMenu(ui, call) {
458
+ for (; ; ) {
459
+ const catalog = await call("hub.list");
460
+ const labels = catalog.entries.map((entry2) => `${entry2.title} \xB7 ${entry2.category} \xB7 ${entry2.id}@${entry2.version}`);
461
+ const picked = await ui.select("Hub", [...labels, "Refresh catalog", "Done"]);
462
+ if (picked === "Refresh catalog") {
463
+ const refreshed = await call("hub.list", { refresh: true });
464
+ if (refreshed.error) ui.notify(`${refreshed.error} Using ${refreshed.origin} content.`, "warning");
465
+ continue;
466
+ }
467
+ const entry = catalog.entries[labels.indexOf(picked ?? "")];
468
+ if (!entry) return;
469
+ const summary = `${entry.description}
470
+
471
+ Publisher: ${entry.publisher}
472
+ License: ${entry.license}
473
+ Source: ${entry.sourceUrl}
474
+
475
+ ${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."}`;
476
+ if (!await ui.confirm(`Install ${entry.title} ${entry.version}?`, summary)) continue;
477
+ try {
478
+ const receipt = await call("hub.install", { id: entry.id, version: entry.version });
479
+ 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");
480
+ } catch (error) {
481
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
482
+ }
483
+ }
484
+ }
485
+
486
+ // src/terminal/control.ts
487
+ var claims = /* @__PURE__ */ new Map();
488
+ function claimTerminalControl(connection = connectionFromEnv()) {
489
+ const identity = JSON.stringify([connection.socket, connection.token]);
490
+ let claim = claims.get(identity);
491
+ if (!claim) {
492
+ claim = socketCall(connection, "control.claim", {}, 15e3).then(({ key }) => {
493
+ if (!key) throw new Error("The terminal controller returned no control key.");
494
+ return key;
495
+ });
496
+ claims.set(identity, claim);
497
+ }
498
+ return claim;
499
+ }
500
+
501
+ // src/tools/askUser.ts
502
+ var ASK_USER = "ask_user";
503
+ var MAX_OPTIONS = 6;
504
+ var text = (value) => typeof value === "string" ? value.trim() : "";
505
+ function describeAnswer(answer) {
506
+ const parts = [];
507
+ if (answer.selected.length) parts.push(`The user chose: ${answer.selected.join(", ")}.`);
508
+ if (answer.text) parts.push(answer.selected.length ? `They added: ${answer.text}` : `The user answered: ${answer.text}`);
509
+ for (const [choice, note] of Object.entries(answer.notes ?? {})) parts.push(`Their note for ${choice}: ${note}`);
510
+ return parts.join(" ") || "The user did not answer.";
511
+ }
512
+ function askUserTool(run) {
513
+ return {
514
+ name: ASK_USER,
515
+ label: "Ask the user",
516
+ 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.",
517
+ parameters: (Type2) => Type2.Object({
518
+ questions: Type2.Array(Type2.Object({
519
+ summary: Type2.String({ description: "A concise 2\u20133 word summary label" }),
520
+ question: Type2.String({ description: "A clear, standalone question; aim for 25 words or fewer" }),
521
+ options: Type2.Optional(
522
+ Type2.Array(Type2.Object({
523
+ label: Type2.String({ description: "A short choice" }),
524
+ description: Type2.String({ description: "Required. Briefly explain this choice in 25 words or fewer." })
525
+ }), { maxItems: MAX_OPTIONS })
526
+ ),
527
+ multiSelect: Type2.Optional(Type2.Boolean())
528
+ }), { minItems: 1, maxItems: 5 })
529
+ }),
530
+ execute: async (input) => {
531
+ if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > 5) return "Ask between one and five questions.";
532
+ const questions = input.questions.map((raw) => {
533
+ const item = raw;
534
+ const summary = text(item.summary);
535
+ const question = text(item.question);
536
+ const options = (Array.isArray(item.options) ? item.options : []).map((option) => ({ label: text(option.label), description: text(option.description) })).filter((option) => option.label).slice(0, MAX_OPTIONS);
537
+ return { summary, question, options, multiSelect: options.length > 1 && item.multiSelect === true, allowText: true };
538
+ });
539
+ if (questions.some((question) => !question.summary || question.summary.length > 100)) return "Each question needs a short summary of no more than 100 characters.";
540
+ if (questions.some((question) => !question.question)) return "Every question needs a question sentence.";
541
+ if (questions.some((question) => question.options.some((option) => !option.description))) return "Every choice needs a short description. Aim for 25 words or fewer.";
542
+ const answers = await run.requestAnswers(questions);
543
+ return questions.map((question, index) => `${question.summary}: ${describeAnswer(answers[index])}`).join("\n");
544
+ }
545
+ };
546
+ }
547
+
140
548
  // src/terminal/piExtension.ts
141
549
  function resultSize(result) {
142
550
  const content = result?.content;
@@ -160,19 +568,48 @@ async function piExtension_default(pi) {
160
568
  try {
161
569
  await terminalCall("startup", { phase: stage });
162
570
  const connection = connectionFromEnv();
163
- const control = await socketCall(connection, "control.claim", {}, 15e3).then((claimed) => claimed.key).catch(() => null);
571
+ const control = await claimTerminalControl(connection).catch(() => null);
572
+ pi.registerCommand("hub", {
573
+ description: "Browse and install reviewed skills, plugins, and reusable documents",
574
+ handler: async (_args, ctx) => {
575
+ if (!control) return ctx.ui.notify("This session cannot install hub items.", "warning");
576
+ await runHubMenu(
577
+ { select: (title, options) => ctx.ui.select(title, options), confirm: (title, message2) => ctx.ui.confirm(title, message2), notify: (message2, level) => ctx.ui.notify(message2, level) },
578
+ (method, params) => socketCall(connection, method, params ?? {}, 3e5, control)
579
+ );
580
+ }
581
+ });
164
582
  pi.registerCommand("plugins", {
165
583
  description: "See what this session loads, and switch a plugin on or off or trust its code",
166
584
  handler: async (_args, ctx) => {
167
585
  if (!control) return ctx.ui.notify("This session cannot change plugins.", "warning");
168
586
  const { projectId } = await socketCall(connection, "plugins.session", {}, 15e3, control).catch(() => ({ projectId: null }));
169
587
  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) },
588
+ { 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
589
  (method, params) => socketCall(connection, method, params ?? {}, 6e4, control),
172
590
  projectId
173
591
  );
174
592
  }
175
593
  });
594
+ pi.registerCommand("accounts", {
595
+ description: "Add, sign in to, check, switch, or remove the Pi accounts this harness uses",
596
+ handler: async (_args, ctx) => {
597
+ if (!control) return ctx.ui.notify("This session cannot manage accounts.", "warning");
598
+ const session = await socketCall(connection, "accounts.session", {}, 15e3, control).catch(() => null);
599
+ if (!session) return ctx.ui.notify("This session has no account to manage.", "warning");
600
+ const outcome = await runAccountsMenu(
601
+ {
602
+ select: (title, options) => ctx.ui.select(title, options),
603
+ confirm: (title, message2) => ctx.ui.confirm(title, message2),
604
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
605
+ notify: (message2, level) => ctx.ui.notify(message2, level)
606
+ },
607
+ (method, params) => socketCall(connection, method, params ?? {}, 12e4, control),
608
+ session
609
+ );
610
+ if (outcome === "relaunch") ctx.shutdown();
611
+ }
612
+ });
176
613
  const { tools } = await socketCall(connection, "tools.list", {}, 15e3);
177
614
  stage = "Tool registration";
178
615
  for (const tool of tools) {
@@ -194,6 +631,7 @@ async function piExtension_default(pi) {
194
631
  }
195
632
  pi.on("session_start", async (_event, ctx) => {
196
633
  context = ctx;
634
+ if (timer) clearInterval(timer);
197
635
  await terminalCall("ready", { nativeSessionId: ctx.sessionManager.getSessionFile() });
198
636
  timer = setInterval(() => {
199
637
  if (polling || !context) return;
@@ -223,8 +661,8 @@ async function piExtension_default(pi) {
223
661
  pi.on("message_end", async (event) => {
224
662
  if (event.message.role !== "assistant") return;
225
663
  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 });
664
+ const text2 = event.message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
665
+ if (text2) await terminalCall("message", { text: text2 });
228
666
  if (event.message.usage) {
229
667
  const reported = event.message.usage;
230
668
  usage.inputTokens += reported.input ?? 0;
@@ -302,9 +740,22 @@ async function piExtension_default(pi) {
302
740
  });
303
741
  pi.on("session_shutdown", async () => {
304
742
  if (timer) clearInterval(timer);
743
+ timer = null;
744
+ context = null;
305
745
  await terminalCall("shutdown").catch(() => {
306
746
  });
307
747
  });
748
+ const ask = askUserTool({ requestAnswers: (questions) => terminalCall("questions", { questions }, 123e4) });
749
+ pi.registerTool({
750
+ name: ask.name,
751
+ label: ask.label,
752
+ description: ask.description,
753
+ parameters: Type.Unsafe(ask.parameters(Type)),
754
+ async execute(_id, input) {
755
+ const result = await ask.execute(input);
756
+ return { content: [{ type: "text", text: typeof result === "string" ? result : result.text }], details: void 0 };
757
+ }
758
+ });
308
759
  pi.registerTool({
309
760
  name: "ask_user_question",
310
761
  label: "Ask a question",
package/docs/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # Harness documentation
2
+
3
+ Current implementation: [Harness terminal frontend](terminal-tui.md). The package-level
4
+ [README](../README.md) describes the embedding API, accounts, plugins, memory and storage.
5
+
6
+ | ID | Record | Type | Status | Related |
7
+ |---|---|---|---|---|
8
+ | ADR-0001 | [Own the terminal frontend and keep Pi as the session runtime](decisions/ADR-0001-owned-terminal.md) | Decision | Accepted; working tree implementation | [Terminal architecture and evidence](terminal-tui.md) |
9
+
10
+ Living reference pages describe checked source behavior. Accepted decisions record the choice;
11
+ they do not establish publication or release. Evidence references on the terminal page identify
12
+ the inspected upstream artifacts and bound the verification claims.
@@ -0,0 +1,87 @@
1
+ ---
2
+ id: ADR-0001
3
+ title: Own the terminal frontend and keep Pi as the session runtime
4
+ status: accepted
5
+ created: 2026-10-17
6
+ updated: 2026-10-17
7
+ related: []
8
+ supersedes: null
9
+ superseded_by: null
10
+ ---
11
+
12
+ # ADR-0001 — Own the terminal frontend and keep Pi as the session runtime
13
+
14
+ ## Context and decision drivers
15
+
16
+ The requested terminal must reproduce the supported Oh My Pi presentation using capabilities
17
+ Harness already has. An extension can replace a header, footer or editor, but Pi's
18
+ `InteractiveMode` still owns transcript layout, command routing, focus and screen lifecycle.
19
+ That arrangement cannot give Harness full control over those surfaces.
20
+
21
+ The existing terminal boundary already provides isolated accounts, a compiled prompt, trusted
22
+ resource selection, a session socket and controller, a final tool gate and one writer for run
23
+ records. Retaining that boundary avoids a second authority for authentication or run state.
24
+
25
+ ## Decision
26
+
27
+ Own the terminal application in `src/terminal/tui/` and run a persistent Pi SDK
28
+ `AgentSessionRuntime` inside the prepared terminal child. Harness owns composition, editor and
29
+ input routing, dialogs, overlays and built-in command dispatch. Pi owns model/tool execution
30
+ and native history. The frontend binds the public extension UI contract in `tui` mode and may
31
+ reuse public rich content components.
32
+
33
+ Keep `TerminalAssets` and `Terminals.prepare/send/state/input/dispose` compatible. Preserve the
34
+ socket/controller bridge and its final gate. Keep the private control claim in process memory
35
+ across native session replacement, revalidate prepared resource trust before every load, and
36
+ scope native resume/import to the terminal's original working folder. Put unavoidable Pi
37
+ implementation imports in one adapter checked against the pinned 1.2.0 version.
38
+
39
+ Use the shipped OMP 18.11.3 artifacts as the visual reference, with Harness identity and existing
40
+ commands. [The living reference](../terminal-tui.md) records artifact digests, evidence and
41
+ verification limits. [NOTICE.md](../../NOTICE.md) preserves the upstream MIT license.
42
+
43
+ ## Alternatives and rejection reasons
44
+
45
+ - **Only install a presentation extension:** leaves the default interactive frontend in control
46
+ of transcript geometry, built-in commands and terminal lifecycle, contrary to the requested ownership.
47
+ - **Call `sessions.run` for each prompt:** recreates and disposes the runtime per turn, losing
48
+ persistent plugin UI/queue state and overlapping terminal controller run accounting.
49
+ - **Replace the execution stack with OMP:** changes tools, model/provider behavior and product
50
+ capabilities beyond the requested presentation migration.
51
+ - **Fork Pi's complete interactive mode:** introduces a large second implementation of session
52
+ and terminal behavior, making upgrades and account/governance boundaries harder to review.
53
+
54
+ ## Consequences and risks
55
+
56
+ Harness must maintain its own command routing, focus, keybindings and extension UI lifecycle.
57
+ The small internal adapter is an explicit upgrade constraint. Native session format and
58
+ account storage stay compatible; cross-folder navigation requires a freshly prepared scope.
59
+ Changes to trusted plugin code require review and reopening the terminal.
60
+
61
+ This record accepts the architectural choice. Implementation exists in the working tree;
62
+ publication and exhaustive visual/provider/platform parity are not implied. The living page
63
+ records passed focused tests and the pending controlled PTY verification result.
64
+
65
+ ## Implementation and evidence
66
+
67
+ - [`TerminalApplication`](../../src/terminal/tui/application.ts) owns the screen and input lifecycle.
68
+ - [`runtime.ts`](../../src/terminal/tui/runtime.ts) owns the persistent SDK adapter and scoped navigation.
69
+ - [`piAdapter.ts`](../../src/terminal/tui/piAdapter.ts) contains pinned compatibility imports.
70
+ - [`piExtension.ts`](../../src/terminal/piExtension.ts) retains gate/telemetry integration.
71
+ - [`runtime.socket.test.ts`](../../src/terminal/tui/runtime.socket.test.ts) checks the real isolated SDK/socket boundary without a provider request.
72
+ - [Reference identities, retained REA evidence and verification limits](../terminal-tui.md#verification-and-remaining-limits).
73
+
74
+ ## Verification follow-up — 2026-10-17
75
+
76
+ The earlier pending-PTY statement above records the state when this decision was written.
77
+ The [living reference](../terminal-tui.md#verification-and-remaining-limits) now records passed
78
+ production PTY scenarios at all three planned sizes, 419 Harness tests, a standalone proof and
79
+ portable OMP/Harness comparison records. The implementation also scopes retained plugin UI
80
+ callbacks and transcript components to a native resource generation, provides frozen runtime
81
+ state, and submits grouped question answers through the existing registry atomically.
82
+
83
+ The personal Codex live attempt reached the provider but was rejected because its usage limit
84
+ had been reached. The isolated credentials were deleted, the source sign-in remained unchanged,
85
+ and terminal restoration passed; see the [live evidence](../evidence/terminal-2026-10-17-followup/live-codex/README.md).
86
+ Successful live streaming/tool acceptance and App renderer acceptance remain open. This evidence
87
+ does not establish a released package or exhaustive platform/visual parity.