@xingular/harness 1.2.0 → 1.3.1-unstable.1

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 (186) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +26 -17
  3. package/builtin/skills/plugin-authoring/SKILL.md +34 -0
  4. package/dist/cli.js +7362 -6239
  5. package/dist/sdk/agents/builtin.js +3 -3
  6. package/dist/sdk/agents/planResearch.d.ts +23 -1
  7. package/dist/sdk/agents/planResearch.js +71 -8
  8. package/dist/sdk/agents/profiles.d.ts +16 -4
  9. package/dist/sdk/agents/profiles.js +75 -12
  10. package/dist/sdk/annotations/bridge.d.ts +2 -0
  11. package/dist/sdk/annotations/bridge.js +4 -0
  12. package/dist/sdk/annotations/capture.d.ts +44 -2
  13. package/dist/sdk/annotations/capture.js +104 -3
  14. package/dist/sdk/annotations/delivery.d.ts +24 -17
  15. package/dist/sdk/annotations/delivery.js +82 -49
  16. package/dist/sdk/annotations/inspect.js +7 -6
  17. package/dist/sdk/annotations/pageScript.js +4 -4
  18. package/dist/sdk/annotations/part.d.ts +0 -2
  19. package/dist/sdk/annotations/part.js +4 -33
  20. package/dist/sdk/annotations/queue.d.ts +2 -2
  21. package/dist/sdk/annotations/queue.js +12 -7
  22. package/dist/sdk/annotations/serverMethods.js +11 -7
  23. package/dist/sdk/annotations/store.d.ts +13 -21
  24. package/dist/sdk/annotations/store.js +47 -37
  25. package/dist/sdk/capabilities/runSnapshot.js +10 -4
  26. package/dist/sdk/commands/list.js +2 -0
  27. package/dist/sdk/commands/registry.d.ts +11 -3
  28. package/dist/sdk/commands/registry.js +3 -2
  29. package/dist/sdk/core/links.d.ts +51 -0
  30. package/dist/sdk/core/links.js +155 -0
  31. package/dist/sdk/core/shell.d.ts +20 -0
  32. package/dist/sdk/core/shell.js +73 -0
  33. package/dist/sdk/core/workspaceBashTool.d.ts +17 -1
  34. package/dist/sdk/core/workspaceBashTool.js +16 -2
  35. package/dist/sdk/core/workspaceSandbox.d.ts +10 -0
  36. package/dist/sdk/core/workspaceSandbox.js +52 -12
  37. package/dist/sdk/core/workspaceWriteOperations.d.ts +11 -0
  38. package/dist/sdk/core/workspaceWriteOperations.js +26 -8
  39. package/dist/sdk/doctor/checks.js +71 -12
  40. package/dist/sdk/harness.d.ts +5 -0
  41. package/dist/sdk/harness.js +11 -6
  42. package/dist/sdk/hub/hub.js +5 -3
  43. package/dist/sdk/index.d.ts +3 -2
  44. package/dist/sdk/index.js +2 -1
  45. package/dist/sdk/plugins/builtin.js +14 -9
  46. package/dist/sdk/plugins/builtinRegistry.d.ts +3 -1
  47. package/dist/sdk/plugins/builtinRegistry.js +9 -3
  48. package/dist/sdk/plugins/discover.d.ts +7 -1
  49. package/dist/sdk/plugins/discover.js +41 -2
  50. package/dist/sdk/plugins/identity.js +1 -1
  51. package/dist/sdk/plugins/install.d.ts +6 -2
  52. package/dist/sdk/plugins/install.js +29 -5
  53. package/dist/sdk/plugins/inventory.js +3 -3
  54. package/dist/sdk/plugins/manifest.d.ts +2 -0
  55. package/dist/sdk/plugins/manifest.js +21 -4
  56. package/dist/sdk/plugins/plugins.d.ts +2 -0
  57. package/dist/sdk/plugins/plugins.js +1 -0
  58. package/dist/sdk/plugins/proposals.d.ts +17 -0
  59. package/dist/sdk/plugins/proposals.js +147 -0
  60. package/dist/sdk/plugins/types.d.ts +1 -1
  61. package/dist/sdk/plugins/types.js +1 -1
  62. package/dist/sdk/prompt/compile.d.ts +13 -4
  63. package/dist/sdk/prompt/compile.js +46 -20
  64. package/dist/sdk/prompt/standard.d.ts +1 -1
  65. package/dist/sdk/prompt/standard.js +1 -1
  66. package/dist/sdk/server/methods.js +14 -2
  67. package/dist/sdk/sessions/compactionCheckpoint.d.ts +3 -3
  68. package/dist/sdk/sessions/compactionCheckpoint.js +12 -12
  69. package/dist/sdk/sessions/continuation.d.ts +4 -2
  70. package/dist/sdk/sessions/continuation.js +7 -6
  71. package/dist/sdk/sessions/contracts.d.ts +22 -4
  72. package/dist/sdk/sessions/contracts.js +12 -7
  73. package/dist/sdk/sessions/planMode.js +2 -2
  74. package/dist/sdk/sessions/runStore.js +3 -1
  75. package/dist/sdk/sessions/runner.js +2 -2
  76. package/dist/sdk/sessions/sessionState.d.ts +13 -0
  77. package/dist/sdk/sessions/sessionState.js +31 -0
  78. package/dist/sdk/sessions/sessions.d.ts +7 -0
  79. package/dist/sdk/sessions/sessions.js +73 -15
  80. package/dist/sdk/sessions/subagentService.d.ts +15 -0
  81. package/dist/sdk/sessions/subagentService.js +195 -55
  82. package/dist/sdk/sessions/subagents.d.ts +10 -1
  83. package/dist/sdk/sessions/subagents.js +4 -0
  84. package/dist/sdk/sessions/workSlice.d.ts +8 -5
  85. package/dist/sdk/sessions/workSlice.js +77 -45
  86. package/dist/sdk/terminal/controller.d.ts +20 -0
  87. package/dist/sdk/terminal/controller.js +43 -18
  88. package/dist/sdk/terminal/opentui/ansi.d.ts +25 -0
  89. package/dist/sdk/terminal/opentui/ansi.js +192 -0
  90. package/dist/sdk/terminal/opentui/model.d.ts +163 -0
  91. package/dist/sdk/terminal/opentui/model.js +482 -0
  92. package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +3 -3
  93. package/dist/sdk/terminal/opentui/subagentsWidget.js +7 -5
  94. package/dist/sdk/terminal/opentui/theme.d.ts +2 -0
  95. package/dist/sdk/terminal/opentui/theme.js +2 -0
  96. package/dist/sdk/terminal/piExtension.js +98 -15
  97. package/dist/sdk/terminal/subagentMonitor.d.ts +17 -4
  98. package/dist/sdk/terminal/subagentMonitor.js +29 -8
  99. package/dist/sdk/terminal/terminals.d.ts +16 -0
  100. package/dist/sdk/terminal/terminals.js +130 -26
  101. package/dist/sdk/terminal/trajectory.d.ts +6 -1
  102. package/dist/sdk/terminal/trajectory.js +8 -0
  103. package/dist/sdk/terminal/tui/annotateCommand.d.ts +5 -1
  104. package/dist/sdk/terminal/tui/annotateCommand.js +21 -7
  105. package/dist/sdk/terminal/tui/annotateSend.d.ts +30 -0
  106. package/dist/sdk/terminal/tui/annotateSend.js +40 -0
  107. package/dist/sdk/terminal/tui/chrome.d.ts +4 -0
  108. package/dist/sdk/terminal/tui/chrome.js +13 -0
  109. package/dist/sdk/terminal/tui/commandPages.d.ts +1 -1
  110. package/dist/sdk/terminal/tui/commandPages.js +16 -11
  111. package/dist/sdk/terminal/tui/commands.d.ts +4 -0
  112. package/dist/sdk/terminal/tui/commands.js +12 -2
  113. package/dist/sdk/terminal/tui/dialogs.js +1 -0
  114. package/dist/sdk/terminal/tui/pageOverlay.d.ts +3 -0
  115. package/dist/sdk/terminal/tui/pageOverlay.js +25 -7
  116. package/dist/sdk/terminal/tui/pages/agentsPage.js +5 -2
  117. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +70 -16
  118. package/dist/sdk/terminal/tui/pages/annotatePage.js +349 -115
  119. package/dist/sdk/terminal/tui/pages/browserPage.js +5 -4
  120. package/dist/sdk/terminal/tui/pages/hubPage.d.ts +14 -1
  121. package/dist/sdk/terminal/tui/pages/hubPage.js +192 -28
  122. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +14 -1
  123. package/dist/sdk/terminal/tui/pages/pluginsPage.js +295 -25
  124. package/dist/sdk/terminal/tui/pages/subagentsPage.js +8 -5
  125. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +4 -2
  126. package/dist/sdk/terminal/tui/pages/usagePage.js +39 -12
  127. package/dist/sdk/terminal/tui/pluginCommands.d.ts +1 -1
  128. package/dist/sdk/terminal/tui/pluginCommands.js +13 -3
  129. package/dist/sdk/tools/defaultSkills.d.ts +4 -0
  130. package/dist/sdk/tools/defaultSkills.js +20 -0
  131. package/dist/sdk/tools/definitions.d.ts +3 -1
  132. package/dist/sdk/tools/definitions.js +17 -1
  133. package/dist/sdk/tools/dispatch.js +12 -0
  134. package/dist/sdk/tools/harnessTools.js +1 -1
  135. package/dist/sdk/tools/sessionName.d.ts +9 -0
  136. package/dist/sdk/tools/sessionName.js +58 -11
  137. package/dist/sdk/tools/spawnSubagent.js +9 -7
  138. package/dist/sdk/tools/todo.d.ts +15 -0
  139. package/dist/sdk/tools/todo.js +64 -0
  140. package/dist/sdk/world/world.d.ts +0 -2
  141. package/dist/terminal-opentui.js +3331 -1584
  142. package/dist/terminal-pi.js +421 -133
  143. package/docs/README.md +3 -0
  144. package/docs/architecture/00-overview.md +1 -1
  145. package/docs/architecture/01-extension-points.md +1 -1
  146. package/docs/architecture/02-sessions-and-runs.md +73 -36
  147. package/docs/architecture/03-variants.md +1 -3
  148. package/docs/architecture/04-prompt.md +24 -13
  149. package/docs/architecture/06-plugins-and-capabilities.md +46 -10
  150. package/docs/architecture/07-tools-and-gate.md +49 -8
  151. package/docs/architecture/09-accounts-and-connectors.md +8 -2
  152. package/docs/architecture/10-server-protocol.md +68 -16
  153. package/docs/architecture/11-terminal.md +94 -45
  154. package/docs/architecture/12-storage.md +1 -0
  155. package/docs/architecture/13-world-tools.md +8 -6
  156. package/docs/architecture/14-surfaces-and-parity.md +4 -1
  157. package/docs/architecture/README.md +20 -18
  158. package/docs/architecture/proposals/PROP-0006-run-checklist.md +2 -0
  159. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +6 -2
  160. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +32 -21
  161. package/docs/architecture/proposals/PROP-0044-work-slice-defaults.md +93 -0
  162. package/docs/architecture/proposals/PROP-0045-rename-checklist-to-todo.md +132 -0
  163. package/docs/architecture/proposals/PROP-0046-progress-aware-stall-guards.md +82 -0
  164. package/docs/architecture/proposals/PROP-0047-plugins-hub-separation.md +122 -0
  165. package/docs/architecture/proposals/PROP-0048-simple-annotate.md +85 -0
  166. package/docs/architecture/proposals/README.md +11 -6
  167. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +39 -0
  168. package/docs/decisions/ADR-0040-architecture-change-workflow.md +31 -0
  169. package/docs/decisions/ADR-0041-plugins-hub-separation.md +41 -0
  170. package/docs/decisions/ADR-0042-windows-shell-without-sandbox.md +40 -0
  171. package/docs/decisions/ADR-0043-parents-continue-stopped-subagents.md +49 -0
  172. package/docs/decisions/ADR-0044-annotations-pending-or-sent.md +24 -0
  173. package/docs/decisions/ADR-0045-default-plugin-authoring-skill.md +9 -0
  174. package/docs/decisions/ADR-0046-hash-bound-plugin-proposals.md +20 -0
  175. package/docs/decisions/ADR-0047-links-open-with-the-os-handler.md +47 -0
  176. package/docs/doctor.md +14 -1
  177. package/docs/parity.md +16 -13
  178. package/docs/releases/1.3.0.md +21 -0
  179. package/docs/releases/1.3.1-unstable.1.md +8 -0
  180. package/docs/releases/README.md +9 -6
  181. package/docs/terminal-tui.md +127 -65
  182. package/package.json +1 -1
  183. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +0 -75
  184. package/dist/sdk/terminal/tui/pages/notesPage.js +0 -201
  185. package/dist/sdk/tools/checklist.d.ts +0 -9
  186. package/dist/sdk/tools/checklist.js +0 -55
