@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
@@ -0,0 +1,297 @@
1
+ // A reply's work slice (PROP-0041): a bounded amount of work, then a handoff checkpoint, then a
2
+ // continuation. The guard here refuses the call that would run past the budget, once with a
3
+ // request for the checkpoint, and ends the reply if the model keeps calling tools. It also names
4
+ // repeated work before it becomes a loop. All decisions are deterministic; no model is consulted.
5
+ import { createHash } from 'node:crypto';
6
+ import { findRun } from "./runStore.js";
7
+ export const WORK_SLICE_EXTENSION = 'work-slice';
8
+ /** The foundation defaults: enough for one meaningful step of work, short enough to report. */
9
+ export const DEFAULT_SLICE_POLICY = {
10
+ enabled: true,
11
+ // Roughly 40 minutes of ordinary work (about 15 s a call), so a checkpoint is a chapter of a
12
+ // task, not an interruption every few edits. A task still gets eight of them before the cap.
13
+ maxToolCalls: 160,
14
+ maxMinutes: 40,
15
+ // A generous cost ceiling, counted on new input, cache writes and output only: heavy reading at
16
+ // a large context can add tens of thousands of tokens a request, so a small cap stopped useful
17
+ // work (see the "Confirm plan model support" thread). 320k is a safety net, not the bound.
18
+ maxNewTokens: 320_000,
19
+ maxSlices: 8,
20
+ continue: true
21
+ };
22
+ const LIMITS = {
23
+ maxToolCalls: { min: 1, max: 2_000 },
24
+ maxMinutes: { min: 1, max: 480 },
25
+ maxNewTokens: { min: 1_000, max: 5_000_000 },
26
+ maxSlices: { min: 1, max: 100 }
27
+ };
28
+ export class WorkSliceError extends Error {
29
+ }
30
+ /**
31
+ * The policy a run asked for, or the defaults. `XINGULAR_WORK_SLICE=off` at launch turns the guard
32
+ * off for every run in this process; nothing is persisted, so restarting without it restores the
33
+ * defaults.
34
+ */
35
+ export function parseWorkSlice(input) {
36
+ if (input === undefined || input === null) {
37
+ const launch = process.env.XINGULAR_WORK_SLICE?.trim().toLowerCase();
38
+ return { ...DEFAULT_SLICE_POLICY, ...(launch === 'off' || launch === '0' || launch === 'false' ? { enabled: false } : {}) };
39
+ }
40
+ if (typeof input !== 'object' || Array.isArray(input))
41
+ throw new WorkSliceError('workSlice must be an object.');
42
+ const record = input;
43
+ const policy = { ...DEFAULT_SLICE_POLICY };
44
+ for (const key of Object.keys(record)) {
45
+ if (key === 'enabled') {
46
+ if (typeof record.enabled !== 'boolean')
47
+ throw new WorkSliceError('workSlice.enabled must be true or false.');
48
+ policy.enabled = record.enabled;
49
+ continue;
50
+ }
51
+ if (key === 'continue') {
52
+ if (typeof record.continue !== 'boolean')
53
+ throw new WorkSliceError('workSlice.continue must be true or false.');
54
+ policy.continue = record.continue;
55
+ continue;
56
+ }
57
+ const limits = LIMITS[key];
58
+ if (!limits)
59
+ throw new WorkSliceError(`workSlice has no field ${key}.`);
60
+ const value = record[key];
61
+ if (typeof value !== 'number' || !Number.isInteger(value))
62
+ throw new WorkSliceError(`workSlice.${key} must be a whole number.`);
63
+ // 0 switches one limit off, as the settings page says.
64
+ if (value === 0) {
65
+ policy[key] = 0;
66
+ continue;
67
+ }
68
+ if (value < limits.min || value > limits.max)
69
+ throw new WorkSliceError(`workSlice.${key} must be between ${limits.min} and ${limits.max}, or 0 for no limit.`);
70
+ policy[key] = value;
71
+ }
72
+ return policy;
73
+ }
74
+ export function createSliceState(_policy, slice = 1, now = Date.now()) {
75
+ return { slice, steps: 0, tokens: 0, startedAt: now, lastTool: null, compactions: 0, checkpointWritten: false, endedBy: null, endedByLimit: null, refusedAt: null };
76
+ }
77
+ /** Counters start again for each reply; a terminal session keeps one guard across replies. */
78
+ export function resetSliceState(state, now = Date.now()) {
79
+ state.steps = 0;
80
+ state.tokens = 0;
81
+ state.startedAt = now;
82
+ state.lastTool = null;
83
+ state.compactions = 0;
84
+ state.checkpointWritten = false;
85
+ state.endedBy = null;
86
+ state.endedByLimit = null;
87
+ state.refusedAt = null;
88
+ }
89
+ /** The first limit this reply has passed, named as a person would say it. */
90
+ export function sliceLimitReached(policy, state, now = Date.now()) {
91
+ if (policy.maxToolCalls > 0 && state.steps >= policy.maxToolCalls)
92
+ return `${policy.maxToolCalls} tool calls`;
93
+ if (policy.maxMinutes > 0 && now - state.startedAt >= policy.maxMinutes * 60_000)
94
+ return `${policy.maxMinutes} minutes`;
95
+ if (policy.maxNewTokens > 0 && state.tokens >= policy.maxNewTokens)
96
+ return `${Math.round(policy.maxNewTokens / 1000)}k new tokens`;
97
+ return null;
98
+ }
99
+ /** Tools a reply may still call while it writes its handoff. A plan update waits for the next slice. */
100
+ const HANDOFF_TOOLS = new Set(['checkpoint_write', 'session_name']);
101
+ /** Tools whose repetition is how waiting works, not a loop. */
102
+ const POLLING_TOOLS = new Set(['wait_subagents', 'subagents.list', 'subagents.status']);
103
+ /** Commands that read as a check, for "no progress" and "still failing" rules. */
104
+ export function looksLikeCheck(command) {
105
+ return /\b(test|tests|typecheck|tsc|vitest|jest|cargo|check:|prove|lint|ci|build)\b/u.test(command);
106
+ }
107
+ /**
108
+ * A stable rendering of a value: object keys sorted, so argument order never changes the result.
109
+ * Depth is bounded so a cyclic or absurdly nested input cannot recurse forever.
110
+ */
111
+ function canonical(value, depth = 0) {
112
+ if (depth > 8)
113
+ return '…';
114
+ if (value === null || typeof value !== 'object')
115
+ return JSON.stringify(value) ?? 'null';
116
+ if (Array.isArray(value))
117
+ return `[${value.map((item) => canonical(item, depth + 1)).join(',')}]`;
118
+ const entries = Object.entries(value).filter(([, item]) => item !== undefined);
119
+ entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
120
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item, depth + 1)}`).join(',')}}`;
121
+ }
122
+ /**
123
+ * A call's identity: its name and every argument, hashed so the key stays small even when an
124
+ * argument is a whole file. A changed argument is a different call; only an identical one repeats.
125
+ * Reading a fixed handful of fields made every `checklist_write` look identical, so a reply that
126
+ * reported its progress was told it was repeating work.
127
+ */
128
+ const signature = (name, input) => `${name}\u0000${createHash('sha256').update(canonical(input ?? null)).digest('hex')}`;
129
+ const clip = (value, limit) => (value.length <= limit ? value : `${value.slice(0, limit - 1)}…`);
130
+ function handoffRequest(limit, state) {
131
+ return clip(`This reply reached its work-slice limit (${limit}; step ${state.steps}). Call checkpoint_write now with what is done, the single next step, anything blocking, the files you touched and the checks that matter. Then tell the user in a few lines where you stopped. Do not call other tools.`, 600);
132
+ }
133
+ function stalledNote(reason) {
134
+ return clip(`You are repeating work: ${reason}. Do not repeat it. Choose a different approach, read what the earlier attempt returned, or call checkpoint_write with the blocker so the next slice can decide.`, 600);
135
+ }
136
+ /**
137
+ * Bounds one reply and names repeated work. The first refusal asks for a checkpoint; a tool call
138
+ * after that refusal ends the reply. A repeated call or failing command is named once, then ends it.
139
+ */
140
+ export function workSliceGuard(options) {
141
+ const { policy, state } = options;
142
+ const calls = new Map();
143
+ const edits = new Map();
144
+ const failures = new Map();
145
+ let stepsSinceProgress = 0;
146
+ let nudged = false;
147
+ /** Repetition is judged inside one reply; the next reply starts from a clean count. */
148
+ const resetRepetition = () => {
149
+ calls.clear();
150
+ edits.clear();
151
+ failures.clear();
152
+ stepsSinceProgress = 0;
153
+ nudged = false;
154
+ };
155
+ const end = (kind, limit) => {
156
+ state.endedBy = kind;
157
+ state.endedByLimit = limit;
158
+ options.onEnd?.(kind, limit);
159
+ const reason = kind === 'stuck'
160
+ ? stalledNote(limit)
161
+ : kind === 'compaction-thrash'
162
+ ? handoffRequest('three context compactions in one reply', state)
163
+ : handoffRequest(limit, state);
164
+ return { block: true, terminate: true, reason };
165
+ };
166
+ const repeated = (name, input) => {
167
+ if (POLLING_TOOLS.has(name))
168
+ return null;
169
+ const key = signature(name, input);
170
+ const count = (calls.get(key) ?? 0) + 1;
171
+ calls.set(key, count);
172
+ if (count < 3)
173
+ return null;
174
+ return name === 'bash' ? `the same Bash command ${count} times (steps ending at ${state.steps})` : `the same ${name} call ${count} times (steps ending at ${state.steps})`;
175
+ };
176
+ const failing = (name, input) => {
177
+ if (name !== 'bash')
178
+ return null;
179
+ const command = input && typeof input === 'object' ? String(input.command ?? '') : '';
180
+ if (!command || !looksLikeCheck(command))
181
+ return null;
182
+ const count = failures.get(command) ?? 0;
183
+ return count >= 2 ? `the check ${command} failed ${count} times` : null;
184
+ };
185
+ return (pi) => {
186
+ pi.on('agent_start', () => {
187
+ resetSliceState(state);
188
+ resetRepetition();
189
+ });
190
+ pi.on('message_end', (event) => {
191
+ const usage = event.message.usage;
192
+ if (event.message.role !== 'assistant' || !usage)
193
+ return;
194
+ // New tokens only: the cached prefix was sent before and is not new context or new cost.
195
+ state.tokens += (usage.input ?? 0) + (usage.cacheWrite ?? 0) + (usage.output ?? 0);
196
+ });
197
+ pi.on('tool_result', (event) => {
198
+ if (event.toolName === 'checkpoint_write' && !event.isError) {
199
+ state.checkpointWritten = true;
200
+ stepsSinceProgress = 0;
201
+ nudged = false;
202
+ }
203
+ if (event.toolName !== 'bash')
204
+ return;
205
+ const command = event.input && typeof event.input === 'object' ? String(event.input.command ?? '') : '';
206
+ if (!command || !looksLikeCheck(command))
207
+ return;
208
+ if (event.isError) {
209
+ failures.set(command, (failures.get(command) ?? 0) + 1);
210
+ return;
211
+ }
212
+ // A passing check is progress: the repetition rules start over.
213
+ failures.delete(command);
214
+ edits.clear();
215
+ calls.clear();
216
+ stepsSinceProgress = 0;
217
+ nudged = false;
218
+ });
219
+ pi.on('tool_call', (event) => {
220
+ const name = event.toolName;
221
+ state.steps += 1;
222
+ state.lastTool = name;
223
+ const handoff = HANDOFF_TOOLS.has(name);
224
+ // A handoff is always callable; after the slice ended it is the only thing that is.
225
+ if (state.endedBy) {
226
+ return handoff ? undefined : { block: true, terminate: true, reason: 'This reply is over. Its checkpoint is recorded; nothing else runs.' };
227
+ }
228
+ if (state.compactions >= 3) {
229
+ if (handoff) {
230
+ state.endedBy = 'compaction-thrash';
231
+ state.endedByLimit = 'three compactions';
232
+ return undefined;
233
+ }
234
+ return end('compaction-thrash', 'three compactions');
235
+ }
236
+ const limit = sliceLimitReached(policy, state);
237
+ if (limit) {
238
+ // The slice ends here; the model gets one wrap-up request to write the handoff. A handoff
239
+ // call is never refused, including when it is the very first call past the budget.
240
+ state.endedBy = 'budget';
241
+ state.endedByLimit = limit;
242
+ state.refusedAt ??= state.steps;
243
+ stepsSinceProgress = 0;
244
+ return handoff ? undefined : { block: true, reason: handoffRequest(limit, state) };
245
+ }
246
+ if (handoff)
247
+ return undefined;
248
+ const edit = name === 'edit' || name === 'write';
249
+ const path = edit && event.input && typeof event.input === 'object' ? String(event.input.path ?? '') : '';
250
+ if (edit && path) {
251
+ const count = (edits.get(path) ?? 0) + 1;
252
+ edits.set(path, count);
253
+ stepsSinceProgress = 0;
254
+ if (count >= 4) {
255
+ const reason = `${path} was edited ${count} times with no passing check between`;
256
+ if (nudged)
257
+ return end('stuck', reason);
258
+ nudged = true;
259
+ return { block: true, reason: stalledNote(reason) };
260
+ }
261
+ }
262
+ else {
263
+ stepsSinceProgress += 1;
264
+ }
265
+ const repeat = repeated(name, event.input);
266
+ const failure = failing(name, event.input);
267
+ const noProgress = stepsSinceProgress >= 25 ? `${stepsSinceProgress} steps with no file change and no passing check` : null;
268
+ const reason = repeat ?? failure ?? noProgress;
269
+ if (!reason)
270
+ return undefined;
271
+ if (nudged)
272
+ return end('stuck', reason);
273
+ nudged = true;
274
+ stepsSinceProgress = 0;
275
+ return { block: true, reason: stalledNote(reason) };
276
+ });
277
+ };
278
+ }
279
+ /**
280
+ * Which slice of its session a run is, from 1. Counts the recorded continuation chain, so it
281
+ * survives a restart and needs no extra field on the run record.
282
+ */
283
+ export function sliceOf(root, runId, limit = 100) {
284
+ let current = runId;
285
+ let count = 0;
286
+ const seen = new Set();
287
+ while (current && count < limit && !seen.has(current)) {
288
+ seen.add(current);
289
+ count += 1;
290
+ current = findRun(root, current)?.resumedFromRunId ?? null;
291
+ }
292
+ return Math.max(1, count);
293
+ }
294
+ /** The reason a checkpoint the harness derives (no model handoff) records. */
295
+ export function derivedReason(end) {
296
+ return end ?? 'requested';
297
+ }
@@ -0,0 +1,15 @@
1
+ import type { RunRecord } from "./contracts.js";
2
+ import type { ScopeDecision } from "./policy.js";
3
+ export declare const WORKSPACE_FILE_WRITE_TOOLS: Set<string>;
4
+ export interface WorkspaceWriteInspection {
5
+ input: unknown;
6
+ path: string | null;
7
+ outside: boolean;
8
+ reason: string | null;
9
+ }
10
+ /** Canonicalize native file-write arguments and identify an explicit workspace escape. */
11
+ export declare function inspectWorkspaceWrite(workspaceRoot: string, cwd: string, tool: string, input: unknown): WorkspaceWriteInspection;
12
+ /** Adds the mandatory per-operation boundary without allowing it to waive host hard denials. */
13
+ export declare function combineWorkspaceScope(scope: ScopeDecision, inspection: WorkspaceWriteInspection): ScopeDecision;
14
+ /** Resolve a root for pre-boundary run records, then inspect the write using the pinned directory. */
15
+ export declare function inspectRunWorkspaceWrite(run: RunRecord, tool: string, input: unknown): WorkspaceWriteInspection;
@@ -0,0 +1,46 @@
1
+ import { isWithinWorkspace, resolveWorkspaceRoot, resolveWritePath, verifyWorkspaceRoot } from "../core/workspace.js";
2
+ export const WORKSPACE_FILE_WRITE_TOOLS = new Set(['edit', 'write']);
3
+ /** Canonicalize native file-write arguments and identify an explicit workspace escape. */
4
+ export function inspectWorkspaceWrite(workspaceRoot, cwd, tool, input) {
5
+ if (!WORKSPACE_FILE_WRITE_TOOLS.has(tool.toLowerCase()) || !input || typeof input !== 'object' || Array.isArray(input)) {
6
+ return { input, path: null, outside: false, reason: null };
7
+ }
8
+ const args = input;
9
+ if (typeof args.path !== 'string')
10
+ return { input, path: null, outside: false, reason: null };
11
+ const root = verifyWorkspaceRoot(workspaceRoot);
12
+ const path = resolveWritePath(args.path, cwd);
13
+ const outside = !isWithinWorkspace(root, path);
14
+ return {
15
+ input: { ...args, path },
16
+ path,
17
+ outside,
18
+ reason: outside ? `Write target ${path} is outside the active project workspace ${root}.` : null
19
+ };
20
+ }
21
+ /** Adds the mandatory per-operation boundary without allowing it to waive host hard denials. */
22
+ export function combineWorkspaceScope(scope, inspection) {
23
+ if (!inspection.outside)
24
+ return scope;
25
+ if (!scope.allowed && (scope.automatic || !scope.requiresApproval))
26
+ return scope;
27
+ return {
28
+ ...scope,
29
+ allowed: false,
30
+ requiresApproval: true,
31
+ humanOnly: true,
32
+ allowGlobalApproval: scope.humanOnly ? scope.allowGlobalApproval : true,
33
+ reason: inspection.reason
34
+ };
35
+ }
36
+ /** Resolve a root for pre-boundary run records, then inspect the write using the pinned directory. */
37
+ export function inspectRunWorkspaceWrite(run, tool, input) {
38
+ const cwd = run.workingDirectory;
39
+ const root = run.workspaceRoot ?? (cwd ? resolveWorkspaceRoot(cwd) : null);
40
+ if (!root || !cwd) {
41
+ if (WORKSPACE_FILE_WRITE_TOOLS.has(tool.toLowerCase()))
42
+ throw new Error('The active project workspace could not be resolved; file writes are blocked.');
43
+ return { input, path: null, outside: false, reason: null };
44
+ }
45
+ return inspectWorkspaceWrite(root, cwd, tool, input);
46
+ }
@@ -1,4 +1,5 @@
1
1
  import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { describePlugin } from "../plugins/surfaces.js";
