@xingular/harness 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/CHANGELOG.md +33 -5
  2. package/README.md +70 -19
  3. package/dist/cli.js +10321 -5785
  4. package/dist/sdk/agents/builtin.d.ts +2 -0
  5. package/dist/sdk/agents/builtin.js +34 -0
  6. package/dist/sdk/agents/narrow.d.ts +13 -0
  7. package/dist/sdk/agents/narrow.js +29 -0
  8. package/dist/sdk/agents/profiles.d.ts +80 -0
  9. package/dist/sdk/agents/profiles.js +221 -0
  10. package/dist/sdk/annotations/bridge.d.ts +42 -0
  11. package/dist/sdk/annotations/bridge.js +264 -0
  12. package/dist/sdk/annotations/capture.d.ts +116 -0
  13. package/dist/sdk/annotations/capture.js +308 -0
  14. package/dist/sdk/annotations/delivery.d.ts +72 -0
  15. package/dist/sdk/annotations/delivery.js +130 -0
  16. package/dist/sdk/annotations/inspect.d.ts +10 -0
  17. package/dist/sdk/annotations/inspect.js +33 -0
  18. package/dist/sdk/annotations/isolated.d.ts +6 -0
  19. package/dist/sdk/annotations/isolated.js +51 -0
  20. package/dist/sdk/annotations/pageScript.d.ts +28 -0
  21. package/dist/sdk/annotations/pageScript.js +570 -0
  22. package/dist/sdk/annotations/part.d.ts +10 -0
  23. package/dist/sdk/annotations/part.js +144 -0
  24. package/dist/sdk/annotations/queue.d.ts +4 -0
  25. package/dist/sdk/annotations/queue.js +40 -0
  26. package/dist/sdk/annotations/serverMethods.d.ts +3 -0
  27. package/dist/sdk/annotations/serverMethods.js +62 -0
  28. package/dist/sdk/annotations/store.d.ts +160 -0
  29. package/dist/sdk/annotations/store.js +386 -0
  30. package/dist/sdk/browser/index.d.ts +14 -3
  31. package/dist/sdk/browser/index.js +11 -4
  32. package/dist/sdk/browser/serverMethods.d.ts +3 -0
  33. package/dist/sdk/browser/serverMethods.js +7 -0
  34. package/dist/sdk/browser/session.d.ts +1 -0
  35. package/dist/sdk/browser/session.js +45 -9
  36. package/dist/sdk/cli.js +19 -11
  37. package/dist/sdk/core/bus.js +1 -1
  38. package/dist/sdk/core/env.d.ts +1 -1
  39. package/dist/sdk/core/env.js +1 -1
  40. package/dist/sdk/doctor/serverMethods.d.ts +3 -0
  41. package/dist/sdk/doctor/serverMethods.js +8 -0
  42. package/dist/sdk/harness.d.ts +15 -0
  43. package/dist/sdk/harness.js +44 -5
  44. package/dist/sdk/history/recall.js +8 -8
  45. package/dist/sdk/history/serverMethods.d.ts +3 -0
  46. package/dist/sdk/history/serverMethods.js +8 -0
  47. package/dist/sdk/hub/cli.d.ts +1 -1
  48. package/dist/sdk/hub/cli.js +1 -1
  49. package/dist/sdk/hub/schema.d.ts +1 -1
  50. package/dist/sdk/hub/serverMethods.d.ts +3 -0
  51. package/dist/sdk/hub/serverMethods.js +11 -0
  52. package/dist/sdk/index.d.ts +10 -0
  53. package/dist/sdk/index.js +7 -0
  54. package/dist/sdk/mcp/connections.js +14 -7
  55. package/dist/sdk/mcp/serverMethods.d.ts +3 -0
  56. package/dist/sdk/mcp/serverMethods.js +22 -0
  57. package/dist/sdk/plugins/builtin.d.ts +1 -0
  58. package/dist/sdk/plugins/builtin.js +128 -33
  59. package/dist/sdk/plugins/builtinRegistry.d.ts +53 -0
  60. package/dist/sdk/plugins/builtinRegistry.js +49 -0
  61. package/dist/sdk/plugins/cli.d.ts +1 -1
  62. package/dist/sdk/plugins/cli.js +4 -3
  63. package/dist/sdk/prompt/compile.d.ts +5 -2
  64. package/dist/sdk/prompt/compile.js +25 -8
  65. package/dist/sdk/refine/cli.d.ts +1 -1
  66. package/dist/sdk/refine/cli.js +2 -2
  67. package/dist/sdk/refine/serverMethods.d.ts +3 -0
  68. package/dist/sdk/refine/serverMethods.js +20 -0
  69. package/dist/sdk/server/methods.js +9 -86
  70. package/dist/sdk/server/params.d.ts +4 -0
  71. package/dist/sdk/server/params.js +11 -0
  72. package/dist/sdk/sessions/contracts.d.ts +17 -0
  73. package/dist/sdk/sessions/contracts.js +42 -3
  74. package/dist/sdk/sessions/planMode.d.ts +5 -2
  75. package/dist/sdk/sessions/planMode.js +12 -3
  76. package/dist/sdk/sessions/reasoningSummaries.d.ts +2 -2
  77. package/dist/sdk/sessions/reasoningSummaries.js +3 -3
  78. package/dist/sdk/sessions/registry.d.ts +1 -0
  79. package/dist/sdk/sessions/registry.js +4 -1
  80. package/dist/sdk/sessions/runIndex.js +7 -7
  81. package/dist/sdk/sessions/runStore.js +3 -1
  82. package/dist/sdk/sessions/runner.js +2 -2
  83. package/dist/sdk/sessions/sessions.d.ts +17 -5
  84. package/dist/sdk/sessions/sessions.js +79 -12
  85. package/dist/sdk/sessions/sideQuestionServerMethods.d.ts +3 -0
  86. package/dist/sdk/sessions/sideQuestionServerMethods.js +19 -0
  87. package/dist/sdk/sessions/sideQuestions.d.ts +66 -0
  88. package/dist/sdk/sessions/sideQuestions.js +290 -0
  89. package/dist/sdk/sessions/subagentServerMethods.d.ts +3 -0
  90. package/dist/sdk/sessions/subagentServerMethods.js +41 -0
  91. package/dist/sdk/sessions/subagentService.d.ts +13 -2
  92. package/dist/sdk/sessions/subagentService.js +34 -16
  93. package/dist/sdk/sessions/subagents.d.ts +9 -1
  94. package/dist/sdk/sessions/subagents.js +19 -0
  95. package/dist/sdk/terminal/browser.d.ts +62 -0
  96. package/dist/sdk/terminal/browser.js +41 -0
  97. package/dist/sdk/terminal/browserControl.d.ts +29 -0
  98. package/dist/sdk/terminal/browserControl.js +186 -0
  99. package/dist/sdk/terminal/commandHelp.d.ts +17 -0
  100. package/dist/sdk/terminal/commandHelp.js +20 -0
  101. package/dist/sdk/terminal/controller.d.ts +7 -1
  102. package/dist/sdk/terminal/controller.js +34 -2
  103. package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +28 -0
  104. package/dist/sdk/terminal/opentui/subagentsWidget.js +52 -0
  105. package/dist/sdk/terminal/piExtension.js +33 -48
  106. package/dist/sdk/terminal/socket.d.ts +6 -3
  107. package/dist/sdk/terminal/socket.js +40 -9
  108. package/dist/sdk/terminal/standalone.d.ts +2 -0
  109. package/dist/sdk/terminal/standalone.js +50 -10
  110. package/dist/sdk/terminal/subagentMonitor.d.ts +38 -0
  111. package/dist/sdk/terminal/subagentMonitor.js +121 -0
  112. package/dist/sdk/terminal/terminals.d.ts +42 -6
  113. package/dist/sdk/terminal/terminals.js +359 -18
  114. package/dist/sdk/terminal/trajectory.js +2 -2
  115. package/dist/sdk/terminal/tui/annotateCommand.d.ts +3 -0
  116. package/dist/sdk/terminal/tui/annotateCommand.js +22 -0
  117. package/dist/sdk/terminal/tui/commandPages.d.ts +5 -1
  118. package/dist/sdk/terminal/tui/commandPages.js +54 -6
  119. package/dist/sdk/terminal/tui/commands.d.ts +8 -4
  120. package/dist/sdk/terminal/tui/commands.js +181 -58
  121. package/dist/sdk/terminal/tui/dialogGeometry.d.ts +2 -2
  122. package/dist/sdk/terminal/tui/dialogGeometry.js +4 -2
  123. package/dist/sdk/terminal/tui/dialogs.d.ts +6 -1
  124. package/dist/sdk/terminal/tui/dialogs.js +9 -2
  125. package/dist/sdk/terminal/tui/modelPicker.js +1 -6
  126. package/dist/sdk/terminal/tui/page.d.ts +3 -0
  127. package/dist/sdk/terminal/tui/pageOverlay.js +17 -6
  128. package/dist/sdk/terminal/tui/pages/accountsPage.d.ts +26 -4
  129. package/dist/sdk/terminal/tui/pages/accountsPage.js +264 -36
  130. package/dist/sdk/terminal/tui/pages/agentsPage.d.ts +74 -0
  131. package/dist/sdk/terminal/tui/pages/agentsPage.js +190 -0
  132. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +94 -0
  133. package/dist/sdk/terminal/tui/pages/annotatePage.js +341 -0
  134. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +87 -0
  135. package/dist/sdk/terminal/tui/pages/browserPage.js +738 -0
  136. package/dist/sdk/terminal/tui/pages/doctorPage.d.ts +2 -0
  137. package/dist/sdk/terminal/tui/pages/doctorPage.js +103 -15
  138. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +75 -0
  139. package/dist/sdk/terminal/tui/pages/notesPage.js +201 -0
  140. package/dist/sdk/terminal/tui/pages/settingsPage.d.ts +8 -0
  141. package/dist/sdk/terminal/tui/pages/settingsPage.js +16 -1
  142. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +23 -0
  143. package/dist/sdk/terminal/tui/pages/subagentsPage.js +70 -0
  144. package/dist/sdk/terminal/tui/pages/trajectoryPage.d.ts +12 -5
  145. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +156 -83
  146. package/dist/sdk/terminal/tui/pages/worktreesPage.d.ts +72 -0
  147. package/dist/sdk/terminal/tui/pages/worktreesPage.js +203 -0
  148. package/dist/sdk/terminal/tui/planMode.d.ts +1 -1
  149. package/dist/sdk/terminal/tui/planMode.js +1 -1
  150. package/dist/sdk/terminal/tui/pluginCommands.d.ts +14 -0
  151. package/dist/sdk/terminal/tui/pluginCommands.js +27 -0
  152. package/dist/sdk/terminal/tui/questions.d.ts +2 -0
  153. package/dist/sdk/terminal/tui/runtime.js +4 -2
  154. package/dist/sdk/terminal/tui/settingsModel.d.ts +2 -0
  155. package/dist/sdk/terminal/tui/settingsModel.js +32 -2
  156. package/dist/sdk/terminal/tui/sideQuestion.d.ts +26 -26
  157. package/dist/sdk/terminal/tui/sideQuestion.js +131 -98
  158. package/dist/sdk/terminal/tui/ui.d.ts +2 -0
  159. package/dist/sdk/terminal/tui/ui.js +1 -0
  160. package/dist/sdk/tools/askUser.js +26 -8
  161. package/dist/sdk/tools/checklist.js +2 -2
  162. package/dist/sdk/tools/harnessTools.d.ts +2 -0
  163. package/dist/sdk/tools/harnessTools.js +1 -1
  164. package/dist/sdk/tools/spawnSubagent.js +13 -4
  165. package/dist/sdk/web/part.d.ts +10 -0
  166. package/dist/sdk/web/part.js +6 -0
  167. package/dist/sdk/web/serverMethods.d.ts +3 -0
  168. package/dist/sdk/web/serverMethods.js +7 -0
  169. package/dist/sdk/wiki/serverMethods.d.ts +3 -0
  170. package/dist/sdk/wiki/serverMethods.js +26 -0
  171. package/dist/sdk/worktrees/cli.d.ts +11 -0
  172. package/dist/sdk/worktrees/cli.js +146 -0
  173. package/dist/sdk/worktrees/copy.d.ts +9 -0
  174. package/dist/sdk/worktrees/copy.js +111 -0
  175. package/dist/sdk/worktrees/git.d.ts +16 -0
  176. package/dist/sdk/worktrees/git.js +81 -0
  177. package/dist/sdk/worktrees/serverMethods.d.ts +3 -0
  178. package/dist/sdk/worktrees/serverMethods.js +30 -0
  179. package/dist/sdk/worktrees/service.d.ts +73 -0
  180. package/dist/sdk/worktrees/service.js +483 -0
  181. package/dist/sdk/worktrees/setup.d.ts +23 -0
  182. package/dist/sdk/worktrees/setup.js +65 -0
  183. package/dist/sdk/worktrees/types.d.ts +91 -0
  184. package/dist/sdk/worktrees/types.js +1 -0
  185. package/dist/sdk/world/cli.d.ts +2 -2
  186. package/dist/sdk/world/cli.js +6 -6
  187. package/dist/sdk/world/world.d.ts +3 -1
  188. package/dist/sdk/world/world.js +8 -9
  189. package/dist/terminal-opentui.js +14540 -7374
  190. package/dist/terminal-pi.js +285 -666
  191. package/docs/README.md +22 -2
  192. package/docs/architecture/00-overview.md +28 -16
  193. package/docs/architecture/02-sessions-and-runs.md +72 -15
  194. package/docs/architecture/03-variants.md +10 -2
  195. package/docs/architecture/04-prompt.md +26 -6
  196. package/docs/architecture/06-plugins-and-capabilities.md +39 -3
  197. package/docs/architecture/07-tools-and-gate.md +21 -6
  198. package/docs/architecture/09-accounts-and-connectors.md +8 -0
  199. package/docs/architecture/10-server-protocol.md +111 -2
  200. package/docs/architecture/11-terminal.md +185 -5
  201. package/docs/architecture/12-storage.md +15 -1
  202. package/docs/architecture/13-world-tools.md +28 -6
  203. package/docs/architecture/14-surfaces-and-parity.md +89 -0
  204. package/docs/architecture/README.md +15 -4
  205. package/docs/architecture/proposals/PROP-0006-run-checklist.md +4 -2
  206. package/docs/architecture/proposals/PROP-0013-resource-browser.md +58 -0
  207. package/docs/architecture/proposals/PROP-0014-fullscreen-browser-tabs.md +58 -0
  208. package/docs/architecture/proposals/PROP-0015-one-capability-surface.md +88 -0
  209. package/docs/architecture/proposals/PROP-0016-managed-worktrees.md +156 -0
  210. package/docs/architecture/proposals/PROP-0017-page-annotations.md +127 -0
  211. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +179 -0
  212. package/docs/architecture/proposals/PROP-0019-contextual-side-questions.md +122 -0
  213. package/docs/architecture/proposals/PROP-0020-builtin-feature-plugins.md +105 -0
  214. package/docs/architecture/proposals/PROP-0021-command-namespaces-and-duplicates.md +99 -0
  215. package/docs/architecture/proposals/PROP-0022-plan-only-questions.md +102 -0
  216. package/docs/architecture/proposals/PROP-0023-xagent-cli-name.md +61 -0
  217. package/docs/architecture/proposals/PROP-0024-question-sketch-previews.md +130 -0
  218. package/docs/architecture/proposals/PROP-0025-contextual-question-wireframes.md +49 -0
  219. package/docs/architecture/proposals/PROP-0026-connected-sketch-display.md +94 -0
  220. package/docs/architecture/proposals/PROP-0027-xingular-agent-product-identity.md +60 -0
  221. package/docs/architecture/proposals/PROP-0028-normalize-question-wireframes.md +55 -0
  222. package/docs/architecture/proposals/PROP-0029-agent-profiles.md +124 -0
  223. package/docs/architecture/proposals/PROP-0030-subagent-title.md +86 -0
  224. package/docs/architecture/proposals/README.md +25 -0
  225. package/docs/decisions/ADR-0009-xharness-command.md +6 -5
  226. package/docs/decisions/ADR-0014-public-release-baseline.md +3 -2
  227. package/docs/decisions/ADR-0015-resource-browser.md +28 -0
  228. package/docs/decisions/ADR-0016-fullscreen-browser-tabs.md +31 -0
  229. package/docs/decisions/ADR-0017-one-capability-surface.md +34 -0
  230. package/docs/decisions/ADR-0018-builtin-feature-plugins.md +35 -0
  231. package/docs/decisions/ADR-0019-managed-git-worktrees.md +31 -0
  232. package/docs/decisions/ADR-0020-page-annotations.md +37 -0
  233. package/docs/decisions/ADR-0021-required-command-namespaces.md +40 -0
  234. package/docs/decisions/ADR-0022-plan-only-question-tools.md +34 -0
  235. package/docs/decisions/ADR-0023-contextual-side-question-runs.md +35 -0
  236. package/docs/decisions/ADR-0024-xagent-command.md +40 -0
  237. package/docs/decisions/ADR-0025-question-choice-sketches.md +49 -0
  238. package/docs/decisions/ADR-0026-contextual-question-wireframes.md +58 -0
  239. package/docs/decisions/ADR-0027-connected-sketch-display.md +60 -0
  240. package/docs/decisions/ADR-0028-xingular-agent-product-identity.md +56 -0
  241. package/docs/decisions/ADR-0029-normalized-question-wireframe-borders.md +33 -0
  242. package/docs/decisions/ADR-0030-agent-profiles.md +43 -0
  243. package/docs/doctor.md +10 -4
  244. package/docs/features/README.md +26 -0
  245. package/docs/features/interfaces/README.md +1 -1
  246. package/docs/future-work.md +48 -0
  247. package/docs/parity.md +78 -0
  248. package/docs/releases/1.0.0.md +2 -2
  249. package/docs/releases/4.0.0.md +46 -0
  250. package/docs/releases/README.md +20 -15
  251. package/docs/study/00-index.md +5 -0
  252. package/docs/study/autonomous-engineering-sources.md +277 -0
  253. package/docs/study/autonomy-sources.md +147 -0
  254. package/docs/study/proactive-autonomy.md +408 -0
  255. package/docs/study/tui-resource-browser.md +78 -0
  256. package/docs/terminal-tui.md +325 -93
  257. package/package.json +3 -3
