@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
@@ -1,6 +1,6 @@
1
1
  import { PLAN_RESEARCH_PLUGINS } from "./planResearch.js";
2
2
  const RECALL = ['history_search', 'history_read', 'trajectory_search', 'trajectory_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load'];
3
- const PROGRESS = ['checklist_write', 'session_name'];
3
+ const PROGRESS = ['todo_write', 'session_name'];
4
4
  const WEB = ['web_search', 'web_fetch'];
5
5
  export const BUILTIN_PROFILES = [
6
6
  {
@@ -10,7 +10,7 @@ export const BUILTIN_PROFILES = [
10
10
  instructions: [
11
11
  'You carry out one piece of work from your brief, end to end.',
12
12
  '- Read the code you change first and follow the conventions you find.',
13
- '- Keep a checklist with checklist_write when the brief has more than one step, and name your session with session_name.',
13
+ '- Keep a todo list with todo_write when the brief has more than one step, and name your session with session_name.',
14
14
  '- Make the change, then check it: run the relevant tests, type checks or commands.',
15
15
  '- Stay inside the brief. If something outside it blocks you, stop and say so instead of widening the change.',
16
16
  '- Finish with a short report: what you changed (files), how you checked it, and anything left open.'
@@ -25,7 +25,7 @@ export const BUILTIN_PROFILES = [
25
25
  instructions: [
26
26
  'You answer a question about the code, files or history in your brief. You never change anything.',
27
27
  '- Search broadly first, then read the parts that matter.',
28
- '- Keep a checklist with checklist_write when the search has several parts, and name your session with session_name.',
28
+ '- Keep a todo list with todo_write when the search has several parts, and name your session with session_name.',
29
29
  '- Cite what you found as path:line.',
30
30
  '- Return conclusions, not file dumps: what you found, where, and how sure you are.'
31
31
  ].join('\n'),
@@ -1,5 +1,27 @@
1
+ import type { CapabilitySet } from "../capabilities/types.js";
2
+ import type { PluginDescriptor } from "../plugins/types.js";
1
3
  import type { AgentProfile } from "./profiles.js";
2
4
  export declare const PLAN_RESEARCH_TOOLS: ReadonlySet<string>;
3
5
  export declare const PLAN_RESEARCH_PLUGINS: readonly string[];
4
6
  export declare const PLAN_RESEARCH_EVIDENCE_TOOLS: ReadonlySet<string>;
5
- export declare function planResearchProfile(profile: Pick<AgentProfile, 'tools' | 'plugins'> | null | undefined): boolean;
7
+ /** Capability snapshot needed to tell whether a profile can research in this particular run. */
8
+ export interface PlanResearchContext {
9
+ frozenTools: readonly string[];
10
+ contributions: readonly string[];
11
+ descriptors: readonly PluginDescriptor[];
12
+ }
13
+ export interface PlanResearchEligibility {
14
+ eligible: boolean;
15
+ refusal: string | null;
16
+ /** The exact safe child snapshot to launch when eligible. */
17
+ capabilities: CapabilitySet;
18
+ unavailableTools: string[];
19
+ }
20
+ /** Whether the profile's explicit allowlists are restricted to read-only research. */
21
+ export declare function planResearchProfile(profile: Pick<AgentProfile, 'name' | 'tools' | 'plugins'> | null | undefined): boolean;
22
+ /**
23
+ * Evaluate the same effective intersection used for launch, including tool ownership removed by
24
+ * profile plugin narrowing. An explicit request is a strict override; omission inherits the safe
25
+ * profile/parent intersection.
26
+ */
27
+ export declare function planResearchEligibility(profile: Pick<AgentProfile, 'name' | 'tools' | 'plugins'> | null | undefined, context: PlanResearchContext, requested?: readonly string[]): PlanResearchEligibility;
@@ -1,5 +1,6 @@
1
+ import { narrowForProfile } from "./narrow.js";
1
2
  export const PLAN_RESEARCH_TOOLS = new Set([
2
- 'read', 'grep', 'find', 'ls', 'checklist_write', 'session_name',
3
+ 'read', 'grep', 'find', 'ls', 'todo_write', 'session_name',
3
4
  'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
4
5
  'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load',
5
6
  'web_search', 'web_fetch'
@@ -8,12 +9,74 @@ export const PLAN_RESEARCH_PLUGINS = ['history', 'variant', 'web', 'wiki'];
8
9
  export const PLAN_RESEARCH_EVIDENCE_TOOLS = new Set([
9
10
  'read', 'grep', 'find', 'ls', 'history_search', 'trajectory_search', 'wiki_search', 'web_search', 'web_fetch'
10
11
  ]);
12
+ function profileRefusal(profile) {
13
+ if (!profile)
14
+ return 'Plan-mode delegation requires a named, eligible read-only research profile.';
15
+ if (!Array.isArray(profile.tools) || profile.tools.length === 0) {
16
+ return `Profile ${profile.name} must declare an explicit tools allowlist for plan-mode research.`;
17
+ }
18
+ const unsafeTools = profile.tools.filter(name => !PLAN_RESEARCH_TOOLS.has(name));
19
+ if (unsafeTools.length) {
20
+ return `Profile ${profile.name} includes non-research tools: ${[...new Set(unsafeTools)].join(', ')}. Use a profile whose tools are all plan research tools.`;
21
+ }
22
+ if (!profile.tools.some(name => PLAN_RESEARCH_EVIDENCE_TOOLS.has(name))) {
23
+ return `Profile ${profile.name} must allow at least one read-only research evidence tool.`;
24
+ }
25
+ if (!Array.isArray(profile.plugins)) {
26
+ return `Profile ${profile.name} must declare an explicit plugin allowlist for plan-mode research.`;
27
+ }
28
+ const unsafePlugins = profile.plugins.filter(name => !PLAN_RESEARCH_PLUGINS.includes(name));
29
+ if (unsafePlugins.length) {
30
+ return `Profile ${profile.name} includes non-research plugins: ${[...new Set(unsafePlugins)].join(', ')}. Use only the plan research plugins: ${PLAN_RESEARCH_PLUGINS.join(', ')}.`;
31
+ }
32
+ return null;
33
+ }
34
+ /** Whether the profile's explicit allowlists are restricted to read-only research. */
11
35
  export function planResearchProfile(profile) {
12
- return Boolean(profile
13
- && Array.isArray(profile.tools)
14
- && profile.tools.length > 0
15
- && profile.tools.some(name => PLAN_RESEARCH_EVIDENCE_TOOLS.has(name))
16
- && profile.tools.every(name => PLAN_RESEARCH_TOOLS.has(name))
17
- && Array.isArray(profile.plugins)
18
- && profile.plugins.every(name => PLAN_RESEARCH_PLUGINS.includes(name)));
36
+ return profileRefusal(profile) === null;
37
+ }
38
+ /**
39
+ * Evaluate the same effective intersection used for launch, including tool ownership removed by
40
+ * profile plugin narrowing. An explicit request is a strict override; omission inherits the safe
41
+ * profile/parent intersection.
42
+ */
43
+ export function planResearchEligibility(profile, context, requested) {
44
+ const empty = { version: 1, tools: [], connectors: [], contributions: [] };
45
+ const profileProblem = profileRefusal(profile);
46
+ if (profileProblem)
47
+ return { eligible: false, refusal: profileProblem, capabilities: empty, unavailableTools: [] };
48
+ const parent = {
49
+ version: 1,
50
+ tools: [...context.frozenTools],
51
+ connectors: [],
52
+ contributions: [...context.contributions]
53
+ };
54
+ const narrowed = narrowForProfile(parent, profile, requested, context.descriptors);
55
+ const capabilities = {
56
+ ...narrowed.capabilities,
57
+ tools: narrowed.capabilities.tools.filter(name => PLAN_RESEARCH_TOOLS.has(name)),
58
+ connectors: []
59
+ };
60
+ if (requested) {
61
+ const effective = new Set(capabilities.tools);
62
+ const incompatible = [...new Set(requested.filter(name => !PLAN_RESEARCH_TOOLS.has(name)
63
+ || !profile.tools.includes(name)
64
+ || !context.frozenTools.includes(name)
65
+ || !effective.has(name)))];
66
+ if (incompatible.length) {
67
+ return {
68
+ eligible: false,
69
+ refusal: `Plan-mode tools override contains incompatible tool(s): ${incompatible.join(', ')}. Omit \`tools\` to inherit the selected profile's safe research intersection, or retry with only tools allowed by that profile, available in this parent run, and in the plan research set.`,
70
+ capabilities,
71
+ unavailableTools: narrowed.unavailable
72
+ };
73
+ }
74
+ }
75
+ if (!capabilities.tools.some(name => PLAN_RESEARCH_EVIDENCE_TOOLS.has(name))) {
76
+ const refusal = requested
77
+ ? `The tools override [${requested.join(', ')}] leaves no available research evidence tool for profile ${profile.name}. Omit \`tools\` to inherit the safe intersection, or retry with an available evidence tool from this profile.`
78
+ : `Profile ${profile.name} has no read-only research evidence tool after intersecting its allowlists with this run's frozen capabilities and plugin contributions. Choose a profile with an available evidence tool.`;
79
+ return { eligible: false, refusal, capabilities, unavailableTools: narrowed.unavailable };
80
+ }
81
+ return { eligible: true, refusal: null, capabilities, unavailableTools: narrowed.unavailable };
19
82
  }
@@ -1,8 +1,11 @@
1
1
  import { type Effort } from "../core/effort.js";
2
+ import { type PlanResearchContext } from "./planResearch.js";
2
3
  export declare const PROFILE_NAME: RegExp;
4
+ /** Names a profile cannot take: `main` is how rosters show the agent a person talks to. */
5
+ export declare const RESERVED_PROFILE_NAMES: readonly string[];
3
6
  export declare const PROFILE_COLORS: readonly ["blue", "green", "yellow", "red", "magenta", "cyan", "orange", "purple"];
4
7
  export type ProfileColor = (typeof PROFILE_COLORS)[number];
5
- export type ProfileSource = 'builtin' | 'global' | 'project' | 'variant';
8
+ export type ProfileSource = 'builtin' | 'global' | 'project' | 'variant' | 'plugin';
6
9
  /** Where a person's own profile file lives. */
7
10
  export type ProfileScope = 'global' | 'project';
8
11
  /** Which variant a profile's subagent runs: a scope's head or version, none, or the parent's (null). */
@@ -51,12 +54,21 @@ export declare function formatVariant(variant: ProfileVariant): string | null;
51
54
  export declare function parseProfile(text: string, name: string, source: ProfileSource, path: string | null): AgentProfile;
52
55
  /** The file text for a profile, as `createProfile` writes it. */
53
56
  export declare function profileText(profile: ProfileDefinition): string;
57
+ /**
58
+ * A profile a package brings. It is named by its frontmatter, since such packages are often written
59
+ * for other agent harnesses whose files are not named after their agents. What only those
60
+ * harnesses understand is dropped: their tool names are translated, a model without a provider
61
+ * (`inherit`, `sonnet`) is left to the parent, and a package never pins a variant or plugins. A
62
+ * tool list of which nothing translates keeps the subagent read-only rather than widening it.
63
+ */
64
+ export declare function parsePackageProfile(text: string, path: string): AgentProfile;
54
65
  /**
55
66
  * Every profile a session in `cwd` can use. An unreadable file is reported in `problems`; it hides
56
67
  * nothing, so a broken project file leaves the global or built-in profile of that name in force.
68
+ * `packageProfiles` are the profile files of the session's switched-on packages.
57
69
  */
58
- export declare function listProfiles(root: string, cwd?: string | null, variantDir?: string | null): ProfileListing;
59
- export declare function resolveProfile(root: string, cwd: string | null | undefined, name: string, variantDir?: string | null): AgentProfile | null;
70
+ export declare function listProfiles(root: string, cwd?: string | null, variantDir?: string | null, packageProfiles?: readonly string[]): ProfileListing;
71
+ export declare function resolveProfile(root: string, cwd: string | null | undefined, name: string, variantDir?: string | null, packageProfiles?: readonly string[]): AgentProfile | null;
60
72
  /** Writes a new profile file from the template, or a copy of `from`. Refuses to overwrite one. */
61
73
  export declare function createProfile(root: string, cwd: string | null | undefined, input: {
62
74
  name: string;
@@ -71,7 +83,7 @@ export declare function removeProfile(root: string, cwd: string | null | undefin
71
83
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
72
84
  export declare const PROFILE_SECTION_ORDER = 64;
73
85
  /** The prompt section that lists the profiles a run can start subagents from. */
74
- export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean): {
86
+ export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean, researchContext?: PlanResearchContext, packageProfiles?: readonly string[]): {
75
87
  id: string;
76
88
  title: string;
77
89
  body: string;
@@ -1,17 +1,21 @@
1
1
  // Agent profiles (PROP-0029): named bundles a subagent starts from, with its instructions, colour,
2
2
  // tools, plugins, variant, model and effort. They come from four places, later ones winning:
3
3
  // built-ins in code, `<data root>/agents/*.md`, `<project>/.xingular/agents/*.md`; a variant's own
4
- // c5 subagents fill in names nobody else took. Profiles only narrow what the parent has.
4
+ // c5 subagents, then the profiles of switched-on packages, fill in names nobody else took.
5
+ // Profiles only narrow what the parent has.
5
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
7
+ import { basename, dirname, join } from 'node:path';
7
8
  import { EFFORTS } from "../core/effort.js";
8
9
  import { invalid, notFound } from "../core/errors.js";
9
10
  import { parse, serialize } from "../core/frontmatter.js";
10
11
  import { atomicWrite } from "../core/fs.js";
11
12
  import { subagentOf } from "../variant/materialise.js";
12
13
  import { BUILTIN_PROFILES } from "./builtin.js";
13
- import { planResearchProfile } from "./planResearch.js";
14
+ import { planResearchEligibility, planResearchProfile } from "./planResearch.js";
14
15
  export const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
16
+ /** Names a profile cannot take: `main` is how rosters show the agent a person talks to. */
17
+ export const RESERVED_PROFILE_NAMES = ['main'];
18
+ const reservedRefusal = (name) => `${name} is reserved for the main agent; choose another profile name.`;
15
19
  export const PROFILE_COLORS = ['blue', 'green', 'yellow', 'red', 'magenta', 'cyan', 'orange', 'purple'];
16
20
  /** Nearest folder above `cwd` that holds `.git`, or `cwd` itself. */
17
21
  export function projectRoot(cwd) {
@@ -65,6 +69,8 @@ export function formatVariant(variant) {
65
69
  export function parseProfile(text, name, source, path) {
66
70
  if (!PROFILE_NAME.test(name))
67
71
  throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
72
+ if (RESERVED_PROFILE_NAMES.includes(name))
73
+ throw invalid(reservedRefusal(name));
68
74
  const parsed = parse(text);
69
75
  if (!parsed)
70
76
  throw invalid('The profile needs a frontmatter block between --- lines.');
@@ -124,7 +130,7 @@ function variantDefinitions(variantDir) {
124
130
  if (!folder || !existsSync(folder))
125
131
  return [];
126
132
  return readdirSync(folder).sort().flatMap(name => {
127
- const definition = subagentOf(variantDir, name);
133
+ const definition = RESERVED_PROFILE_NAMES.includes(name) ? null : subagentOf(variantDir, name);
128
134
  if (!definition)
129
135
  return [];
130
136
  return [{
@@ -134,11 +140,49 @@ function variantDefinitions(variantDir) {
134
140
  }];
135
141
  });
136
142
  }
143
+ // Tool names that agent plugins written for other harnesses use, as this harness names them.
144
+ const FOREIGN_TOOLS = {
145
+ Read: 'read', Grep: 'grep', Glob: 'find', LS: 'ls', Edit: 'edit', MultiEdit: 'edit', Write: 'write', Bash: 'bash',
146
+ WebSearch: 'web_search', WebFetch: 'web_fetch', TodoWrite: 'todo_write'
147
+ };
148
+ const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls'];
149
+ /**
150
+ * A profile a package brings. It is named by its frontmatter, since such packages are often written
151
+ * for other agent harnesses whose files are not named after their agents. What only those
152
+ * harnesses understand is dropped: their tool names are translated, a model without a provider
153
+ * (`inherit`, `sonnet`) is left to the parent, and a package never pins a variant or plugins. A
154
+ * tool list of which nothing translates keeps the subagent read-only rather than widening it.
155
+ */
156
+ export function parsePackageProfile(text, path) {
157
+ const parsed = parse(text);
158
+ if (!parsed)
159
+ throw invalid('The profile needs a frontmatter block between --- lines.');
160
+ const front = parsed.front;
161
+ const name = optionalText(front.name, 'name');
162
+ if (!name || !PROFILE_NAME.test(name))
163
+ throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
164
+ if (RESERVED_PROFILE_NAMES.includes(name))
165
+ throw invalid(reservedRefusal(name));
166
+ const description = optionalText(front.description, 'description');
167
+ if (!description)
168
+ throw invalid('The profile needs a description.');
169
+ const declared = stringList(front.tools, 'tools');
170
+ const tools = declared ? [...new Set(declared.flatMap((tool) => FOREIGN_TOOLS[tool] ?? (/^[a-z][a-z0-9_]*$/.test(tool) ? [tool] : [])))] : null;
171
+ const color = typeof front.color === 'string' && PROFILE_COLORS.includes(front.color) ? front.color : 'blue';
172
+ const model = typeof front.model === 'string' && front.model.includes('/') ? front.model : null;
173
+ const effort = typeof front.effort === 'string' && EFFORTS.includes(front.effort) ? front.effort : null;
174
+ return {
175
+ name, description, color, instructions: parsed.body.trim(),
176
+ tools: tools && !tools.length ? READ_ONLY_TOOLS : tools, plugins: null, variant: null, model, effort,
177
+ source: 'plugin', path
178
+ };
179
+ }
137
180
  /**
138
181
  * Every profile a session in `cwd` can use. An unreadable file is reported in `problems`; it hides
139
182
  * nothing, so a broken project file leaves the global or built-in profile of that name in force.
183
+ * `packageProfiles` are the profile files of the session's switched-on packages.
140
184
  */
141
- export function listProfiles(root, cwd, variantDir) {
185
+ export function listProfiles(root, cwd, variantDir, packageProfiles = []) {
142
186
  const profiles = new Map();
143
187
  const problems = [];
144
188
  for (const definition of BUILTIN_PROFILES)
@@ -149,12 +193,25 @@ export function listProfiles(root, cwd, variantDir) {
149
193
  for (const definition of variantDefinitions(variantDir))
150
194
  if (!profiles.has(definition.name))
151
195
  profiles.set(definition.name, definition);
196
+ // A package cannot replace a profile a person, the harness or the variant already has.
197
+ for (const path of packageProfiles) {
198
+ try {
199
+ const profile = parsePackageProfile(readFileSync(path, 'utf8'), path);
200
+ if (!profiles.has(profile.name))
201
+ profiles.set(profile.name, profile);
202
+ else
203
+ problems.push({ name: profile.name, source: 'plugin', path, error: `A ${profiles.get(profile.name).source} profile already has this name, so the package's is not used.` });
204
+ }
205
+ catch (error) {
206
+ problems.push({ name: basename(path, '.md'), source: 'plugin', path, error: error instanceof Error ? error.message : String(error) });
207
+ }
208
+ }
152
209
  return { profiles: [...profiles.values()].sort((left, right) => left.name.localeCompare(right.name)), problems };
153
210
  }
154
- export function resolveProfile(root, cwd, name, variantDir) {
211
+ export function resolveProfile(root, cwd, name, variantDir, packageProfiles = []) {
155
212
  if (!PROFILE_NAME.test(name))
156
213
  return null;
157
- return listProfiles(root, cwd, variantDir).profiles.find(profile => profile.name === name) ?? null;
214
+ return listProfiles(root, cwd, variantDir, packageProfiles).profiles.find(profile => profile.name === name) ?? null;
158
215
  }
159
216
  function scopeDir(root, cwd, scope) {
160
217
  const dirs = profileDirs(root, cwd);
@@ -175,6 +232,8 @@ export function createProfile(root, cwd, input) {
175
232
  const name = typeof input.name === 'string' ? input.name.trim() : '';
176
233
  if (!PROFILE_NAME.test(name))
177
234
  throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
235
+ if (RESERVED_PROFILE_NAMES.includes(name))
236
+ throw invalid(reservedRefusal(name));
178
237
  if (input.scope !== 'global' && input.scope !== 'project')
179
238
  throw invalid('scope must be global or project.');
180
239
  const dir = scopeDir(root, cwd, input.scope);
@@ -207,16 +266,20 @@ export function removeProfile(root, cwd, input) {
207
266
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
208
267
  export const PROFILE_SECTION_ORDER = 64;
209
268
  /** The prompt section that lists the profiles a run can start subagents from. */
210
- export function profileSection(root, cwd, variantDir, planResearchOnly = false) {
211
- const { profiles } = listProfiles(root, cwd, variantDir);
212
- const listed = planResearchOnly ? profiles.filter(planResearchProfile) : profiles;
269
+ export function profileSection(root, cwd, variantDir, planResearchOnly = false, researchContext, packageProfiles = []) {
270
+ const { profiles } = listProfiles(root, cwd, variantDir, packageProfiles);
271
+ const listed = planResearchOnly
272
+ ? profiles.filter(profile => researchContext
273
+ ? planResearchEligibility(profile, researchContext).eligible
274
+ : planResearchProfile(profile))
275
+ : profiles;
213
276
  return {
214
277
  id: 'subagents',
215
278
  title: 'Subagents',
216
279
  body: [
217
280
  planResearchOnly
218
- ? 'Plan mode may start only a named profile listed here, for read-only research. Do not omit agent or use a profile not listed.'
219
- : 'Delegate when it meaningfully reduces work: choose a substantial, bounded task that can proceed independently, such as unfamiliar cross-file research likely to take several searches or a self-contained implementation. Do a known-file lookup, one or two simple tool calls, a tiny edit or tightly coupled work yourself. Prefer a named profile: explorer for repository research, researcher for public-source research, and worker for a defined change it can own end to end; omit agent only when no profile fits. Each child starts without this conversation, so give it the goal, relevant context or paths, non-goals, expected result and acceptance or verification; use its own checklist for multi-step work. Prefer context-heavy research with concise evidence-backed findings. Parallelize only independent tasks and never assign overlapping edits. Return without waiting only when useful independent work remains; otherwise wait. You own integration, verification and the user-facing report. A profile can only narrow your tools.',
281
+ ? 'Plan mode may start only a named profile listed here, for read-only research. Do not omit agent or use a profile not listed. Omit the optional tools field to inherit the safe available intersection; specify it only to narrow further.'
282
+ : 'Delegate when it meaningfully reduces work: choose a substantial, bounded task that can proceed independently, such as unfamiliar cross-file research likely to take several searches or a self-contained implementation. Do a known-file lookup, one or two simple tool calls, a tiny edit or tightly coupled work yourself. Prefer a named profile: explorer for repository research, researcher for public-source research, and worker for a defined change it can own end to end; omit agent only when no profile fits. Each child starts without this conversation, so give it the goal, relevant context or paths, non-goals, expected result and acceptance or verification; use its own todo list for multi-step work. Prefer context-heavy research with concise evidence-backed findings. Parallelize only independent tasks and never assign overlapping edits. Return without waiting only when useful independent work remains; otherwise wait. You own integration, verification and the user-facing report. A profile can only narrow your tools.',
220
283
  ...(listed.length ? listed.map((profile) => `- ${profile.name} — ${profile.description}`) : ['- No eligible read-only research profiles are configured.'])
221
284
  ].join('\n'),
222
285
  budget: 6_000,
@@ -29,6 +29,8 @@ export declare class NotesBridge {
29
29
  private settingsFile;
30
30
  /** Starts the bridge (once) on the port and token kept from last time, so the app's snippet stays valid. */
31
31
  start(): Promise<BridgeInfo>;
32
+ /** What a window loads, once the bridge is listening. */
33
+ current(): BridgeInfo | null;
32
34
  private listen;
33
35
  list(): BridgeWindow[];
34
36
  /** Sends one helper operation to a window and waits for its JSON reply. */
@@ -71,6 +71,10 @@ export class NotesBridge {
71
71
  this.starting ??= this.listen().finally(() => { this.starting = null; });
72
72
  return this.starting;
73
73
  }
74
+ /** What a window loads, once the bridge is listening. */
75
+ current() {
76
+ return this.info;
77
+ }
74
78
  async listen() {
75
79
  const settingsFile = this.settingsFile();
76
80
  if (existsSync(settingsFile))
@@ -28,7 +28,7 @@ export interface CaptureWindow {
28
28
  /** One comment the person submitted in the inspector's popover. */
29
29
  export interface InspectResult extends CaptureEnvelope {
30
30
  body: string;
31
- action: 'save' | 'queue' | 'send';
31
+ action: 'save' | 'send';
32
32
  }
33
33
  export interface InspectState {
34
34
  /** False once the person or the page ended it. */
@@ -44,16 +44,43 @@ export interface InspectState {
44
44
  }
45
45
  /** Opens a DevTools connection to a running app; tests replace it. */
46
46
  export type AppConnector = (endpoint: string) => Promise<Browser>;
47
+ /** A desktop app on this computer that answers on a DevTools port. */
48
+ export interface DevToolsApp {
49
+ endpoint: string;
50
+ port: number;
51
+ /** The title of its first window, or what the app calls itself. */
52
+ name: string;
53
+ /** What the DevTools port reports, such as `Chrome/126.0` or `Electron/31.0`. */
54
+ browser: string;
55
+ electron: boolean;
56
+ windows: number;
57
+ }
58
+ /** Reads a JSON answer from a loopback DevTools port, or null when nothing answers in time; tests replace it. */
59
+ export type DevToolsFetch = (url: string, timeoutMs: number) => Promise<unknown>;
60
+ /** Starts an app the person named; tests replace it. */
61
+ export type AppSpawner = (command: string, args: string[]) => void;
47
62
  export interface AnnotationCaptureOptions {
48
63
  stateDir: string;
49
64
  launch?: Launcher;
50
65
  network?: NetworkPolicy;
51
66
  connect?: AppConnector;
52
67
  bridge?: NotesBridge;
68
+ fetchJson?: DevToolsFetch;
69
+ spawnApp?: AppSpawner;
70
+ /** A free loopback port for an app started here; tests replace it. */
71
+ freePort?: () => Promise<number>;
72
+ /** How long a started app has to open its DevTools port, in milliseconds. */
73
+ launchTimeoutMs?: number;
53
74
  }
75
+ /** Where apps started with `--remote-debugging-port` or `--inspect` usually listen. */
76
+ export declare const DEVTOOLS_PORTS: readonly [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230];
77
+ /** The program inside a macOS `.app` bundle, or the path itself. */
78
+ export declare function appExecutable(path: string): string;
54
79
  export declare class AnnotationCapture {
55
80
  private readonly options;
56
81
  private readonly targets;
82
+ /** Ports the person attached to or started an app on, checked again by `discover`. */
83
+ private readonly knownPorts;
57
84
  private readonly inspecting;
58
85
  private readonly network;
59
86
  readonly bridge: NotesBridge;
@@ -62,11 +89,14 @@ export declare class AnnotationCapture {
62
89
  private target;
63
90
  private replace;
64
91
  private call;
65
- /** What `key` captures in now. */
92
+ /** What `key` captures in now, with the script to load while it captures a bridge. */
66
93
  describe(key: string): {
67
94
  kind: CaptureTargetSpec['kind'];
68
95
  app: NoteApp | null;
69
96
  inspecting: boolean;
97
+ bridge?: {
98
+ snippet: string;
99
+ };
70
100
  };
71
101
  /** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
72
102
  attach(key: string, spec: CaptureTargetSpec): Promise<{
@@ -76,6 +106,18 @@ export declare class AnnotationCapture {
76
106
  bridge?: BridgeInfo;
77
107
  }>;
78
108
  windows(key: string): Promise<CaptureWindow[]>;
109
+ /** Apps on this computer that answer on a DevTools port: the usual ports and any used before. */
110
+ discover(ports?: readonly number[]): Promise<DevToolsApp[]>;
111
+ /**
112
+ * Starts the app the person named with a DevTools port and attaches to its windows. A macOS `.app`
113
+ * bundle starts its own program. The app keeps running when the capture ends.
114
+ */
115
+ launchApp(key: string, path: string, args?: readonly string[]): Promise<{
116
+ kind: 'cdp';
117
+ app: NoteApp | null;
118
+ windows: CaptureWindow[];
119
+ endpoint: string;
120
+ }>;
79
121
  /** Switches to another window or tab of the target; an inspector there starts again. */
80
122
  selectWindow(key: string, id: string): Promise<void>;
81
123
  /** Shows the capture window, at `url` when given. Captures in the browser again if an app was attached. */
@@ -5,8 +5,11 @@
5
5
  // - `bridge`: app windows that loaded the page bridge script (a macOS web view has no DevTools port).
6
6
  // The person selects text, clicks an element, or inspects with the popover; the front end pulls
7
7
  // what they did through the page helper.
8
+ import { spawn } from 'node:child_process';
8
9
  import { randomUUID } from 'node:crypto';
9
- import { join } from 'node:path';
10
+ import { existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { createServer } from 'node:net';
12
+ import { basename, join } from 'node:path';
10
13
  import { readBrowserConfig } from "../browser/config.js";
11
14
  import { NetworkPolicy } from "../browser/network.js";
12
15
  import { BrowserSession, fileName, launchBrowser } from "../browser/session.js";
@@ -14,6 +17,56 @@ import { NotesBridge } from "./bridge.js";
14
17
  import { evaluateIsolated } from "./isolated.js";
15
18
  import { pageCall, pageReply } from "./pageScript.js";
16
19
  import { validateUrl } from "./store.js";
20
+ /** Where apps started with `--remote-debugging-port` or `--inspect` usually listen. */
21
+ export const DEVTOOLS_PORTS = [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230];
22
+ const PROBE_MS = 400;
23
+ const fetchJson = async (url, timeoutMs) => {
24
+ try {
25
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
26
+ return response.ok ? await response.json() : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ const spawnApp = (command, args) => {
33
+ // The app is the person's: it keeps running after the terminal ends.
34
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
35
+ child.on('error', () => undefined);
36
+ child.unref();
37
+ };
38
+ const freePort = () => new Promise((resolve, reject) => {
39
+ const server = createServer();
40
+ server.unref();
41
+ server.on('error', reject);
42
+ server.listen(0, '127.0.0.1', () => {
43
+ const address = server.address();
44
+ server.close(() => typeof address === 'object' && address ? resolve(address.port) : reject(new Error('No free port.')));
45
+ });
46
+ });
47
+ /** The program inside a macOS `.app` bundle, or the path itself. */
48
+ export function appExecutable(path) {
49
+ if (!/\.app\/?$/.test(path))
50
+ return path;
51
+ const folder = join(path, 'Contents', 'MacOS');
52
+ if (!existsSync(folder))
53
+ throw new Error(`${path} has no Contents/MacOS folder.`);
54
+ const programs = readdirSync(folder).filter((name) => !name.startsWith('.') && statSync(join(folder, name)).isFile());
55
+ const named = basename(path.replace(/\/$/, ''), '.app');
56
+ const program = programs.find((name) => name === named) ?? programs[0];
57
+ if (!program)
58
+ throw new Error(`${path} has no program in Contents/MacOS.`);
59
+ return join(folder, program);
60
+ }
61
+ function describeApp(port, version, list) {
62
+ const agent = typeof version['User-Agent'] === 'string' ? version['User-Agent'] : '';
63
+ const electron = /Electron\/([\d.]+)/.exec(agent);
64
+ const browser = electron ? `Electron/${electron[1]}` : typeof version.Browser === 'string' ? version.Browser : 'DevTools';
65
+ const pages = Array.isArray(list) ? list.filter((entry) => !!entry && typeof entry === 'object' && entry.type === 'page' && !/^(?:devtools|chrome-extension):/.test(String(entry.url ?? ''))) : [];
66
+ const title = pages.map((page) => typeof page.title === 'string' ? page.title.trim() : '').find(Boolean);
67
+ const product = /\)\s+([A-Za-z][\w .-]*?)\/[\d.]+\s+(?:Chrome|Electron)\//.exec(agent)?.[1];
68
+ return { endpoint: `http://127.0.0.1:${port}`, port, name: (title || product || browser).slice(0, 120), browser, electron: !!electron, windows: pages.length };
69
+ }
17
70
  const IDLE_MS = 60 * 60_000;
18
71
  const LOOPBACK = /^(?:https?|wss?):\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/;
19
72
  const connectApp = async (endpoint) => {
@@ -130,6 +183,8 @@ class WebTarget {
130
183
  export class AnnotationCapture {
131
184
  options;
132
185
  targets = new Map();
186
+ /** Ports the person attached to or started an app on, checked again by `discover`. */
187
+ knownPorts = new Set();
133
188
  inspecting = new Map();
134
189
  network;
135
190
  bridge;
@@ -170,10 +225,11 @@ export class AnnotationCapture {
170
225
  async call(key, operation) {
171
226
  return pageReply((await this.target(key).evaluate(operation)).reply);
172
227
  }
173
- /** What `key` captures in now. */
228
+ /** What `key` captures in now, with the script to load while it captures a bridge. */
174
229
  describe(key) {
175
230
  const target = this.targets.get(key);
176
- return { kind: target?.kind ?? 'web', app: target?.app ?? null, inspecting: this.inspecting.has(key) };
231
+ const bridge = target?.kind === 'bridge' ? this.bridge.current() : null;
232
+ return { kind: target?.kind ?? 'web', app: target?.app ?? null, inspecting: this.inspecting.has(key), ...(bridge ? { bridge: { snippet: bridge.snippet } } : {}) };
177
233
  }
178
234
  /** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
179
235
  async attach(key, spec) {
@@ -189,6 +245,9 @@ export class AnnotationCapture {
189
245
  const name = spec.name?.trim() || `App at ${new URL(spec.endpoint).host}`;
190
246
  const target = new CdpTarget(browser, { kind: 'cdp', name: name.slice(0, 200) });
191
247
  await this.replace(key, target);
248
+ const port = Number(new URL(spec.endpoint).port);
249
+ if (port)
250
+ this.knownPorts.add(port);
192
251
  return { kind: 'cdp', app: target.app, windows: await target.windows() };
193
252
  }
194
253
  if (spec.kind === 'bridge') {
@@ -202,6 +261,48 @@ export class AnnotationCapture {
202
261
  windows(key) {
203
262
  return this.target(key).windows();
204
263
  }
264
+ /** Apps on this computer that answer on a DevTools port: the usual ports and any used before. */
265
+ async discover(ports = []) {
266
+ const probe = this.options.fetchJson ?? fetchJson;
267
+ const wanted = [...new Set([...DEVTOOLS_PORTS, ...this.knownPorts, ...ports])].filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
268
+ const found = await Promise.all(wanted.map(async (port) => {
269
+ const version = await probe(`http://127.0.0.1:${port}/json/version`, PROBE_MS);
270
+ if (!version || typeof version !== 'object')
271
+ return null;
272
+ // A Node `--inspect` port answers too, but has no windows to annotate.
273
+ if (typeof version.webSocketDebuggerUrl !== 'string' && typeof version.Browser !== 'string')
274
+ return null;
275
+ if (/^node(?:\.js)?\//i.test(String(version.Browser ?? '')))
276
+ return null;
277
+ return describeApp(port, version, await probe(`http://127.0.0.1:${port}/json/list`, PROBE_MS));
278
+ }));
279
+ return found.filter((app) => app !== null).sort((left, right) => left.port - right.port);
280
+ }
281
+ /**
282
+ * Starts the app the person named with a DevTools port and attaches to its windows. A macOS `.app`
283
+ * bundle starts its own program. The app keeps running when the capture ends.
284
+ */
285
+ async launchApp(key, path, args = []) {
286
+ const program = appExecutable(path.trim());
287
+ if (!program)
288
+ throw new Error('Give the path of the app to start.');
289
+ const port = await (this.options.freePort ?? freePort)();
290
+ (this.options.spawnApp ?? spawnApp)(program, [...args, `--remote-debugging-port=${port}`]);
291
+ this.knownPorts.add(port);
292
+ const endpoint = `http://127.0.0.1:${port}`;
293
+ const probe = this.options.fetchJson ?? fetchJson;
294
+ const deadline = Date.now() + (this.options.launchTimeoutMs ?? 20_000);
295
+ for (;;) {
296
+ if (await probe(`${endpoint}/json/version`, PROBE_MS))
297
+ break;
298
+ if (Date.now() > deadline)
299
+ throw new Error(`${basename(path)} did not open a DevTools port. Only Chromium and Electron apps can be inspected this way.`);
300
+ await new Promise((resolve) => setTimeout(resolve, 250));
301
+ }
302
+ const name = basename(path.trim().replace(/\/$/, '')).replace(/\.app$/, '');
303
+ const attached = await this.attach(key, { kind: 'cdp', endpoint, name });
304
+ return { kind: 'cdp', app: attached.app, windows: attached.windows, endpoint };
305
+ }
205
306
  /** Switches to another window or tab of the target; an inspector there starts again. */
206
307
  async selectWindow(key, id) {
207
308
  const inspecting = this.inspecting.has(key);