@xingular/harness 0.1.5 → 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.
- package/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/dist/terminal-pi.js
CHANGED
|
@@ -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,
|
|
10
|
-
super(
|
|
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
|
|
94
|
-
var
|
|
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,
|
|
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),
|
|
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 ===
|
|
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
|
|
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,
|
|
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
|
|
227
|
-
if (
|
|
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.
|
|
Binary file
|
|
Binary file
|