@xingular/harness 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,326 @@
1
+ // src/terminal/piExtension.ts
2
+ import { Type } from "typebox";
3
+
4
+ // src/terminal/client.ts
5
+ import { createConnection } from "node:net";
6
+ var SOCKET_ENV = "XINGULAR_HARNESS_SOCKET";
7
+ var TOKEN_ENV = "XINGULAR_HARNESS_TOKEN";
8
+ var SocketError = class extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ }
13
+ };
14
+ function connectionFromEnv(env = process.env) {
15
+ const socket = env[SOCKET_ENV];
16
+ const token = env[TOKEN_ENV];
17
+ if (!socket || !token) throw new SocketError("not_configured", `This command only works inside a terminal session the harness started (${SOCKET_ENV} and ${TOKEN_ENV} are not set).`);
18
+ return { socket, token };
19
+ }
20
+ function request(connection, body, what, timeoutMs) {
21
+ return new Promise((resolve, reject) => {
22
+ const stream = createConnection(connection.socket);
23
+ let buffer = "";
24
+ let settled = false;
25
+ const finish = (action) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ stream.end();
30
+ action();
31
+ };
32
+ const timer = setTimeout(() => {
33
+ stream.destroy();
34
+ finish(() => reject(new SocketError("timeout", `The harness did not answer ${what}.`)));
35
+ }, timeoutMs);
36
+ stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body })}
37
+ `));
38
+ stream.on("data", (chunk) => {
39
+ buffer += chunk.toString("utf8");
40
+ const newline = buffer.indexOf("\n");
41
+ if (newline < 0) return;
42
+ try {
43
+ const reply = JSON.parse(buffer.slice(0, newline));
44
+ if (reply.error) {
45
+ const error = typeof reply.error === "string" ? { message: reply.error } : reply.error;
46
+ finish(() => reject(new SocketError(error.code ?? "error", error.message ?? `${what} failed.`)));
47
+ } else finish(() => resolve(reply.result));
48
+ } catch {
49
+ finish(() => reject(new SocketError("invalid_reply", "The harness sent an unreadable reply.")));
50
+ }
51
+ });
52
+ stream.on("error", (error) => finish(() => reject(new SocketError("unavailable", `The harness is not reachable: ${error.message}.`))));
53
+ stream.on("close", () => finish(() => reject(new SocketError("unavailable", "The harness closed the connection."))));
54
+ });
55
+ }
56
+ function socketCall(connection, call, params = {}, timeoutMs = 12e4, control) {
57
+ return request(connection, { call, params, ...control ? { control } : {} }, call, timeoutMs);
58
+ }
59
+ function terminalCall(event, data = {}, timeoutMs = 6e5) {
60
+ return request(connectionFromEnv(), { event, data }, event, timeoutMs);
61
+ }
62
+
63
+ // src/terminal/pluginMenu.ts
64
+ var here = (attachment, projectId) => {
65
+ if (projectId && attachment.on.projectIds.includes(projectId)) return true;
66
+ if (projectId && attachment.off.projectIds.includes(projectId)) return false;
67
+ return attachment.global;
68
+ };
69
+ var mark = (on) => on ? "\u25CF" : "\u25CB";
70
+ function menuRows(plugins, projectId) {
71
+ const rows = [];
72
+ for (const plugin of plugins) {
73
+ if (plugin.attachment) {
74
+ const on = here(plugin.attachment, projectId);
75
+ rows.push({ id: plugin.id, plugin, whole: true, on, label: `${mark(on)} ${plugin.title}${plugin.trust === "untrusted" ? " \xB7 code not trusted" : plugin.runsCode ? " \xB7 runs code" : ""}` });
76
+ continue;
77
+ }
78
+ if (plugin.trust !== "none") rows.push({ id: plugin.id, plugin, whole: true, on: plugin.trust === "trusted", label: `${mark(plugin.trust === "trusted")} ${plugin.title} \xB7 ${plugin.trust === "trusted" ? "code trusted" : "code not trusted"}` });
79
+ for (const entry of plugin.contributions) {
80
+ if (!entry.attachment) continue;
81
+ rows.push({ id: entry.id, plugin, whole: false, on: entry.state === "on", label: `${mark(entry.state === "on")} ${entry.name} \xB7 ${kindWord(entry)} in ${plugin.title}` });
82
+ }
83
+ }
84
+ return rows;
85
+ }
86
+ var kindWord = (entry) => entry.kind === "connection" ? "connected app" : entry.kind === "code" ? "extension" : entry.kind;
87
+ var SWITCH_OFF_PROJECT = "Switch off for this project";
88
+ var SWITCH_ON_PROJECT = "Switch on for this project";
89
+ var SWITCH_OFF_EVERYWHERE = "Switch off everywhere";
90
+ var SWITCH_ON_EVERYWHERE = "Switch on everywhere";
91
+ var TRUST = "Trust its code";
92
+ var UNTRUST = "Stop trusting its code";
93
+ var BACK = "Back";
94
+ var DONE = "Done";
95
+ function rowActions(row, projectId) {
96
+ const switchable = row.whole ? row.plugin.attachment !== null : true;
97
+ const actions = [];
98
+ if (switchable) {
99
+ if (projectId) actions.push(row.on ? SWITCH_OFF_PROJECT : SWITCH_ON_PROJECT);
100
+ actions.push(row.on ? SWITCH_OFF_EVERYWHERE : SWITCH_ON_EVERYWHERE);
101
+ }
102
+ if (row.whole && row.plugin.code) actions.push(row.plugin.trust === "untrusted" ? TRUST : UNTRUST);
103
+ return [...actions, BACK];
104
+ }
105
+ async function runPluginsMenu(ui, call, projectId) {
106
+ let changed = false;
107
+ for (; ; ) {
108
+ const rows = menuRows(await call("plugins.list"), projectId);
109
+ if (!rows.length) {
110
+ ui.notify("There is nothing to switch or trust here yet.", "info");
111
+ return;
112
+ }
113
+ const picked = await ui.select(`Plugins${changed ? " (changes apply when this session restarts)" : ""}`, [...rows.map((row2) => row2.label), DONE]);
114
+ const row = rows.find((candidate) => candidate.label === picked);
115
+ if (!row) break;
116
+ const action = await ui.select(`${row.plugin.title}: ${row.plugin.description || "No description."}`, rowActions(row, projectId));
117
+ if (!action || action === BACK) continue;
118
+ try {
119
+ if (action === TRUST || action === UNTRUST) {
120
+ const code = row.plugin.code;
121
+ if (action === TRUST) {
122
+ const sure = await ui.confirm("Trust this code?", `${code.files.join("\n")}
123
+
124
+ Fingerprint ${code.hash.slice(0, 12)}. It runs inside the session and can do anything this app can, including reading your files and credentials.`);
125
+ if (!sure) continue;
126
+ }
127
+ await call(action === TRUST ? "plugins.trust" : "plugins.untrust", { hash: code.hash });
128
+ } else {
129
+ const on = action === SWITCH_ON_PROJECT || action === SWITCH_ON_EVERYWHERE;
130
+ await call(on ? "plugins.attach" : "plugins.detach", { id: row.id, everywhere: action === SWITCH_ON_EVERYWHERE || action === SWITCH_OFF_EVERYWHERE });
131
+ }
132
+ changed = true;
133
+ } catch (error) {
134
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
135
+ }
136
+ }
137
+ if (changed) ui.notify("Saved. It applies when this session restarts.", "info");
138
+ }
139
+
140
+ // src/terminal/piExtension.ts
141
+ function resultSize(result) {
142
+ const content = result?.content;
143
+ if (!Array.isArray(content)) return typeof result === "string" ? result.length : null;
144
+ return content.reduce((total, part) => total + (part?.type === "text" && typeof part.text === "string" ? part.text.length : 0), 0);
145
+ }
146
+ async function piExtension_default(pi) {
147
+ await terminalCall("startup", { phase: "Pi extension loaded" });
148
+ let context = null;
149
+ let timer = null;
150
+ let polling = false;
151
+ let failure;
152
+ const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, requests: 0, costUsd: 0 });
153
+ let usage = emptyUsage();
154
+ let reasoningTokens;
155
+ let costReported = false;
156
+ let requestStartedAt = null;
157
+ const toolStartedAt = /* @__PURE__ */ new Map();
158
+ const fileTools = /* @__PURE__ */ new Map();
159
+ let stage = "Tool discovery";
160
+ try {
161
+ await terminalCall("startup", { phase: stage });
162
+ const connection = connectionFromEnv();
163
+ const control = await socketCall(connection, "control.claim", {}, 15e3).then((claimed) => claimed.key).catch(() => null);
164
+ pi.registerCommand("plugins", {
165
+ description: "See what this session loads, and switch a plugin on or off or trust its code",
166
+ handler: async (_args, ctx) => {
167
+ if (!control) return ctx.ui.notify("This session cannot change plugins.", "warning");
168
+ const { projectId } = await socketCall(connection, "plugins.session", {}, 15e3, control).catch(() => ({ projectId: null }));
169
+ 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) },
171
+ (method, params) => socketCall(connection, method, params ?? {}, 6e4, control),
172
+ projectId
173
+ );
174
+ }
175
+ });
176
+ const { tools } = await socketCall(connection, "tools.list", {}, 15e3);
177
+ stage = "Tool registration";
178
+ for (const tool of tools) {
179
+ pi.registerTool({
180
+ name: tool.name,
181
+ label: tool.label,
182
+ description: tool.description,
183
+ parameters: Type.Unsafe(tool.parameters),
184
+ async execute(_id, input) {
185
+ const result = await socketCall(connection, "tools.call", { name: tool.name, input }, 123e4);
186
+ return { content: [{ type: "text", text: result.text }], details: void 0 };
187
+ }
188
+ });
189
+ }
190
+ } catch (error) {
191
+ await terminalCall("startupFailed", { reason: `${stage} failed: ${error instanceof Error ? error.message : "Unavailable"}` }).catch(() => {
192
+ });
193
+ throw error;
194
+ }
195
+ pi.on("session_start", async (_event, ctx) => {
196
+ context = ctx;
197
+ await terminalCall("ready", { nativeSessionId: ctx.sessionManager.getSessionFile() });
198
+ timer = setInterval(() => {
199
+ if (polling || !context) return;
200
+ polling = true;
201
+ void terminalCall("poll", {}, 5e3).then(({ command }) => {
202
+ if (command?.type === "interrupt") context?.abort();
203
+ else if (command?.type === "send") {
204
+ if (context?.ui.getEditorText().trim()) throw new Error("The terminal contains a user draft.");
205
+ pi.sendUserMessage(command.text);
206
+ }
207
+ }).catch(() => {
208
+ context?.abort();
209
+ if (timer) clearInterval(timer);
210
+ }).finally(() => {
211
+ polling = false;
212
+ });
213
+ }, 100);
214
+ });
215
+ pi.on("before_agent_start", async (event, ctx) => {
216
+ context = ctx;
217
+ failure = void 0;
218
+ usage = emptyUsage();
219
+ reasoningTokens = void 0;
220
+ costReported = false;
221
+ await terminalCall("begin", { text: event.prompt });
222
+ });
223
+ pi.on("message_end", async (event) => {
224
+ if (event.message.role !== "assistant") return;
225
+ 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 });
228
+ if (event.message.usage) {
229
+ const reported = event.message.usage;
230
+ usage.inputTokens += reported.input ?? 0;
231
+ usage.outputTokens += reported.output ?? 0;
232
+ usage.cacheReadTokens += reported.cacheRead ?? 0;
233
+ usage.cacheWriteTokens += reported.cacheWrite ?? 0;
234
+ usage.requests += 1;
235
+ if (typeof reported.reasoning === "number") reasoningTokens = (reasoningTokens ?? 0) + reported.reasoning;
236
+ const cost = reported.cost?.total;
237
+ if (typeof cost === "number") {
238
+ usage.costUsd += cost;
239
+ costReported = true;
240
+ }
241
+ const identity = { model: event.message.model, provider: event.message.provider };
242
+ const { costUsd, ...tokens } = usage;
243
+ await terminalCall("usage", {
244
+ ...tokens,
245
+ ...costReported ? { costUsd, costSource: "harness" } : {},
246
+ ...reasoningTokens === void 0 ? {} : { reasoningTokens },
247
+ contextTokens: (reported.input ?? 0) + (reported.cacheRead ?? 0) + (reported.cacheWrite ?? 0),
248
+ ...context?.model?.contextWindow ? { contextWindow: context.model.contextWindow } : {},
249
+ ...identity,
250
+ request: {
251
+ index: usage.requests,
252
+ ...identity,
253
+ responseId: event.message.responseId,
254
+ inputTokens: reported.input ?? 0,
255
+ cacheReadTokens: reported.cacheRead ?? 0,
256
+ cacheWriteTokens: reported.cacheWrite ?? 0,
257
+ outputTokens: reported.output ?? 0,
258
+ ...typeof reported.reasoning === "number" ? { reasoningTokens: reported.reasoning } : {},
259
+ ...typeof cost === "number" ? { costUsd: cost, costSource: "harness" } : {},
260
+ ...requestStartedAt === null ? {} : { durationMs: Date.now() - requestStartedAt },
261
+ stopReason: event.message.stopReason
262
+ }
263
+ });
264
+ requestStartedAt = null;
265
+ }
266
+ });
267
+ pi.on("turn_start", async () => {
268
+ requestStartedAt = Date.now();
269
+ });
270
+ pi.on("tool_call", async (event) => {
271
+ try {
272
+ const policy = await terminalCall("policy", { tool: event.toolName, args: event.input });
273
+ if (policy.requiresApproval) {
274
+ const approval = await terminalCall("approval", { requestId: event.toolCallId, target: event.toolName, action: JSON.stringify(event.input), tool: event.toolName, args: event.input });
275
+ if (approval.decision === "approved") return;
276
+ return { block: true, reason: "The user denied this action. Ask how they would like to proceed." };
277
+ }
278
+ if (!policy.allowed) return { block: true, reason: policy.reason ?? "Outside what this session may do." };
279
+ } catch {
280
+ return { block: true, reason: "The terminal controller is unavailable." };
281
+ }
282
+ });
283
+ pi.on("tool_execution_start", async (event) => {
284
+ if (["edit", "write"].includes(event.toolName) && typeof event.args.path === "string") fileTools.set(event.toolCallId, event.args.path);
285
+ toolStartedAt.set(event.toolCallId, Date.now());
286
+ await terminalCall("tool", { phase: "start", id: event.toolCallId, name: event.toolName, summary: JSON.stringify(event.args).slice(0, 200) });
287
+ });
288
+ pi.on("tool_execution_end", async (event) => {
289
+ const started = toolStartedAt.get(event.toolCallId);
290
+ toolStartedAt.delete(event.toolCallId);
291
+ const resultChars = resultSize(event.result);
292
+ await terminalCall("tool", { phase: "end", id: event.toolCallId, name: event.toolName, ok: !event.isError, ...started === void 0 ? {} : { durationMs: Date.now() - started }, ...resultChars === null ? {} : { resultChars } });
293
+ const path = fileTools.get(event.toolCallId);
294
+ fileTools.delete(event.toolCallId);
295
+ if (!event.isError && path) await terminalCall("file", { path, change: "modified" });
296
+ });
297
+ pi.on("agent_settled", async () => {
298
+ await terminalCall("settled", { error: failure });
299
+ });
300
+ pi.on("user_bash", async () => {
301
+ throw new Error("Use the bash tool so this action can be checked.");
302
+ });
303
+ pi.on("session_shutdown", async () => {
304
+ if (timer) clearInterval(timer);
305
+ await terminalCall("shutdown").catch(() => {
306
+ });
307
+ });
308
+ pi.registerTool({
309
+ name: "ask_user_question",
310
+ label: "Ask a question",
311
+ description: "Ask the user a question and wait for their answer.",
312
+ parameters: Type.Object({
313
+ question: Type.String(),
314
+ options: Type.Optional(Type.Array(Type.Object({ label: Type.String(), description: Type.Optional(Type.String()) }))),
315
+ multiSelect: Type.Optional(Type.Boolean())
316
+ }),
317
+ async execute(id, input) {
318
+ const answer = await terminalCall("question", { requestId: id, ...input });
319
+ return { content: [{ type: "text", text: JSON.stringify(answer) }], details: void 0 };
320
+ }
321
+ });
322
+ await terminalCall("startup", { phase: "Pi hooks registered" });
323
+ }
324
+ export {
325
+ piExtension_default as default
326
+ };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Xingular Harness \u2014 a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./*": "./src/*"
9
+ },
10
+ "bin": {
11
+ "xingular-harness": "./dist/cli.js"
12
+ },
13
+ "scripts": {
14
+ "build": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/cli.js && esbuild src/terminal/piExtension.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/terminal-pi.js",
15
+ "prepack": "npm run build",
16
+ "typecheck": "tsc --noEmit -p tsconfig.json",
17
+ "test": "vitest run",
18
+ "prove": "npm run build && node scripts/prove-standalone.mjs"
19
+ },
20
+ "dependencies": {
21
+ "@earendil-works/pi-coding-agent": "1.1.0",
22
+ "@modelcontextprotocol/client": "^2.2.0",
23
+ "@modelcontextprotocol/server": "^2.2.0",
24
+ "typebox": "^1.3.7",
25
+ "yaml": "^2.8.1",
26
+ "zod": "^4.1.11"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.19.1",
30
+ "esbuild": "^0.25.10",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^3.2.4"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.13"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "dist",
40
+ "builtin",
41
+ "README.md"
42
+ ]
43
+ }
@@ -0,0 +1,223 @@
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmdirSync, statSync, writeFileSync } from 'node:fs'
2
+ import { homedir, tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'vitest'
5
+
6
+ import { Bus, type Notice } from '../core/bus'
7
+ import { Accounts, classifyMessage } from './accounts'
8
+ import { assertIsolated, configDir, ensureHome, ensureSharedDir, launchEnv, nativeConfig, sharedDir } from './env'
9
+ import { fileSecretStore, memorySecretStore } from './secrets'
10
+ import { addAccount, audit, deriveHealth, emptyState, loadRegistry, loadState, resolve, saveRegistry, updateState, type Issue } from './store'
11
+
12
+ const temp = () => mkdtempSync(join(tmpdir(), 'harness-accounts-'))
13
+ const ago = (ms: number): string => new Date(Date.now() - ms).toISOString()
14
+ const HOUR = 3_600_000
15
+
16
+ function service(dir = temp()) {
17
+ const secrets = memorySecretStore()
18
+ const notices: Notice[] = []
19
+ const bus = new Bus()
20
+ bus.on((notice) => void notices.push(notice))
21
+ return { dir, secrets, notices, accounts: new Accounts({ dir, secrets, bus }) }
22
+ }
23
+
24
+ test('health reflects sign-in, errors and limits', () => {
25
+ const state = { ...emptyState(), signedIn: true }
26
+ expect(deriveHealth(state)).toBe('ok')
27
+ state.limits.push({ id: '5h', label: '', usedPercent: 100, resetsAt: ago(-HOUR) })
28
+ expect(deriveHealth(state)).toBe('limited')
29
+ state.limits[0].resetsAt = ago(HOUR)
30
+ expect(deriveHealth(state)).toBe('ok')
31
+ state.lastError = { category: 'auth_expired', message: '', at: '', resetsAt: null }
32
+ expect(deriveHealth(state)).toBe('auth_expired')
33
+ state.lastError = null
34
+ state.signedIn = false
35
+ expect(deriveHealth(state)).toBe('signed_out')
36
+ })
37
+
38
+ test('a limit error without a reset time expires', () => {
39
+ const issue = (at: number, resetsAt: number | null): Issue => ({ category: 'quota', message: 'usage limit', at: ago(at), resetsAt: resetsAt === null ? null : ago(-resetsAt) })
40
+ const state = { ...emptyState(), signedIn: true }
41
+ expect(deriveHealth({ ...state, lastError: issue(5 * 60_000, null) })).toBe('limited')
42
+ expect(deriveHealth({ ...state, lastError: issue(96 * HOUR, null) })).toBe('ok')
43
+ expect(deriveHealth({ ...state, lastError: issue(96 * HOUR, HOUR) })).toBe('limited')
44
+ })
45
+
46
+ test('resolve prefers explicit, then pool, then default, and never skips silently', () => {
47
+ const dir = temp()
48
+ expect(() => resolve(dir)).toThrow('Add a Pi account first.')
49
+ const first = addAccount(dir, 'A', 'subscription')
50
+ const second = addAccount(dir, 'B', 'subscription')
51
+ expect(resolve(dir).id).toBe(first.id)
52
+ expect(resolve(dir, second.id).id).toBe(second.id)
53
+ // An account of a provider the harness no longer runs is kept in the file and never chosen.
54
+ const registry = loadRegistry(dir)
55
+ registry.accounts.push({ id: 'legacy', provider: 'codex', label: 'Old', authKind: 'subscription', createdAt: '' })
56
+ registry.pools.push({ id: 'p', provider: 'pi', label: 'Work', accountIds: [first.id, second.id] })
57
+ saveRegistry(dir, registry)
58
+ expect(() => resolve(dir, 'legacy')).toThrow('another provider')
59
+ expect(loadRegistry(dir).accounts).toHaveLength(3)
60
+
61
+ updateState(dir, first.id, (state) => void (state.signedIn = false))
62
+ expect(resolve(dir, null, 'p').id).toBe(second.id)
63
+ updateState(dir, second.id, (state) => void (state.lastError = { category: 'auth_expired', message: '', at: '', resetsAt: null }))
64
+ expect(() => resolve(dir, null, 'p')).toThrow('Every account in Work is unavailable.')
65
+ })
66
+
67
+ function walk(dir: string): string[] {
68
+ return readdirSync(dir).flatMap((name) => (statSync(join(dir, name)).isDirectory() ? walk(join(dir, name)) : [join(dir, name)]))
69
+ }
70
+
71
+ test('audit and state never hold secrets', async () => {
72
+ const { dir, accounts } = service()
73
+ audit(dir, 'a', 'login.failed', 'token sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789 rejected')
74
+ const text = readFileSync(join(dir, 'audit.jsonl'), 'utf8')
75
+ expect(text).not.toContain('abcdefghijklmnop')
76
+ expect(text).toContain('[redacted]')
77
+
78
+ const account = addAccount(dir, 'A', 'apiKey')
79
+ await accounts.writeCredential(account.id, 'anthropic', { type: 'api_key', key: 'sk-secret-value' })
80
+ expect((await accounts.readCredential(account.id, 'anthropic'))?.key).toBe('sk-secret-value')
81
+ for (const file of walk(dir)) expect(readFileSync(file, 'utf8')).not.toContain('sk-secret-value')
82
+ expect((await accounts.listCredentials(account.id))[0].type).toBe('api_key')
83
+ await expect(accounts.writeCredential(account.id, '../x', null)).rejects.toThrow('Invalid provider name.')
84
+ })
85
+
86
+ test('a failed terminal credential launch can retry without a stale reference count', async () => {
87
+ const { dir, accounts } = service()
88
+ const account = addAccount(dir, 'A', 'apiKey')
89
+ ensureHome(dir, account)
90
+ await accounts.writeCredential(account.id, 'openai', { type: 'api_key', key: 'probe' })
91
+ const auth = join(configDir(dir, account), 'auth.json')
92
+ mkdirSync(auth)
93
+ await expect(accounts.terminalBegin(account)).rejects.toThrow()
94
+ rmdirSync(auth)
95
+ await accounts.terminalBegin(account)
96
+ expect(statSync(auth).isFile()).toBe(true)
97
+ expect(statSync(auth).mode & 0o777).toBe(0o600)
98
+ await accounts.terminalEnd(account)
99
+ expect(existsSync(auth)).toBe(false)
100
+ })
101
+
102
+ test('a terminal round-trips credentials through auth.json', async () => {
103
+ const { dir, accounts } = service()
104
+ const account = addAccount(dir, 'A', 'apiKey')
105
+ ensureHome(dir, account)
106
+ await accounts.writeCredential(account.id, 'openai', { type: 'api_key', key: 'k1' })
107
+ await accounts.terminalBegin(account)
108
+ const auth = join(configDir(dir, account), 'auth.json')
109
+ expect(readFileSync(auth, 'utf8')).toContain('k1')
110
+ writeFileSync(auth, JSON.stringify({ anthropic: { type: 'oauth', access: 'a', refresh: 'r', expires: 1 } }))
111
+ await accounts.terminalEnd(account)
112
+ expect(existsSync(auth)).toBe(false)
113
+ expect(loadState(dir, account.id).piProviders).toEqual(['anthropic'])
114
+ expect((await accounts.readCredential(account.id, 'anthropic'))?.refresh).toBe('r')
115
+ })
116
+
117
+ test('creating, renaming and removing an account is audited and announced', async () => {
118
+ const { dir, accounts, secrets, notices } = service()
119
+ const created = accounts.create(' Work ', 'apiKey').accounts[0]
120
+ expect([created.label, created.isDefault, created.state.health]).toEqual(['Work', true, 'unknown'])
121
+ expect(lstatSync(join(configDir(dir, created), 'skills')).isSymbolicLink()).toBe(true)
122
+ await accounts.writeCredential(created.id, 'openai', { type: 'api_key', key: 'k' })
123
+ expect(() => accounts.create('', 'apiKey')).toThrow()
124
+ expect(() => accounts.create('x', 'password')).toThrow('Unknown sign-in type.')
125
+ accounts.rename(created.id, 'Home')
126
+ await accounts.remove(created.id)
127
+ expect(accounts.overview().accounts).toEqual([])
128
+ expect(existsSync(join(dir, created.id))).toBe(false)
129
+ expect(secrets.values.size).toBe(0)
130
+ expect(notices.filter((notice) => notice.type === 'accounts.changed').length).toBeGreaterThanOrEqual(3)
131
+ expect(readFileSync(join(dir, 'audit.jsonl'), 'utf8')).toContain('account.deleted')
132
+ })
133
+
134
+ test('run events move an account between healthy and limited', () => {
135
+ const { dir, accounts, notices } = service()
136
+ const account = addAccount(dir, 'A', 'subscription')
137
+ const event = (type: string, payload: unknown) => ({ id: 'e', runId: 'r', sessionId: 's', sequence: 0, timestamp: '', type: type as never, payload })
138
+ accounts.onRunEvent(event('account.status', { accountId: account.id, category: 'quota', message: 'usage limit reached for sk-abcdefghijklmnopqrstuvwxyz012345' }), null)
139
+ const limited = loadState(dir, account.id)
140
+ expect(limited.health).toBe('limited')
141
+ expect(limited.lastError?.message).toContain('[redacted]')
142
+ expect(notices.at(-1)).toMatchObject({ type: 'accounts.changed', health: 'limited', previous: 'unknown' })
143
+ // A finished run proves the sign-in works, but a limit error stays until it resets.
144
+ accounts.onRunEvent(event('completed', {}), { accountId: account.id } as never)
145
+ expect(loadState(dir, account.id)).toMatchObject({ signedIn: true, health: 'limited' })
146
+ accounts.onRunEvent(event('account.status', { accountId: account.id, category: 'auth_expired', message: '401' }), null)
147
+ expect(loadState(dir, account.id)).toMatchObject({ signedIn: false, health: 'auth_expired' })
148
+ accounts.onRunEvent(event('completed', {}), { accountId: account.id } as never)
149
+ expect(loadState(dir, account.id).health).toBe('ok')
150
+ })
151
+
152
+ test('provider errors are classified', () => {
153
+ expect(classifyMessage('401 Unauthorized')).toBe('auth_expired')
154
+ expect(classifyMessage('You have hit your usage limit')).toBe('quota')
155
+ expect(classifyMessage('429 Too Many Requests')).toBe('rate_limited')
156
+ expect(classifyMessage('fetch failed')).toBe('network')
157
+ expect(classifyMessage('the model said no')).toBeNull()
158
+ })
159
+
160
+ test('native config lists what is shared and what exists', () => {
161
+ const dir = temp()
162
+ const before = nativeConfig(dir)
163
+ expect(before.entries.some((entry) => entry.name === 'extensions' && entry.directory)).toBe(true)
164
+ expect(before.entries.every((entry) => !entry.present)).toBe(true)
165
+ expect(existsSync(before.path)).toBe(false)
166
+ writeFileSync(join(ensureSharedDir(dir), 'AGENTS.md'), 'x')
167
+ expect(nativeConfig(dir).entries.find((entry) => entry.name === 'AGENTS.md')?.present).toBe(true)
168
+ })
169
+
170
+ test('launch env isolates and strips inherited credentials', () => {
171
+ const dir = temp()
172
+ const env = launchEnv(dir, { id: 'acc1' }, { SOMEVENDOR_API_KEY: 'leak', PATH: '/bin' })
173
+ expect(env.PI_CODING_AGENT_DIR?.startsWith(dir)).toBe(true)
174
+ expect(env).toMatchObject({ SOMEVENDOR_API_KEY: null, CLAUDE_CONFIG_DIR: null, CODEX_HOME: null })
175
+ expect('PATH' in env).toBe(false)
176
+ })
177
+
178
+ test('accounts share config but not credentials', () => {
179
+ const dir = temp()
180
+ const first = ensureHome(dir, { id: 'acc1' })
181
+ const second = ensureHome(dir, { id: 'acc2' })
182
+ writeFileSync(join(first, 'settings.json'), '{"theme":"dark"}\n')
183
+ mkdirSync(join(first, 'skills/review'), { recursive: true })
184
+ writeFileSync(join(first, 'auth.json'), '{}')
185
+ expect(readFileSync(join(second, 'settings.json'), 'utf8')).toBe('{"theme":"dark"}\n')
186
+ expect(statSync(join(second, 'skills/review')).isDirectory()).toBe(true)
187
+ expect(existsSync(join(second, 'auth.json'))).toBe(false)
188
+ expect(lstatSync(join(first, 'auth.json')).isSymbolicLink()).toBe(false)
189
+ expect(readlinkSync(join(first, 'skills'))).toBe(join(sharedDir(dir), 'skills'))
190
+ })
191
+
192
+ test('existing account config moves into the shared folder, newest wins', async () => {
193
+ const dir = temp()
194
+ for (const id of ['acc1', 'acc2']) {
195
+ mkdirSync(configDir(dir, { id }), { recursive: true })
196
+ writeFileSync(join(configDir(dir, { id }), 'settings.json'), `{"from":"${id}"}`)
197
+ await new Promise((resolve) => setTimeout(resolve, 20))
198
+ }
199
+ ensureHome(dir, { id: 'acc1' })
200
+ // acc2's settings are newer than the shared copy acc1 just moved in, so they win.
201
+ const second = ensureHome(dir, { id: 'acc2' })
202
+ expect(readFileSync(join(sharedDir(dir), 'settings.json'), 'utf8')).toBe('{"from":"acc2"}')
203
+ expect(readdirSync(sharedDir(dir)).some((name) => name.startsWith('settings.json.agentx-backup-'))).toBe(true)
204
+ expect(lstatSync(join(second, 'settings.json')).isSymbolicLink()).toBe(true)
205
+ })
206
+
207
+ test('refuses default tool directories', () => {
208
+ const dir = temp()
209
+ for (const name of ['.codex', '.claude', '.pi/agent']) expect(() => assertIsolated(dir, join(homedir(), name))).toThrow()
210
+ expect(() => assertIsolated(dir, '/tmp/elsewhere')).toThrow()
211
+ expect(() => assertIsolated(dir, join(dir, 'a/home/pi'))).not.toThrow()
212
+ })
213
+
214
+ test('a file secret store keeps secrets owner-only', async () => {
215
+ const path = join(temp(), 'nested/secrets.json')
216
+ const secrets = fileSecretStore(path)
217
+ expect(await secrets.get('a/b')).toBeNull()
218
+ await secrets.set('a/b', 'value')
219
+ expect(await secrets.get('a/b')).toBe('value')
220
+ expect(statSync(path).mode & 0o777).toBe(0o600)
221
+ await secrets.delete('a/b')
222
+ expect(await secrets.get('a/b')).toBeNull()
223
+ })