@xingular/harness 1.1.1 → 1.2.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -25
  3. package/dist/cli.js +6675 -2501
  4. package/dist/sdk/accounts/accounts.d.ts +27 -3
  5. package/dist/sdk/accounts/accounts.js +78 -24
  6. package/dist/sdk/accounts/secrets.d.ts +12 -0
  7. package/dist/sdk/accounts/secrets.js +114 -7
  8. package/dist/sdk/accounts/store.d.ts +26 -1
  9. package/dist/sdk/accounts/store.js +1 -1
  10. package/dist/sdk/agents/builtin.js +15 -1
  11. package/dist/sdk/agents/planResearch.d.ts +5 -0
  12. package/dist/sdk/agents/planResearch.js +19 -0
  13. package/dist/sdk/agents/profiles.d.ts +2 -1
  14. package/dist/sdk/agents/profiles.js +9 -4
  15. package/dist/sdk/annotations/pageScript.js +349 -48
  16. package/dist/sdk/annotations/part.js +34 -23
  17. package/dist/sdk/annotations/store.d.ts +7 -0
  18. package/dist/sdk/annotations/store.js +13 -5
  19. package/dist/sdk/browser/backend.d.ts +16 -0
  20. package/dist/sdk/browser/backend.js +17 -0
  21. package/dist/sdk/browser/contracts.d.ts +19 -0
  22. package/dist/sdk/browser/contracts.js +116 -0
  23. package/dist/sdk/browser/tools.d.ts +1 -4
  24. package/dist/sdk/browser/tools.js +14 -91
  25. package/dist/sdk/cli.js +5 -4
  26. package/dist/sdk/commands/list.d.ts +3 -0
  27. package/dist/sdk/commands/list.js +75 -0
  28. package/dist/sdk/commands/registry.d.ts +250 -0
  29. package/dist/sdk/commands/registry.js +47 -0
  30. package/dist/sdk/commands/routes.d.ts +16 -0
  31. package/dist/sdk/commands/routes.js +33 -0
  32. package/dist/sdk/core/commandBudget.d.ts +23 -0
  33. package/dist/sdk/core/commandBudget.js +57 -0
  34. package/dist/sdk/core/index.js +17 -1
  35. package/dist/sdk/core/interactiveProfile.d.ts +46 -0
  36. package/dist/sdk/core/interactiveProfile.js +143 -0
  37. package/dist/sdk/core/workspace.d.ts +13 -0
  38. package/dist/sdk/core/workspace.js +88 -0
  39. package/dist/sdk/core/workspaceBashTool.d.ts +4 -0
  40. package/dist/sdk/core/workspaceBashTool.js +36 -0
  41. package/dist/sdk/core/workspaceSandbox.d.ts +31 -0
  42. package/dist/sdk/core/workspaceSandbox.js +320 -0
  43. package/dist/sdk/core/workspaceWriteOperations.d.ts +22 -0
  44. package/dist/sdk/core/workspaceWriteOperations.js +206 -0
  45. package/dist/sdk/doctor/types.d.ts +2 -2
  46. package/dist/sdk/harness.d.ts +7 -0
  47. package/dist/sdk/harness.js +42 -2
  48. package/dist/sdk/index.d.ts +6 -1
  49. package/dist/sdk/index.js +3 -0
  50. package/dist/sdk/pi/runtime.d.ts +2 -1
  51. package/dist/sdk/pi/runtime.js +2 -2
  52. package/dist/sdk/plugins/builtin.js +29 -12
  53. package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
  54. package/dist/sdk/plugins/builtinRegistry.js +30 -8
  55. package/dist/sdk/plugins/catalogue.js +2 -1
  56. package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
  57. package/dist/sdk/plugins/commandPrefix.js +21 -0
  58. package/dist/sdk/plugins/firstParty.d.ts +1 -1
  59. package/dist/sdk/plugins/firstParty.js +8 -2
  60. package/dist/sdk/plugins/inventory.js +4 -3
  61. package/dist/sdk/plugins/manifest.d.ts +2 -0
  62. package/dist/sdk/plugins/manifest.js +2 -1
  63. package/dist/sdk/plugins/surfaces.d.ts +5 -0
  64. package/dist/sdk/plugins/surfaces.js +11 -0
  65. package/dist/sdk/plugins/types.d.ts +15 -0
  66. package/dist/sdk/prompt/compile.d.ts +14 -0
  67. package/dist/sdk/prompt/compile.js +65 -29
  68. package/dist/sdk/prompt/standard.d.ts +17 -0
  69. package/dist/sdk/prompt/standard.js +21 -0
  70. package/dist/sdk/server/methods.js +18 -0
  71. package/dist/sdk/server/remote.js +36 -11
  72. package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
  73. package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
  74. package/dist/sdk/sessions/continuation.d.ts +3 -0
  75. package/dist/sdk/sessions/continuation.js +5 -1
  76. package/dist/sdk/sessions/contracts.d.ts +168 -0
  77. package/dist/sdk/sessions/contracts.js +59 -1
  78. package/dist/sdk/sessions/control.d.ts +72 -0
  79. package/dist/sdk/sessions/control.js +158 -0
  80. package/dist/sdk/sessions/initialBash.d.ts +7 -0
  81. package/dist/sdk/sessions/initialBash.js +7 -0
  82. package/dist/sdk/sessions/planMode.d.ts +14 -3
  83. package/dist/sdk/sessions/planMode.js +73 -16
  84. package/dist/sdk/sessions/registry.d.ts +1 -1
  85. package/dist/sdk/sessions/registry.js +2 -2
  86. package/dist/sdk/sessions/runStore.d.ts +14 -0
  87. package/dist/sdk/sessions/runStore.js +35 -0
  88. package/dist/sdk/sessions/runner.d.ts +40 -2
  89. package/dist/sdk/sessions/runner.js +161 -13
  90. package/dist/sdk/sessions/sessions.d.ts +23 -3
  91. package/dist/sdk/sessions/sessions.js +202 -17
  92. package/dist/sdk/sessions/subagentService.d.ts +9 -0
  93. package/dist/sdk/sessions/subagentService.js +63 -4
  94. package/dist/sdk/sessions/workSlice.d.ts +103 -0
  95. package/dist/sdk/sessions/workSlice.js +297 -0
  96. package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
  97. package/dist/sdk/sessions/workspaceBoundary.js +46 -0
  98. package/dist/sdk/terminal/browserControl.js +2 -1
  99. package/dist/sdk/terminal/controller.d.ts +11 -3
  100. package/dist/sdk/terminal/controller.js +40 -13
  101. package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
  102. package/dist/sdk/terminal/opentui/theme.js +87 -0
  103. package/dist/sdk/terminal/piExtension.d.ts +6 -1
  104. package/dist/sdk/terminal/piExtension.js +154 -54
  105. package/dist/sdk/terminal/standalone.d.ts +7 -0
  106. package/dist/sdk/terminal/standalone.js +72 -13
  107. package/dist/sdk/terminal/subagentMonitor.d.ts +11 -2
  108. package/dist/sdk/terminal/subagentMonitor.js +32 -1
  109. package/dist/sdk/terminal/terminals.d.ts +19 -2
  110. package/dist/sdk/terminal/terminals.js +245 -35
  111. package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
  112. package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
  113. package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
  114. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  115. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  116. package/dist/sdk/terminal/tui/commands.js +205 -124
  117. package/dist/sdk/terminal/tui/list.d.ts +14 -1
  118. package/dist/sdk/terminal/tui/list.js +96 -14
  119. package/dist/sdk/terminal/tui/pageOverlay.js +13 -4
  120. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
  121. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
  122. package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
  123. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +0 -5
  124. package/dist/sdk/terminal/tui/pages/pluginsPage.js +9 -3
  125. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
  126. package/dist/sdk/terminal/tui/pages/subagentsPage.js +98 -16
  127. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
  128. package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
  129. package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
  130. package/dist/sdk/terminal/tui/piAdapter.js +3 -5
  131. package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
  132. package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
  133. package/dist/sdk/terminal/tui/runtime.js +23 -3
  134. package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
  135. package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
  136. package/dist/sdk/tools/checklist.js +1 -0
  137. package/dist/sdk/tools/checkpoint.d.ts +27 -0
  138. package/dist/sdk/tools/checkpoint.js +154 -0
  139. package/dist/sdk/tools/getCapabilities.d.ts +31 -0
  140. package/dist/sdk/tools/getCapabilities.js +34 -0
  141. package/dist/sdk/tools/spawnSubagent.js +4 -3
  142. package/dist/sdk/usage/analytics.d.ts +20 -0
  143. package/dist/sdk/usage/analytics.js +151 -0
  144. package/dist/sdk/usage/ledger.d.ts +38 -0
  145. package/dist/sdk/usage/ledger.js +142 -0
  146. package/dist/sdk/usage/limits/adapters.d.ts +11 -0
  147. package/dist/sdk/usage/limits/adapters.js +349 -0
  148. package/dist/sdk/usage/limits/capture.d.ts +8 -0
  149. package/dist/sdk/usage/limits/capture.js +47 -0
  150. package/dist/sdk/usage/limits/http.d.ts +12 -0
  151. package/dist/sdk/usage/limits/http.js +51 -0
  152. package/dist/sdk/usage/limits/normalize.d.ts +15 -0
  153. package/dist/sdk/usage/limits/normalize.js +211 -0
  154. package/dist/sdk/usage/limits/service.d.ts +30 -0
  155. package/dist/sdk/usage/limits/service.js +176 -0
  156. package/dist/sdk/usage/limits/types.d.ts +65 -0
  157. package/dist/sdk/usage/limits/types.js +1 -0
  158. package/dist/sdk/usage/serverMethods.d.ts +3 -0
  159. package/dist/sdk/usage/serverMethods.js +22 -0
  160. package/dist/sdk/usage/service.d.ts +32 -0
  161. package/dist/sdk/usage/service.js +66 -0
  162. package/dist/sdk/usage/settings.d.ts +7 -0
  163. package/dist/sdk/usage/settings.js +28 -0
  164. package/dist/sdk/usage/types.d.ts +60 -0
  165. package/dist/sdk/usage/types.js +4 -0
  166. package/dist/terminal-opentui.js +5207 -1883
  167. package/dist/terminal-pi.js +1658 -271
  168. package/docs/architecture/02-sessions-and-runs.md +128 -26
  169. package/docs/architecture/04-prompt.md +50 -12
  170. package/docs/architecture/06-plugins-and-capabilities.md +24 -7
  171. package/docs/architecture/07-tools-and-gate.md +104 -24
  172. package/docs/architecture/08-memory-and-history.md +4 -2
  173. package/docs/architecture/09-accounts-and-connectors.md +31 -1
  174. package/docs/architecture/10-server-protocol.md +58 -3
  175. package/docs/architecture/11-terminal.md +105 -42
  176. package/docs/architecture/12-storage.md +30 -2
  177. package/docs/architecture/13-world-tools.md +22 -1
  178. package/docs/architecture/14-surfaces-and-parity.md +54 -7
  179. package/docs/architecture/README.md +3 -1
  180. package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
  181. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
  182. package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
  183. package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
  184. package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
  185. package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
  186. package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
  187. package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
  188. package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
  189. package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
  190. package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
  191. package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
  192. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +247 -0
  193. package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
  194. package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
  195. package/docs/architecture/proposals/README.md +14 -0
  196. package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
  197. package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
  198. package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
  199. package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
  200. package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
  201. package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
  202. package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
  203. package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
  204. package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
  205. package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
  206. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
  207. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
  208. package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
  209. package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
  210. package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
  211. package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
  212. package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
  213. package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
  214. package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
  215. package/docs/parity.md +80 -24
  216. package/docs/releases/1.1.1.md +28 -0
  217. package/docs/releases/1.2.0.md +49 -0
  218. package/docs/releases/4.0.0.md +10 -9
  219. package/docs/releases/README.md +35 -30
  220. package/docs/study/00-index.md +3 -0
  221. package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
  222. package/docs/terminal-tui.md +100 -59
  223. package/package.json +4 -3
