@xingular/harness 1.3.1-unstable.0 → 1.3.1-unstable.2

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 (116) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +16 -12
  3. package/builtin/skills/plugin-authoring/SKILL.md +34 -0
  4. package/dist/cli.js +2334 -1715
  5. package/dist/sdk/agents/profiles.d.ts +13 -4
  6. package/dist/sdk/agents/profiles.js +59 -7
  7. package/dist/sdk/annotations/bridge.d.ts +2 -0
  8. package/dist/sdk/annotations/bridge.js +4 -0
  9. package/dist/sdk/annotations/capture.d.ts +44 -2
  10. package/dist/sdk/annotations/capture.js +104 -3
  11. package/dist/sdk/annotations/delivery.d.ts +24 -17
  12. package/dist/sdk/annotations/delivery.js +82 -49
  13. package/dist/sdk/annotations/inspect.js +7 -6
  14. package/dist/sdk/annotations/pageScript.js +4 -4
  15. package/dist/sdk/annotations/part.d.ts +0 -2
  16. package/dist/sdk/annotations/part.js +4 -33
  17. package/dist/sdk/annotations/queue.d.ts +2 -2
  18. package/dist/sdk/annotations/queue.js +12 -7
  19. package/dist/sdk/annotations/serverMethods.js +11 -7
  20. package/dist/sdk/annotations/store.d.ts +13 -21
  21. package/dist/sdk/annotations/store.js +47 -37
  22. package/dist/sdk/commands/list.js +2 -0
  23. package/dist/sdk/core/links.d.ts +51 -0
  24. package/dist/sdk/core/links.js +155 -0
  25. package/dist/sdk/harness.d.ts +5 -0
  26. package/dist/sdk/harness.js +11 -6
  27. package/dist/sdk/index.d.ts +1 -0
  28. package/dist/sdk/index.js +1 -0
  29. package/dist/sdk/plugins/builtin.js +11 -6
  30. package/dist/sdk/plugins/builtinRegistry.d.ts +3 -1
  31. package/dist/sdk/plugins/builtinRegistry.js +9 -3
  32. package/dist/sdk/plugins/discover.d.ts +7 -1
  33. package/dist/sdk/plugins/discover.js +41 -2
  34. package/dist/sdk/plugins/identity.js +1 -1
  35. package/dist/sdk/plugins/inventory.js +1 -1
  36. package/dist/sdk/plugins/manifest.d.ts +2 -0
  37. package/dist/sdk/plugins/manifest.js +21 -4
  38. package/dist/sdk/plugins/plugins.d.ts +2 -0
  39. package/dist/sdk/plugins/plugins.js +1 -0
  40. package/dist/sdk/plugins/proposals.d.ts +17 -0
  41. package/dist/sdk/plugins/proposals.js +147 -0
  42. package/dist/sdk/plugins/types.d.ts +1 -1
  43. package/dist/sdk/plugins/types.js +1 -1
  44. package/dist/sdk/prompt/compile.d.ts +11 -2
  45. package/dist/sdk/prompt/compile.js +32 -4
  46. package/dist/sdk/prompt/standard.d.ts +1 -1
  47. package/dist/sdk/prompt/standard.js +1 -1
  48. package/dist/sdk/server/methods.js +10 -0
  49. package/dist/sdk/sessions/sessionState.d.ts +13 -0
  50. package/dist/sdk/sessions/sessionState.js +31 -0
  51. package/dist/sdk/sessions/sessions.d.ts +7 -0
  52. package/dist/sdk/sessions/sessions.js +45 -5
  53. package/dist/sdk/sessions/subagentService.js +3 -3
  54. package/dist/sdk/terminal/opentui/theme.d.ts +2 -0
  55. package/dist/sdk/terminal/opentui/theme.js +1 -0
  56. package/dist/sdk/terminal/piExtension.js +71 -2
  57. package/dist/sdk/terminal/terminals.d.ts +9 -0
  58. package/dist/sdk/terminal/terminals.js +46 -14
  59. package/dist/sdk/terminal/tui/annotateCommand.d.ts +5 -1
  60. package/dist/sdk/terminal/tui/annotateCommand.js +21 -7
  61. package/dist/sdk/terminal/tui/annotateSend.d.ts +30 -0
  62. package/dist/sdk/terminal/tui/annotateSend.js +40 -0
  63. package/dist/sdk/terminal/tui/commandPages.d.ts +1 -1
  64. package/dist/sdk/terminal/tui/commandPages.js +2 -7
  65. package/dist/sdk/terminal/tui/commands.js +4 -2
  66. package/dist/sdk/terminal/tui/dialogs.js +1 -0
  67. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  68. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  69. package/dist/sdk/terminal/tui/pageOverlay.d.ts +3 -0
  70. package/dist/sdk/terminal/tui/pageOverlay.js +12 -1
  71. package/dist/sdk/terminal/tui/pages/agentsPage.js +5 -2
  72. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +70 -16
  73. package/dist/sdk/terminal/tui/pages/annotatePage.js +349 -115
  74. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +6 -0
  75. package/dist/sdk/terminal/tui/pages/pluginsPage.js +99 -1
  76. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +4 -2
  77. package/dist/sdk/terminal/tui/pages/usagePage.js +39 -12
  78. package/dist/sdk/terminal/tui/pluginCommands.d.ts +1 -1
  79. package/dist/sdk/terminal/tui/pluginCommands.js +13 -3
  80. package/dist/sdk/tools/defaultSkills.d.ts +4 -0
  81. package/dist/sdk/tools/defaultSkills.js +20 -0
  82. package/dist/sdk/tools/definitions.d.ts +3 -1
  83. package/dist/sdk/tools/definitions.js +17 -1
  84. package/dist/sdk/tools/dispatch.js +12 -0
  85. package/dist/sdk/tools/harnessTools.js +1 -1
  86. package/dist/sdk/tools/sessionName.d.ts +7 -0
  87. package/dist/sdk/tools/sessionName.js +53 -8
  88. package/dist/sdk/world/world.d.ts +0 -2
  89. package/dist/terminal-opentui.js +2019 -1269
  90. package/dist/terminal-pi.js +106 -7
  91. package/docs/README.md +3 -0
  92. package/docs/architecture/02-sessions-and-runs.md +11 -5
  93. package/docs/architecture/03-variants.md +1 -3
  94. package/docs/architecture/04-prompt.md +16 -7
  95. package/docs/architecture/06-plugins-and-capabilities.md +36 -4
  96. package/docs/architecture/07-tools-and-gate.md +20 -5
  97. package/docs/architecture/10-server-protocol.md +52 -7
  98. package/docs/architecture/11-terminal.md +50 -19
  99. package/docs/architecture/12-storage.md +1 -0
  100. package/docs/architecture/13-world-tools.md +8 -6
  101. package/docs/architecture/README.md +3 -1
  102. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +6 -2
  103. package/docs/architecture/proposals/PROP-0048-simple-annotate.md +85 -0
  104. package/docs/architecture/proposals/README.md +1 -0
  105. package/docs/decisions/ADR-0044-annotations-pending-or-sent.md +24 -0
  106. package/docs/decisions/ADR-0045-default-plugin-authoring-skill.md +9 -0
  107. package/docs/decisions/ADR-0046-hash-bound-plugin-proposals.md +20 -0
  108. package/docs/decisions/ADR-0047-links-open-with-the-os-handler.md +47 -0
  109. package/docs/parity.md +7 -4
  110. package/docs/releases/1.3.1-unstable.1.md +8 -0
  111. package/docs/releases/1.3.1-unstable.2.md +11 -0
  112. package/docs/releases/README.md +7 -4
  113. package/docs/terminal-tui.md +62 -23
  114. package/package.json +1 -1
  115. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +0 -75
  116. package/dist/sdk/terminal/tui/pages/notesPage.js +0 -201
