@xingular/harness 1.2.0 → 1.3.1-unstable.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +10 -5
  3. package/dist/cli.js +5560 -5056
  4. package/dist/sdk/agents/builtin.js +3 -3
  5. package/dist/sdk/agents/planResearch.d.ts +23 -1
  6. package/dist/sdk/agents/planResearch.js +71 -8
  7. package/dist/sdk/agents/profiles.d.ts +4 -1
  8. package/dist/sdk/agents/profiles.js +17 -6
  9. package/dist/sdk/capabilities/runSnapshot.js +10 -4
  10. package/dist/sdk/commands/registry.d.ts +11 -3
  11. package/dist/sdk/commands/registry.js +3 -2
  12. package/dist/sdk/core/shell.d.ts +20 -0
  13. package/dist/sdk/core/shell.js +73 -0
  14. package/dist/sdk/core/workspaceBashTool.d.ts +17 -1
  15. package/dist/sdk/core/workspaceBashTool.js +16 -2
  16. package/dist/sdk/core/workspaceSandbox.d.ts +10 -0
  17. package/dist/sdk/core/workspaceSandbox.js +52 -12
  18. package/dist/sdk/core/workspaceWriteOperations.d.ts +11 -0
  19. package/dist/sdk/core/workspaceWriteOperations.js +26 -8
  20. package/dist/sdk/doctor/checks.js +71 -12
  21. package/dist/sdk/hub/hub.js +5 -3
  22. package/dist/sdk/index.d.ts +2 -2
  23. package/dist/sdk/index.js +1 -1
  24. package/dist/sdk/plugins/builtin.js +3 -3
  25. package/dist/sdk/plugins/install.d.ts +6 -2
  26. package/dist/sdk/plugins/install.js +29 -5
  27. package/dist/sdk/plugins/inventory.js +2 -2
  28. package/dist/sdk/prompt/compile.d.ts +2 -2
  29. package/dist/sdk/prompt/compile.js +14 -16
  30. package/dist/sdk/server/methods.js +4 -2
  31. package/dist/sdk/sessions/compactionCheckpoint.d.ts +3 -3
  32. package/dist/sdk/sessions/compactionCheckpoint.js +12 -12
  33. package/dist/sdk/sessions/continuation.d.ts +4 -2
  34. package/dist/sdk/sessions/continuation.js +7 -6
  35. package/dist/sdk/sessions/contracts.d.ts +22 -4
  36. package/dist/sdk/sessions/contracts.js +12 -7
  37. package/dist/sdk/sessions/planMode.js +2 -2
  38. package/dist/sdk/sessions/runStore.js +3 -1
  39. package/dist/sdk/sessions/runner.js +2 -2
  40. package/dist/sdk/sessions/sessions.js +29 -11
  41. package/dist/sdk/sessions/subagentService.d.ts +15 -0
  42. package/dist/sdk/sessions/subagentService.js +194 -54
  43. package/dist/sdk/sessions/subagents.d.ts +10 -1
  44. package/dist/sdk/sessions/subagents.js +4 -0
  45. package/dist/sdk/sessions/workSlice.d.ts +8 -5
  46. package/dist/sdk/sessions/workSlice.js +77 -45
  47. package/dist/sdk/terminal/controller.d.ts +20 -0
  48. package/dist/sdk/terminal/controller.js +43 -18
  49. package/dist/sdk/terminal/opentui/ansi.d.ts +25 -0
  50. package/dist/sdk/terminal/opentui/ansi.js +192 -0
  51. package/dist/sdk/terminal/opentui/model.d.ts +163 -0
  52. package/dist/sdk/terminal/opentui/model.js +482 -0
  53. package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +3 -3
  54. package/dist/sdk/terminal/opentui/subagentsWidget.js +7 -5
  55. package/dist/sdk/terminal/opentui/theme.js +1 -0
  56. package/dist/sdk/terminal/piExtension.js +28 -14
  57. package/dist/sdk/terminal/subagentMonitor.d.ts +17 -4
  58. package/dist/sdk/terminal/subagentMonitor.js +29 -8
  59. package/dist/sdk/terminal/terminals.d.ts +7 -0
  60. package/dist/sdk/terminal/terminals.js +86 -14
  61. package/dist/sdk/terminal/trajectory.d.ts +6 -1
  62. package/dist/sdk/terminal/trajectory.js +8 -0
  63. package/dist/sdk/terminal/tui/chrome.d.ts +4 -0
  64. package/dist/sdk/terminal/tui/chrome.js +13 -0
  65. package/dist/sdk/terminal/tui/commandPages.js +14 -4
  66. package/dist/sdk/terminal/tui/commands.d.ts +4 -0
  67. package/dist/sdk/terminal/tui/commands.js +8 -0
  68. package/dist/sdk/terminal/tui/pageOverlay.js +13 -6
  69. package/dist/sdk/terminal/tui/pages/browserPage.js +5 -4
  70. package/dist/sdk/terminal/tui/pages/hubPage.d.ts +14 -1
  71. package/dist/sdk/terminal/tui/pages/hubPage.js +192 -28
  72. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +8 -1
  73. package/dist/sdk/terminal/tui/pages/pluginsPage.js +197 -25
  74. package/dist/sdk/terminal/tui/pages/subagentsPage.js +8 -5
  75. package/dist/sdk/tools/sessionName.d.ts +2 -0
  76. package/dist/sdk/tools/sessionName.js +5 -3
  77. package/dist/sdk/tools/spawnSubagent.js +9 -7
  78. package/dist/sdk/tools/todo.d.ts +15 -0
  79. package/dist/sdk/tools/todo.js +64 -0
  80. package/dist/terminal-opentui.js +1416 -348
  81. package/dist/terminal-pi.js +315 -126
  82. package/docs/architecture/00-overview.md +1 -1
  83. package/docs/architecture/01-extension-points.md +1 -1
  84. package/docs/architecture/02-sessions-and-runs.md +65 -34
  85. package/docs/architecture/04-prompt.md +8 -6
  86. package/docs/architecture/06-plugins-and-capabilities.md +10 -6
  87. package/docs/architecture/07-tools-and-gate.md +31 -5
  88. package/docs/architecture/09-accounts-and-connectors.md +8 -2
  89. package/docs/architecture/10-server-protocol.md +16 -9
  90. package/docs/architecture/11-terminal.md +51 -26
  91. package/docs/architecture/14-surfaces-and-parity.md +4 -1
  92. package/docs/architecture/README.md +18 -17
  93. package/docs/architecture/proposals/PROP-0006-run-checklist.md +2 -0
  94. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +32 -21
  95. package/docs/architecture/proposals/PROP-0044-work-slice-defaults.md +93 -0
  96. package/docs/architecture/proposals/PROP-0045-rename-checklist-to-todo.md +132 -0
  97. package/docs/architecture/proposals/PROP-0046-progress-aware-stall-guards.md +82 -0
  98. package/docs/architecture/proposals/PROP-0047-plugins-hub-separation.md +122 -0
  99. package/docs/architecture/proposals/README.md +10 -6
  100. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +39 -0
  101. package/docs/decisions/ADR-0040-architecture-change-workflow.md +31 -0
  102. package/docs/decisions/ADR-0041-plugins-hub-separation.md +41 -0
  103. package/docs/decisions/ADR-0042-windows-shell-without-sandbox.md +40 -0
  104. package/docs/decisions/ADR-0043-parents-continue-stopped-subagents.md +49 -0
  105. package/docs/doctor.md +14 -1
  106. package/docs/parity.md +9 -9
  107. package/docs/releases/1.3.0.md +21 -0
  108. package/docs/releases/README.md +2 -2
  109. package/docs/terminal-tui.md +85 -46
  110. package/package.json +1 -1
  111. package/dist/sdk/tools/checklist.d.ts +0 -9
  112. 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,5 +1,8 @@
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
8
  export type ProfileSource = 'builtin' | 'global' | 'project' | 'variant';