@@ -1,3 +1,4 @@
1
+ import { usagePayloadSchema } from "../sessions/contracts.js";
1
2
  export const ACTIVE_SUBAGENT_STATUSES = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input']);
2
3
  export function terminalSubagentDuration(activity, now = Date.now()) {
3
4
  const start = Date.parse(activity.startedAt);
@@ -41,7 +42,7 @@ function stringOf(value) {
41
42
  function latestTool(tools) {
42
43
  return [...tools.values()].sort((left, right) => right.sequence - left.sequence)[0]?.text;
43
44
  }
44
- /** Volatile, read-only activity projection for the active terminal parent run. */
45
+ /** Volatile, read-only activity projection for the latest terminal parent run. */
45
46
  export class SubagentMonitor {
46
47
  parentRunId = null;
47
48
  activities = new Map();
@@ -64,12 +65,22 @@ export class SubagentMonitor {
64
65
  activity: statusActivity(run.status),
65
66
  checklist: null,
66
67
  profile: run.profile ?? null,
68
+ accountLabel: run.accountLabel ?? null,
69
+ model: run.model ?? null,
70
+ effort: run.effort ?? null,
71
+ usage: null,
67
72
  tools: new Map(),
68
73
  lastTool: null
69
74
  };
70
75
  row.parentRunId = run.parentRunId;
71
76
  if (run.profile !== undefined)
72
77
  row.profile = run.profile;
78
+ if (run.accountLabel !== undefined)
79
+ row.accountLabel = run.accountLabel;
80
+ if (run.model !== undefined && !row.usage?.model)
81
+ row.model = run.model;
82
+ if (run.effort !== undefined)
83
+ row.effort = run.effort;
73
84
  row.title = run.title;
74
85
  row.goal = run.goal;
75
86
  row.status = run.status;
@@ -94,6 +105,26 @@ export class SubagentMonitor {
94
105
  row.lastTool = null;
95
106
  row.activity = 'Responding';
96
107
  }
108
+ else if (event.type === 'usage.updated') {
109
+ const parsed = usagePayloadSchema.safeParse(payload);
110
+ if (parsed.success) {
111
+ const usage = parsed.data;
112
+ row.usage = {
113
+ ...(usage.provider === undefined ? {} : { provider: usage.provider }),
114
+ ...(usage.model === undefined ? {} : { model: usage.model }),
115
+ ...(usage.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
116
+ ...(usage.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
117
+ ...(usage.cacheReadTokens === undefined ? {} : { cacheReadTokens: usage.cacheReadTokens }),
118
+ ...(usage.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: usage.cacheWriteTokens }),
119
+ ...(usage.reasoningTokens === undefined ? {} : { reasoningTokens: usage.reasoningTokens }),
120
+ ...(usage.costUsd === undefined ? {} : { costUsd: usage.costUsd }),
121
+ ...(usage.costSource === undefined ? {} : { costSource: usage.costSource }),
122
+ ...(usage.requests === undefined ? {} : { requests: usage.requests }),
123
+ ...(usage.contextTokens === undefined ? {} : { contextTokens: usage.contextTokens }),
124
+ ...(usage.contextWindow === undefined ? {} : { contextWindow: usage.contextWindow })
125
+ };
126
+ }
127
+ }
97
128
  else if (event.type === 'checklist.updated') {
98
129
  const items = Array.isArray(payload.items) ? payload.items : [];
99
130
  row.checklist = items.length ? { done: items.filter(item => item?.status === 'completed').length, total: items.length } : null;
@@ -22,6 +22,8 @@ import type { World } from "../world/world.js";
22
22
  import type { Worktrees } from "../worktrees/service.js";
23
23
  import type { AnnotationCapture } from "../annotations/capture.js";
24
24
  import type { NoteDeliveries } from "../annotations/delivery.js";
25
+ import type { Usage } from "../usage/service.js";
26
+ import type { UsageLimits } from "../usage/limits/service.js";
25
27
  /** The files a terminal session needs on disk, outside this process. */
26
28
  export interface TerminalAssets {
27
29
  /** The Node binary that runs the commands. */
@@ -103,6 +105,10 @@ export interface TerminalsOptions {
103
105
  noteCapture?: AnnotationCapture;
104
106
  /** Page notes on their way to a session (PROP-0018). */
105
107
  pageNotes?: NoteDeliveries;
108
+ /** Usage analytics over the derived ledger (`/usage:analysis`). */
109
+ usage?: Usage;
110
+ /** Provider plan windows, balances and rate limits (`/usage:limit`). */
111
+ usageLimits?: UsageLimits;
106
112
  }
107
113
  /** What `/plugins` in a terminal can do. Each takes the session's folder, account and project. */
108
114
  export interface PluginControl {
@@ -147,10 +153,17 @@ export declare class Terminals {
147
153
  constructor(options: TerminalsOptions);
148
154
  /** The commands a terminal session finds on PATH. Rewritten only when they change. */
149
155
  private ensureCommands;
150
- /** The broker's tools, then the world tools whose plugins are on for this session. */
156
+ /**
157
+ * The harness's own History, Wiki and skill tools, then the broker's, then the world tools whose
158
+ * plugins are on for this session: the same tools a structured session has (INV-PAR-1).
159
+ */
151
160
  private tools;
161
+ private readonly ownNames;
162
+ private ownTools;
152
163
  /** The world tools a new session will have, named in its instructions. */
153
164
  private worldToolNames;
165
+ /** The harness's own History, Wiki and skill tools a new session will have. */
166
+ private ownToolNames;
154
167
  /** Whether a world tool's plugin is switched on for a session's folder, account and project. */
155
168
  private worldOn;
156
169
  private appsCall;
@@ -169,7 +182,9 @@ export declare class Terminals {
169
182
  private agentsCall;
170
183
  /** `/worktree`: the managed worktrees of the repository this session works in. */
171
184
  private worktreesCall;
172
- /** `/plugin:notes` and `/plugin:annotate`: the page notes a person keeps, and the visible browser they capture them in. */
185
+ /** `/usage:analysis` and `/usage:limit`: usage for this session's own account, project or session. */
186
+ private usageCall;
187
+ /** `/annotate:notes` and `/annotate:`: Web annotations and their visible capture browser. */
173
188
  private annotationsCall;
174
189
  /** The account a session asked to switch to. Taken once, after its Pi process has ended and before `dispose`. */
175
190
  takeRelaunch(sessionId: string): TerminalRelaunch | null;
@@ -179,6 +194,8 @@ export declare class Terminals {
179
194
  /** The run a native turn reports into: the one reserved by `send`, or a new one for what the person typed. */
180
195
  private begin;
181
196
  private decide;
197
+ private workspaceEscape;
198
+ private workspaceBoundaryBypass;
182
199
  private subagentsCall;
183
200
  private componentsCall;
184
201
  private trajectoryCall;
@@ -13,6 +13,8 @@ import { readAudit } from "../accounts/store.js";
13
13
  import { log } from "../core/bus.js";
14
14
  import { HarnessError, invalid } from "../core/errors.js";
15
15
  import { newId, now } from "../core/fs.js";
16
+ import { formatBashCommandForApproval } from "../core/workspaceBashTool.js";
17
+ import { resolveWorkspaceRoot } from "../core/workspace.js";
16
18
  import { prependSearchPath, searchPath } from "../core/path.js";
17
19
  import { dispatch } from "../tools/dispatch.js";
18
20
  import { materialise } from "../variant/materialise.js";
@@ -21,7 +23,13 @@ import * as variantStore from "../variant/store.js";
21
23
  import { compile, copyToRun, record, render, saveForSession } from "../prompt/compile.js";
22
24
  import { isActiveStatus } from "../sessions/contracts.js";
23
25
  import { findRun, loadRun, saveRun, updateRun } from "../sessions/runStore.js";
26
+ import { combineWorkspaceScope, inspectRunWorkspaceWrite } from "../sessions/workspaceBoundary.js";
24
27
  import { CHECKLIST_WRITE } from "../tools/checklist.js";
28
+ import { DEFAULT_TOOL_NAMES } from "../tools/definitions.js";
29
+ import { harnessTools } from "../tools/harnessTools.js";
30
+ import { HARNESS_PLUGINS } from "../plugins/builtin.js";
31
+ import * as attachments from "../plugins/attachments.js";
32
+ import { FIRST_PARTY_PLUGINS } from "../plugins/firstParty.js";
25
33
  import { createProfile, listProfiles, profileDirs, profileSection, removeProfile, resolveProfile } from "../agents/profiles.js";
26
34
  import { SESSION_NAME } from "../tools/sessionName.js";
27
35
  import { contributionId } from "../plugins/identity.js";
@@ -108,7 +116,12 @@ export class Terminals {
108
116
  const owner = this.sessions.get(parent.sessionId);
109
117
  if (owner?.controller.activeRun?.runId === parent.id) {
110
118
  owner.controller.observe(event, true);
111
- owner.controller.observeSubagent(parent.id, record, event, this.subagentProfile(record));
119
+ const settings = options.subagents?.runtimeSettings(record.id);
120
+ owner.controller.observeSubagent(parent.id, record, event, this.subagentProfile(record), {
121
+ accountLabel: owner.account.label,
122
+ model: settings?.model ?? null,
123
+ effort: settings?.effort ?? null
124
+ });
112
125
  break;
113
126
  }
114
127
  parentId = parent.parentRunId;
@@ -153,23 +166,48 @@ export class Terminals {
153
166
  }
154
167
  return bin;
155
168
  }
156
- /** The broker's tools, then the world tools whose plugins are on for this session. */
169
+ /**
170
+ * The harness's own History, Wiki and skill tools, then the broker's, then the world tools whose
171
+ * plugins are on for this session: the same tools a structured session has (INV-PAR-1).
172
+ */
157
173
  async tools(session, listing) {
158
174
  const brokered = this.options.apps.brokerTools(session);
159
175
  const live = this.sessions.get(session.sessionId);
160
- const { world } = this.options;
161
- if (!world || !live)
176
+ if (!live)
162
177
  return brokered;
178
+ const own = await this.ownTools(live, listing);
179
+ const { world } = this.options;
180
+ if (!world)
181
+ return [...own, ...brokered];
163
182
  const context = { run: live.controller.activeRun, caller: live.caller, attended: true, subagent: false, sessionKey: session.sessionId, cwd: live.input.cwd };
164
183
  if (!listing)
165
- return [...brokered, ...world.tools(context, (_plugin, tool) => this.worldNames.get(session.sessionId)?.has(tool) ?? false)];
184
+ return [...own, ...brokered, ...world.tools(context, (_plugin, tool) => this.worldNames.get(session.sessionId)?.has(tool) ?? false)];
166
185
  const on = await this.worldOn(live);
167
186
  const tools = world.tools(context, (plugin, tool) => on(plugin, tool));
168
187
  this.worldNames.set(session.sessionId, new Set(tools.map((tool) => tool.name)));
169
188
  for (const tool of tools)
170
189
  if (tool.readOnly)
171
190
  this.worldReadOnly.add(tool.name);
172
- return [...brokered, ...tools];
191
+ return [...own, ...brokered, ...tools];
192
+ }
193
+ ownNames = new Map();
194
+ async ownTools(live, listing) {
195
+ const sessionId = live.caller.sessionId;
196
+ const all = harnessTools(this.options.env, live.caller, { variantDir: this.variantDirs.get(sessionId) ?? null })
197
+ .filter((tool) => DEFAULT_TOOL_NAMES.includes(tool.name));
198
+ if (!listing)
199
+ return all.filter((tool) => this.ownNames.get(sessionId)?.has(tool.name) ?? false);
200
+ const on = await this.worldOn(live);
201
+ const owner = (tool) => HARNESS_PLUGINS.find((plugin) => plugin.tools?.some((entry) => entry.name === tool))?.id ?? null;
202
+ const tools = all.filter((tool) => {
203
+ const plugin = owner(tool.name);
204
+ return !plugin || on(plugin, tool.name);
205
+ });
206
+ this.ownNames.set(sessionId, new Set(tools.map((tool) => tool.name)));
207
+ for (const tool of tools)
208
+ if (tool.readOnly)
209
+ this.worldReadOnly.add(tool.name);
210
+ return tools;
173
211
  }
174
212
  /** The world tools a new session will have, named in its instructions. */
175
213
  async worldToolNames(input, account) {
@@ -181,6 +219,16 @@ export class Terminals {
181
219
  const on = plugins ? new Set(plugins.flatMap((plugin) => plugin.contributions.filter((item) => item.state === 'on').map((item) => item.id))) : null;
182
220
  return world.plugins.flatMap((plugin) => (plugin.tools ?? []).map((tool) => tool.name).filter((name) => !on || on.has(contributionId(plugin.id, 'tool', name))));
183
221
  }
222
+ /** The harness's own History, Wiki and skill tools a new session will have. */
223
+ async ownToolNames(input, account) {
224
+ const list = this.options.pluginControl?.list;
225
+ const plugins = list ? await list({ cwd: input.cwd, accountId: account.id, projectId: input.projectId ?? null }) : null;
226
+ const on = plugins ? new Set(plugins.flatMap((plugin) => plugin.contributions.filter((item) => item.state === 'on').map((item) => item.id))) : null;
227
+ return DEFAULT_TOOL_NAMES.filter((name) => {
228
+ const plugin = HARNESS_PLUGINS.find((entry) => entry.tools?.some((tool) => tool.name === name));
229
+ return !on || !plugin || on.has(contributionId(plugin.id, 'tool', name));
230
+ });
231
+ }
184
232
  /** Whether a world tool's plugin is switched on for a session's folder, account and project. */
185
233
  async worldOn(live) {
186
234
  const list = this.options.pluginControl?.list;
@@ -203,6 +251,17 @@ export class Terminals {
203
251
  const { Type } = await import('typebox');
204
252
  return { tools: (await this.tools(session, true)).map((tool) => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters(Type) })) };
205
253
  }
254
+ case 'firstParty.list': {
255
+ // The harness's own extensions switched on for this session, with their settings.
256
+ const live = this.sessions.get(session.sessionId);
257
+ const list = this.options.pluginControl?.list;
258
+ if (!live || !list)
259
+ return { plugins: [] };
260
+ const plugins = await list({ cwd: live.input.cwd, accountId: live.account.id, projectId: live.input.projectId ?? null });
261
+ const on = new Set(plugins.flatMap((plugin) => plugin.contributions.filter((item) => item.state === 'on').map((item) => item.id)));
262
+ const settings = attachments.load(this.options.env.root).settings;
263
+ return { plugins: FIRST_PARTY_PLUGINS.filter((plugin) => plugin.code?.some((code) => on.has(`${plugin.id}/code/${code.name}`))).map((plugin) => ({ id: plugin.id, settings: settings[plugin.id] ?? {} })) };
264
+ }
206
265
  case 'tools.call': {
207
266
  const tool = (await this.tools(session, false)).find((candidate) => candidate.name === params.name);
208
267
  if (!tool)
@@ -451,7 +510,45 @@ export class Terminals {
451
510
  throw new HarnessError('unknown_method', `Unknown call ${call}.`);
452
511
  }
453
512
  }
454
- /** `/plugin:notes` and `/plugin:annotate`: the page notes a person keeps, and the visible browser they capture them in. */
513
+ /** `/usage:analysis` and `/usage:limit`: usage for this session's own account, project or session. */
514
+ async usageCall(live, call, params) {
515
+ const usage = this.options.usage;
516
+ const limits = this.options.usageLimits;
517
+ if (!usage || !limits)
518
+ throw invalid('Usage is unavailable in this host.');
519
+ if (call === 'terminal.usage.summary') {
520
+ const scope = params.scope === 'session' || params.scope === 'project' || params.scope === 'all' ? params.scope : 'account';
521
+ if (scope === 'project' && !live.input.projectId)
522
+ throw invalid('This session has no project to report usage for.');
523
+ const period = params.period === 'today' || params.period === '30d' || params.period === 'all' ? params.period : '7d';
524
+ return usage.summary({
525
+ scope,
526
+ sessionId: live.input.sessionId,
527
+ accountId: live.account.id,
528
+ projectId: live.input.projectId ?? null,
529
+ period,
530
+ timezoneOffsetMinutes: typeof params.timezoneOffsetMinutes === 'number' ? params.timezoneOffsetMinutes : 0
531
+ });
532
+ }
533
+ if (call === 'terminal.usage.limits') {
534
+ return params.refresh === true ? limits.refresh(live.account.id, { force: true }) : limits.read(live.account.id);
535
+ }
536
+ if (call === 'terminal.usage.headers') {
537
+ if (typeof params.provider !== 'string' || !params.provider)
538
+ throw invalid('A provider is required.');
539
+ if (typeof params.status !== 'number' || !Number.isFinite(params.status))
540
+ throw invalid('A response status is required.');
541
+ const headers = params.headers && typeof params.headers === 'object' && !Array.isArray(params.headers)
542
+ ? Object.fromEntries(Object.entries(params.headers).filter((entry) => typeof entry[1] === 'string'))
543
+ : {};
544
+ if (!Object.keys(headers).length)
545
+ return {};
546
+ limits.recordHeaders(live.account.id, params.provider, { status: params.status, headers });
547
+ return {};
548
+ }
549
+ throw invalid('Unknown usage operation.');
550
+ }
551
+ /** `/annotate:notes` and `/annotate:`: Web annotations and their visible capture browser. */
455
552
  async annotationsCall(live, call, params) {
456
553
  const root = this.options.env.root;
457
554
  const sessionId = live.input.sessionId;
@@ -578,6 +675,7 @@ export class Terminals {
578
675
  sessionId: input.sessionId,
579
676
  nativeSessionId: live.controller.nativeSessionId,
580
677
  workingDirectory: input.cwd,
678
+ workspaceRoot: resolveWorkspaceRoot(input.cwd),
581
679
  worktreeId: input.run?.worktreeId ?? null,
582
680
  goal: text,
583
681
  attachmentCount: 0,
@@ -586,7 +684,9 @@ export class Terminals {
586
684
  status: 'queued',
587
685
  exitCode: null,
588
686
  handoffId: null,
589
- accountId: live.account.id
687
+ accountId: live.account.id,
688
+ variantScope: live.variant?.scope ?? null,
689
+ variantVersion: live.variant?.fingerprint ?? null
590
690
  };
591
691
  }
592
692
  /** The run a native turn reports into: the one reserved by `send`, or a new one for what the person typed. */
@@ -612,57 +712,151 @@ export class Terminals {
612
712
  const record = loadRun(this.options.env.root, run.runId);
613
713
  if (!isActiveStatus(record.status))
614
714
  throw invalid(`The run is no longer active (its status is ${record.status}).`);
615
- const call = { run: record, caller: { ...live.caller, runId: run.runId }, tool, args };
715
+ const name = tool.toLowerCase();
716
+ const workspaceRoot = record.workspaceRoot ?? resolveWorkspaceRoot(record.workingDirectory ?? live.input.cwd);
717
+ if (live.input.readOnly && ['bash', 'edit', 'write'].includes(name))
718
+ return { allowed: false, reason: 'This session is read-only.', workspaceRoot };
719
+ let inspection;
720
+ try {
721
+ inspection = inspectRunWorkspaceWrite(record, tool, args);
722
+ }
723
+ catch (error) {
724
+ return { allowed: false, reason: error instanceof Error ? error.message : 'The active project workspace could not be verified.', workspaceRoot };
725
+ }
726
+ const call = { run: record, caller: { ...live.caller, runId: run.runId }, tool, args: inspection.input };
616
727
  const { policy } = this.options;
617
728
  if (policy.scope) {
618
- const scope = await policy.scope(call);
729
+ const scope = combineWorkspaceScope(await policy.scope(call), inspection);
619
730
  if (!scope.allowed && (scope.automatic || !scope.requiresApproval))
620
- return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.' };
731
+ return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.', workspaceRoot };
621
732
  try {
622
733
  await policy.authorize?.(call);
623
734
  }
624
735
  catch (error) {
625
- return { allowed: false, reason: error instanceof Error ? error.message : 'Tool unavailable in the settings.' };
736
+ return { allowed: false, reason: error instanceof Error ? error.message : 'Tool unavailable in the settings.', workspaceRoot };
626
737
  }
627
738
  const requiresApproval = !!scope.requiresApproval || (scope.allowed && !!scope.humanOnly);
628
739
  return {
629
740
  allowed: scope.allowed,
630
741
  requiresApproval,
631
742
  reason: scope.reason ?? null,
743
+ workspaceRoot,
632
744
  rememberable: scope.allowed && !scope.humanOnly,
633
745
  ...(scope.humanOnly ? { standing: scope.allowGlobalApproval ? 'switch' : 'none' } : {})
634
746
  };
635
747
  }
636
- // With no application policy: reads run, a read-only session refuses the rest, and anything else asks.
637
- const name = tool.toLowerCase();
638
- if (name === SESSION_NAME || name === CHECKLIST_WRITE || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name))
639
- return { allowed: true };
640
- if (live.input.readOnly)
641
- return { allowed: false, reason: 'This session is read-only.' };
642
- return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.' };
748
+ if (inspection.outside)
749
+ return { allowed: false, requiresApproval: true, rememberable: false, standing: 'switch', reason: inspection.reason, workspaceRoot };
750
+ if (name === SESSION_NAME || name === CHECKLIST_WRITE || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name) || ['bash', 'edit', 'write'].includes(name)) {
751
+ return { allowed: true, workspaceRoot };
752
+ }
753
+ return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.', workspaceRoot };
754
+ }
755
+ async workspaceEscape(live, run, tool, args, reason) {
756
+ if (tool !== 'bash' || live.input.readOnly)
757
+ return false;
758
+ const record = loadRun(this.options.env.root, run.runId);
759
+ if (!isActiveStatus(record.status) || (record.trigger != null && record.trigger !== 'user'))
760
+ return false;
761
+ const call = { run: record, caller: { ...live.caller, runId: run.runId }, tool, args };
762
+ const scope = (await this.options.policy.scope?.(call)) ?? { allowed: true };
763
+ if (scope.automatic || (!scope.allowed && !scope.requiresApproval))
764
+ return false;
765
+ try {
766
+ await this.options.policy.authorize?.(call);
767
+ }
768
+ catch {
769
+ return false;
770
+ }
771
+ const operationArgs = (args && typeof args === 'object' && !Array.isArray(args) ? args : {});
772
+ const command = typeof operationArgs.command === 'string' ? operationArgs.command : '';
773
+ if (operationArgs.workspaceWriteScope !== 'outside' || !command)
774
+ return false;
775
+ const workspaceRoot = record.workspaceRoot ?? resolveWorkspaceRoot(record.workingDirectory ?? live.input.cwd);
776
+ const decision = await run.requestApproval({
777
+ target: 'workspace-write',
778
+ action: `${reason} Workspace: ${workspaceRoot}. This exact command will run once with broader filesystem access.\n\nCommand:\n${formatBashCommandForApproval(command)}`,
779
+ operation: { tool, args: operationArgs },
780
+ rememberable: false,
781
+ standing: 'none'
782
+ });
783
+ if (decision !== 'approved')
784
+ return false;
785
+ const currentRecord = loadRun(this.options.env.root, run.runId);
786
+ if (!isActiveStatus(currentRecord.status) || (currentRecord.trigger != null && currentRecord.trigger !== 'user'))
787
+ return false;
788
+ const currentCall = { run: currentRecord, caller: { ...live.caller, runId: run.runId }, tool, args: operationArgs };
789
+ const currentScope = (await this.options.policy.scope?.(currentCall)) ?? { allowed: true };
790
+ if (currentScope.automatic || (!currentScope.allowed && !currentScope.requiresApproval))
791
+ return false;
792
+ try {
793
+ await this.options.policy.authorize?.(currentCall);
794
+ return true;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
800
+ async workspaceBoundaryBypass(live, run, tool, args) {
801
+ if (tool !== 'bash' || live.input.readOnly || !this.options.approvals.isAuto('pi'))
802
+ return false;
803
+ const record = loadRun(this.options.env.root, run.runId);
804
+ if (!isActiveStatus(record.status) || (record.trigger != null && record.trigger !== 'user'))
805
+ return false;
806
+ const operationArgs = (args && typeof args === 'object' && !Array.isArray(args) ? args : {});
807
+ if (typeof operationArgs.command !== 'string' || !operationArgs.command)
808
+ return false;
809
+ const call = { run: record, caller: { ...live.caller, runId: run.runId }, tool, args: operationArgs };
810
+ try {
811
+ const scope = (await this.options.policy.scope?.(call)) ?? { allowed: true };
812
+ if (scope.automatic || (!scope.allowed && !scope.requiresApproval) || (scope.humanOnly && !scope.allowGlobalApproval))
813
+ return false;
814
+ await this.options.policy.authorize?.(call);
815
+ return true;
816
+ }
817
+ catch {
818
+ return false;
819
+ }
643
820
  }
644
821
  async subagentsCall(live, call, params) {
645
822
  if (call === 'terminal.subagents.monitor')
646
823
  return { children: live.controller.subagentSnapshot() };
647
824
  const service = this.options.subagents;
648
- if (!service || live.input.subagents === null) {
825
+ if ((!service || live.input.subagents === null) && call !== 'terminal.subagents.register') {
649
826
  if (call === 'terminal.subagents.tools')
650
827
  return { tools: [] };
651
828
  throw invalid('Subagents are disabled for this terminal.');
652
829
  }
653
- if (call === 'terminal.subagents.tools')
654
- return { tools: subagentTools(service, '').map(tool => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters(Type) })) };
830
+ if (call === 'terminal.subagents.tools') {
831
+ const variantDir = this.variantDirs.get(live.input.sessionId) ?? null;
832
+ return {
833
+ tools: subagentTools(service, '').map(tool => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters(Type) })),
834
+ profiles: {
835
+ normal: profileSection(this.options.env.root, live.input.cwd, variantDir).body,
836
+ plan: profileSection(this.options.env.root, live.input.cwd, variantDir, true).body
837
+ }
838
+ };
839
+ }
655
840
  const run = live.controller.activeRun;
656
841
  if (!run)
657
842
  throw new HarnessError('run_not_active', 'This terminal has no active parent run.');
843
+ if (call === 'terminal.subagents.planSafe') {
844
+ const reason = service.planSpawnRefusal(run.runId, params.input);
845
+ return { allowed: reason === null, ...(reason ? { reason } : {}) };
846
+ }
658
847
  if (call === 'terminal.subagents.register') {
659
848
  if (!Array.isArray(params.tools) || params.tools.some((name) => typeof name !== 'string'))
660
849
  throw invalid('Native tool names are required.');
850
+ if (params.plan !== undefined && typeof params.plan !== 'boolean')
851
+ throw invalid('plan must be a boolean.');
661
852
  const readOnly = live.input.readOnly === true;
662
853
  const readTools = new Set([...READ_ONLY_TOOLS, SESSION_NAME, CHECKLIST_WRITE, SPAWN_SUBAGENT, WAIT_SUBAGENTS, 'ask_user', 'mcp_list_tools', 'mcp_call_tool', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load']);
663
- const tools = params.tools.filter((name) => !readOnly || readTools.has(name));
664
- const capabilities = this.options.resolveCapabilities ? await this.options.resolveCapabilities(live.caller, tools, live.input.cwd) : { version: 1, tools, connectors: [], contributions: [] };
665
- freezeRun(this.options.env.root, run.runId, live.caller, capabilities);
854
+ const tools = [...new Set(params.tools)].filter(name => (!readOnly || readTools.has(name)) && (params.plan === true || (name !== 'ask_user' && name !== 'ask_user_question')));
855
+ const caller = { ...live.caller, runId: run.runId };
856
+ const capabilities = this.options.resolveCapabilities ? await this.options.resolveCapabilities(caller, tools, live.input.cwd) : { version: 1, tools, connectors: [], contributions: [] };
857
+ freezeRun(this.options.env.root, run.runId, caller, capabilities);
858
+ if (!service || live.input.subagents === null)
859
+ return { runId: run.runId, connectors: capabilities.connectors };
666
860
  service.register({
667
861
  run: { id: run.runId, sessionId: live.input.sessionId, mode: live.input.run?.mode ?? 'code', projectId: live.input.projectId, worktreeId: live.input.run?.worktreeId },
668
862
  cwd: live.input.cwd, sessionDir: this.historyDir(live.input), text: '', caller: live.caller,
@@ -670,11 +864,12 @@ export class Terminals {
670
864
  effort: typeof params.effort === 'string' ? params.effort : null,
671
865
  launch: live.input.launch, tools: { allow: capabilities.tools, gated: capabilities.tools.filter(name => !readTools.has(name)) },
672
866
  connections: live.input.connections === false ? false : { readOnly }, subagents: live.input.subagents,
673
- variant: this.variantDirs.has(live.input.sessionId) ? { dir: this.variantDirs.get(live.input.sessionId), scope: live.input.variant && 'scope' in live.input.variant ? live.input.variant.scope ?? null : null } : null,
867
+ plan: params.plan === true,
868
+ variant: live.variant ? { dir: live.variant.dir, scope: live.variant.scope } : null,
674
869
  capabilities
675
870
  }, live.account.id, capabilities);
676
871
  service.restore(run.runId, params.history ?? { owners: [], receipts: [] });
677
- return { runId: run.runId };
872
+ return { runId: run.runId, connectors: capabilities.connectors };
678
873
  }
679
874
  if (call === 'terminal.subagents.verify') {
680
875
  await service.verify(run.runId, params.history);
@@ -834,16 +1029,20 @@ export class Terminals {
834
1029
  if (variant)
835
1030
  this.variantDirs.set(sessionId, variant.dir);
836
1031
  const worldTools = await this.worldToolNames(input, preparedAccount);
837
- const launchTools = [...new Set([...(input.launch?.tools ?? []), CHECKLIST_WRITE, SESSION_NAME, ...worldTools])];
1032
+ const ownTools = await this.ownToolNames(input, preparedAccount);
1033
+ const launchTools = [...new Set([...(input.launch?.tools ?? []), CHECKLIST_WRITE, SESSION_NAME, ...ownTools, ...(broker ? apps.toolNames : []), ...worldTools])];
838
1034
  // The profiles replace the variant's own subagent list, which they include.
839
1035
  const profiles = this.options.subagents && input.subagents !== null ? profileSection(root, input.cwd, variant?.dir ?? null) : null;
840
1036
  const launchSections = profiles ? [...(input.launch?.sections ?? []).filter((section) => section.id !== profiles.id), profiles] : input.launch?.sections;
841
- const pack = compile(env, caller, { ...input.launch, sections: launchSections, tools: launchTools }, variant && profiles ? { ...variant, sections: variant.sections.filter((section) => section.id !== profiles.id) } : variant);
1037
+ // A terminal is a person's own session: the same standard sections a host's session has.
1038
+ const standard = input.launch?.standard === null ? null : input.launch?.standard ?? { cwd: input.cwd };
1039
+ const pack = compile(env, caller, { ...input.launch, standard, sections: launchSections, tools: launchTools }, variant && profiles ? { ...variant, sections: variant.sections.filter((section) => section.id !== profiles.id) } : variant);
842
1040
  record(root, pack);
843
1041
  mkdirSync(this.historyDir(input), { recursive: true });
844
1042
  saveForSession(input.sessionFolder, pack);
845
1043
  const live = {
846
1044
  input,
1045
+ variant,
847
1046
  account: preparedAccount,
848
1047
  broker,
849
1048
  caller,
@@ -853,6 +1052,12 @@ export class Terminals {
853
1052
  nativeSessionId: input.nativeSessionId ?? null,
854
1053
  begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
855
1054
  policy: (run, tool, args) => this.decide(live, run, tool, args),
1055
+ workspaceRoot: run => {
1056
+ const record = loadRun(this.options.env.root, run.runId);
1057
+ return record.workspaceRoot ?? resolveWorkspaceRoot(record.workingDirectory ?? live.input.cwd);
1058
+ },
1059
+ workspaceEscape: (run, tool, args, reason) => this.workspaceEscape(live, run, tool, args, reason),
1060
+ workspaceBoundaryBypass: (run, tool, args) => this.workspaceBoundaryBypass(live, run, tool, args),
856
1061
  allowToolGlobally: target => { this.options.approvals.allowTool('pi', target); },
857
1062
  native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
858
1063
  telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
@@ -980,11 +1185,15 @@ export class Terminals {
980
1185
  : this.options.approvals.revokeTool('pi', params.tool);
981
1186
  return { globalTools: policy.globalTools.pi ?? [] };
982
1187
  }
1188
+ if (call.startsWith('terminal.usage.'))
1189
+ return this.usageCall(live, call, params);
983
1190
  if (call !== 'terminal.resources')
984
1191
  return live.controller.control(call, params);
985
1192
  const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, preparedAccount), install: false, caller: { projectId: input.projectId ?? null } }) ?? null;
986
- if (resourceIdentity(current) !== resourceIdentity(loading))
1193
+ const currentVariant = variant ? materialise(root, { dir: variant.dir, scope: variant.scope }, { trust: this.options.trust ?? null }) : null;
1194
+ if (resourceIdentity(current) !== resourceIdentity(loading) || (variant && (currentVariant?.fingerprint !== variant.fingerprint || JSON.stringify(currentVariant.extensionPaths) !== JSON.stringify(variant.extensionPaths)))) {
987
1195
  throw new HarnessError('terminal_resources_changed', 'Trusted resources changed since this terminal was prepared. Review /plugins and reopen the terminal to load the new selection.');
1196
+ }
988
1197
  return {};
989
1198
  } }
990
1199
  });
@@ -993,16 +1202,17 @@ export class Terminals {
993
1202
  const args = [...command.argsPrefix, '--append-system-prompt', render(env, pack)];
994
1203
  if (input.model)
995
1204
  args.push('--model', input.model);
996
- if (loading) {
1205
+ {
997
1206
  // Pi's own discovery is off: the terminal loads what the harness found and nothing else.
998
1207
  args.push('--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes');
999
- for (const path of [...loading.extensionPaths, ...loading.builtins, ...(variant?.extensionPaths ?? [])])
1208
+ // The pi command's own extensions are not loaded: a host's session cannot have them (PROP-0039).
1209
+ for (const path of [...(loading?.extensionPaths ?? []), ...(variant?.extensionPaths ?? [])])
1000
1210
  args.push('--extension', path);
1001
- for (const path of [...(variant?.skillPaths ?? []), ...loading.skillPaths])
1211
+ for (const path of [...(variant?.skillPaths ?? []), ...(loading?.skillPaths ?? [])])
1002
1212
  args.push('--skill', path);
1003
- for (const path of loading.promptPaths)
1213
+ for (const path of loading?.promptPaths ?? [])
1004
1214
  args.push('--prompt-template', path);
1005
- for (const path of loading.themePaths)
1215
+ for (const path of loading?.themePaths ?? [])
1006
1216
  args.push('--theme', path);
1007
1217
  }
1008
1218
  // Last, so its gate sees a tool call as every other extension left it.
@@ -1,3 +1,3 @@
1
1
  import type { AnnotateStart } from "./pages/annotatePage.js";
2
- /** `/plugin:annotate` accepts a page address, DevTools target, or page-bridge target. */
2
+ /** `/annotate:annotate` accepts a page address, DevTools target, or page-bridge target. */
3
3
  export declare function parseAnnotate(argument: string): AnnotateStart | string;
@@ -1,4 +1,4 @@
1
- /** `/plugin:annotate` accepts a page address, DevTools target, or page-bridge target. */
1
+ /** `/annotate:annotate` accepts a page address, DevTools target, or page-bridge target. */
2
2
  export function parseAnnotate(argument) {
3
3
  const words = argument.trim().split(/\s+/).filter(Boolean);
4
4
  if (!words.length)
@@ -6,7 +6,7 @@ export function parseAnnotate(argument) {
6
6
  if (words[0] === '--app') {
7
7
  const endpoint = words[1];
8
8
  if (!endpoint)
9
- return 'Give the app\'s DevTools address: /plugin:annotate --app http://127.0.0.1:9229';
9
+ return 'Give the app\'s DevTools address: /annotate:annotate --app http://127.0.0.1:9229';
10
10
  const address = /^\d+$/.test(endpoint) ? `http://127.0.0.1:${endpoint}` : endpoint;
11
11
  const name = words.slice(2).join(' ');
12
12
  return { kind: 'cdp', endpoint: address, ...(name ? { name } : {}) };
@@ -16,7 +16,7 @@ export function parseAnnotate(argument) {
16
16
  return { kind: 'bridge', ...(name ? { name } : {}) };
17
17
  }
18
18
  if (words.length > 1 || words[0].startsWith('--'))
19
- return 'Use /plugin:annotate [url], /plugin:annotate --app <DevTools address>, or /plugin:annotate --bridge.';
19
+ return 'Use /annotate:annotate [url], /annotate:annotate --app <DevTools address>, or /annotate:annotate --bridge.';
20
20
  const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(words[0]) ? words[0] : /^(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(words[0]) ? `http://${words[0]}` : `https://${words[0]}`;
21
21
  return { kind: 'web', url };
22
22
  }
@@ -1,11 +1,13 @@
1
1
  import type { TerminalPage } from "./page.js";
2
2
  import type { TerminalCommandHost } from "./commands.js";
3
3
  import { type AnnotateStart } from "./pages/annotatePage.js";
4
- export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents';
4
+ export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents' | 'usage';
5
5
  /** Construct one independent full-screen page for a slash command. */
6
6
  export declare function createCommandPage(host: TerminalCommandHost, id: CommandPageId, options?: {
7
7
  runId?: string;
8
8
  url?: string;
9
9
  annotate?: AnnotateStart;
10
+ usageTab?: 'analysis' | 'limit';
11
+ usagePeriod?: 'today' | '7d' | '30d' | 'all';
10
12
  close?(): void;
11
13
  }): TerminalPage;
@@ -10,12 +10,15 @@ import { NotesPage } from "./pages/notesPage.js";
10
10
  import { AnnotatePage } from "./pages/annotatePage.js";
11
11
  import { SubagentsPage } from "./pages/subagentsPage.js";
12
12
  import { AgentsPage } from "./pages/agentsPage.js";
13
+ import { UsagePage } from "./pages/usagePage.js";
13
14
  import { createSettingsModel } from "./settingsModel.js";
14
15
  /** Construct one independent full-screen page for a slash command. */
15
16
  export function createCommandPage(host, id, options = {}) {
16
17
  const theme = () => host.ui.theme;
17
18
  const notify = (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info');
18
19
  switch (id) {
20
+ case 'usage':
21
+ return new UsagePage({ theme, call: host.control, changed: host.changed, initialTab: options.usageTab === 'limit' ? 'limits' : 'analysis', ...(options.usagePeriod ? { initialPeriod: options.usagePeriod } : {}) });
19
22
  case 'doctor':
20
23
  return new DoctorPage({ theme, call: host.control, confirm: host.ui.confirm.bind(host.ui), select: host.ui.select.bind(host.ui), changed: host.changed,
21
24
  selection: () => {