@@ -0,0 +1,27 @@
1
+ import { BUILTIN_PLUGIN_REGISTRY } from "../../plugins/builtin.js";
2
+ import { parseAnnotate } from "./annotateCommand.js";
3
+ /** Terminal adapters for commands declared by Harness-owned feature plugins. */
4
+ export function createBuiltinPluginCommandHandlers(host, openPage) {
5
+ const handlers = new Map([
6
+ ['doctor', async () => openPage('doctor')],
7
+ ['worktree', async () => openPage('worktree')],
8
+ ['trajectory', async (argument) => openPage('trajectory', { runId: argument })],
9
+ ['subagents', async () => openPage('subagents')],
10
+ ['agents', async () => openPage('agents')],
11
+ ['notes', async () => openPage('notes')],
12
+ ['annotate', async (argument) => {
13
+ const start = parseAnnotate(argument);
14
+ if (typeof start === 'string')
15
+ host.ui.notify(start, 'error');
16
+ else
17
+ await openPage('annotate', { annotate: start });
18
+ }]
19
+ ]);
20
+ const commands = BUILTIN_PLUGIN_REGISTRY.commands();
21
+ for (const command of commands) {
22
+ if (!handlers.has(command.name))
23
+ throw new Error(`No terminal handler for built-in plugin command ${command.pluginId}:${command.name}.`);
24
+ }
25
+ return handlers;
26
+ }
27
+ export { parseAnnotate };
@@ -4,10 +4,12 @@ export interface TerminalQuestion {
4
4
  requestId: string;
5
5
  question?: string;
6
6
  summary?: string;
7
+ uiSketch?: true;
7
8
  options: {
8
9
  label: string;
9
10
  description?: string;
10
11
  recommended?: boolean;
12
+ sketch?: string;
11
13
  }[];
12
14
  multiSelect?: boolean;
13
15
  allowText?: boolean;
@@ -2,7 +2,7 @@ import { createAgentSession, createAgentSessionRuntime, DefaultResourceLoader, M
2
2
  import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs';
3
3
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import terminalExtension from "../piExtension.js";
5
- import { planModeTools } from "./planMode.js";
5
+ import { normalModeTools, planModeTools, QUESTION_TOOL_NAMES } from "./planMode.js";
6
6
  import { claimTerminalControl, controlCall } from "../control.js";
7
7
  import { loadPiAdapter } from "./piAdapter.js";
8
8
  import { loadPi } from "../../pi/runtime.js";
@@ -197,6 +197,7 @@ export async function createTerminalRuntime(args, host) {
197
197
  ...(selected?.model ? { model: selected.model } : {}),
198
198
  ...(thinkingLevel ? { thinkingLevel } : {})
199
199
  });
200
+ created.session.setActiveToolsByName(normalModeTools(created.session.getActiveToolNames()));
200
201
  const services = { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics: [] };
201
202
  host.resources?.(services);
202
203
  return { ...created, services, diagnostics: services.diagnostics };
@@ -296,7 +297,8 @@ export async function createTerminalRuntime(args, host) {
296
297
  if (!session.isIdle)
297
298
  throw new Error('A plan request must wait until the current response settles.');
298
299
  const originalTools = session.getActiveToolNames();
299
- const allowedTools = planModeTools(originalTools);
300
+ const registeredQuestions = session.getAllTools().map(tool => tool.name).filter(name => QUESTION_TOOL_NAMES.has(name));
301
+ const allowedTools = planModeTools(originalTools, [], registeredQuestions);
300
302
  // The session's own model and effort come back once the plan is done.
301
303
  const previous = { model: session.model, effort: session.thinkingLevel };
302
304
  planRun.allowedTools = new Set(allowedTools);
@@ -3,10 +3,12 @@ import type { TerminalSettingItem } from "./pages/settingsPage.js";
3
3
  export interface SettingsModelHost {
4
4
  session(): AgentSession;
5
5
  ui: ExtensionUIContext;
6
+ control<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
6
7
  hotkeys?(): string;
7
8
  refresh(): void;
8
9
  }
9
10
  export declare function createSettingsModel(host: SettingsModelHost): {
10
11
  items: () => TerminalSettingItem[];
11
12
  apply: (id: string) => Promise<void>;
13
+ load: () => Promise<void>;
12
14
  };
@@ -1,5 +1,7 @@
1
- const GROUP_ORDER = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction'];
1
+ const PRIVATE_ACCESS_ID = 'browser.allowPrivate';
2
+ const GROUP_ORDER = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction', 'Network'];
2
3
  export function createSettingsModel(host) {
4
+ let browserSettings = null;
3
5
  const booleans = (session) => {
4
6
  const settings = session.settingsManager;
5
7
  return [
@@ -40,9 +42,37 @@ export function createSettingsModel(host) {
40
42
  const split = option.indexOf(':');
41
43
  return { id: String(index), tab: locations[index][0], group: locations[index][1], label: split < 0 ? option : option.slice(0, split), value: split < 0 ? '' : option.slice(split + 1).trim(), description: descriptions[index] };
42
44
  });
45
+ if (browserSettings)
46
+ result.push({
47
+ id: PRIVATE_ACCESS_ID,
48
+ tab: 'Browser',
49
+ group: 'Network',
50
+ label: 'Private address access',
51
+ value: browserSettings.allowPrivate ? 'On' : 'Off',
52
+ description: 'This device-wide setting affects agent browsing and annotation capture. When on, pages can reach localhost and private networks.'
53
+ });
43
54
  return result.sort((a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group));
44
55
  }
56
+ async function load() {
57
+ const config = await host.control('terminal.browser.config');
58
+ if (!config || typeof config.allowPrivate !== 'boolean')
59
+ throw new Error('The Harness returned invalid browser settings.');
60
+ browserSettings = { allowPrivate: config.allowPrivate };
61
+ }
45
62
  async function apply(id) {
63
+ if (id === PRIVATE_ACCESS_ID) {
64
+ if (!browserSettings)
65
+ return;
66
+ const allowPrivate = !browserSettings.allowPrivate;
67
+ if (allowPrivate && !await host.ui.confirm('Allow private network addresses?', 'This device-wide setting lets pages opened by agent browsing and annotation capture reach localhost and private networks.'))
68
+ return;
69
+ const saved = await host.control('terminal.browser.configure', { allowPrivate });
70
+ if (!saved || typeof saved.allowPrivate !== 'boolean')
71
+ throw new Error('The Harness returned invalid browser settings.');
72
+ browserSettings = { allowPrivate: saved.allowPrivate };
73
+ host.refresh();
74
+ return;
75
+ }
46
76
  const session = host.session();
47
77
  const index = Number(id);
48
78
  const switches = booleans(session);
@@ -80,5 +110,5 @@ export function createSettingsModel(host) {
80
110
  host.ui.notify(host.hotkeys?.() ?? 'Use /help for commands. Keyboard bindings are managed by the terminal application.', 'info');
81
111
  host.refresh();
82
112
  }
83
- return { items, apply };
113
+ return { items, apply, load };
84
114
  }
@@ -1,29 +1,20 @@
1
- import { type AgentSession, type AgentSessionServices, type Theme } from '@earendil-works/pi-coding-agent';
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
2
  import { type Component } from '@earendil-works/pi-tui';
3
- export declare const SIDE_PROMPT: string;
4
- export declare const ANSWER_ROWS = 8;
5
- /**
6
- * A bounded plain-text excerpt of the main conversation: the last few user and assistant turns, the
7
- * tools an assistant turn called by name, and whether the reply is still running. Hidden reasoning,
8
- * tool inputs and results, and images are left out.
9
- */
10
- export declare function sideQuestionSnapshot(messages: readonly unknown[], running: boolean): string;
11
- /** What the controller needs from a session; a Pi `AgentSession` satisfies it through `createSideSession`. */
12
- export interface SideSession {
13
- prompt(text: string): Promise<void>;
14
- abort(): Promise<void>;
15
- subscribe(listener: () => void): () => void;
16
- answer(): string;
17
- /** Why the provider ended the turn in error, if it did. */
18
- failure(): string | undefined;
19
- dispose(): void;
20
- }
21
- /** A separate in-memory session on the same account and model, with no tools, extensions, skills or project files. */
22
- export declare function createSideSession(services: AgentSessionServices, main: AgentSession): Promise<SideSession>;
3
+ import type { SideQuestionContext, SideQuestionRead } from "../../sessions/sideQuestions.js";
23
4
  export type SideState = 'thinking' | 'answered' | 'failed';
5
+ type SideQuestionStart = {
6
+ runId: string;
7
+ sessionId: string;
8
+ nativeSessionId: string | null;
9
+ };
10
+ /** Visible native history and tool activity, with hidden reasoning and image bytes omitted. */
11
+ export declare function sideQuestionSnapshot(messages: readonly unknown[], running: boolean): string;
24
12
  export interface SideQuestionOptions {
25
- create(): Promise<SideSession>;
26
- snapshot(): string;
13
+ context(): SideQuestionContext;
14
+ start(question: string, context: SideQuestionContext): Promise<SideQuestionStart>;
15
+ read(runId: string, afterSequence: number): Promise<SideQuestionRead>;
16
+ interrupt(runId: string): Promise<unknown>;
17
+ copyText(text: string): Promise<void>;
27
18
  theme(): Theme;
28
19
  /** Puts the dock above the composer, or takes it away. */
29
20
  mount(dock: Component | undefined): void;
@@ -31,23 +22,32 @@ export interface SideQuestionOptions {
31
22
  }
32
23
  export declare class SideQuestion {
33
24
  private readonly options;
34
- private session;
35
- private unsubscribe;
25
+ private runId;
36
26
  private generation;
27
+ private pollTimer;
37
28
  private question;
38
29
  private answerText;
39
30
  private error;
40
31
  private current;
41
32
  private startedAt;
33
+ private afterSequence;
34
+ private answerOffset;
42
35
  private readonly dock;
43
36
  constructor(options: SideQuestionOptions);
44
37
  get visible(): boolean;
45
38
  get state(): SideState | undefined;
39
+ get answer(): string;
46
40
  /** Asks one question, replacing any earlier one. Empty text dismisses. */
47
41
  ask(question: string): Promise<void>;
42
+ /** Scrolls the answer while it is longer than the dock. */
43
+ scroll(direction: 'up' | 'down'): void;
44
+ /** Copies the whole side answer, including any lines outside the dock. */
45
+ copyAnswer(): Promise<void>;
48
46
  /** Interrupts a running side question and removes the dock. */
49
47
  dismiss(): Promise<void>;
48
+ private poll;
50
49
  private release;
51
- /** The dock: a neutral rule, the inline question, its answer, and a hint, separated by blank rows. */
50
+ /** The dock: question, a scrollable answer window, and a hint for browsing or copying. */
52
51
  private render;
53
52
  }
53
+ export {};
@@ -1,89 +1,79 @@
1
- // `/btw <question>`: a side question answered by a separate, tool-free session while the main
2
- // conversation carries on. It lives in a dock above the composer, never enters the transcript, the
3
- // queue or the run history, and is gone on dismiss, on a session change, or when a new one replaces it.
4
- import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager } from '@earendil-works/pi-coding-agent';
5
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
6
2
  import { contentWidth, footerRow, neutralRule, padded, safeText } from "./chrome.js";
7
3
  import { getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from "./workingStatus.js";
8
- export const SIDE_PROMPT = [
9
- 'You answer one short side question from a person whose main task is still running in another session.',
10
- 'You have no tools: you cannot read or change files, run commands, browse, or call anything, and you must not say you did.',
11
- 'Use the conversation excerpt for context and do not invent what it does not show. Answer briefly and plainly.'
12
- ].join(' ');
13
- const TURNS = 6;
14
- const TURN_CHARS = 1_200;
15
- const TOTAL_CHARS = 6_000;
16
4
  const QUESTION_ROWS = 2;
17
- export const ANSWER_ROWS = 8;
5
+ const ANSWER_ROWS = 8;
6
+ const MAX_CLIENT_CONTEXT_CHARS = 300_000;
7
+ const POLL_INTERVAL_MS = 160;
18
8
  function blocks(content) {
19
9
  if (typeof content === 'string')
20
10
  return [{ type: 'text', text: content }];
21
11
  return Array.isArray(content) ? content.filter((block) => typeof block === 'object' && block !== null) : [];
22
12
  }
23
- const clip = (text, limit) => text.length > limit ? `${text.slice(0, limit)}…` : text;
24
- /**
25
- * A bounded plain-text excerpt of the main conversation: the last few user and assistant turns, the
26
- * tools an assistant turn called by name, and whether the reply is still running. Hidden reasoning,
27
- * tool inputs and results, and images are left out.
28
- */
13
+ function contentText(content) {
14
+ return blocks(content).filter(block => block.type === 'text' && typeof block.text === 'string').map(block => String(block.text)).join('\n').trim();
15
+ }
16
+ function serialized(value) {
17
+ if (typeof value === 'string')
18
+ return value;
19
+ try {
20
+ return JSON.stringify(value) ?? '';
21
+ }
22
+ catch {
23
+ return '[unavailable]';
24
+ }
25
+ }
26
+ /** Visible native history and tool activity, with hidden reasoning and image bytes omitted. */
29
27
  export function sideQuestionSnapshot(messages, running) {
30
- const turns = [];
28
+ const lines = [];
31
29
  for (const message of messages) {
32
- const { role, content } = (message ?? {});
33
- if (role !== 'user' && role !== 'assistant')
34
- continue;
35
- const parts = blocks(content);
36
- const text = parts.filter(block => block.type === 'text' && typeof block.text === 'string').map(block => String(block.text)).join('\n').trim();
37
- const tools = [...new Set(parts.filter(block => block.type === 'toolCall' && typeof block.name === 'string').map(block => String(block.name)))];
38
- if (!text && !tools.length)
30
+ if (!message || typeof message !== 'object')
39
31
  continue;
40
- turns.push(`${role === 'user' ? 'User' : 'Assistant'}: ${clip(safeText(text).replace(/\s+/g, ' ').trim(), TURN_CHARS)}${tools.length ? ` [used tools: ${tools.join(', ')}]` : ''}`);
32
+ const item = message;
33
+ const parts = blocks(item.content);
34
+ if (item.role === 'user' || item.role === 'assistant') {
35
+ const text = parts.filter(block => block.type === 'text' && typeof block.text === 'string').map(block => String(block.text)).join('\n').trim();
36
+ if (text)
37
+ lines.push(`${item.role === 'user' ? 'User' : 'Assistant'}:\n${safeText(text)}`);
38
+ for (const call of parts.filter(block => block.type === 'toolCall')) {
39
+ const name = typeof call.name === 'string' ? call.name : 'unknown tool';
40
+ lines.push(`Assistant tool call (${name}):\n${safeText(serialized(call.arguments))}`);
41
+ }
42
+ if (parts.some(block => block.type === 'image'))
43
+ lines.push(`${item.role === 'user' ? 'User' : 'Assistant'} attached an image; image bytes are omitted.`);
44
+ }
45
+ else if (item.role === 'toolResult') {
46
+ const name = typeof item.toolName === 'string' ? item.toolName : typeof item.name === 'string' ? item.name : 'unknown tool';
47
+ const output = contentText(item.content);
48
+ lines.push(`Tool result (${name}${typeof item.toolCallId === 'string' ? `, ${item.toolCallId}` : ''}):\n${safeText(output || '(no text result)')}`);
49
+ if (parts.some(block => block.type === 'image'))
50
+ lines.push('Tool result also contained an image; image bytes are omitted.');
51
+ }
41
52
  }
42
- let recent = turns.slice(-TURNS);
43
- while (recent.length > 1 && recent.join('\n').length > TOTAL_CHARS)
44
- recent = recent.slice(1);
45
- const lines = ['Conversation excerpt, oldest first:', ...(recent.length ? recent : ['(nothing yet)'])];
53
+ if (!lines.length)
54
+ lines.push('(No visible conversation messages yet.)');
46
55
  if (running)
47
- lines.push('', 'The main reply is still running; its latest turn above may be unfinished.');
48
- return lines.join('\n');
56
+ lines.push('The main reply is still running; its latest message may be unfinished.');
57
+ const full = lines.join('\n\n');
58
+ return full.length > MAX_CLIENT_CONTEXT_CHARS
59
+ ? `[Earlier visible context omitted to fit the side-question request.]\n\n${full.slice(-MAX_CLIENT_CONTEXT_CHARS)}`
60
+ : full;
49
61
  }
50
- /** A separate in-memory session on the same account and model, with no tools, extensions, skills or project files. */
51
- export async function createSideSession(services, main) {
52
- const model = main.model;
53
- if (!model || model.id === 'unknown')
54
- throw new Error('Choose a model before asking a side question.');
55
- const settingsManager = SettingsManager.inMemory({});
56
- const resourceLoader = new DefaultResourceLoader({
57
- cwd: services.cwd, agentDir: services.agentDir, settingsManager,
58
- noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPrompt: SIDE_PROMPT
59
- });
60
- await resourceLoader.reload();
61
- const { session } = await createAgentSession({
62
- cwd: services.cwd, agentDir: services.agentDir, modelRuntime: services.modelRuntime, settingsManager, resourceLoader,
63
- sessionManager: SessionManager.inMemory(services.cwd), model, thinkingLevel: 'off', noTools: 'all'
64
- });
65
- return {
66
- prompt: text => session.prompt(text),
67
- abort: () => session.abort(),
68
- subscribe: listener => session.subscribe(() => listener()),
69
- answer: () => session.getLastAssistantText() ?? '',
70
- failure: () => {
71
- const last = session.messages.at(-1);
72
- return last?.role === 'assistant' && last.stopReason === 'error' ? last.errorMessage ?? 'The provider returned an error.' : undefined;
73
- },
74
- dispose: () => session.dispose()
75
- };
62
+ function payload(event) {
63
+ return event.payload && typeof event.payload === 'object' ? event.payload : {};
76
64
  }
77
65
  export class SideQuestion {
78
66
  options;
79
- session;
80
- unsubscribe;
67
+ runId;
81
68
  generation = 0;
69
+ pollTimer;
82
70
  question = '';
83
71
  answerText = '';
84
72
  error;
85
73
  current;
86
74
  startedAt = 0;
75
+ afterSequence = -1;
76
+ answerOffset = 0;
87
77
  dock;
88
78
  constructor(options) {
89
79
  this.options = options;
@@ -91,6 +81,7 @@ export class SideQuestion {
91
81
  }
92
82
  get visible() { return this.current !== undefined; }
93
83
  get state() { return this.current; }
84
+ get answer() { return this.answerText; }
94
85
  /** Asks one question, replacing any earlier one. Empty text dismisses. */
95
86
  async ask(question) {
96
87
  const text = question.trim();
@@ -98,73 +89,111 @@ export class SideQuestion {
98
89
  await this.dismiss();
99
90
  return;
100
91
  }
101
- await this.release();
102
92
  const generation = ++this.generation;
93
+ await this.release();
103
94
  this.question = text;
104
95
  this.startedAt = Date.now();
105
96
  this.answerText = '';
106
97
  this.error = undefined;
98
+ this.answerOffset = 0;
99
+ this.afterSequence = -1;
107
100
  this.current = 'thinking';
108
101
  this.options.mount(this.dock);
109
102
  this.options.changed();
110
103
  try {
111
- const session = await this.options.create();
104
+ const started = await this.options.start(text, this.options.context());
112
105
  if (generation !== this.generation) {
113
- session.dispose();
106
+ await this.options.interrupt(started.runId).catch(() => undefined);
114
107
  return;
115
108
  }
116
- this.session = session;
117
- this.unsubscribe = session.subscribe(() => {
118
- if (generation !== this.generation)
119
- return;
120
- this.answerText = session.answer();
121
- this.options.changed();
122
- });
123
- await session.prompt(`${this.options.snapshot()}\n\nSide question: ${text}`);
124
- if (generation !== this.generation)
125
- return;
126
- this.answerText = session.answer();
127
- const failure = session.failure();
128
- if (failure) {
129
- this.current = 'failed';
130
- this.error = failure;
131
- }
132
- else
133
- this.current = 'answered';
109
+ this.runId = started.runId;
110
+ void this.poll(generation);
134
111
  }
135
112
  catch (error) {
136
113
  if (generation !== this.generation)
137
114
  return;
138
115
  this.current = 'failed';
139
116
  this.error = error instanceof Error ? error.message : String(error);
117
+ this.options.changed();
140
118
  }
119
+ }
120
+ /** Scrolls the answer while it is longer than the dock. */
121
+ scroll(direction) {
122
+ const step = Math.max(1, ANSWER_ROWS - 2);
123
+ this.answerOffset = Math.max(0, this.answerOffset + (direction === 'up' ? -step : step));
141
124
  this.options.changed();
142
125
  }
126
+ /** Copies the whole side answer, including any lines outside the dock. */
127
+ async copyAnswer() {
128
+ if (this.answerText)
129
+ await this.options.copyText(this.answerText);
130
+ }
143
131
  /** Interrupts a running side question and removes the dock. */
144
132
  async dismiss() {
145
133
  this.generation++;
146
- this.current = undefined;
147
134
  await this.release();
135
+ this.current = undefined;
148
136
  this.options.mount(undefined);
149
137
  this.options.changed();
150
138
  }
151
- async release() {
152
- const session = this.session;
153
- this.session = undefined;
154
- this.unsubscribe?.();
155
- this.unsubscribe = undefined;
156
- if (!session)
139
+ async poll(generation) {
140
+ const runId = this.runId;
141
+ if (!runId || generation !== this.generation)
157
142
  return;
158
143
  try {
159
- await session.abort();
144
+ const result = await this.options.read(runId, this.afterSequence);
145
+ if (generation !== this.generation)
146
+ return;
147
+ for (const event of result.events) {
148
+ this.afterSequence = Math.max(this.afterSequence, event.sequence);
149
+ const data = payload(event);
150
+ if (event.type === 'message.delta' && typeof data.text === 'string')
151
+ this.answerText += data.text;
152
+ if (event.type === 'message.completed' && data.role === 'assistant' && typeof data.text === 'string')
153
+ this.answerText = data.text;
154
+ if (event.type === 'failed')
155
+ this.error = typeof data.message === 'string' ? data.message : 'The side question failed.';
156
+ if (event.type === 'status.changed' && data.status === 'failed')
157
+ this.error = typeof data.reason === 'string' ? data.reason : 'The side question failed.';
158
+ if (event.type === 'status.changed' && data.status === 'interrupted')
159
+ this.error = 'The side question was interrupted.';
160
+ if (event.type === 'completed')
161
+ this.current = 'answered';
162
+ if (event.type === 'failed' || event.type === 'status.changed' && ['failed', 'interrupted'].includes(String(data.status)))
163
+ this.current = 'failed';
164
+ }
165
+ if (result.run.status === 'completed')
166
+ this.current = 'answered';
167
+ if (result.run.status === 'failed' || result.run.status === 'interrupted') {
168
+ this.current = 'failed';
169
+ this.error ??= result.run.terminalReason ?? 'The side question did not complete.';
170
+ }
171
+ this.options.changed();
172
+ if (this.current === 'thinking')
173
+ this.pollTimer = setTimeout(() => { void this.poll(generation); }, POLL_INTERVAL_MS);
160
174
  }
161
- catch { /* A side run that cannot be stopped is still dropped. */ }
175
+ catch (error) {
176
+ if (generation !== this.generation)
177
+ return;
178
+ this.current = 'failed';
179
+ this.error = error instanceof Error ? error.message : String(error);
180
+ this.options.changed();
181
+ }
182
+ }
183
+ async release() {
184
+ if (this.pollTimer)
185
+ clearTimeout(this.pollTimer);
186
+ this.pollTimer = undefined;
187
+ const runId = this.runId;
188
+ this.runId = undefined;
189
+ if (!runId || this.current !== 'thinking')
190
+ return;
162
191
  try {
163
- session.dispose();
192
+ await this.options.interrupt(runId);
164
193
  }
165
- catch { /* Cleanup cannot keep the dock open. */ }
194
+ catch { /* The dock can still be dismissed if a run already stopped. */ }
166
195
  }
167
- /** The dock: a neutral rule, the inline question, its answer, and a hint, separated by blank rows. */
196
+ /** The dock: question, a scrollable answer window, and a hint for browsing or copying. */
168
197
  render(width) {
169
198
  if (width < 1 || !this.current)
170
199
  return [];
@@ -184,14 +213,18 @@ export class SideQuestion {
184
213
  question[QUESTION_ROWS - 1] = truncateToWidth(question[QUESTION_ROWS - 1], inner - 1, '') + theme.fg('muted', '…');
185
214
  const text = this.current === 'failed' ? `${this.answerText ? `${this.answerText}\n` : ''}${this.error ?? 'The side question failed.'}` : this.answerText;
186
215
  const wrapped = text ? text.split('\n').flatMap(line => line ? wrapTextWithAnsi(safeText(line), inner) : ['']) : [];
187
- const shown = wrapped.slice(0, ANSWER_ROWS).map(line => this.current === 'failed' ? theme.fg('error', line) : line);
188
- const more = wrapped.length - shown.length;
216
+ const maxOffset = Math.max(0, wrapped.length - ANSWER_ROWS);
217
+ this.answerOffset = Math.min(this.answerOffset, maxOffset);
218
+ const shown = wrapped.slice(this.answerOffset, this.answerOffset + ANSWER_ROWS).map(line => this.current === 'failed' ? theme.fg('error', line) : line);
219
+ const before = this.answerOffset;
220
+ const after = Math.max(0, wrapped.length - before - shown.length);
221
+ const textHint = before || after ? `PgUp/PgDn browse · ${before ? `${before} above` : ''}${before && after ? ' · ' : ''}${after ? `${after} below` : ''} · ` : '';
189
222
  return [
190
223
  neutralRule(width, theme),
191
224
  ...question.map(line => padded(line, width)),
192
225
  ...(shown.length ? ['', ...shown.map(line => padded(line, width))] : []),
193
226
  '',
194
- footerRow(`${more > 0 ? `+${more} more lines · ` : ''}/btw or Esc to dismiss`, width, theme)
227
+ footerRow(`${textHint}Ctrl+X copy · /btw or Esc dismiss`, width, theme)
195
228
  ].map(line => truncateToWidth(line, width, ''));
196
229
  }
197
230
  }
@@ -1,5 +1,6 @@
1
1
  import { Theme, type AutocompleteProviderFactory, type ExtensionUIContext, type KeybindingsManager, type WorkingIndicatorOptions } from '@earendil-works/pi-coding-agent';
2
2
  import { type AutocompleteProvider, type EditorComponent, type TUI } from '@earendil-works/pi-tui';
3
+ import { type GroupedSelectOption } from "./dialogs.js";
3
4
  type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0];
4
5
  type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0];
5
6
  type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0];
@@ -37,6 +38,7 @@ export interface TerminalUiOptions {
37
38
  export interface TerminalUi extends ExtensionUIContext {
38
39
  readonly state: TerminalUiState;
39
40
  secretInput: ExtensionUIContext['input'];
41
+ selectGrouped(title: string, options: readonly GroupedSelectOption[], opts?: Parameters<ExtensionUIContext['select']>[2]): Promise<string | undefined>;
40
42
  wrapAutocomplete(provider: AutocompleteProvider): AutocompleteProvider;
41
43
  reset(): void;
42
44
  setThemes(themes: Theme[]): void;
@@ -22,6 +22,7 @@ export function createTerminalUi(options) {
22
22
  return {
23
23
  state,
24
24
  select: (title, items, opts) => dialogs.select(title, items, opts),
25
+ selectGrouped: (title, items, opts) => dialogs.selectGrouped(title, items, opts),
25
26
  confirm: (title, message, opts) => dialogs.confirm(title, message, opts),
26
27
  input: (title, placeholder, opts) => dialogs.input(title, placeholder, opts),
27
28
  secretInput: (title, placeholder, opts) => dialogs.input(title, placeholder, opts, true),
@@ -1,3 +1,5 @@
1
+ // Asking the person a question and waiting for the answer: part of every session.
2
+ import { QUESTION_SKETCH_MAX_LENGTH, QUESTION_SKETCH_PATTERN, questionSketchError } from "../sessions/contracts.js";
1
3
  export const ASK_USER = 'ask_user';
2
4
  const MAX_OPTIONS = 6;
3
5
  const text = (value) => (typeof value === 'string' ? value.trim() : '');
@@ -16,16 +18,18 @@ export function askUserTool(run) {
16
18
  return {
17
19
  name: ASK_USER,
18
20
  label: 'Ask the user',
19
- description: 'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 2–3 word summary and aim to keep each question to 25 words or fewer. ' +
20
- 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. Use it when you need a decision, not for small talk.',
21
+ description: 'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 1–2 word label and aim to keep each question to 25 words or fewer. ' +
22
+ 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision, not for small talk.',
21
23
  parameters: (Type) => Type.Object({
22
24
  questions: Type.Array(Type.Object({
23
- summary: Type.String({ description: 'A concise 2–3 word summary label' }),
25
+ summary: Type.String({ description: 'A concise label of one or two words' }),
24
26
  question: Type.String({ description: 'A clear, standalone question; aim for 25 words or fewer' }),
27
+ uiSketch: Type.Optional(Type.Literal(true, { description: 'Enable an ASCII sketch preview; when true, every choice must include a sketch.' })),
25
28
  options: Type.Array(Type.Object({
26
29
  label: Type.String({ description: 'A short choice; do not add a recommendation suffix to the label' }),
27
30
  description: Type.String({ description: 'Required. Briefly explain this choice in 25 words or fewer.' }),
28
- recommended: Type.Optional(Type.Boolean({ description: 'Whether to show this choice as a visual recommendation; at least one choice per question must be recommended, it is not preselected, and answers use the original label' }))
31
+ recommended: Type.Optional(Type.Boolean({ description: 'Whether to show this choice as a visual recommendation; at least one choice per question must be recommended, it is not preselected, and answers use the original label' })),
32
+ sketch: Type.Optional(Type.String({ minLength: 1, maxLength: QUESTION_SKETCH_MAX_LENGTH, pattern: QUESTION_SKETCH_PATTERN, description: 'Literal ASCII UI wireframe: 1–24 LF-separated lines, at most 80 printable ASCII characters per line. Include the affected element and neighboring UI using structured table/panel borders with fixed, matching outer-edge columns on every row. Required for every choice when uiSketch is true.' }))
29
33
  }), {
30
34
  minItems: 1,
31
35
  maxItems: MAX_OPTIONS,
@@ -37,26 +41,33 @@ export function askUserTool(run) {
37
41
  execute: async (input) => {
38
42
  if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > 5)
39
43
  return 'Ask between one and five questions.';
44
+ let invalidSketchMode = false;
45
+ let invalidSketchValue = false;
40
46
  const questions = input.questions.map((raw) => {
41
47
  const item = raw;
42
48
  const summary = text(item.summary);
43
49
  const question = text(item.question);
50
+ if (item.uiSketch !== undefined && item.uiSketch !== true)
51
+ invalidSketchMode = true;
44
52
  const options = (Array.isArray(item.options) ? item.options : [])
45
53
  .map((option) => {
46
54
  const choice = option;
55
+ if (choice.sketch !== undefined && typeof choice.sketch !== 'string')
56
+ invalidSketchValue = true;
47
57
  return {
48
58
  label: text(choice.label),
49
59
  description: text(choice.description),
50
- ...(choice.recommended === true ? { recommended: true } : {})
60
+ ...(choice.recommended === true ? { recommended: true } : {}),
61
+ ...(typeof choice.sketch === 'string' ? { sketch: choice.sketch } : {})
51
62
  };
52
63
  })
53
64
  .filter((option) => option.label)
54
65
  .slice(0, MAX_OPTIONS);
55
66
  const multiSelect = options.length > 1 && item.multiSelect === true;
56
- return { summary, question, options, multiSelect, allowText: true };
67
+ return { summary, question, options, ...(item.uiSketch === true ? { uiSketch: true } : {}), multiSelect, allowText: true };
57
68
  });
58
- if (questions.some((question) => !question.summary || question.summary.length > 100))
59
- return 'Each question needs a short summary of no more than 100 characters.';
69
+ if (questions.some((question) => !question.summary || question.summary.length > 100 || question.summary.split(/\s+/u).filter(Boolean).length > 2))
70
+ return 'Each question needs a concise 1–2 word summary label of no more than 100 characters.';
60
71
  if (questions.some((question) => !question.question))
61
72
  return 'Every question needs a question sentence.';
62
73
  if (questions.some((question) => question.options.length === 0))
@@ -67,6 +78,13 @@ export function askUserTool(run) {
67
78
  return 'A single-choice question may recommend at most one option.';
68
79
  if (questions.some((question) => question.options.some((option) => !option.description)))
69
80
  return 'Every choice needs a short description. Aim for 25 words or fewer.';
81
+ if (invalidSketchMode)
82
+ return 'Set uiSketch to true to enable question sketches.';
83
+ if (invalidSketchValue)
84
+ return 'Each choice sketch must be text.';
85
+ const sketchError = questions.map((question) => questionSketchError(question.uiSketch, question.options)).find(Boolean);
86
+ if (sketchError)
87
+ return sketchError;
70
88
  const answers = await run.requestAnswers(questions);
71
89
  return questions.map((question, index) => `${question.summary}: ${describeAnswer(answers[index])}`).join('\n');
72
90
  }