2
3
  import * as path from 'node:path';
3
4
  import { basename, dirname, join, relative, resolve } from 'node:path';
4
5
  import { invalid } from "../core/errors.js";
@@ -46,7 +47,7 @@ export class BrowserControl {
46
47
  ])
47
48
  add({ id: `foundation:${id}`, section: 'foundation', name: name, description: description, owner: 'Foundation', origin: 'Built-in', status: 'Read-only' });
48
49
  for (const plugin of plugins) {
49
- add({ id: `plugin:${plugin.id}`, section: 'plugins', name: plugin.title, description: plugin.description,
50
+ add({ id: `plugin:${plugin.id}`, section: 'plugins', name: plugin.title, description: describePlugin(plugin),
50
51
  owner: plugin.title, origin: plugin.source, status: plugin.required ? 'Required' : plugin.trust === 'untrusted' ? 'Needs trust' : 'Saved configuration',
51
52
  pluginId: plugin.id, path: plugin.path ?? undefined, required: plugin.required });
52
53
  for (const entry of plugin.contributions) {
@@ -1,9 +1,10 @@
1
1
  import { type RunEvent, type RunRecord } from "../sessions/contracts.js";
2
2
  import type { ActiveRun, ApprovalDecision, QuestionAnswer } from "../sessions/registry.js";
3
- import { type TerminalSubagentActivity, type TerminalSubagentProfile } from "./subagentMonitor.js";
3
+ import { type TerminalSubagentActivity, type TerminalSubagentProfile, type TerminalSubagentRun } from "./subagentMonitor.js";
4
4
  export interface PolicyReply {
5
5
  allowed: boolean;
6
6
  reason?: string | null;
7
+ workspaceRoot?: string;
7
8
  requiresApproval?: boolean;
8
9
  rememberable?: boolean;
9
10
  standing?: 'full' | 'switch' | 'none';
@@ -19,6 +20,12 @@ export interface ControllerHooks {
19
20
  }): ActiveRun;
20
21
  /** Whether a native tool call may run. */
21
22
  policy(run: ActiveRun, tool: string, args: unknown): Promise<PolicyReply>;
23
+ /** The workspace root pinned on the run record. */
24
+ workspaceRoot(run: ActiveRun): string;
25
+ /** One-shot approval before an explicitly broad Bash command runs. */
26
+ workspaceEscape(run: ActiveRun, tool: string, args: unknown, reason: string): Promise<boolean>;
27
+ /** Whether Full Permission may bypass the filesystem boundary for this Bash command. */
28
+ workspaceBoundaryBypass(run: ActiveRun, tool: string, args: unknown): Promise<boolean>;
22
29
  /** Saves a user-selected tool grant in the shared local approval store. */
23
30
  allowToolGlobally(target: string): void;
24
31
  /** The native conversation identity changed. */
@@ -37,6 +44,7 @@ export declare class TerminalController {
37
44
  private run;
38
45
  private readonly pending;
39
46
  private readonly subagents;
47
+ private subagentParentRunId;
40
48
  private ready;
41
49
  private disposed;
42
50
  private dirty;
@@ -67,8 +75,8 @@ export declare class TerminalController {
67
75
  /** Project registry-owned questions, including grouped requests, into the terminal inbox. */
68
76
  observe(event: RunEvent, descendant?: boolean): void;
69
77
  /** A child event that belongs to the active terminal parent run. */
70
- observeSubagent(parentRunId: string, run: RunRecord, event: RunEvent, profile?: TerminalSubagentProfile | null): void;
71
- /** Read-only child activity for the active run. */
78
+ observeSubagent(parentRunId: string, run: RunRecord, event: RunEvent, profile?: TerminalSubagentProfile | null, runtime?: Pick<TerminalSubagentRun, 'accountLabel' | 'model' | 'effort'>): void;
79
+ /** Read-only child activity for the latest parent run, retained until another run begins. */
72
80
  subagentSnapshot(): TerminalSubagentActivity[];
73
81
  /** The socket grants this interface only to the process that owns terminal input. */
74
82
  control(call: string, data: Record<string, any>): Promise<unknown>;
@@ -14,6 +14,7 @@ export class TerminalController {
14
14
  run = null;
15
15
  pending = new Map();
16
16
  subagents = new SubagentMonitor();
17
+ subagentParentRunId = null;
17
18
  ready = false;
18
19
  disposed = false;
19
20
  dirty = false;
@@ -52,6 +53,7 @@ export class TerminalController {
52
53
  throw new Error('The native prompt did not match the reserved delivery. Inspect the terminal before continuing.');
53
54
  const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text });
54
55
  this.run = run;
56
+ this.subagentParentRunId = run.runId;
55
57
  this.subagents.begin(run.runId);
56
58
  run.onInterrupt(() => void this.commands.push({ type: 'interrupt' }));
57
59
  if (text)
@@ -60,10 +62,8 @@ export class TerminalController {
60
62
  this.delivery = null;
61
63
  return run;
62
64
  }
63
- finish(outcome, message) {
64
- const runId = this.run?.runId;
65
- this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined);
66
- this.subagents.clear(runId);
65
+ finish(outcome, message, reason) {
66
+ this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined, reason);
67
67
  this.run = null;
68
68
  this.seen.clear();
69
69
  this.pending.clear();
@@ -133,10 +133,11 @@ export class TerminalController {
133
133
  const payload = event.payload;
134
134
  this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId });
135
135
  }
136
- else if (descendant && event.type === 'approval.requested') {
136
+ else if (event.type === 'approval.requested') {
137
137
  const payload = event.payload;
138
- if (!payload.automatic)
138
+ if (!payload.automatic && (descendant || payload.target === 'workspace-write')) {
139
139
  this.pending.set(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId });
140
+ }
140
141
  }
141
142
  else if (event.type === 'approval.resolved') {
142
143
  this.pending.delete(String(event.payload.approvalId));
@@ -146,7 +147,7 @@ export class TerminalController {
146
147
  }
147
148
  }
148
149
  /** A child event that belongs to the active terminal parent run. */
149
- observeSubagent(parentRunId, run, event, profile = null) {
150
+ observeSubagent(parentRunId, run, event, profile = null, runtime = {}) {
150
151
  if (this.disposed || this.activeRun?.runId !== parentRunId || run.relation !== 'subagent')
151
152
  return;
152
153
  this.subagents.observe(parentRunId, {
@@ -158,13 +159,13 @@ export class TerminalController {
158
159
  status: runStatusSchema.parse(run.status),
159
160
  startedAt: run.startedAt,
160
161
  endedAt: run.endedAt,
161
- profile
162
+ profile,
163
+ ...runtime
162
164
  }, event);
163
165
  }
164
- /** Read-only child activity for the active run. */
166
+ /** Read-only child activity for the latest parent run, retained until another run begins. */
165
167
  subagentSnapshot() {
166
- const run = this.activeRun;
167
- return run ? this.subagents.snapshot(run.runId) : [];
168
+ return this.subagentParentRunId ? this.subagents.snapshot(this.subagentParentRunId) : [];
168
169
  }
169
170
  /** The socket grants this interface only to the process that owns terminal input. */
170
171
  async control(call, data) {
@@ -185,6 +186,20 @@ export class TerminalController {
185
186
  throw new HarnessError('invalid_request', 'An active run and tool name are required.');
186
187
  return this.hooks.policy(run, data.tool, data.args ?? {});
187
188
  }
189
+ if (call === 'terminal.workspaceBoundaryBypass') {
190
+ const run = this.activeRun;
191
+ if (!run || data.tool !== 'bash')
192
+ throw new HarnessError('invalid_request', 'An active run and Bash command are required.');
193
+ const args = data.args && typeof data.args === 'object' && !Array.isArray(data.args) ? data.args : {};
194
+ return { bypass: await this.hooks.workspaceBoundaryBypass(run, 'bash', args) };
195
+ }
196
+ if (call === 'terminal.workspaceEscape') {
197
+ const run = this.activeRun;
198
+ if (!run || data.tool !== 'bash' || typeof data.reason !== 'string' || !data.reason)
199
+ throw new HarnessError('invalid_request', 'An active run, Bash command, and outside-scope request are required.');
200
+ const args = data.args && typeof data.args === 'object' && !Array.isArray(data.args) ? data.args : {};
201
+ return { approved: await this.hooks.workspaceEscape(run, 'bash', args, data.reason) };
202
+ }
188
203
  if (call === 'terminal.approval')
189
204
  return this.requestNativeApproval(data);
190
205
  if (call === 'terminal.answers' && this.hooks.answerQuestions) {
@@ -294,6 +309,10 @@ export class TerminalController {
294
309
  }
295
310
  this.ready = true;
296
311
  if (typeof data.nativeSessionId === 'string' && data.nativeSessionId) {
312
+ if (this.nativeId !== data.nativeSessionId) {
313
+ this.subagents.clear();
314
+ this.subagentParentRunId = null;
315
+ }
297
316
  this.nativeId = data.nativeSessionId;
298
317
  this.hooks.native(data.nativeSessionId);
299
318
  }
@@ -301,7 +320,7 @@ export class TerminalController {
301
320
  }
302
321
  if (event === 'begin') {
303
322
  const run = this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''));
304
- return { runId: run.runId };
323
+ return { runId: run.runId, workspaceRoot: this.hooks.workspaceRoot(run) };
305
324
  }
306
325
  if (event === 'shutdown') {
307
326
  this.ready = false;
@@ -309,7 +328,11 @@ export class TerminalController {
309
328
  return {};
310
329
  }
311
330
  if (event === 'settled') {
312
- this.finish(data.aborted ? 'interrupted' : data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined);
331
+ // A reply that stopped at its work-slice boundary is interrupted, not completed: the record
332
+ // says `slice_complete` (or `stalled`) so a host can continue it (PROP-0041).
333
+ const reason = typeof data.reason === 'string' ? data.reason : undefined;
334
+ const outcome = data.error ? 'failed' : data.aborted || reason ? 'interrupted' : 'completed';
335
+ this.finish(outcome, data.error ? String(data.error) : undefined, reason);
313
336
  return {};
314
337
  }
315
338
  const run = this.run;
@@ -356,6 +379,8 @@ export class TerminalController {
356
379
  run.emit('usage.updated', data);
357
380
  else if (event === 'checklist')
358
381
  run.emit('checklist.updated', { items: data.items });
382
+ else if (event === 'checkpoint')
383
+ run.emit('checkpoint', data);
359
384
  else if (event === 'file')
360
385
  run.emit('file.changed', data);
361
386
  else if (event === 'message')
@@ -379,5 +404,7 @@ export class TerminalController {
379
404
  this.delivery?.reject(new Error('The terminal stopped before prompt acknowledgement.'));
380
405
  this.delivery = null;
381
406
  this.finish('interrupted');
407
+ this.subagents.clear();
408
+ this.subagentParentRunId = null;
382
409
  }
383
410
  }
@@ -0,0 +1,43 @@
1
+ export interface OpenTuiTheme {
2
+ background: string;
3
+ panel: string;
4
+ /** The translucent layer that dims the console behind a centered page dialog. */
5
+ scrim: string;
6
+ border: string;
7
+ borderFocus: string;
8
+ text: string;
9
+ muted: string;
10
+ dim: string;
11
+ /** The shared selected-row field and its two text tones. */
12
+ selectionBg: string;
13
+ selectionText: string;
14
+ selectionMuted: string;
15
+ questionSelectedBg: string;
16
+ questionActionBg: string;
17
+ questionActionText: string;
18
+ primary: string;
19
+ accent: string;
20
+ success: string;
21
+ warning: string;
22
+ error: string;
23
+ /** The composer's Bash mode marker and rules; the command body keeps the normal text color. */
24
+ bashMode: string;
25
+ /** The composer's rules while plan mode is on. */
26
+ planMode: string;
27
+ /** Image and pasted-text labels in the composer and in sent messages. */
28
+ attachment: string;
29
+ purple: string;
30
+ cyan: string;
31
+ }
32
+ export declare const opentuiTheme: OpenTuiTheme;
33
+ /** The colour of an agent profile's name (PROP-0029); an unknown name falls back to the accent. */
34
+ export declare function profileColor(theme: OpenTuiTheme, name: string): string;
35
+ export declare function blendHexColor(from: string, to: string, amount: number): string;
36
+ export declare function composerRuleColor(theme: OpenTuiTheme, mode: {
37
+ bash: boolean;
38
+ plan: boolean;
39
+ overlay: boolean;
40
+ }): string;
41
+ export declare function noticeColor(theme: OpenTuiTheme, type: string): string;
42
+ export declare function toolColor(theme: OpenTuiTheme, status: 'running' | 'ok' | 'error' | 'pending'): string;
43
+ export declare function thinkingLevelColor(theme: OpenTuiTheme, level: string): string;