@@ -4,12 +4,28 @@ import { randomUUID } from 'node:crypto';
4
4
  import { accessSync, constants, existsSync, lstatSync, mkdtempSync, realpathSync, readdirSync, rmSync, statSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { hostShell, shellArguments } from "./shell.js";
7
9
  import { isWithinWorkspace, verifyWorkspaceRoot } from "./workspace.js";
8
10
  const MARKER_PREFIX = '__XINGULAR_WORKSPACE_SANDBOX_STARTED_';
9
11
  const bashWriteScope = new AsyncLocalStorage();
10
12
  export function runWithWorkspaceWriteScope(scope, action) {
11
13
  return bashWriteScope.run(scope, action);
12
14
  }
15
+ /** Why a Windows command asks first: Windows has no write-boundary sandbox the harness can use. */
16
+ export const WINDOWS_UNSANDBOXED_REASON = 'Windows has no workspace write-boundary sandbox, so this command would run without one. It will not run until this one-call request is approved.';
17
+ const SANDBOX_BACKENDS = {
18
+ darwin: ['/usr/bin/sandbox-exec'],
19
+ linux: ['/usr/bin/bwrap', '/bin/bwrap']
20
+ };
21
+ /**
22
+ * The operating system's write-boundary sandbox for this platform, or null when it is not
23
+ * installed. Bash cannot run without it, so readiness diagnostics report it before a command runs.
24
+ */
25
+ export function workspaceSandboxBackend(platform = process.platform) {
26
+ const candidates = SANDBOX_BACKENDS[platform];
27
+ return candidates ? systemExecutable(candidates) : null;
28
+ }
13
29
  function systemExecutable(candidates) {
14
30
  for (const candidate of candidates) {
15
31
  try {
@@ -29,6 +45,18 @@ function shellPath() {
29
45
  throw new Error('The workspace write-boundary sandbox requires Bash, but no Bash executable was found.');
30
46
  return shell;
31
47
  }
48
+ function requireShell(resolve) {
49
+ const shell = resolve();
50
+ if (!shell) {
51
+ throw new Error(process.platform === 'win32'
52
+ ? 'No shell was found: neither Git Bash nor PowerShell is available. Bash was not run.'
53
+ : 'No Bash executable was found. Bash was not run.');
54
+ }
55
+ return shell;
56
+ }
57
+ function unconfinedCommand(shell, command) {
58
+ return { executable: shell.executable, args: shellArguments(shell, command), marker: '' };
59
+ }
32
60
  function sbplString(value) {
33
61
  return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`;
34
62
  }
@@ -72,13 +100,13 @@ export function workspaceSandboxCommand(platform, workspaceRoot, cwd, command, a
72
100
  return readOnlySandboxCommand(platform, realpathSync(readOnlyTempRoot), cwd, shell, wrappedCommand, marker);
73
101
  const writeRoots = [...new Set([workspaceRoot, ...additionalWriteRoots].map((root) => realpathSync(root)))].sort((left, right) => left.length - right.length);
74
102
  if (platform === 'darwin') {
75
- const executable = systemExecutable(['/usr/bin/sandbox-exec']);
103
+ const executable = workspaceSandboxBackend(platform);
76
104
  if (!executable)
77
105
  throw new Error('Workspace write-boundary sandbox unavailable: sandbox-exec was not found. Bash was not run.');
78
106
  return { executable, args: ['-p', macSandboxProfile(writeRoots), shell, '-c', wrappedCommand], marker };
79
107
  }
80
108
  if (platform === 'linux') {
81
- const executable = systemExecutable(['/usr/bin/bwrap', '/bin/bwrap']);
109
+ const executable = workspaceSandboxBackend(platform);
82
110
  if (!executable)
83
111
  throw new Error('Workspace write-boundary sandbox unavailable: install bubblewrap (bwrap). Bash was not run.');
84
112
  return {
@@ -99,13 +127,13 @@ export function workspaceSandboxCommand(platform, workspaceRoot, cwd, command, a
99
127
  }
100
128
  function readOnlySandboxCommand(platform, tempRoot, cwd, shell, wrappedCommand, marker) {
101
129
  if (platform === 'darwin') {
102
- const executable = systemExecutable(['/usr/bin/sandbox-exec']);
130
+ const executable = workspaceSandboxBackend(platform);
103
131
  if (!executable)
104
132
  throw new Error('Read-only Bash sandbox unavailable: sandbox-exec was not found. Bash was not run.');
105
133
  return { executable, args: ['-p', macReadOnlyProfile(tempRoot), shell, '-c', wrappedCommand], marker };
106
134
  }
107
135
  if (platform === 'linux') {
108
- const executable = systemExecutable(['/usr/bin/bwrap', '/bin/bwrap']);
136
+ const executable = workspaceSandboxBackend(platform);
109
137
  if (!executable)
110
138
  throw new Error('Read-only Bash sandbox unavailable: install bubblewrap (bwrap). Bash was not run.');
111
139
  return {
@@ -127,7 +155,12 @@ function readOnlySandboxCommand(platform, tempRoot, cwd, shell, wrappedCommand,
127
155
  throw new Error(`Read-only Bash sandbox unavailable on ${platform}. Bash was not run.`);
128
156
  }
129
157
  function killProcess(child) {
130
- if (child.pid && process.platform !== 'win32') {
158
+ if (child.pid && process.platform === 'win32') {
159
+ // Git Bash and PowerShell start their own children; end the whole tree.
160
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
161
+ return;
162
+ }
163
+ if (child.pid) {
131
164
  try {
132
165
  process.kill(-child.pid, 'SIGKILL');
133
166
  return;
@@ -270,9 +303,12 @@ export function createWorkspaceBashOperations(workspaceRoot, options = {}) {
270
303
  if (!isWithinWorkspace(root, canonicalCwd))
271
304
  throw new Error('The Bash working directory is outside the active project workspace.');
272
305
  const scope = options.workspaceWriteScope?.() ?? bashWriteScope.getStore() ?? 'workspace';
306
+ const platform = options.platform ?? process.platform;
307
+ const shell = options.shell ?? hostShell;
273
308
  // Nothing widens a read-only scope: no Full Permission bypass, no outside request.
274
309
  if (scope === 'read-only') {
275
- const platform = options.platform ?? process.platform;
310
+ if (platform === 'win32')
311
+ throw new Error('Plan-mode Bash needs a read-only sandbox, which Windows does not have. Bash was not run.');
276
312
  const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'harness-read-only-bash-')));
277
313
  try {
278
314
  const spec = workspaceSandboxCommand(platform, root, canonicalCwd, command, [], tempRoot);
@@ -292,11 +328,15 @@ export function createWorkspaceBashOperations(workspaceRoot, options = {}) {
292
328
  bypass = false;
293
329
  }
294
330
  if (bypass) {
295
- const result = await runProcess({ executable: shellPath(), args: ['-c', command], marker: '' }, canonicalCwd, execOptions, null);
331
+ const result = await runProcess(unconfinedCommand(requireShell(shell), command), canonicalCwd, execOptions, null);
296
332
  return { exitCode: result.exitCode };
297
333
  }
298
- if (scope === 'outside') {
299
- const reason = 'This command requests filesystem writes outside the active project workspace. It will not run until this one-call request is approved.';
334
+ // Without a sandbox every Windows command is an outside request: a person approves it once.
335
+ if (scope === 'outside' || platform === 'win32') {
336
+ const resolved = requireShell(shell);
337
+ const reason = scope === 'outside'
338
+ ? 'This command requests filesystem writes outside the active project workspace. It will not run until this one-call request is approved.'
339
+ : WINDOWS_UNSANDBOXED_REASON;
300
340
  let approved = false;
301
341
  try {
302
342
  approved = await options.requestWorkspaceWriteScope?.({ command, cwd: canonicalCwd, workspaceRoot: root, reason }) ?? false;
@@ -306,12 +346,12 @@ export function createWorkspaceBashOperations(workspaceRoot, options = {}) {
306
346
  }
307
347
  if (!approved)
308
348
  throw new Error(`${reason} Bash was not run.`);
309
- execOptions.onData(Buffer.from('\n[Outside-workspace writes approved for this command; running once.]\n'));
310
- const result = await runProcess({ executable: shellPath(), args: ['-c', command], marker: '' }, canonicalCwd, execOptions, null);
349
+ if (scope === 'outside')
350
+ execOptions.onData(Buffer.from('\n[Outside-workspace writes approved for this command; running once.]\n'));
351
+ const result = await runProcess(unconfinedCommand(resolved, command), canonicalCwd, execOptions, null);
311
352
  return { exitCode: result.exitCode };
312
353
  }
313
354
  assertNoExternalHardlinks(root);
314
- const platform = options.platform ?? process.platform;
315
355
  const spec = workspaceSandboxCommand(platform, root, canonicalCwd, command);
316
356
  const result = await runProcess(spec, canonicalCwd, execOptions, spec.marker);
317
357
  return { exitCode: result.exitCode };
@@ -7,6 +7,17 @@ export interface WorkspaceWriteAuthorization {
7
7
  expectedContentHash?: string;
8
8
  signal?: AbortSignal;
9
9
  }
10
+ export interface AtomicWriteLaunch {
11
+ executable: string;
12
+ args: string[];
13
+ env: NodeJS.ProcessEnv;
14
+ }
15
+ /**
16
+ * How the atomic write helper starts. macOS and Linux run it inside the write-boundary sandbox.
17
+ * Windows has none, so Node runs the helper directly; the target was still canonicalized and
18
+ * authorized, and the rename replaces the directory entry rather than writing through an alias.
19
+ */
20
+ export declare function atomicWriteLaunch(platform: NodeJS.Platform, root: string, additionalRoots: readonly string[]): AtomicWriteLaunch;
10
21
  declare function writeWorkspaceFile(path: string, content: string): Promise<void>;
11
22
  export declare const workspaceWriteOperations: {
12
23
  mkdir: (_directory: string) => Promise<void>;
@@ -52,6 +52,29 @@ try {
52
52
  throw error
53
53
  }
54
54
  `;
55
+ /**
56
+ * How the atomic write helper starts. macOS and Linux run it inside the write-boundary sandbox.
57
+ * Windows has none, so Node runs the helper directly; the target was still canonicalized and
58
+ * authorized, and the rename replaces the directory entry rather than writing through an alias.
59
+ */
60
+ export function atomicWriteLaunch(platform, root, additionalRoots) {
61
+ if (platform === 'win32') {
62
+ // Node needs SystemRoot to start on Windows; keep only what the helper requires.
63
+ const env = {};
64
+ for (const name of ['SystemRoot', 'windir', 'TEMP', 'TMP', 'USERPROFILE']) {
65
+ if (process.env[name])
66
+ env[name] = process.env[name];
67
+ }
68
+ return { executable: process.execPath, args: ['-e', atomicWriteScript], env };
69
+ }
70
+ const command = `exec ${shellQuote(process.execPath)} -e ${shellQuote(atomicWriteScript)}`;
71
+ const spec = workspaceSandboxCommand(platform, root, root, command, additionalRoots);
72
+ return {
73
+ executable: spec.executable,
74
+ args: spec.args,
75
+ env: { PATH: '/usr/bin:/bin', ...(process.env.HOME ? { HOME: process.env.HOME } : {}) }
76
+ };
77
+ }
55
78
  function shellQuote(value) {
56
79
  return `'${value.replaceAll("'", "'\\''")}'`;
57
80
  }
@@ -96,17 +119,12 @@ async function runAtomicWrite(authorization, path, content) {
96
119
  throw new Error('The write authorization no longer matches the active workspace target.');
97
120
  }
98
121
  const additionalRoots = outside ? [nearestExistingDirectory(canonicalPath)] : [];
99
- const command = `exec ${shellQuote(process.execPath)} -e ${shellQuote(atomicWriteScript)}`;
100
- const spec = workspaceSandboxCommand(process.platform, root, root, command, additionalRoots);
101
- const env = {
102
- PATH: '/usr/bin:/bin',
103
- ...(process.env.HOME ? { HOME: process.env.HOME } : {})
104
- };
122
+ const launch = atomicWriteLaunch(process.platform, root, additionalRoots);
105
123
  await new Promise((resolvePromise, rejectPromise) => {
106
- const child = spawn(spec.executable, spec.args, {
124
+ const child = spawn(launch.executable, launch.args, {
107
125
  cwd: root,
108
126
  detached: process.platform !== 'win32',
109
- env,
127
+ env: launch.env,
110
128
  stdio: ['pipe', 'pipe', 'pipe'],
111
129
  windowsHide: true
112
130
  });
@@ -1,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
2
- import { delimiter, isAbsolute, join } from 'node:path';
3
+ import { basename, delimiter, isAbsolute, join } from 'node:path';
4
+ import { hostShell, shellLabel } from "../core/shell.js";
5
+ import { createWorkspaceBashOperations, workspaceSandboxBackend } from "../core/workspaceSandbox.js";
6
+ import { resolveWorkspaceRoot } from "../core/workspace.js";
3
7
  import { defaultAssets } from "../terminal/terminals.js";
4
8
  import { loadPi, PINNED_PI_VERSION } from "../pi/runtime.js";
5
9
  import { list as listPlugins } from "../plugins/api.js";
@@ -12,18 +16,23 @@ import { BRAVE, TAVILY, EXA } from "../web/search.js";
12
16
  import { KEYLESS_SEARCH, WEB_PLUGIN } from "../web/tools.js";
13
17
  const pass = (evidence) => ({ status: 'pass', evidence });
14
18
  const fail = (evidence, remedy) => ({ status: 'fail', evidence, remedy });
15
- function executable(name) {
19
+ function resolvedExecutable(name) {
16
20
  const paths = isAbsolute(name) ? [name] : (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(dir => join(dir, name));
17
- return paths.some(path => {
21
+ for (const path of paths) {
18
22
  try {
19
23
  accessSync(path, constants.X_OK);
20
- return statSync(path).isFile();
21
- }
22
- catch {
23
- return false;
24
+ if (statSync(path).isFile())
25
+ return path;
24
26
  }
25
- });
27
+ catch { /* try the next PATH entry */ }
28
+ }
29
+ return null;
26
30
  }
31
+ function executable(name) {
32
+ return resolvedExecutable(name) !== null;
33
+ }
34
+ /** One command written by the harness, not the model: it prints a fixed word and touches nothing. */
35
+ const BASH_PROBE_COMMAND = `printf '%s\\n' harness-doctor`;
27
36
  export async function builtinChecks(harness, context, secrets, assets = defaultAssets()) {
28
37
  const check = (id, category, title, local) => ({ id, category, title, local });
29
38
  const account = () => context.accountId ? harness.accounts.find(context.accountId) : null;
@@ -96,10 +105,60 @@ export async function builtinChecks(harness, context, secrets, assets = defaultA
96
105
  }), tools: ['read', 'grep', 'find', 'ls', 'write', 'edit']
97
106
  },
98
107
  {
99
- ...check('native.bash', 'tools', 'Native shell tool', async () => process.platform === 'win32'
100
- ? { status: 'unverified', evidence: 'Windows shell resolution is delegated to Pi; no shell was launched.' }
101
- : executable('bash') ? pass('Bash is executable on the inherited PATH. No shell command was run.') : fail('Bash was not found on the inherited PATH.', 'Verify the shell executable used by Pi.')),
102
- tools: ['bash']
108
+ ...check('native.bash', 'tools', 'Native shell tool', async () => {
109
+ if (process.platform === 'win32') {
110
+ const shell = hostShell();
111
+ if (!shell)
112
+ return fail('Neither Git Bash nor PowerShell was found.', 'Install Git for Windows, or restore Windows PowerShell.');
113
+ return { status: 'warning', evidence: `Commands run in ${shellLabel(shell)} at ${shell.executable}. Windows has no workspace write-boundary sandbox, so each command asks for approval unless Full Permission is on, and plan mode cannot run commands. No shell command was run.`, remedy: 'Approve each command when asked, or turn on Full Permission for this session.' };
114
+ }
115
+ const shell = resolvedExecutable('bash');
116
+ if (!shell)
117
+ return fail('Bash was not found on the inherited PATH.', 'Verify the shell executable used by Pi.');
118
+ // Bash runs inside the platform's write-boundary sandbox; without that backend every call
119
+ // fails before the command starts, so presence of bash alone is not readiness.
120
+ const backend = workspaceSandboxBackend(process.platform);
121
+ return backend
122
+ ? pass(`Bash is executable at ${shell}, and the ${basename(backend)} workspace write-boundary sandbox it runs inside is present. No shell command was run.`)
123
+ : fail(`Bash is executable at ${shell}, but the workspace write-boundary sandbox this platform needs is missing, so every Bash call fails before it runs.`, process.platform === 'linux' ? 'Install bubblewrap (bwrap) so Bash can run inside the workspace boundary.' : 'Restore the platform sandbox executable so Bash can run inside the workspace boundary.');
124
+ }),
125
+ tools: ['bash'],
126
+ live: {
127
+ notice: 'Run one fixed command written by the harness (printf) in the working folder, inside the same workspace write-boundary sandbox as a normal Bash call. No model input, no network request, no file written; only the exit status is reported.',
128
+ run: async (probe) => {
129
+ if (process.platform === 'win32')
130
+ return { status: 'unverified', evidence: 'On Windows every command needs a person to approve it, so the diagnostic does not run one.' };
131
+ const refusal = await harness.sessions.diagnosticGate(probe.run, context.caller).check('bash', { command: BASH_PROBE_COMMAND });
132
+ if (refusal)
133
+ return { status: 'blocked', evidence: `The tool policy refused the diagnostic command: ${refusal}`, remedy: 'Review what the policy allows Bash to do in this session.' };
134
+ const toolCallId = randomUUID();
135
+ probe.run.emit('tool.started', { toolCallId, name: 'bash', summary: 'Synthetic diagnostic command' });
136
+ let output = '';
137
+ try {
138
+ // The same root, boundary, hard-link scan and sandbox a real Bash call takes. A
139
+ // diagnostic never widens that boundary, so Full Permission cannot change this result.
140
+ const workspaceRoot = resolveWorkspaceRoot(context.cwd);
141
+ const { exitCode } = await createWorkspaceBashOperations(workspaceRoot).exec(BASH_PROBE_COMMAND, workspaceRoot, {
142
+ onData: chunk => { if (output.length < 400)
143
+ output += chunk.toString('utf8'); },
144
+ signal: probe.signal,
145
+ timeout: 20
146
+ });
147
+ probe.run.emit('tool.completed', { toolCallId, name: 'bash', ok: exitCode === 0 });
148
+ return exitCode === 0 && output.includes('harness-doctor')
149
+ ? pass('A real command ran in the working folder through the workspace sandbox and returned exit status 0 with the expected output.')
150
+ : fail(`The shell returned exit status ${exitCode ?? 'unknown'} for the diagnostic command.`, 'Review the shell executable, the workspace write-boundary sandbox and the working folder permissions.');
151
+ }
152
+ catch (error) {
153
+ probe.run.emit('tool.completed', { toolCallId, name: 'bash', ok: false });
154
+ return {
155
+ status: 'blocked',
156
+ evidence: `The command could not start in this working folder, so the shell itself was not exercised: ${error instanceof Error ? error.message : String(error)}`,
157
+ remedy: 'Resolve what stops Bash before it runs (a hard-link alias in the workspace, a missing sandbox backend, or a redirected workspace root), or choose another working folder.'
158
+ };
159
+ }
160
+ }
161
+ }
103
162
  },
104
163
  check('runtime.storage', 'runtime', 'State and data storage', async () => {
105
164
  accessSync(harness.stateDir, constants.R_OK | constants.W_OK);
@@ -4,6 +4,7 @@ import { Hub, type HubOptions } from "./hub/hub.js";
4
4
  import { type SecretStore } from "./accounts/secrets.js";
5
5
  import { Bus } from "./core/bus.js";
6
6
  import { type Directory, type HarnessEnv } from "./core/env.js";
7
+ import { type LinkOpener } from "./core/links.js";
7
8
  import { ConnectedApps } from "./mcp/service.js";
8
9
  import type { AppOAuthClients } from "./mcp/types.js";
9
10
  import { Lessons } from "./history/lessons.js";
@@ -69,6 +70,8 @@ export interface HarnessOptions {
69
70
  hookImporter?: (address: string) => Promise<unknown>;
70
71
  /** Where managed worktrees are made, the prefix of branches they get, and how many `prune` keeps. */
71
72
  worktrees?: WorktreesOptions;
73
+ /** How a link a person clicked reaches the operating system. Defaults to the system's own opener. */
74
+ linkOpener?: LinkOpener;
72
75
  }
73
76
  /** What starting on a data root found and repaired. */
74
77
  export interface Recovery {
@@ -99,6 +102,8 @@ export declare class Harness {
99
102
  readonly env: Env;
100
103
  readonly stateDir: string;
101
104
  readonly policy: ToolPolicy;
105
+ /** How a link a person clicked reaches the operating system (page 10, `links.open`). */
106
+ readonly linkOpener: LinkOpener;
102
107
  readonly accounts: Accounts;
103
108
  readonly approvals: Approvals;
104
109
  readonly runs: RunRegistry;
@@ -15,6 +15,7 @@ import { DEFAULT_PRODUCT_NAME, EMPTY_DIRECTORY } from "./core/env.js";
15
15
  import { HarnessError } from "./core/errors.js";
16
16
  import { readJsonOr } from "./core/fs.js";
17
17
  import { closeIndex } from "./core/index.js";
18
+ import { openWithSystem } from "./core/links.js";
18
19
  import { ConnectedApps } from "./mcp/service.js";
19
20
  import { migrateConnectionScopes } from "./mcp/scopeMigration.js";
20
21
  import { Lessons } from "./history/lessons.js";
@@ -26,6 +27,7 @@ import { VariantTrust } from "./variant/trust.js";
26
27
  import * as notes from "./wiki/notes.js";
27
28
  import * as pluginApi from "./plugins/api.js";
28
29
  import * as pluginInstall from "./plugins/install.js";
30
+ import * as pluginProposals from "./plugins/proposals.js";
29
31
  import * as attachments from "./plugins/attachments.js";
30
32
  import { HARNESS_PLUGINS } from "./plugins/builtin.js";
31
33
  import { World } from "./world/world.js";
@@ -72,6 +74,8 @@ export class Harness {
72
74
  env;
73
75
  stateDir;
74
76
  policy;
77
+ /** How a link a person clicked reaches the operating system (page 10, `links.open`). */
78
+ linkOpener;
75
79
  accounts;
76
80
  approvals;
77
81
  runs;
@@ -118,6 +122,7 @@ export class Harness {
118
122
  this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus);
119
123
  this.hub = new Hub(this, options.hub);
120
124
  this.policy = options.policy ?? {};
125
+ this.linkOpener = options.linkOpener ?? openWithSystem;
121
126
  configureBuiltins(options.builtins ?? []);
122
127
  this.trust = new VariantTrust(this.stateDir);
123
128
  this.refineHost = options.refineHost ?? null;
@@ -189,7 +194,7 @@ export class Harness {
189
194
  worktrees: this.worktrees
190
195
  });
191
196
  this.sideQuestions = new SideQuestions({ env: this.env, sessions: this.sessions, runs: this.runs });
192
- this.world = new World({ root: () => this.env.root, stateDir: this.stateDir, secrets, browser: this.browserEnabled ? undefined : false, notesChanged: (sessionId, ids) => this.bus.emit({ type: 'annotations.changed', sessionId, ids }) });
197
+ this.world = new World({ root: () => this.env.root, stateDir: this.stateDir, secrets, browser: this.browserEnabled ? undefined : false });
193
198
  // The harness's own world tools come first; a tool an application provides under the same name is not added again.
194
199
  this.sessions.provide(this.world.provider());
195
200
  this.doctor = new Doctor({
@@ -200,7 +205,7 @@ export class Harness {
200
205
  this.refine = new Refinements({ root: () => this.env.root, bus: this.bus, trust: this.trust, host: () => this.refineHost, runtime: refineRuntime });
201
206
  this.lessons = new Lessons({ root: () => this.env.root, runtime: refineRuntime });
202
207
  this.pageNotes = new NoteDeliveries({ root: () => this.env.root, bus: this.bus, terminals: () => this.terminals ?? null });
203
- this.terminals = new Terminals({ trust: this.trust, subagents: this.subagents, sideQuestions: this.sideQuestions, 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, usage: this.usage, usageLimits: this.usageLimits, 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), configure: (params) => pluginApi.configure(this, params), install: params => pluginInstall.install(this, params), remove: params => pluginInstall.remove(this, params) }, assets: options.assets, hub: this.hub, world: this.world, doctor: this.doctor, worktrees: this.worktrees, noteCapture: this.noteCapture, pageNotes: this.pageNotes });
208
+ this.terminals = new Terminals({ trust: this.trust, subagents: this.subagents, sideQuestions: this.sideQuestions, 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, usage: this.usage, usageLimits: this.usageLimits, 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), configure: (params) => pluginApi.configure(this, params), install: params => pluginInstall.install(this, params), remove: params => pluginInstall.remove(this, params), proposals: { list: () => pluginProposals.list(this.env.root), preview: id => pluginProposals.preview(this.env.root, id), accept: (id, hash) => pluginProposals.accept(this, id, hash), reject: (id, hash) => pluginProposals.reject(this.env.root, id, hash) } }, assets: options.assets, hub: this.hub, world: this.world, doctor: this.doctor, worktrees: this.worktrees, noteCapture: this.noteCapture, pageNotes: this.pageNotes });
204
209
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)));
205
210
  this.runs.listen((event, run) => {
206
211
  this.accounts.onRunEvent(event, run);
@@ -215,9 +220,9 @@ export class Harness {
215
220
  if (runEnded)
216
221
  indexRunUsage(this.env.root, event.runId);
217
222
  if (runEnded && run?.sessionId) {
218
- // Notes sent with "Proceed now" while this session was working go out now; the terminal
219
- // needs a moment to see its run as ended.
220
- setTimeout(() => void this.pageNotes.runEnded(run.sessionId).catch((error) => log('page notes', error instanceof Error ? error.message : String(error))), 250).unref?.();
223
+ // Notes sent while this session was working go out now (runEnded waits for the terminal to
224
+ // see its run as ended, and tries again while it is still busy).
225
+ void this.pageNotes.runEnded(run.sessionId).catch((error) => log('page notes', error instanceof Error ? error.message : String(error)));
221
226
  }
222
227
  if ((event.type === 'completed' || event.type === 'failed') && run) {
223
228
  if (event.type === 'completed')
@@ -294,7 +299,7 @@ export class Harness {
294
299
  if (!sessionId || recoveredSessions.has(sessionId))
295
300
  continue;
296
301
  recoveredSessions.add(sessionId);
297
- setTimeout(() => void this.pageNotes.runEnded(sessionId).catch((error) => log('page notes', error instanceof Error ? error.message : String(error))), 250).unref?.();
302
+ void this.pageNotes.runEnded(sessionId).catch((error) => log('page notes', error instanceof Error ? error.message : String(error)));
298
303
  }
299
304
  catch {
300
305
  // A malformed run record does not prevent the rest of recovery.
@@ -316,7 +316,7 @@ export class Hub {
316
316
  attachments.save(root, choicesBefore);
317
317
  if (entry.delivery.kind === 'pi-package')
318
318
  for (const pluginId of receipt.resourceIds)
319
- await packages.remove(this.harness, { pluginId });
319
+ await packages.removeDirect(this.harness, { pluginId });
320
320
  throw error;
321
321
  }
322
322
  this.harness.bus.emit({ type: 'hub.changed' });
@@ -404,8 +404,10 @@ export class Hub {
404
404
  }
405
405
  }
406
406
  if (receipt.entry.delivery.kind === 'pi-package')
407
- for (const pluginId of receipt.resourceIds)
408
- await packages.remove(this.harness, { pluginId });
407
+ for (const pluginId of receipt.resourceIds) {
408
+ if ((await pluginApi.list(this.harness, {})).some((plugin) => plugin.id === pluginId && plugin.install))
409
+ await packages.removeDirect(this.harness, { pluginId });
410
+ }
409
411
  writeJson(join(root, 'hub', 'installed.json'), records.filter((item) => item !== receipt));
410
412
  committed = true;
411
413
  try {
@@ -6,6 +6,7 @@ export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema,
6
6
  export { Bus, log, type Notice } from "./core/bus.js";
7
7
  export { EMPTY_DIRECTORY, type Directory, type HarnessEnv } from "./core/env.js";
8
8
  export { HarnessError, invalid, notFound, toErrorShape, type ErrorShape } from "./core/errors.js";
9
+ export { linkOpenCommand, linkTarget, openableTarget, openLink, openLinkTarget, openWithSystem, type LinkOpener, type LinkTarget, type OpenedTarget } from "./core/links.js";
9
10
  export { findBinary, searchPath } from "./core/path.js";
10
11
  export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from "./accounts/secrets.js";
11
12
  export type { Account, AccountsOverview, Pool } from "./accounts/store.js";
@@ -16,7 +17,7 @@ export type { RunCapabilitySnapshot } from "./capabilities/runSnapshot.js";
16
17
  export type { CallerContext } from "./core/caller.js";
17
18
  export { dispatch as harnessCall } from "./tools/dispatch.js";
18
19
  export { compile, render, SECTION_ORDER, CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY, type ContextPack, type HostSection, type HostToolGroup, type Launch, type Reference, type Section } from "./prompt/compile.js";
19
- export { runEventSchema, runRecordSchema, isActiveStatus, type ChecklistItem, type RunEvent, type RunEventType, type RunRecord } from "./sessions/contracts.js";
20
+ export { runEventSchema, runRecordSchema, isActiveStatus, type TodoItem, type RunEvent, type RunEventType, type RunRecord } from "./sessions/contracts.js";
20
21
  export type { ApprovalFacts, ApprovalPolicy, AutomaticReason } from "./sessions/approvals.js";
21
22
  export type { ScopeDecision, ToolCall, ToolPolicy } from "./sessions/policy.js";
22
23
  export type { ActiveRun, ApprovalDecision, ApprovalInput, QuestionAnswer, QuestionInput } from "./sessions/registry.js";
@@ -30,7 +31,7 @@ export type { Effort } from "./sessions/runner.js";
30
31
  export { describeTool, HARNESS_TOOLS, DEFAULT_TOOL_NAMES, WIKI_WRITE, type HarnessCall, type HarnessToolDefinition } from "./tools/definitions.js";
31
32
  export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from "./tools/types.js";
32
33
  export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from "./tools/spawnSubagent.js";
33
- export { CHECKLIST_WRITE, checklistOf } from "./tools/checklist.js";
34
+ export { TODO_WRITE, todoOf, CHECKLIST_WRITE, checklistOf } from "./tools/todo.js";
34
35
  export { BUILTIN_PROFILES } from "./agents/builtin.js";
35
36
  export { PROFILE_COLORS, createProfile, listProfiles, parseProfile, profileDirs, removeProfile, resolveProfile, type AgentProfile, type ProfileColor, type ProfileListing, type ProfileProblem, type ProfileScope, type ProfileSource, type ProfileVariant } from "./agents/profiles.js";
36
37
  export { continuationOf, type Continuation, type ContinuationBlock } from "./sessions/continuation.js";
package/dist/sdk/index.js CHANGED
@@ -15,6 +15,7 @@ export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema }
15
15
  export { Bus, log } from "./core/bus.js";
16
16
  export { EMPTY_DIRECTORY } from "./core/env.js";
17
17
  export { HarnessError, invalid, notFound, toErrorShape } from "./core/errors.js";
18
+ export { linkOpenCommand, linkTarget, openableTarget, openLink, openLinkTarget, openWithSystem } from "./core/links.js";
18
19
  export { findBinary, searchPath } from "./core/path.js";
19
20
  export { fileSecretStore, memorySecretStore, secretKey } from "./accounts/secrets.js";
20
21
  export { dispatch as harnessCall } from "./tools/dispatch.js";
@@ -25,7 +26,7 @@ export { STANDARD_CONNECTIONS } from "./prompt/standard.js";
25
26
  export { SideQuestions, SIDE_QUESTION_INSTRUCTION, buildSideQuestionText } from "./sessions/sideQuestions.js";
26
27
  export { describeTool, HARNESS_TOOLS, DEFAULT_TOOL_NAMES, WIKI_WRITE } from "./tools/definitions.js";
27
28
  export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from "./tools/spawnSubagent.js";
28
- export { CHECKLIST_WRITE, checklistOf } from "./tools/checklist.js";
29
+ export { TODO_WRITE, todoOf, CHECKLIST_WRITE, checklistOf } from "./tools/todo.js";
29
30
  export { BUILTIN_PROFILES } from "./agents/builtin.js";
30
31
  export { PROFILE_COLORS, createProfile, listProfiles, parseProfile, profileDirs, removeProfile, resolveProfile } from "./agents/profiles.js";
31
32
  export { continuationOf } from "./sessions/continuation.js";
@@ -1,13 +1,13 @@
1
1
  // Built-in feature modules register their contributions here; this registry never loads Pi code.
2
2
  import { CONNECTION_TOOLS, NATIVE_GROUP } from "../prompt/compile.js";
3
3
  import { ASK_USER, askUserTool } from "../tools/askUser.js";
4
- import { CHECKLIST_WRITE, checklistTool } from "../tools/checklist.js";
4
+ import { TODO_WRITE, todoTool } from "../tools/todo.js";
5
5
  import { CHECKPOINT_WRITE, checkpointTool } from "../tools/checkpoint.js";
6
6
  import { sliceOf } from "../sessions/workSlice.js";
7
7
  import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from "../sessions/subagents.js";
8
8
  import { subagentTools } from "../tools/spawnSubagent.js";
9
9
  import { SESSION_NAME } from "../tools/sessionName.js";
10
- import { RECALL_TOOLS, SKILL_TOOLS, TRAJECTORY_TOOLS, WIKI_READ_TOOLS, WIKI_WRITE_TOOLS } from "../tools/definitions.js";
10
+ import { PLUGIN_PROPOSAL_TOOLS, RECALL_TOOLS, SKILL_TOOLS, TRAJECTORY_TOOLS, WIKI_READ_TOOLS, WIKI_WRITE_TOOLS } from "../tools/definitions.js";
11
11
  import { FIRST_PARTY_PLUGINS } from "./firstParty.js";
12
12
  import { WEB_PLUGIN } from "../web/tools.js";
13
13
  import { KEYLESS_SEARCH, webPart } from "../web/part.js";
@@ -79,7 +79,7 @@ const WIKI_PLUGIN = {
79
79
  };
80
80
  const HISTORY_PLUGIN = { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]), toolGroups: ['History'] };
81
81
  const ASK_USER_PLUGIN = { id: 'ask-user', title: 'Ask the user', description: 'Lets the agent stop and ask a question with choices.', required: 'An agent must be able to stop and ask you.', tools: [{ name: ASK_USER }] };
82
- const CHECKLIST_PLUGIN = { id: 'checklist', title: 'Checklist', description: 'Lets the agent keep a checklist of the steps of its work, recorded with the run so progress can be shown and picked up again.', required: 'Progress of longer work is kept through it.', tools: [{ name: CHECKLIST_WRITE, description: 'Write the checklist' }] };
82
+ const TODO_PLUGIN = { id: 'todo', title: 'Todo', description: 'Lets the agent keep a todo list of the steps of its work, recorded with the run so progress can be shown and picked up again.', required: 'Progress of longer work is kept through it.', tools: [{ name: TODO_WRITE, description: 'Write the todo list' }] };
83
83
  const CHECKPOINT_PLUGIN = { id: 'checkpoint', title: 'Checkpoint', description: 'Lets a bounded reply record a handoff at a work-slice boundary so the next slice starts from it.', required: 'A reply that stops at its budget leaves the state the next one resumes from.', tools: [{ name: CHECKPOINT_WRITE, description: 'Write the handoff checkpoint' }] };
84
84
  const CONNECTED_APPS_PLUGIN = {
85
85
  id: 'connected-apps',
@@ -93,6 +93,7 @@ const CONNECTED_APPS_PLUGIN = {
93
93
  const DOCTOR_PLUGIN = { id: 'doctor', title: 'Doctor', description: 'Local readiness checks and explicitly confirmed live probes for accounts, models and tools.', required: 'Every local terminal has readiness diagnostics.' };
94
94
  const WORKTREES_PLUGIN = { id: 'worktrees', title: 'Worktrees', description: 'Create and manage isolated git worktrees, move changes between them, and restore removed worktrees.', required: 'Managed worktrees are available to every local terminal.' };
95
95
  const HUB_PLUGIN = { id: 'hub', title: 'Hub', description: 'Browse and install reviewed skills, plugins and documents from configured registries.', required: 'Reviewed Harness resources are available to every host.' };
96
+ const PLUGIN_AUTHORING = { id: 'plugin-authoring', title: 'Plugin authoring', description: 'Propose plugins for a person to review.', required: 'Plugin proposals are reviewed before installation.', tools: tools(PLUGIN_PROPOSAL_TOOLS) };
96
97
  const REFINEMENT_PLUGIN = { id: 'refinement', title: 'Refinement', description: 'Improve variants from recorded evidence through isolated refinement runs and reviewed candidates.', required: 'Variant refinement is available to every host.' };
97
98
  const CAPABILITIES_PLUGIN = { id: 'capabilities', title: 'Capability discovery', description: 'Inspect the callable tools of the current run, including their input schemas. Discovery never grants access.', required: 'Every interactive session can inspect its own registered capabilities.', tools: [{ name: GET_CAPABILITIES, description: 'Inspect current capabilities' }] };
98
99
  const USAGE_PLUGIN = {
@@ -130,7 +131,7 @@ export const BUILTIN_PLUGIN_REGISTRY = createBuiltinPluginRegistry([
130
131
  serverMethods: historyServerMethods
131
132
  },
132
133
  { descriptor: ASK_USER_PLUGIN, toolOrder: 40, tools: context => context.harnessTools && context.allowedTools.includes(ASK_USER) ? [askUserTool(context.run)] : [] },
133
- { descriptor: CHECKLIST_PLUGIN, toolOrder: 50, tools: context => context.harnessTools ? [checklistTool(context.run)] : [] },
134
+ { descriptor: TODO_PLUGIN, toolOrder: 50, tools: context => context.harnessTools ? [todoTool(context.run)] : [] },
134
135
  { descriptor: CHECKPOINT_PLUGIN, toolOrder: 51, tools: context => context.harnessTools && context.allowedTools.includes(CHECKPOINT_WRITE) ? [checkpointTool(context.run, () => sliceOf(context.env.root, context.runId))] : [] },
135
136
  {
136
137
  descriptor: CONNECTED_APPS_PLUGIN,
@@ -139,6 +140,7 @@ export const BUILTIN_PLUGIN_REGISTRY = createBuiltinPluginRegistry([
139
140
  serverMethods: connectedAppServerMethods
140
141
  },
141
142
  { descriptor: HUB_PLUGIN, serverMethods: hubServerMethods },
143
+ { descriptor: PLUGIN_AUTHORING, ...toolContribution(PLUGIN_PROPOSAL_TOOLS, 31) },
142
144
  { descriptor: REFINEMENT_PLUGIN, serverMethods: refinementServerMethods },
143
145
  { descriptor: SIDE_QUESTIONS_PLUGIN, serverMethods: sideQuestionServerMethods },
144
146
  {
@@ -154,12 +156,14 @@ export const BUILTIN_PLUGIN_REGISTRY = createBuiltinPluginRegistry([
154
156
  {
155
157
  descriptor: ANNOTATIONS_PLUGIN,
156
158
  commandPrefix: 'annotate',
157
- defaultCommand: 'annotate',
159
+ defaultCommand: 'open',
158
160
  commands: [
159
- { name: 'notes', description: 'Review web annotations saved from pages and apps' },
160
- { name: 'annotate', description: 'Inspect a page or app, comment on components, and send fixes: /annotate:annotate [url | --app <devtools address> | --bridge]' }
161
+ { name: 'open', rootOnly: true, description: 'Annotate a page or app and send it to the agent: /annotate: [url | --app <port> | --bridge]' },
162
+ { name: 'notes', description: 'Every saved web annotation, searchable' },
163
+ { name: 'submit', description: 'Send this session\'s unsent annotations with your request: /annotate:submit <request>' },
164
+ { name: 'clear', description: 'Resolve this session\'s unsent annotations and stop sharing them' }
161
165
  ],
162
- createWorldPart: ({ options }) => annotationsPart({ root: options.root, changed: options.notesChanged }),
166
+ createWorldPart: ({ options }) => annotationsPart({ root: options.root }),
163
167
  serverMethods: annotationServerMethods
164
168
  },
165
169
  { descriptor: CAPABILITIES_PLUGIN },
@@ -169,7 +173,8 @@ export const BUILTIN_PLUGIN_REGISTRY = createBuiltinPluginRegistry([
169
173
  defaultCommand: 'analysis',
170
174
  commands: [
171
175
  { name: 'analysis', description: 'Token, cost and latency totals: /usage:analysis [today | 7d | 30d | all]' },
172
- { name: 'limit', description: 'Provider plan windows, balances and rate limits: /usage:limit' }
176
+ { name: 'limit', description: 'Provider plan windows, balances and rate limits: /usage:limit' },
177
+ { name: 'accounts', description: 'Provider limits across all accounts: /usage:accounts' }
173
178
  ],
174
179
  serverMethods: usageServerMethods
175
180
  },
@@ -14,6 +14,8 @@ export interface BuiltinPluginCommand {
14
14
  name: string;
15
15
  description: string;
16
16
  legacyBare: boolean;
17
+ /** Reached only as the namespace root (`/annotate:`); it has no `/<prefix>:<name>` route and is not listed by name. */
18
+ rootOnly?: boolean;
17
19
  }
18
20
  export interface BuiltinWorldContext {
19
21
  options: WorldOptions;
@@ -36,7 +38,7 @@ export interface BuiltinPluginModule {
36
38
  descriptor: PluginDescriptor;
37
39
  /** Stable terminal command namespace; defaults to the plugin id. */
38
40
  commandPrefix?: string;
39
- /** Command invoked by the namespace root, such as `/annotate:`. */
41
+ /** Command invoked by the namespace root, such as `/annotate:`. Mark it `rootOnly` to give it no named route. */
40
42
  defaultCommand?: string;
41
43
  /** Stable order for model tools, independent of descriptor display order. */
42
44
  toolOrder?: number;
@@ -27,6 +27,10 @@ export function createBuiltinPluginRegistry(modules) {
27
27
  if (module.defaultCommand && !module.commands?.some(command => command.name === module.defaultCommand)) {
28
28
  throw new Error(`Unknown default command for built-in plugin ${id}: ${module.defaultCommand}`);
29
29
  }
30
+ for (const command of module.commands ?? []) {
31
+ if (command.rootOnly && command.name !== module.defaultCommand)
32
+ throw new Error(`Root-only command ${prefix}:${command.name} must be the default command of built-in plugin ${id}.`);
33
+ }
30
34
  }
31
35
  const commands = modules.flatMap(module => (module.commands ?? []).map(command => ({
32
36
  pluginId: module.descriptor.id,
@@ -34,12 +38,14 @@ export function createBuiltinPluginRegistry(modules) {
34
38
  ...command,
35
39
  legacyBare: !!module.descriptor.required
36
40
  })));
37
- const byRoute = new Map(commands.map(command => [`${command.prefix}:${command.name}`, command]));
41
+ const allRoutes = new Map(commands.map(command => [`${command.prefix}:${command.name}`, command]));
42
+ const routed = commands.filter(command => !command.rootOnly);
43
+ const byRoute = new Map(routed.map(command => [`${command.prefix}:${command.name}`, command]));
38
44
  const byName = new Map();
39
- for (const command of commands)
45
+ for (const command of routed)
40
46
  byName.set(command.name, [...(byName.get(command.name) ?? []), command]);
41
47
  const defaults = new Map(modules.flatMap(module => module.defaultCommand
42
- ? [[module.commandPrefix ?? module.descriptor.id, byRoute.get(`${module.commandPrefix ?? module.descriptor.id}:${module.defaultCommand}`)]]
48
+ ? [[module.commandPrefix ?? module.descriptor.id, allRoutes.get(`${module.commandPrefix ?? module.descriptor.id}:${module.defaultCommand}`)]]
43
49
  : []));
44
50
  const toolModules = modules.map((module, index) => ({ module, index })).filter(({ module }) => module.tools)
45
51
  .sort((left, right) => (left.module.toolOrder ?? left.index) - (right.module.toolOrder ?? right.index));