@xingular/harness 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -18,7 +18,7 @@ function connectionFromEnv(env = process.env) {
18
18
  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).`);
19
19
  return { socket, token };
20
20
  }
21
- function request(connection, body2, what, timeoutMs) {
21
+ function request(connection, body, what, timeoutMs) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const stream = createConnection(connection.socket);
24
24
  let buffer = "";
@@ -34,7 +34,7 @@ function request(connection, body2, what, timeoutMs) {
34
34
  stream.destroy();
35
35
  finish(() => reject(new SocketError("timeout", `The harness did not answer ${what}.`)));
36
36
  }, timeoutMs);
37
- stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body2 })}
37
+ stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body })}
38
38
  `));
39
39
  stream.on("data", (chunk) => {
40
40
  buffer += chunk.toString("utf8");
@@ -278,16 +278,16 @@ async function followLogin(ui, call, accountId, loginId) {
278
278
  const deadline = Date.now() + LOGIN_TIMEOUT_MS;
279
279
  let shownRevision = -1;
280
280
  for (; ; ) {
281
- const snapshot2 = await call("accounts.loginStatus", { accountId });
282
- if (!snapshot2 || snapshot2.loginId !== loginId) return;
283
- if (snapshot2.status !== "running") return reportLogin(ui, snapshot2);
284
- if (snapshot2.prompt) {
285
- await answerPrompt(ui, call, loginId, snapshot2.prompt);
281
+ const snapshot = await call("accounts.loginStatus", { accountId });
282
+ if (!snapshot || snapshot.loginId !== loginId) return;
283
+ if (snapshot.status !== "running") return reportLogin(ui, snapshot);
284
+ if (snapshot.prompt) {
285
+ await answerPrompt(ui, call, loginId, snapshot.prompt);
286
286
  continue;
287
287
  }
288
- const step = [...snapshot2.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
- if (step && snapshot2.revision !== shownRevision) {
290
- shownRevision = snapshot2.revision;
288
+ const step = [...snapshot.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
+ if (step && snapshot.revision !== shownRevision) {
290
+ shownRevision = snapshot.revision;
291
291
  const choice = await ui.select(`${step}
292
292
 
293
293
  Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN]);
@@ -306,10 +306,10 @@ Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_I
306
306
  await new Promise((resolve) => setTimeout(resolve, POLL_MS));
307
307
  }
308
308
  }
309
- function reportLogin(ui, snapshot2) {
310
- if (snapshot2.status === "completed") ui.notify("Signed in.", "info");
311
- else if (snapshot2.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
- else ui.notify(snapshot2.error ?? "Sign-in failed.", "error");
309
+ function reportLogin(ui, snapshot) {
310
+ if (snapshot.status === "completed") ui.notify("Signed in.", "info");
311
+ else if (snapshot.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
+ else ui.notify(snapshot.error ?? "Sign-in failed.", "error");
313
313
  }
314
314
  async function answerPrompt(ui, call, loginId, prompt) {
315
315
  const promptId = String(prompt.promptId ?? "");
@@ -764,7 +764,9 @@ var runRecordSchema = z.object({
764
764
  handoffId: text,
765
765
  accountId: text,
766
766
  agentId: text,
767
- trigger: text
767
+ trigger: text,
768
+ harnessScope: text.optional(),
769
+ harnessVersion: text.optional()
768
770
  });
769
771
 
770
772
  // src/core/index.ts
@@ -851,18 +853,11 @@ function askUserTool(run2) {
851
853
  // src/core/frontmatter.ts
852
854
  import { parse as parseYaml, stringify } from "yaml";
853
855
 
854
- // src/memory/store.ts
855
- import { stringify as stringify2 } from "yaml";
856
-
857
- // src/skills/registry.ts
858
- var MAX_SKILL_IMPORT_BYTES = 10 * 1024 * 1024;
859
-
860
856
  // src/prompt/compile.ts
861
- var SECTION_ORDER = { blocks: 30, rules: 40, references: 50, skills: 60, knowledge: 80, tools: 90 };
857
+ var SECTION_ORDER = { system: 25, guardrails: 40, memory: 45, references: 50, subagents: 64, scripts: 66, wiki: 80, tools: 90 };
862
858
  var BUILT_IN_DYNAMIC = [
863
- { id: "blocks", order: SECTION_ORDER.blocks },
864
859
  { id: "references", order: SECTION_ORDER.references },
865
- { id: "knowledge", order: SECTION_ORDER.knowledge }
860
+ { id: "wiki", order: SECTION_ORDER.wiki }
866
861
  ];
867
862
 
868
863
  // src/sessions/planMode.ts
@@ -1,6 +1,6 @@
1
1
  # Harness terminal frontend
2
2
 
3
- Status: implemented in the 2.1.2 release candidate; publication follows the tagged CI workflow. Last updated: 2026-10-19.
3
+ Status: published as `@xingular/harness` 2.1.2. Last updated: 2026-10-19.
4
4
  Decision: [ADR-0001](decisions/ADR-0001-owned-terminal.md).
5
5
 
6
6
  ## Native subagents (2026-10-18)
@@ -148,11 +148,17 @@ Full upstream copyright and MIT permission notices are in [NOTICE.md](../NOTICE.
148
148
  ## Runtime compatibility adapter
149
149
 
150
150
  Most integration uses public Pi exports. [`piAdapter.ts`](../src/terminal/tui/piAdapter.ts)
151
- contains the remaining implementation dependencies and requires exactly Pi **1.2.0** before
152
- loading them:
151
+ contains the remaining presentation dependencies and requires exactly Pi **1.2.0** before
152
+ loading them. The shared [`runtime.ts`](../src/pi/runtime.ts) loader imports Pi first, then invokes
153
+ the same HTTP dispatcher hook as Pi's CLI before any Harness Pi runtime is used. This reapplies
154
+ the process `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` settings after Pi's bundled Undici has
155
+ loaded. Account and model checks, sessions, maintenance, plugin discovery and installation,
156
+ pricing, and the standalone terminal all use this loader. It is process-wide and coalesces
157
+ concurrent initialization.
153
158
 
154
159
  | Internal dependency | Reason |
155
160
  |---|---|
161
+ | `dist/bundle/chunks/chunk-TJXZSVTD.js:configureHttpDispatcher` | Restore Pi's environment-aware HTTP proxy dispatcher after the SDK import |
156
162
  | `dist/extensions/index.js` builtin inventory | Preserve selected `llama.cpp`, `codemode`, `tool-search` and MCP builtins with the CLI's replacement semantics |
157
163
  | `dist/modes/interactive/theme/theme.js:setThemeInstance` | Keep public native rich components on the same active theme as the Harness screen |
158
164
  | `dist/core/tools/renderers/index.js:withBuiltInRenderers` | Preserve existing read/edit/bash/write formatting without replacing tool execution |
@@ -160,7 +166,20 @@ loading them:
160
166
 
161
167
  The adapter is initialized once; its global theme setter and renderer lookup are synchronous
162
168
  thereafter. A Pi dependency upgrade must update this adapter deliberately and rerun the
163
- terminal compatibility tests. These module imports do not instantiate `InteractiveMode`.
169
+ terminal compatibility tests. The dispatcher compatibility check also pins the private Pi bundle
170
+ chunk and fails with a clear error if that hook moves or disappears. A local proxy fixture test
171
+ checks that a Pi HTTP request reaches the configured proxy without contacting an external host.
172
+ These module imports do not instantiate `InteractiveMode`.
173
+
174
+ ## Clipboard image paste
175
+
176
+ The standalone terminal reads Finder-copied file paths before checking clipboard image pixels.
177
+ On macOS, `.heic` and `.heif` files are converted with `/usr/bin/sips` to private temporary PNGs,
178
+ preserving the copied path order and leaving the originals untouched. The temporary files are
179
+ removed when the terminal shuts down. A failed or invalid conversion reports an error and leaves
180
+ the composer unchanged. Clipboard image pixels continue through Pi's native adapter, which reads
181
+ PNG/TIFF on macOS and normalizes TIFF to PNG. This path is terminal-emulator independent; direct
182
+ Warp clipboard acceptance still requires a manual check in Warp.
164
183
 
165
184
  ## Accounts, history and resource trust
166
185
 
@@ -324,9 +343,10 @@ a successful response or tool execution. Harness persisted the failed run and re
324
343
  terminal; the source sign-in file was unchanged and the disposable credentials were deleted.
325
344
  The [live report and capture](evidence/terminal-2026-10-17-followup/live-codex/README.md) retain
326
345
  this quota failure explicitly. Successful live streaming/tool acceptance remains open until
327
- account capacity is available. GitHub gist publication, actual OS clipboard/image transfer,
328
- Windows behavior and every terminal emulator remain unverified. Rich image/diff rendering and clipboard adapters have
329
- focused compatibility tests; that does not establish OS interaction. These changes shipped in
346
+ account capacity is available. GitHub gist publication, actual OS clipboard pixel transfer,
347
+ Windows behavior and every terminal emulator remain unverified. HEIC/HEIF file staging has focused
348
+ conversion tests; that does not establish Finder or Warp OS interaction. Rich image/diff rendering
349
+ and clipboard adapters have focused compatibility tests; that does not establish OS interaction. These changes shipped in
330
350
  `@xingular/harness` 2.0.0 (commit `44471bc`), while the complete plan's acceptance remains
331
351
  open pending the required integration and provider evidence.
332
352
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "2.1.2",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
- "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
5
+ "description": "Xingular Harness — a Pi agent harness built on recursive harnessing (Harness²): versioned eight-component harness trees, refinement from evidence, multi-account sessions, a compiled prompt, project notes, tools, and MCP",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/tanntnny/xingular-harness.git"
@@ -10,6 +10,7 @@ import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
10
10
  import { log, type Bus } from '../core/bus'
11
11
  import { HarnessError, invalid } from '../core/errors'
12
12
  import { atomicWrite, now, readJson, safeId } from '../core/fs'
13
+ import { loadPi } from '../pi/runtime'
13
14
  import type { RunEvent, RunRecord } from '../sessions/contracts'
14
15
  import * as env from './env'
15
16
  import { secretKey, type SecretStore } from './secrets'
@@ -222,7 +223,7 @@ export class Accounts {
222
223
  }
223
224
 
224
225
  private async loadInstallationDeviceId(): Promise<string> {
225
- const { SettingsManager } = await import('@earendil-works/pi-coding-agent')
226
+ const { SettingsManager } = await loadPi()
226
227
  const settings = SettingsManager.create(process.cwd(), env.ensureSharedDir(this.dir))
227
228
  const deviceId = settings.getOrCreateDeviceId()
228
229
  await settings.flush()
@@ -237,7 +238,7 @@ export class Accounts {
237
238
  let runtime = this.runtimes.get(id)
238
239
  if (!runtime) {
239
240
  const configDir = 'configDir' in account ? account.configDir : env.ensureHome(this.dir, account)
240
- runtime = import('@earendil-works/pi-coding-agent')
241
+ runtime = loadPi()
241
242
  .then(({ ModelRuntime }) => ModelRuntime.create({ credentials: this.credentialStore(id) as never, modelsPath: join(configDir, 'models.json') }))
242
243
  .catch((error: unknown) => {
243
244
  this.runtimes.delete(id)
@@ -275,7 +276,7 @@ export class Accounts {
275
276
 
276
277
  /** Providers Pi can sign in to (OAuth) or take a key for, independent of any account. */
277
278
  async providers(): Promise<{ id: string; name: string; oauth: boolean; oauthLabel: string | null; subscription: boolean; apiKey: boolean }[]> {
278
- const { ModelRuntime } = await import('@earendil-works/pi-coding-agent')
279
+ const { ModelRuntime } = await loadPi()
279
280
  const none = { read: async () => undefined, list: async () => [], modify: async () => undefined, delete: async () => undefined }
280
281
  const runtime = await ModelRuntime.create({ credentials: none, modelsPath: null, refreshOnCreate: false })
281
282
  return runtime
@@ -2,7 +2,7 @@
2
2
  // stores remain the source of truth; this module gives hosts one capability-shaped contract.
3
3
  import { invalid } from '../core/errors'
4
4
  import type { Harness } from '../harness'
5
- import type { CallerContext } from '../memory/caller'
5
+ import type { CallerContext } from '../core/caller'
6
6
  import type { Launch } from '../prompt/compile'
7
7
  import * as pluginApi from '../plugins/api'
8
8
  import type { Plugin } from '../plugins/types'
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // xingular-harness serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]…
5
5
  // [--host-secrets] [--host-policy] [--host-tools] [--host-directory]
6
6
  // Serves the harness to a host process over stdin/stdout (newline-delimited JSON).
7
- // xingular-harness wiki <command> Memory and Wiki, from inside a terminal session.
7
+ // xingular-harness wiki <command> Project notes, skills and history, from inside a terminal session.
8
+ // xingular-harness refine <command> Refine a harness tree from evidence (refine demo needs no model).
8
9
  // xingular-harness mcp <command> Connected apps, from inside a terminal session.
9
10
  // xingular-harness plugins <command> List, install, switch and trust plugins.
10
11
  import { resolve } from 'node:path'
@@ -13,6 +14,7 @@ import { log } from './core/bus'
13
14
  import { createHarness, type HarnessOptions } from './harness'
14
15
  import { runPluginsCli, terminalAsk } from './plugins/cli'
15
16
  import { runHubCli } from './hub/cli'
17
+ import { runRefineCli } from './refine/cli'
16
18
  import { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
17
19
  import { RpcPeer, stdioTransport } from './server/rpc'
18
20
  import { attach } from './server/serve'
@@ -20,17 +22,18 @@ import { runAppsCli, runWikiCli } from './terminal/cli'
20
22
  import { runStandaloneTerminal, standaloneArguments } from './terminal/standalone'
21
23
 
22
24
  const USAGE = `Usage: xingular-harness [--data <dir>] [--state <dir>]
23
- xingular-harness <terminal | serve | wiki | mcp | plugins | hub> [arguments]
25
+ xingular-harness <terminal | serve | wiki | mcp | plugins | hub | refine> [arguments]
24
26
 
25
27
  (no command) Start the interactive Harness terminal
26
28
  terminal Start the interactive Harness terminal
27
29
  serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]
28
30
  [--host-secrets] [--host-policy] [--host-tools] [--host-directory]
29
31
  Serve the harness over newline-delimited JSON
30
- wiki <command> Memory and Wiki commands for a terminal session
32
+ wiki <command> Wiki, skill and history commands for a terminal session
31
33
  mcp <command> Connected-app commands for a terminal session
32
34
  plugins <command> List, install, switch and trust plugins (plugins help)
33
35
  hub <command> Browse and install curated items (hub help)
36
+ refine <command> Refine a harness from evidence (refine demo, refine run)
34
37
 
35
38
  Standalone terminal options:
36
39
  --data <dir> Data directory (default: ~/.xingular-harness/data)
@@ -105,6 +108,7 @@ async function main(): Promise<number> {
105
108
  if (command === 'mcp') return runAppsCli(rest)
106
109
  if (command === 'plugins') return runPluginsCli(rest, { ask: terminalAsk() })
107
110
  if (command === 'hub') return runHubCli(rest)
111
+ if (command === 'refine') return runRefineCli(rest)
108
112
  process.stdout.write(USAGE)
109
113
  return command ? 2 : 0
110
114
  }
@@ -0,0 +1,34 @@
1
+ // Who a session works as. The embedding application supplies this when a session starts; the
2
+ // harness never looks an agent up by itself. The ids are opaque to the harness.
3
+ import { findProject, type Project } from './projects'
4
+
5
+ export interface CallerContext {
6
+ /** Opaque, host-owned policy data frozen when this session starts. The harness only forwards it to the embedding app's tool policy. */
7
+ policyContext?: unknown
8
+ runId?: string | null
9
+ sessionId?: string | null
10
+ projectId?: string | null
11
+ /** The caller: decides which plugins and connectors reach it, and whose history it recalls. */
12
+ agentId?: string | null
13
+ /** Callers whose conversations this one may not recall (opaque ids from the embedding host). */
14
+ recallExcludeAgents?: string[] | null
15
+ /** The caller's name, recorded on what it writes. */
16
+ agentName?: string | null
17
+ /** `Chat` for an agent's own conversation, `Code` for a session in a project. */
18
+ actor?: string | null
19
+ /** `cli` when the caller reaches the harness through a terminal command. */
20
+ surface?: string | null
21
+ }
22
+
23
+ export const isSession = (context: CallerContext): boolean => context.actor === 'Code'
24
+ export const actorOf = (context: CallerContext): string => (isSession(context) ? 'Code' : 'Chat')
25
+ export const usesCli = (context: CallerContext): boolean => context.surface === 'cli'
26
+
27
+ export function projectOf(root: string, context: CallerContext): Project | null {
28
+ if (!context.projectId) return null
29
+ try {
30
+ return findProject(root, context.projectId)
31
+ } catch {
32
+ return null
33
+ }
34
+ }
package/src/core/fs.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs'
2
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from 'node:fs'
3
3
  import { basename, dirname, isAbsolute, join, normalize, sep } from 'node:path'
4
4
 
5
5
  import { HarnessError, invalid } from './errors'
@@ -90,3 +90,19 @@ export function now(): string {
90
90
  export function newId(): string {
91
91
  return randomUUID()
92
92
  }
93
+
94
+ export function isDir(path: string): boolean {
95
+ try {
96
+ return statSync(path).isDirectory()
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ export function isFile(path: string): boolean {
103
+ try {
104
+ return statSync(path).isFile()
105
+ } catch {
106
+ return false
107
+ }
108
+ }
package/src/harness.ts CHANGED
@@ -1,6 +1,7 @@
1
- // The harness, assembled: Pi sessions on isolated accounts, a compiled prompt, a memory
2
- // lifecycle, tools, connected apps, and skills. An application embeds one of these and adds
3
- // its own prompt sections, tools, and policy; nothing here knows what that application is.
1
+ // The harness, assembled: Pi sessions on isolated accounts, versioned harness trees that shape
2
+ // them, refinement that improves those trees from evidence, a compiled prompt, project notes,
3
+ // tools, and connected apps. An application embeds one of these and adds its own prompt
4
+ // sections, tools, and policy; nothing here knows what that application is.
4
5
  import { Subagents } from './sessions/subagentService'
5
6
  import { mkdirSync } from 'node:fs'
6
7
  import { join } from 'node:path'
@@ -15,18 +16,15 @@ import { DEFAULT_PRODUCT_NAME, EMPTY_DIRECTORY, type Directory, type HarnessEnv
15
16
  import { HarnessError } from './core/errors'
16
17
  import { readJsonOr } from './core/fs'
17
18
  import { closeIndex } from './core/index'
18
- import { projectsOrNone } from './core/projects'
19
19
  import { ConnectedApps } from './mcp/service'
20
20
  import type { AppOAuthClients } from './mcp/types'
21
21
  import { migrateConnectionScopes } from './mcp/scopeMigration'
22
- import type { MaintainerFactory } from './memory/maintainer'
23
- import { Maintenance } from './memory/maintenance'
24
- import * as migrate from './memory/migrate'
25
- import { writeIndex } from './memory/proposals'
26
- import * as recall from './memory/recall'
27
- import * as store from './memory/store'
28
- import { countPages } from './memory/wiki'
29
- import * as wikiIndex from './memory/wikiIndex'
22
+ import * as recall from './history/recall'
23
+ import type { RefineHost } from './refine/host'
24
+ import { Refinements } from './refine/service'
25
+ import { configureBuiltins } from './tree/components'
26
+ import { TreeTrust } from './tree/trust'
27
+ import * as notes from './wiki/notes'
30
28
  import * as pluginApi from './plugins/api'
31
29
  import * as attachments from './plugins/attachments'
32
30
  import { HARNESS_PLUGINS } from './plugins/builtin'
@@ -40,7 +38,6 @@ import { RunRegistry } from './sessions/registry'
40
38
  import { markInterruptedRuns } from './sessions/runStore'
41
39
  import type { SessionFactory } from './sessions/runner'
42
40
  import { Sessions } from './sessions/sessions'
43
- import * as registry from './skills/registry'
44
41
  import { Terminals, type TerminalAssets } from './terminal/terminals'
45
42
 
46
43
  export interface HarnessOptions {
@@ -57,19 +54,13 @@ export interface HarnessOptions {
57
54
  policy?: ToolPolicy
58
55
  /** Replaces Pi, for tests. */
59
56
  sessionFactory?: SessionFactory
60
- maintainerFactory?: MaintainerFactory
61
- /** Delegate maintenance execution to the embedding host through durable requests. */
62
- externalMaintenance?: boolean
63
- maintenanceExcludedTriggers?: string[]
64
- /** Queue consolidation after every settled run (the default). False leaves memory to explicit proposals and scheduled merges. */
65
- maintenanceRunTrigger?: boolean
66
- /** How long the maintenance queue stays empty before full memory blocks are condensed. */
67
- idleMs?: number
57
+ /** Where refinement executions work and with what; without it, a temporary copy of the task's source and Pi's own tools. */
58
+ refineHost?: RefineHost
68
59
  /** Where the built commands a terminal session runs are, when they are not beside this package. */
69
60
  assets?: TerminalAssets
70
61
  /** Optional legacy executable override. The default is the Harness-owned terminal frontend. */
71
62
  piProgram?: () => string | null
72
- /** Folders of built-in skills, prompts, and rules, each laid out like `registry/`. */
63
+ /** Folders of built-in skills, prompts, and rules for the component library, each laid out like `registry/`. */
73
64
  builtins?: string[]
74
65
  /**
75
66
  * The application's own parts, described as plugins: which tools, prompt sections and tool
@@ -90,7 +81,10 @@ export interface HarnessOptions {
90
81
  /** What starting on a data root found and repaired. */
91
82
  export interface Recovery {
92
83
  interruptedRuns: number
84
+ /** Notes in the Wiki. */
93
85
  reindexedPages: number
86
+ /** Where an earlier Wiki's memory, facts and revisions were moved, when they were. */
87
+ legacyWiki?: string | null
94
88
  /** Set when the storage migration failed; the old data is left as it was. */
95
89
  migrationError: string | null
96
90
  }
@@ -133,7 +127,11 @@ export class Harness {
133
127
  applicationPlugins: ReadonlySet<string> = new Set()
134
128
  readonly subagents: Subagents
135
129
  readonly sessions: Sessions
136
- readonly maintenance: Maintenance
130
+ /** Which plugin code in harness trees a person trusted. */
131
+ readonly trust: TreeTrust
132
+ /** Refinement: improving a harness tree from evidence of how it ran. */
133
+ readonly refine: Refinements
134
+ refineHost: RefineHost | null
137
135
  readonly terminals: Terminals
138
136
  /** Resolves when what was loaded in the background at construction is in place. */
139
137
  readonly ready: Promise<void>
@@ -147,7 +145,9 @@ export class Harness {
147
145
  this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus)
148
146
  this.hub = new Hub(this, options.hub)
149
147
  this.policy = options.policy ?? {}
150
- registry.configureBuiltins(options.builtins ?? [])
148
+ configureBuiltins(options.builtins ?? [])
149
+ this.trust = new TreeTrust(this.stateDir)
150
+ this.refineHost = options.refineHost ?? null
151
151
  const secrets = options.secrets ?? fileSecretStore(join(this.stateDir, 'secrets.json'))
152
152
  this.accounts = new Accounts({ dir: join(this.stateDir, 'accounts'), secrets, bus: this.bus })
153
153
  this.approvals = new Approvals(this.stateDir, this.bus)
@@ -200,10 +200,11 @@ export class Harness {
200
200
  broker: this.apps,
201
201
  plugins: this.plugins,
202
202
  resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
203
- factory: options.sessionFactory
203
+ factory: options.sessionFactory,
204
+ trust: this.trust
204
205
  })
205
- this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs, external: options.externalMaintenance, excludedTriggers: options.maintenanceExcludedTriggers, runTrigger: options.maintenanceRunTrigger })
206
- this.terminals = new Terminals({ subagents: this.subagents, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
206
+ this.refine = new Refinements({ root: () => this.env.root, bus: this.bus, trust: this.trust, host: () => this.refineHost, runtime: { root: () => this.env.root, runs: this.runs, run: (input) => this.sessions.run(input) } })
207
+ this.terminals = new Terminals({ trust: this.trust, subagents: this.subagents, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
207
208
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
208
209
  this.runs.listen((event, run) => {
209
210
  this.accounts.onRunEvent(event, run)
@@ -212,8 +213,6 @@ export class Harness {
212
213
  if ((event.type === 'completed' || event.type === 'failed') && run) {
213
214
  this.hooks.emit({ type: 'history.run.ended', runId: run.id, sessionId: run.sessionId, status: run.status, goal: run.goal ?? null, agentId: run.agentId ?? null, projectId: run.projectId ?? null, startedAt: run.startedAt, endedAt: run.endedAt ?? null, at: event.timestamp })
214
215
  }
215
- if (event.type === 'completed' || event.type === 'failed' || (event.type === 'status.changed' && (event.payload as { status?: string }).status === 'interrupted')) this.maintenance.finishRun(event.runId, event.type !== 'completed')
216
- this.maintenance.onRunEvent(event)
217
216
  })
218
217
  }
219
218
 
@@ -230,27 +229,28 @@ export class Harness {
230
229
  }
231
230
 
232
231
  /**
233
- * Opens a data root: brings its Wiki to the current layout, settles runs that never ended,
234
- * and refreshes the index. `agentIds` are the agents that get their own memory blocks when an
235
- * older Wiki is migrated.
232
+ * Opens a data root: moves an earlier Wiki's memory and facts aside (once), lays out the Wiki,
233
+ * settles runs that never ended, and refreshes the history index. `agentIds` is accepted for
234
+ * hosts written against earlier versions and is not used.
236
235
  */
237
- open(dataRoot: string, agentIds: readonly string[] = []): Recovery {
236
+ open(dataRoot: string, _agentIds: readonly string[] = []): Recovery {
238
237
  if (this.hub.busy && this.env.dataRoot !== dataRoot) throw new HarnessError('hub_busy', 'Wait for the hub installation before changing the data location.')
239
238
  if (this.env.dataRoot && this.env.dataRoot !== dataRoot) closeIndex(this.env.dataRoot)
240
239
  this.env.dataRoot = dataRoot
241
240
  this.hub.watch(dataRoot)
242
241
  const root = dataRoot
243
- this.hooks.watch(store.wikiDir(root))
242
+ this.hooks.watch(notes.wikiDir(root))
244
243
  void this.hooks.reload()
245
- for (const folder of ['registry/skills', 'registry/prompts', 'registry/rules', 'config', 'runs']) mkdirSync(join(root, folder), { recursive: true })
244
+ for (const folder of ['registry/skills', 'registry/prompts', 'registry/rules', 'config', 'runs', 'harnesses']) mkdirSync(join(root, folder), { recursive: true })
246
245
  let migrationError: string | null = null
247
- let wikiMigrationError: string | null = null
246
+ let legacyWiki: string | null = null
248
247
  try {
249
- if (migrate.run(this.env, agentIds)) log(`started Wiki storage version ${migrate.WIKI_VERSION}; the earlier Wiki is kept as a backup in the data root`)
248
+ legacyWiki = notes.moveLegacy(root)
249
+ if (legacyWiki) log(`moved the earlier Wiki's memory, facts and revisions to ${legacyWiki}`)
250
+ notes.ensureLayout(root, this.env.productName)
250
251
  } catch (error) {
251
252
  migrationError = error instanceof Error ? error.message : String(error)
252
- wikiMigrationError = migrationError
253
- log('Wiki migration failed', migrationError)
253
+ log('Wiki layout', migrationError)
254
254
  }
255
255
  try {
256
256
  migrateConnectionScopes(root, this.stateDir)
@@ -259,55 +259,36 @@ export class Harness {
259
259
  migrationError = migrationError ? `${migrationError}; connector scope migration: ${detail}` : detail
260
260
  log('connector scope migration failed', detail)
261
261
  }
262
- if (!wikiMigrationError) {
263
- try {
264
- store.ensureLayout(root, this.env.productName)
265
- // Every project has its fixed pages, and the generated indexes match the files.
266
- for (const project of projectsOrNone(root)) store.ensureProjectNamespace(root, project.slug, project.name)
267
- writeIndex(store.wikiDir(root))
268
- } catch (error) {
269
- log('Wiki layout', error instanceof Error ? error.message : String(error))
270
- }
271
- }
272
262
  let interruptedRuns = 0
273
263
  try {
274
264
  interruptedRuns = markInterruptedRuns(root, `${this.env.productName} stopped before the run settled.`)
275
265
  } catch (error) {
276
266
  log('run recovery', error instanceof Error ? error.message : String(error))
277
267
  }
278
- let reindexedPages: number
279
- try {
280
- reindexedPages = wikiIndex.ensure(root)
281
- } catch (error) {
282
- log('index', error instanceof Error ? error.message : String(error))
283
- reindexedPages = countPages(root)
284
- }
285
268
  try {
286
- // Runs recorded while nothing was running, or before recall existed, become searchable.
269
+ // Runs recorded while nothing was running become searchable.
287
270
  recall.sync(root)
288
271
  } catch (error) {
289
272
  log('recall', error instanceof Error ? error.message : String(error))
290
273
  }
291
- this.recovery = { interruptedRuns, reindexedPages, migrationError }
292
- this.maintenance.start()
274
+ this.recovery = { interruptedRuns, reindexedPages: safeCount(root), migrationError, legacyWiki }
293
275
  return this.recovery
294
276
  }
295
277
 
296
- /** Deletes the index and rebuilds it from the files. Returns the pages indexed. */
278
+ /** Rebuilds the history index from the run records. Returns the notes in the Wiki. */
297
279
  rebuildIndex(): number {
298
- const pages = wikiIndex.rebuild(this.env.root)
299
280
  recall.sync(this.env.root)
300
- this.recovery = { ...this.recovery, reindexedPages: pages }
301
- return pages
281
+ const count = safeCount(this.env.root)
282
+ this.recovery = { ...this.recovery, reindexedPages: count }
283
+ return count
302
284
  }
303
285
 
304
- /** Stops live runs and releases the index. */
286
+ /** Stops live runs and refinements and releases the index. */
305
287
  async close(): Promise<void> {
306
288
  this.hub.close()
307
- this.maintenance.stop()
289
+ await this.refine.close()
308
290
  await this.terminals.close()
309
291
  await this.runs.interruptAll()
310
- await this.maintenance.settled()
311
292
  await this.hooks.settled().catch(() => undefined)
312
293
  this.hooks.close()
313
294
  await this.apps.close()
@@ -315,6 +296,14 @@ export class Harness {
315
296
  }
316
297
  }
317
298
 
299
+ function safeCount(root: string): number {
300
+ try {
301
+ return notes.paths(root).length
302
+ } catch {
303
+ return 0
304
+ }
305
+ }
306
+
318
307
  export function createHarness(options: HarnessOptions): Harness {
319
308
  const harness = new Harness(options)
320
309
  if (options.dataRoot) harness.open(options.dataRoot)
@@ -9,7 +9,7 @@ import { newId, writeJson } from '../core/fs'
9
9
  import { closeIndex } from '../core/index'
10
10
  import { runRecordSchema, type RunRecord } from '../sessions/contracts'
11
11
  import { parseEvent } from '../sessions/runStore'
12
- import type { CallerContext } from './caller'
12
+ import type { CallerContext } from '../core/caller'
13
13
  import { passages, read, readTool, search, searchTool, sync } from './recall'
14
14
 
15
15
  const run = (id: string, mode: string, session: string, project: string | null, goal: string): RunRecord =>
@@ -12,7 +12,7 @@ import { charCount, takeChars } from '../core/text'
12
12
  import type { RunEvent, RunRecord } from '../sessions/contracts'
13
13
  import { readAllRuns } from '../sessions/runIndex'
14
14
  import { findRun, readEvents } from '../sessions/runStore'
15
- import { isSession, projectOf, type CallerContext } from './caller'
15
+ import { isSession, projectOf, type CallerContext } from '../core/caller'
16
16
 
17
17
  const PASSAGE_LIMIT = 6000
18
18
  const READ_LIMIT = 12_000
@@ -8,9 +8,9 @@ import { closeIndex } from '../core/index'
8
8
  import { writeJson } from '../core/fs'
9
9
  import { runRecordSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
10
10
  import { appendEvent, saveRun } from '../sessions/runStore'
11
- import { WIKI_AGENT_TOOLS } from '../tools/definitions'
12
- import type { CallerContext } from './caller'
13
- import { dispatch } from './dispatch'
11
+ import { HARNESS_TOOLS } from '../tools/definitions'
12
+ import type { CallerContext } from '../core/caller'
13
+ import { dispatch } from '../tools/dispatch'
14
14
  import { readTool, searchTool } from './trajectory'
15
15
 
16
16
  const roots: string[] = []
@@ -170,7 +170,7 @@ test('trajectory calls reject malformed filters and missing current runs', () =>
170
170
  test('the model tool registry exposes both trajectory calls through Wiki dispatch', () => {
171
171
  const { root, env } = setup()
172
172
  save(root, run('registered-run'))
173
- const calls = WIKI_AGENT_TOOLS.map((tool) => tool.call)
173
+ const calls = HARNESS_TOOLS.map((tool) => tool.call)
174
174
  expect(calls).toContain('trajectory.search')
175
175
  expect(calls).toContain('trajectory.read')
176
176
  expect(JSON.parse(dispatch(env, agent, 'trajectory.search', {})).runs[0].id).toBe('registered-run')
@@ -8,7 +8,7 @@ import { scrubSensitive } from '../mcp/connections'
8
8
  import { isActiveStatus, runStatusSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
9
9
  import { readAllRuns } from '../sessions/runIndex'
10
10
  import { findRun, snapshot } from '../sessions/runStore'
11
- import type { CallerContext } from './caller'
11
+ import type { CallerContext } from '../core/caller'
12
12
 
13
13
  const SEARCH_DEFAULT = 10
14
14
  const SEARCH_MAX = 30