@@ -160,6 +160,23 @@ function validNote(note, id) {
160
160
  if (!note || note.id !== id || note.author !== 'user' || !Number.isSafeInteger(note.version) || note.version < 1 || !Array.isArray(note.grants) || !Array.isArray(note.history)) {
161
161
  throw new HarnessError('invalid_data', `Note ${id} is not a valid note.`);
162
162
  }
163
+ return upgradeDelivery(note);
164
+ }
165
+ /** Notes written before PROP-0048 had a queue and a "fixed" claim: read them as pending or sent. */
166
+ function upgradeDelivery(note) {
167
+ const delivery = note.delivery;
168
+ if (!delivery)
169
+ return note;
170
+ if (delivery.state === 'queued')
171
+ delivery.state = 'pending';
172
+ else if (delivery.state === 'fixed') {
173
+ delivery.state = 'sent';
174
+ delivery.sentAt ??= delivery.fixedAt;
175
+ }
176
+ delete delivery.fixedAt;
177
+ delete delivery.summary;
178
+ if (delivery.state !== 'pending' && delivery.state !== 'sent')
179
+ note.delivery = null;
163
180
  return note;
164
181
  }
165
182
  export function read(root, id) {
@@ -240,7 +257,7 @@ export function update(root, id, expectedVersion, patch) {
240
257
  if (patch.status !== 'open' && patch.status !== 'resolved')
241
258
  throw invalid('A note is open or resolved.');
242
259
  note.status = patch.status;
243
- if (patch.status === 'resolved' && note.delivery?.state === 'queued')
260
+ if (patch.status === 'resolved' && note.delivery?.state === 'pending')
244
261
  note.delivery = null;
245
262
  }
246
263
  if (patch.target !== undefined)
@@ -263,6 +280,9 @@ export function unshare(root, id, principal) {
263
280
  const who = validatePrincipal(principal);
264
281
  const note = current(root, id, undefined);
265
282
  note.grants = note.grants.filter((grant) => !samePrincipal(grant.principal, who));
283
+ // A session that may no longer read the note is not sent it either.
284
+ if (note.delivery?.state === 'pending' && who.sessionId && note.delivery.sessionId === who.sessionId)
285
+ note.delivery = null;
266
286
  return save(root, note, 'unshare');
267
287
  }
268
288
  /** The host withdrew what a binding stood for (a tab closed or was shared again): every grant made with it ends. */
@@ -320,45 +340,48 @@ export function importNotes(root, notes) {
320
340
  }
321
341
  return { imported, skipped };
322
342
  }