@@ -71,7 +74,7 @@ export declare function removeProfile(root: string, cwd: string | null | undefin
71
74
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
72
75
  export declare const PROFILE_SECTION_ORDER = 64;
73
76
  /** 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): {
77
+ export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean, researchContext?: PlanResearchContext): {
75
78
  id: string;
76
79
  title: string;
77
80
  body: string;
@@ -10,8 +10,11 @@ import { parse, serialize } from "../core/frontmatter.js";
10
10
  import { atomicWrite } from "../core/fs.js";
11
11
  import { subagentOf } from "../variant/materialise.js";
12
12
  import { BUILTIN_PROFILES } from "./builtin.js";
13
- import { planResearchProfile } from "./planResearch.js";
13
+ import { planResearchEligibility, planResearchProfile } from "./planResearch.js";
14
14
  export const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
15
+ /** Names a profile cannot take: `main` is how rosters show the agent a person talks to. */
16
+ export const RESERVED_PROFILE_NAMES = ['main'];
17
+ const reservedRefusal = (name) => `${name} is reserved for the main agent; choose another profile name.`;
15
18
  export const PROFILE_COLORS = ['blue', 'green', 'yellow', 'red', 'magenta', 'cyan', 'orange', 'purple'];
16
19
  /** Nearest folder above `cwd` that holds `.git`, or `cwd` itself. */
17
20
  export function projectRoot(cwd) {
@@ -65,6 +68,8 @@ export function formatVariant(variant) {
65
68
  export function parseProfile(text, name, source, path) {
66
69
  if (!PROFILE_NAME.test(name))
67
70
  throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
71
+ if (RESERVED_PROFILE_NAMES.includes(name))
72
+ throw invalid(reservedRefusal(name));
68
73
  const parsed = parse(text);
69
74
  if (!parsed)
70
75
  throw invalid('The profile needs a frontmatter block between --- lines.');
@@ -124,7 +129,7 @@ function variantDefinitions(variantDir) {
124
129
  if (!folder || !existsSync(folder))
125
130
  return [];
126
131
  return readdirSync(folder).sort().flatMap(name => {
127
- const definition = subagentOf(variantDir, name);
132
+ const definition = RESERVED_PROFILE_NAMES.includes(name) ? null : subagentOf(variantDir, name);
128
133
  if (!definition)
129
134
  return [];
130
135
  return [{
@@ -175,6 +180,8 @@ export function createProfile(root, cwd, input) {
175
180
  const name = typeof input.name === 'string' ? input.name.trim() : '';
176
181
  if (!PROFILE_NAME.test(name))
177
182
  throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
183
+ if (RESERVED_PROFILE_NAMES.includes(name))
184
+ throw invalid(reservedRefusal(name));
178
185
  if (input.scope !== 'global' && input.scope !== 'project')
179
186
  throw invalid('scope must be global or project.');
180
187
  const dir = scopeDir(root, cwd, input.scope);
@@ -207,16 +214,20 @@ export function removeProfile(root, cwd, input) {
207
214
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
208
215
  export const PROFILE_SECTION_ORDER = 64;
209
216
  /** The prompt section that lists the profiles a run can start subagents from. */
210
- export function profileSection(root, cwd, variantDir, planResearchOnly = false) {
217
+ export function profileSection(root, cwd, variantDir, planResearchOnly = false, researchContext) {
211
218
  const { profiles } = listProfiles(root, cwd, variantDir);
212
- const listed = planResearchOnly ? profiles.filter(planResearchProfile) : profiles;
219
+ const listed = planResearchOnly
220
+ ? profiles.filter(profile => researchContext
221
+ ? planResearchEligibility(profile, researchContext).eligible
222
+ : planResearchProfile(profile))
223
+ : profiles;
213
224
  return {
214
225
  id: 'subagents',
215
226
  title: 'Subagents',
216
227
  body: [
217
228
  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.',
229
+ ? '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.'
230
+ : '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
231
  ...(listed.length ? listed.map((profile) => `- ${profile.name} — ${profile.description}`) : ['- No eligible read-only research profiles are configured.'])
221
232
  ].join('\n'),
222
233
  budget: 6_000,
@@ -6,11 +6,16 @@ const path = (root, runId) => join(root, 'runs', safeId(runId), 'capabilities.js
6
6
  function strings(value) {
7
7
  return Array.isArray(value) ? [...new Set(value.filter((item) => typeof item === 'string' && item.length > 0))] : [];
8
8
  }
9
+ /** Tool names that were renamed; a pinned run keeps the current name so a continuation can use it. */
10
+ const RENAMED_TOOLS = { checklist_write: 'todo_write' };
11
+ function tools(value) {
12
+ return [...new Set(strings(value).map((name) => RENAMED_TOOLS[name] ?? name))];
13
+ }
9
14
  export function capabilitySet(value) {
10
15
  if (!value || typeof value !== 'object' || value.version !== 1)
11
16
  throw invalid('A capability set must use version 1.');
12
17
  const record = value;
13
- return { version: 1, tools: strings(record.tools), connectors: strings(record.connectors), contributions: strings(record.contributions) };
18
+ return { version: 1, tools: tools(record.tools), connectors: strings(record.connectors), contributions: strings(record.contributions) };
14
19
  }
15
20
  /** Writes a run's capability set once. A resumed or duplicate launch cannot replace it. */
16
21
  export function freezeRun(root, runId, caller, set) {
@@ -18,11 +23,12 @@ export function freezeRun(root, runId, caller, set) {
18
23
  const snapshot = {
19
24
  version: 1,
20
25
  caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null },
21
- tools: strings(set.tools),
26
+ tools: tools(set.tools),
22
27
  connectors: strings(set.connectors),
23
28
  contributions: strings(set.contributions)
24
29
  };
25
- const existing = existsSync(target) ? readJson(target) : null;
30
+ const stored = existsSync(target) ? readJson(target) : null;
31
+ const existing = stored ? { ...stored, tools: tools(stored.tools) } : null;
26
32
  if (existing) {
27
33
  if (JSON.stringify(existing) !== JSON.stringify(snapshot))
28
34
  throw new HarnessError('capabilities_frozen', 'This run already has a different frozen capability set.');
@@ -40,7 +46,7 @@ export function loadRun(root, runId) {
40
46
  return {
41
47
  version: 1,
42
48
  caller: { agentId: typeof caller.agentId === 'string' ? caller.agentId : null, projectId: typeof caller.projectId === 'string' ? caller.projectId : null },
43
- tools: strings(record.tools),
49
+ tools: tools(record.tools),
44
50
  connectors: strings(record.connectors),
45
51
  contributions: strings(record.contributions)
46
52
  };
@@ -121,6 +121,13 @@ export declare const CORE_COMMANDS: readonly [{
121
121
  readonly group: "Inspect";
122
122
  readonly kind: "session";
123
123
  readonly methods: readonly ["sessions.export", "sessions.run"];
124
+ }, {
125
+ readonly name: "todo";
126
+ readonly aliases: readonly ["checklist"];
127
+ readonly description: "Show the current todo list";
128
+ readonly group: "Inspect";
129
+ readonly kind: "session";
130
+ readonly methods: readonly ["runs.todo"];
124
131
  }, {
125
132
  readonly name: "trajectory";
126
133
  readonly args: "[run-id]";
@@ -173,15 +180,16 @@ export declare const CORE_COMMANDS: readonly [{
173
180
  readonly kind: "page";
174
181
  }, {
175
182
  readonly name: "plugins";
176
- readonly description: "Switch plugins on or off and trust their code";
183
+ readonly description: "Manage local plugins, packages, trust and settings";
177
184
  readonly group: "Harness";
178
185
  readonly kind: "page";
179
- readonly methods: readonly ["plugins.inventory"];
186
+ readonly methods: readonly ["plugins.list", "plugins.attach", "plugins.detach", "plugins.trust", "plugins.untrust", "plugins.configure", "plugins.install", "plugins.update", "plugins.remove", "hub.list"];
180
187
  }, {
181
188
  readonly name: "hub";
182
- readonly description: "Browse and install reviewed skills, plugins and documents";
189
+ readonly description: "Browse the Community catalog and manage Hub installs";
183
190
  readonly group: "Harness";
184
191
  readonly kind: "page";
192
+ readonly methods: readonly ["hub.list", "hub.install", "hub.uninstall"];
185
193
  }, {
186
194
  readonly name: "components";
187
195
  readonly description: "Browse skills, prompts and rules for this Harness scope";
@@ -18,6 +18,7 @@ export const CORE_COMMANDS = [
18
18
  { name: 'compact', args: '[instructions]', description: 'Compact the context', group: 'Session', kind: 'session', methods: ['sessions.run'] },
19
19
  { name: 'copy', description: 'Copy the last reply', group: 'Inspect', kind: 'local', methods: ['sessions.lastReply'] },
20
20
  { name: 'export', args: '[path.html|path.jsonl]', description: 'Export this branch to HTML or JSONL', group: 'Inspect', kind: 'session', methods: ['sessions.export', 'sessions.run'] },
21
+ { name: 'todo', aliases: ['checklist'], description: 'Show the current todo list', group: 'Inspect', kind: 'session', methods: ['runs.todo'] },
21
22
  { name: 'trajectory', args: '[run-id]', description: 'Inspect recorded runs for this session', group: 'Inspect', kind: 'page', methods: ['runs.list', 'runs.events'] },
22
23
  { name: 'reload', description: 'Reload trusted resources and keybindings', group: 'Harness', kind: 'session', methods: ['sessions.reload'] },
23
24
  { name: 'accounts', description: 'Sign in, switch, check and remove Pi accounts', group: 'Harness', kind: 'page', methods: ['accounts.list'] },
@@ -27,8 +28,8 @@ export const CORE_COMMANDS = [
27
28
  { name: 'connections', description: 'Manage local and remote MCP connections', group: 'Harness', kind: 'page' },
28
29
  { name: 'mcp', description: 'Open Connections in the Harness browser', group: 'Harness', kind: 'page' },
29
30
  { name: 'tools', description: 'Inspect available tools, owners and approvals', group: 'Harness', kind: 'page' },
30
- { name: 'plugins', description: 'Switch plugins on or off and trust their code', group: 'Harness', kind: 'page', methods: ['plugins.inventory'] },
31
- { name: 'hub', description: 'Browse and install reviewed skills, plugins and documents', group: 'Harness', kind: 'page' },
31
+ { name: 'plugins', description: 'Manage local plugins, packages, trust and settings', group: 'Harness', kind: 'page', methods: ['plugins.list', 'plugins.attach', 'plugins.detach', 'plugins.trust', 'plugins.untrust', 'plugins.configure', 'plugins.install', 'plugins.update', 'plugins.remove', 'hub.list'] },
32
+ { name: 'hub', description: 'Browse the Community catalog and manage Hub installs', group: 'Harness', kind: 'page', methods: ['hub.list', 'hub.install', 'hub.uninstall'] },
32
33
  { name: 'components', description: 'Browse skills, prompts and rules for this Harness scope', group: 'Harness', kind: 'page' },
33
34
  { name: 'settings', description: 'Session, terminal and browser settings', group: 'Harness', kind: 'page' },
34
35
  { name: 'doctor', description: 'Check account, model, tools and runtime readiness', group: 'Harness', kind: 'page', methods: ['doctor.start', 'doctor.read'] },
@@ -0,0 +1,20 @@
1
+ export type ShellKind = 'bash' | 'powershell';
2
+ export interface Shell {
3
+ kind: ShellKind;
4
+ executable: string;
5
+ /** Arguments placed before the command text. */
6
+ args: readonly string[];
7
+ }
8
+ export interface ShellLookup {
9
+ env?: NodeJS.ProcessEnv;
10
+ /** Whether a file exists at an absolute path. */
11
+ exists?: (path: string) => boolean;
12
+ /** The first match for an executable name on the search path. */
13
+ onPath?: (name: string) => string | null;
14
+ }
15
+ /** The shell for this machine, resolved once. Null when none was found. */
16
+ export declare function hostShell(): Shell | null;
17
+ export declare function resolveShell(platform: NodeJS.Platform, lookup?: ShellLookup): Shell | null;
18
+ /** The full argument list that runs `command` in `shell`. */
19
+ export declare function shellArguments(shell: Shell, command: string): string[];
20
+ export declare function shellLabel(shell: Shell): string;
@@ -0,0 +1,73 @@
1
+ // The shell the Bash tool runs commands in. POSIX systems use Bash. Windows uses Git Bash when it
2
+ // is installed and PowerShell otherwise, so a fresh machine can run commands with nothing extra.
3
+ import { accessSync, constants, statSync } from 'node:fs';
4
+ import { win32 } from 'node:path';
5
+ import { findBinary } from "./path.js";
6
+ const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'];
7
+ // Windows PowerShell writes the console code page by default; ask for UTF-8 like Pi does.
8
+ const POWERSHELL_UTF8 = 'try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n';
9
+ let cached;
10
+ /** The shell for this machine, resolved once. Null when none was found. */
11
+ export function hostShell() {
12
+ if (cached === undefined)
13
+ cached = resolveShell(process.platform);
14
+ return cached;
15
+ }
16
+ export function resolveShell(platform, lookup = {}) {
17
+ const env = lookup.env ?? process.env;
18
+ const exists = lookup.exists ?? isFile;
19
+ const onPath = lookup.onPath ?? findBinary;
20
+ if (platform !== 'win32') {
21
+ const executable = ['/bin/bash', '/usr/bin/bash'].find((candidate) => exists(candidate));
22
+ return executable ? { kind: 'bash', executable, args: ['-c'] } : null;
23
+ }
24
+ const gitBash = [env.ProgramFiles, env['ProgramFiles(x86)']]
25
+ .filter((folder) => Boolean(folder))
26
+ .map((folder) => win32.join(folder, 'Git', 'bin', 'bash.exe'))
27
+ .find((candidate) => exists(candidate));
28
+ if (gitBash)
29
+ return { kind: 'bash', executable: gitBash, args: ['-c'] };
30
+ const bashOnPath = onPath('bash.exe');
31
+ if (bashOnPath && !isWslLauncher(bashOnPath, env))
32
+ return { kind: 'bash', executable: bashOnPath, args: ['-c'] };
33
+ const existing = (folder, ...parts) => {
34
+ if (!folder)
35
+ return null;
36
+ const candidate = win32.join(folder, ...parts);
37
+ return exists(candidate) ? candidate : null;
38
+ };
39
+ // PowerShell 7 first; Windows PowerShell ships with every supported Windows version.
40
+ const powershell = onPath('pwsh.exe')
41
+ ?? existing(env.ProgramFiles, 'PowerShell', '7', 'pwsh.exe')
42
+ ?? onPath('powershell.exe')
43
+ ?? existing(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
44
+ return powershell ? { kind: 'powershell', executable: powershell, args: POWERSHELL_ARGS } : null;
45
+ }
46
+ /** The full argument list that runs `command` in `shell`. */
47
+ export function shellArguments(shell, command) {
48
+ return [...shell.args, shell.kind === 'powershell' ? `${POWERSHELL_UTF8}${command}` : command];
49
+ }
50
+ export function shellLabel(shell) {
51
+ if (shell.kind === 'powershell')
52
+ return 'PowerShell';
53
+ return /\\git\\/iu.test(shell.executable) ? 'Git Bash' : 'Bash';
54
+ }
55
+ // System32\bash.exe starts WSL, a separate Linux system that does not see Windows paths the same
56
+ // way and needs its command on stdin. It is not a usable Bash for this tool.
57
+ function isWslLauncher(path, env) {
58
+ const normalized = win32.normalize(path).toLowerCase();
59
+ const systemRoot = env.SystemRoot;
60
+ if (systemRoot) {
61
+ return ['System32', 'Sysnative'].some((folder) => normalized === win32.join(systemRoot, folder, 'bash.exe').toLowerCase());
62
+ }
63
+ return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/u.test(normalized);
64
+ }
65
+ function isFile(path) {
66
+ try {
67
+ accessSync(path, constants.F_OK);
68
+ return statSync(path).isFile();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
@@ -1,4 +1,20 @@
1
+ import type { BashOperations } from '@earendil-works/pi-coding-agent';
2
+ import { type Shell } from "./shell.js";
1
3
  /** Quotes command text and escapes terminal control/format characters for approval details. */
2
4
  export declare function formatBashCommandForApproval(command: string): string;
5
+ interface ShellToolFactories<T> {
6
+ createBashTool: (cwd: string, options: {
7
+ operations: BashOperations;
8
+ }) => T;
9
+ createPowerShellTool: (cwd: string, options: {
10
+ operations: BashOperations;
11
+ }) => T;
12
+ }
13
+ /**
14
+ * The `bash` tool for this machine. The capability keeps the name `bash` on every platform; on a
15
+ * Windows machine without Git Bash it runs PowerShell and carries PowerShell's description.
16
+ */
17
+ export declare function createWorkspaceShellTool<T>(pi: ShellToolFactories<T>, cwd: string, operations: BashOperations, shell?: Shell | null, platform?: NodeJS.Platform): T;
3
18
  /** Adds the explicit pre-execution escape request to Pi's native Bash tool. */
4
- export declare function withWorkspaceWriteScope<T>(tool: T): T;
19
+ export declare function withWorkspaceWriteScope<T>(tool: T, note?: string): T;
20
+ export {};
@@ -1,17 +1,31 @@
1
1
  import { Type } from 'typebox';
2
2
  import { COMMAND_TIMEOUT_CEILING_SECONDS, COMMAND_TIMEOUT_MAX_SECONDS, DEFAULT_COMMAND_TIMEOUT_SECONDS, resolveCommandTimeout } from "./commandBudget.js";
3
+ import { hostShell, shellLabel } from "./shell.js";
3
4
  import { runWithWorkspaceWriteScope } from "./workspaceSandbox.js";
4
5
  /** Quotes command text and escapes terminal control/format characters for approval details. */
5
6
  export function formatBashCommandForApproval(command) {
6
7
  return JSON.stringify(command).replace(/[\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
7
8
  }
9
+ /**
10
+ * The `bash` tool for this machine. The capability keeps the name `bash` on every platform; on a
11
+ * Windows machine without Git Bash it runs PowerShell and carries PowerShell's description.
12
+ */
13
+ export function createWorkspaceShellTool(pi, cwd, operations, shell = hostShell(), platform = process.platform) {
14
+ const native = shell?.kind === 'powershell'
15
+ ? { ...pi.createPowerShellTool(cwd, { operations }), name: 'bash' }
16
+ : pi.createBashTool(cwd, { operations });
17
+ const note = platform === 'win32' && shell
18
+ ? ` On this machine commands run in ${shellLabel(shell)}. Windows has no workspace sandbox, so each command asks for approval unless Full Permission is on, and plan mode cannot run commands.`
19
+ : '';
20
+ return withWorkspaceWriteScope(native, note);
21
+ }
8
22
  /** Adds the explicit pre-execution escape request to Pi's native Bash tool. */
9
- export function withWorkspaceWriteScope(tool) {
23
+ export function withWorkspaceWriteScope(tool, note = '') {
10
24
  const native = tool;
11
25
  const execute = native.execute.bind(tool);
12
26
  return {
13
27
  ...tool,
14
- description: `${native.description} Workspace writes are the default. Set workspaceWriteScope to "outside" to request approval before broader writes. Commands run for ${DEFAULT_COMMAND_TIMEOUT_SECONDS} seconds unless timeout says otherwise; above ${COMMAND_TIMEOUT_CEILING_SECONDS} seconds a reason is required, and ${COMMAND_TIMEOUT_MAX_SECONDS} seconds is the maximum.`,
28
+ description: `${native.description}${note} Workspace writes are the default. Set workspaceWriteScope to "outside" to request approval before broader writes. Commands run for ${DEFAULT_COMMAND_TIMEOUT_SECONDS} seconds unless timeout says otherwise; above ${COMMAND_TIMEOUT_CEILING_SECONDS} seconds a reason is required, and ${COMMAND_TIMEOUT_MAX_SECONDS} seconds is the maximum.`,
15
29
  parameters: Type.Object({
16
30
  command: Type.String({ description: 'Shell command to execute' }),
17
31
  timeout: Type.Optional(Type.Number({ description: `Timeout in seconds (default ${DEFAULT_COMMAND_TIMEOUT_SECONDS})` })),
@@ -1,4 +1,5 @@
1
1
  import type { BashOperations } from '@earendil-works/pi-coding-agent';
2
+ import { type Shell } from "./shell.js";
2
3
  /** `read-only` (plan mode) permits no workspace writes, no network and no outside processes. */
3
4
  export type WorkspaceWriteScope = 'workspace' | 'outside' | 'read-only';
4
5
  export declare function runWithWorkspaceWriteScope<T>(scope: WorkspaceWriteScope, action: () => T): T;
@@ -21,11 +22,20 @@ export interface WorkspaceBashOptions {
21
22
  workspaceWriteScope?: () => WorkspaceWriteScope | undefined;
22
23
  /** Exposed for platform-specific tests; production uses the current operating system. */
23
24
  platform?: NodeJS.Platform;
25
+ /** Exposed for tests; production uses the machine's shell (`hostShell`). */
26
+ shell?: () => Shell | null;
24
27
  }
28
+ /** Why a Windows command asks first: Windows has no write-boundary sandbox the harness can use. */
29
+ export declare 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.";
25
30
  export interface WorkspaceSandboxCommand {
26
31
  executable: string;
27
32
  args: string[];
28
33
  marker: string;
29
34
  }
35
+ /**
36
+ * The operating system's write-boundary sandbox for this platform, or null when it is not
37
+ * installed. Bash cannot run without it, so readiness diagnostics report it before a command runs.
38
+ */
39
+ export declare function workspaceSandboxBackend(platform?: NodeJS.Platform): string | null;
30
40
  export declare function workspaceSandboxCommand(platform: NodeJS.Platform, workspaceRoot: string, cwd: string, command: string, additionalWriteRoots?: readonly string[], readOnlyTempRoot?: string | null): WorkspaceSandboxCommand;
31
41
  export declare function createWorkspaceBashOperations(workspaceRoot: string | (() => string | null), options?: WorkspaceBashOptions): BashOperations;