323
- // ---- Delivery to an agent (PROP-0018) ----------------------------------------------------------
343
+ // ---- Delivery to a session (PROP-0048) ----------------------------------------------------------
324
344
  /**
325
- * A person asks a session to act on the note: it is shared with that session (if it was not) and
326
- * waits in the session's queue until it is sent.
345
+ * The note is shared with the session and waits there, unsent, until the person sends it. Calling it
346
+ * again keeps the original time, so the send order stays the order the notes were made.
327
347
  */
328
- export function queue(root, id, expectedVersion, sessionId, options = {}) {
348
+ export function markPending(root, id, expectedVersion, sessionId, options = {}) {
329
349
  const session = safeId(sessionId);
330
350
  const note = current(root, id, expectedVersion);
331
351
  if (note.status === 'resolved')
332
- throw invalid('Reopen this note before queueing it.');
352
+ throw invalid('Reopen this note before sending it.');
333
353
  if (!note.grants.some((grant) => grant.principal.sessionId === session))
334
354
  note.grants.push({ principal: { sessionId: session }, grantedAt: now() });
335
- const confirmationRequired = options.confirm === true || (note.delivery?.confirm === true && options.confirmed !== true);
355
+ const previous = note.delivery?.state === 'pending' && note.delivery.sessionId === session ? note.delivery : null;
356
+ const confirmationRequired = options.confirm === true || (previous?.confirm === true && options.confirmed !== true);
336
357
  note.delivery = {
337
- state: 'queued',
358
+ state: 'pending',
338
359
  sessionId: session,
339
- queuedAt: now(),
340
- ...(options.autoSend ? { autoSend: true } : {}),
360
+ queuedAt: previous?.queuedAt ?? now(),
361
+ ...(options.autoSend || previous?.autoSend ? { autoSend: true } : {}),
341
362
  ...(confirmationRequired ? { confirm: true } : {})
342
363
  };
343
- return save(root, note, 'queue');
344
- }
345
- /** Takes the note out of the queue. It stays shared; unshare withdraws that. */
346
- export function dequeue(root, id, expectedVersion) {
347
- const note = current(root, id, expectedVersion);
348
- if (note.delivery?.state !== 'queued')
349
- throw invalid('This note is not waiting to be sent.');
350
- note.delivery = null;
351
- return save(root, note, 'dequeue');
364
+ return save(root, note, 'pending');
352
365
  }
353
- /** The notes waiting for one session, oldest first. `auto` keeps only those to send when it is idle. */
354
- export function queued(root, sessionId, filter = {}) {
366
+ /** The unsent notes of one session, oldest first. `auto` keeps only those to send when it is idle. */
367
+ export function pending(root, sessionId, filter = {}) {
355
368
  const ids = filter.ids ? new Set(filter.ids) : null;
356
369
  return all(root)
357
- .filter((note) => note.status === 'open' && note.delivery?.state === 'queued' && note.delivery.sessionId === sessionId)
370
+ .filter((note) => note.status === 'open' && note.delivery?.state === 'pending' && note.delivery.sessionId === sessionId)
358
371
  .filter((note) => !filter.auto || (note.delivery?.autoSend === true && note.delivery.confirm !== true))
359
372
  .filter((note) => !ids || ids.has(note.id))
360
373
  .sort((left, right) => (left.delivery.queuedAt).localeCompare(right.delivery.queuedAt) || left.id.localeCompare(right.id));
361
374
  }
375
+ /** `/annotate:clear`: the session's unsent notes are resolved and no longer shared with it. They can be reopened. */
376
+ export function clearPending(root, sessionId) {
377
+ const session = safeId(sessionId);
378
+ return pending(root, session).map((note) => {
379
+ note.status = 'resolved';
380
+ note.delivery = null;
381
+ note.grants = note.grants.filter((grant) => grant.principal.sessionId !== session);
382
+ return save(root, note, 'clear');
383
+ });
384
+ }
362
385
  /** The notes went to the session as part of `runId`. */
363
386
  export function markSent(root, ids, sessionId, runId) {
364
387
  const session = safeId(sessionId);
@@ -367,8 +390,8 @@ export function markSent(root, ids, sessionId, runId) {
367
390
  for (const note of notes) {
368
391
  if (note.status !== 'open')
369
392
  throw invalid('Resolved notes cannot be sent.');
370
- if (note.delivery?.state !== 'queued' || note.delivery.sessionId !== session)
371
- throw invalid('This note is not queued for this session.');
393
+ if (note.delivery?.state !== 'pending' || note.delivery.sessionId !== session)
394
+ throw invalid('This note is not waiting to be sent to this session.');
372
395
  if (note.delivery.confirm)
373
396
  throw invalid('Confirm this note before sending it.');
374
397
  }
@@ -379,16 +402,3 @@ export function markSent(root, ids, sessionId, runId) {
379
402
  return save(root, note, 'deliver');
380
403
  });
381
404
  }
382
- /** The agent says it acted on a note shared with it. The note stays open until a person resolves it. */
383
- export function markFixed(root, id, caller, summary) {
384
- const note = current(root, id, undefined);
385
- if (!visibleTo(note, caller))
386
- throw notFound('Note');
387
- const sessionId = note.delivery?.sessionId ?? caller.sessionId ?? note.grants.find((grant) => grant.principal.sessionId)?.principal.sessionId;
388
- if (!sessionId)
389
- throw invalid('This note was never sent to a session.');
390
- note.delivery = { ...(note.delivery ?? { queuedAt: now() }), state: 'fixed', sessionId, fixedAt: now(), summary: bounded(summary.trim(), 2000, true, 'summary') };
391
- delete note.delivery.autoSend;
392
- delete note.delivery.confirm;
393
- return save(root, note, 'mark_fixed');
394
- }
@@ -22,6 +22,8 @@ export function listCommands(plugins) {
22
22
  aliases.forEach((alias) => taken.add(alias));
23
23
  }
24
24
  for (const command of BUILTIN_PLUGIN_REGISTRY.commands()) {
25
+ if (command.rootOnly)
26
+ continue;
25
27
  const name = `${command.prefix}:${command.name}`;
26
28
  out.set(name, { name, description: command.description, group: 'Plugins', source: 'plugin', owner: command.pluginId });
27
29
  }
@@ -0,0 +1,51 @@
1
+ /** What a link names, before anything touches the filesystem. */
2
+ export type LinkTarget = {
3
+ kind: 'web';
4
+ url: string;
5
+ } | {
6
+ kind: 'mail';
7
+ address: string;
8
+ } | {
9
+ kind: 'path';
10
+ path: string;
11
+ };
12
+ /** What the operating system is asked to open, after the file it names was checked. */
13
+ export type OpenedTarget = {
14
+ kind: 'web';
15
+ url: string;
16
+ } | {
17
+ kind: 'mail';
18
+ address: string;
19
+ } | {
20
+ kind: 'file';
21
+ path: string;
22
+ } | {
23
+ kind: 'directory';
24
+ path: string;
25
+ };
26
+ /** Hands an opened target to the operating system. Replaced in tests, and by a host that has its own opener. */
27
+ export type LinkOpener = (target: OpenedTarget) => Promise<void>;
28
+ export interface LinkOpenCommand {
29
+ command: string;
30
+ args: string[];
31
+ }
32
+ /**
33
+ * What a link names, or null when it names nothing a surface may open. Pure: a path is not
34
+ * checked against the filesystem here, so hover and rendering can classify without I/O.
35
+ */
36
+ export declare function linkTarget(raw: string | null | undefined, options?: {
37
+ base?: string | null;
38
+ }): LinkTarget | null;
39
+ /** The target with its path checked, or the reason the operating system must not be asked. */
40
+ export declare function openableTarget(target: LinkTarget): OpenedTarget;
41
+ /** The address the operating system is given, and the argv that carries it without a shell. */
42
+ export declare function linkOpenCommand(target: OpenedTarget, platform?: NodeJS.Platform): LinkOpenCommand;
43
+ /** The default opener: the operating system's own handler for this kind of target. */
44
+ export declare function openWithSystem(target: OpenedTarget): Promise<void>;
45
+ /** Checks the target and opens it. Returns what was opened, for a caller that reports it. */
46
+ export declare function openLink(target: LinkTarget, opener?: LinkOpener): Promise<OpenedTarget>;
47
+ /** Classifies and opens in one step: the door a host calls for a link a person clicked. */
48
+ export declare function openLinkTarget(raw: string, options?: {
49
+ base?: string | null;
50
+ opener?: LinkOpener;
51
+ }): Promise<OpenedTarget>;
@@ -0,0 +1,155 @@
1
+ // What a link points at, and the one door that hands it to the operating system. Both front ends
2
+ // classify with `linkTarget` and open through `openLink`, so a destination one surface accepts is
3
+ // the destination the other accepts.
4
+ import { spawn } from 'node:child_process';
5
+ import { lstatSync, realpathSync } from 'node:fs';
6
+ import { isAbsolute, resolve } from 'node:path';
7
+ import { HarnessError } from "./errors.js";
8
+ const MAX_LENGTH = 4096;
9
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/u;
10
+ const WEB_ADDRESS = /^https?:/iu;
11
+ const MAIL_ADDRESS = /^mailto:/iu;
12
+ const FILE_ADDRESS = /^file:/iu;
13
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
14
+ const LINE_SUFFIX = /:\d+(?::\d+)?$/u;
15
+ /** A folder the system would launch or install rather than show. */
16
+ const BUNDLE = /\.(?:app|appex|bundle|framework|kext|plugin|prefpane|workflow|xpc)$/iu;
17
+ /** A document whose default application runs it, so a click would execute rather than open. */
18
+ const LAUNCHABLE = /\.(?:action|appimage|bat|cmd|command|desktop|dmg|exe|jar|lnk|mpkg|osax|pkg|ps1|scpt|sh|terminal|tool|url|vb|vbs|webloc|workflow)$/iu;
19
+ const unopenable = (message) => new HarnessError('link_not_openable', message, 'Open the file from the project instead.');
20
+ /**
21
+ * What a link names, or null when it names nothing a surface may open. Pure: a path is not
22
+ * checked against the filesystem here, so hover and rendering can classify without I/O.
23
+ */
24
+ export function linkTarget(raw, options = {}) {
25
+ const value = raw?.trim();
26
+ if (!value || value.length > MAX_LENGTH || CONTROL_CHARACTERS.test(value) || value.startsWith('#'))
27
+ return null;
28
+ if (WEB_ADDRESS.test(value)) {
29
+ try {
30
+ const url = new URL(value);
31
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password)
32
+ return null;
33
+ return { kind: 'web', url: url.href };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (MAIL_ADDRESS.test(value)) {
40
+ const address = value.slice('mailto:'.length).split('?')[0].trim();
41
+ return address && address.includes('@') && !/\s/u.test(address) ? { kind: 'mail', address } : null;
42
+ }
43
+ const path = filePath(value, options.base);
44
+ return path ? { kind: 'path', path } : null;
45
+ }
46
+ function filePath(value, base) {
47
+ let raw = value;
48
+ if (FILE_ADDRESS.test(raw)) {
49
+ try {
50
+ const url = new URL(raw);
51
+ if (url.hostname && url.hostname !== 'localhost')
52
+ return null;
53
+ raw = url.pathname;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ const address = raw.split('#')[0].split('?')[0].replace(LINE_SUFFIX, '');
60
+ let decoded;
61
+ try {
62
+ decoded = decodeURIComponent(address);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ if (!decoded || decoded.includes('\0') || decoded.startsWith('~') || decoded.startsWith('//'))
68
+ return null;
69
+ if (isAbsolute(decoded))
70
+ return decoded;
71
+ if (SCHEME.test(decoded))
72
+ return null;
73
+ if (!base || !isAbsolute(base))
74
+ return null;
75
+ return resolve(base, decoded);
76
+ }
77
+ /** The target with its path checked, or the reason the operating system must not be asked. */
78
+ export function openableTarget(target) {
79
+ if (target.kind !== 'path')
80
+ return target;
81
+ let stats = statsOf(target.path);
82
+ let path = target.path;
83
+ if (stats.isSymbolicLink()) {
84
+ try {
85
+ path = realpathSync(target.path);
86
+ }
87
+ catch {
88
+ throw unopenable('That link points at a file that is not there.');
89
+ }
90
+ stats = statsOf(path);
91
+ }
92
+ if (stats.isDirectory()) {
93
+ if (BUNDLE.test(path))
94
+ throw unopenable('That link points at an application, not a document.');
95
+ return { kind: 'directory', path };
96
+ }
97
+ if (!stats.isFile())
98
+ throw unopenable('That link does not point at a file or a folder.');
99
+ if (LAUNCHABLE.test(path))
100
+ throw unopenable('That kind of file runs when it is opened, so a link cannot open it.');
101
+ if ((stats.mode & 0o111) !== 0)
102
+ throw unopenable('That file is executable, so a link cannot open it.');
103
+ return { kind: 'file', path };
104
+ }
105
+ function statsOf(path) {
106
+ try {
107
+ return lstatSync(path);
108
+ }
109
+ catch {
110
+ throw unopenable('That link points at a file that is not there.');
111
+ }
112
+ }
113
+ /** The address the operating system is given, and the argv that carries it without a shell. */
114
+ export function linkOpenCommand(target, platform = process.platform) {
115
+ const address = target.kind === 'web' ? target.url : target.kind === 'mail' ? `mailto:${target.address}` : target.path;
116
+ if (platform === 'darwin')
117
+ return { command: 'open', args: [address] };
118
+ if (platform === 'win32')
119
+ return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', address] };
120
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd')
121
+ return { command: 'xdg-open', args: [address] };
122
+ throw unopenable(`Opening links is not supported on ${platform}.`);
123
+ }
124
+ /** The default opener: the operating system's own handler for this kind of target. */
125
+ export async function openWithSystem(target) {
126
+ const { command, args } = linkOpenCommand(target);
127
+ await new Promise((done, fail) => {
128
+ let child;
129
+ try {
130
+ child = spawn(command, args, { detached: process.platform !== 'win32', stdio: 'ignore', windowsHide: true });
131
+ }
132
+ catch (error) {
133
+ fail(error);
134
+ return;
135
+ }
136
+ child.once('error', fail);
137
+ child.once('spawn', () => {
138
+ child.unref();
139
+ done();
140
+ });
141
+ });
142
+ }
143
+ /** Checks the target and opens it. Returns what was opened, for a caller that reports it. */
144
+ export async function openLink(target, opener = openWithSystem) {
145
+ const opened = openableTarget(target);
146
+ await opener(opened);
147
+ return opened;
148
+ }
149
+ /** Classifies and opens in one step: the door a host calls for a link a person clicked. */
150
+ export async function openLinkTarget(raw, options = {}) {
151
+ const target = linkTarget(raw, { base: options.base });
152
+ if (!target)
153
+ throw unopenable('This link cannot be opened here.');
154
+ return openLink(target, options.opener);
155
+ }
@@ -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.
@@ -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";
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";
@@ -7,7 +7,7 @@ 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";
@@ -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 = {
@@ -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));
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * `context` is an instruction file Pi reads on its own: `AGENTS.md`, `SYSTEM.md` and their kin.
3
3
  * `hook` is a package's hooks file, which the harness runs itself; Pi never loads it.
4
+ * `agent` is a subagent profile a package brings; the harness reads it, Pi never does.
4
5
  */
5
- export type FoundKind = 'extension' | 'skill' | 'prompt' | 'theme' | 'context' | 'hook';
6
+ export type FoundKind = 'extension' | 'skill' | 'prompt' | 'theme' | 'context' | 'hook' | 'agent';
6
7
  /** Where a found item comes from. `builtin` is one of the Pi command's own extensions. */
7
8
  export type FoundOrigin = 'shared' | 'package' | 'project' | 'home' | 'builtin';
8
9
  export interface Found {
@@ -33,6 +34,11 @@ export type Resolver = (input: ResolveInput) => Promise<Found[]>;
33
34
  /** The extensions built into the `pi` command. A terminal loads them; an SDK session has none. */
34
35
  export declare const PI_BUILTINS: readonly ["llama.cpp", "codemode", "tool-search", "mcp"];
35
36
  export declare const BUILTIN_PREFIX = "builtin:";
37
+ /**
38
+ * The subagent profiles in a package's agents folder: Markdown files whose frontmatter has a
39
+ * `name` and a `description`. Anything else there (notes, a README) is not a profile.
40
+ */
41
+ export declare function packageProfiles(root: string, folder: string): string[];
36
42
  /**
37
43
  * Runs an install with the package manager's install scripts switched off, so fetching a
38
44
  * package runs none of its code. A package that builds itself on install then fails to load,