conductor-remote 1.117.0 → 1.118.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 (200) hide show
  1. package/README.md +10 -8
  2. package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
  3. package/dist/assets/index-BRzsSW8g.css +1 -0
  4. package/dist/assets/index-BaS6qXUW.js +65 -0
  5. package/dist/index.html +2 -2
  6. package/dist/push-sw.js +2 -2
  7. package/dist/sw.js +1 -1
  8. package/dist-node/scripts/nosleep-setup.js +2 -2
  9. package/dist-node/scripts/nosleep.js +3 -3
  10. package/dist-node/scripts/service/commands.js +32 -0
  11. package/dist-node/scripts/service/configuration.js +205 -0
  12. package/dist-node/scripts/service/environment.js +59 -0
  13. package/dist-node/scripts/service/flags.js +45 -0
  14. package/dist-node/scripts/service/installation.js +91 -0
  15. package/dist-node/scripts/service/launch-agent.js +134 -0
  16. package/dist-node/scripts/service/network.js +244 -0
  17. package/dist-node/scripts/service/presentation.js +101 -0
  18. package/dist-node/scripts/service/voice.js +113 -0
  19. package/dist-node/scripts/service.js +5 -980
  20. package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
  21. package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
  22. package/dist-node/src/{roles.js → agents/roles.js} +3 -3
  23. package/dist-node/src/db.js +1 -1
  24. package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
  25. package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
  26. package/dist-node/src/dev-server/ports.js +63 -0
  27. package/dist-node/src/dev-server/processes.js +73 -0
  28. package/dist-node/src/dev-server/proxy.js +158 -0
  29. package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
  30. package/dist-node/src/dev-server/types.js +1 -0
  31. package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
  32. package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
  33. package/dist-node/src/{git.js → git/diff.js} +1 -1
  34. package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
  35. package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
  36. package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
  37. package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
  38. package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
  39. package/dist-node/src/http/router-types.js +2 -0
  40. package/dist-node/src/http/router.js +78 -0
  41. package/dist-node/src/http/routes/create-workspace.js +90 -0
  42. package/dist-node/src/http/routes/files.js +53 -0
  43. package/dist-node/src/http/routes/prompts.js +363 -0
  44. package/dist-node/src/http/routes/sessions.js +279 -0
  45. package/dist-node/src/http/routes/state.js +186 -0
  46. package/dist-node/src/http/routes/system.js +220 -0
  47. package/dist-node/src/http/routes/voice.js +117 -0
  48. package/dist-node/src/http/routes/workflows.js +248 -0
  49. package/dist-node/src/http/routes/workspaces.js +267 -0
  50. package/dist-node/src/http/services/base.js +252 -0
  51. package/dist-node/src/http/services/delegations.js +301 -0
  52. package/dist-node/src/http/services/delivery.js +433 -0
  53. package/dist-node/src/http/services/files.js +199 -0
  54. package/dist-node/src/http/services/mcp.js +94 -0
  55. package/dist-node/src/http/services/responses.js +120 -0
  56. package/dist-node/src/http/services/voice.js +147 -0
  57. package/dist-node/src/http/services/workflow-probes.js +215 -0
  58. package/dist-node/src/http/services/workflow-state.js +195 -0
  59. package/dist-node/src/http/services/workflow.js +366 -0
  60. package/dist-node/src/http/services.js +35 -0
  61. package/dist-node/src/mcp/arguments.js +14 -0
  62. package/dist-node/src/mcp/dispatcher.js +56 -0
  63. package/dist-node/src/mcp/formatters.js +110 -0
  64. package/dist-node/src/mcp/protocol.js +7 -0
  65. package/dist-node/src/mcp/registry.js +66 -0
  66. package/dist-node/src/mcp/tools/agent-options.js +122 -0
  67. package/dist-node/src/mcp/tools/chat-actions.js +194 -0
  68. package/dist-node/src/mcp/tools/chats.js +194 -0
  69. package/dist-node/src/mcp/tools/delegation.js +135 -0
  70. package/dist-node/src/mcp/tools/system.js +135 -0
  71. package/dist-node/src/mcp/tools/voice.js +136 -0
  72. package/dist-node/src/mcp/tools/workspaces.js +259 -0
  73. package/dist-node/src/mcp/types.js +1 -0
  74. package/dist-node/src/mcp.js +6 -4
  75. package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
  76. package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
  77. package/dist-node/src/orchestration/delegation/codec.js +201 -0
  78. package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
  79. package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
  80. package/dist-node/src/orchestration/delegation/queue.js +255 -0
  81. package/dist-node/src/orchestration/delegation/store.js +111 -0
  82. package/dist-node/src/orchestration/delegation/types.js +2 -0
  83. package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
  84. package/dist-node/src/orchestration/persistence/codecs.js +148 -0
  85. package/dist-node/src/orchestration/persistence/connection.js +205 -0
  86. package/dist-node/src/orchestration/persistence/db.js +101 -0
  87. package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
  88. package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
  89. package/dist-node/src/orchestration/persistence/effects.js +109 -0
  90. package/dist-node/src/orchestration/persistence/errors.js +39 -0
  91. package/dist-node/src/orchestration/persistence/events.js +29 -0
  92. package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
  93. package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
  94. package/dist-node/src/orchestration/persistence/jobs.js +159 -0
  95. package/dist-node/src/orchestration/persistence/projections.js +190 -0
  96. package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
  97. package/dist-node/src/orchestration/persistence/records.js +115 -0
  98. package/dist-node/src/orchestration/persistence/relays.js +78 -0
  99. package/dist-node/src/orchestration/persistence/runs.js +156 -0
  100. package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
  101. package/dist-node/src/orchestration/persistence/types.js +1 -0
  102. package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
  103. package/dist-node/src/orchestration/persistence/values.js +114 -0
  104. package/dist-node/src/orchestration/workflow/commands.js +309 -0
  105. package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
  106. package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
  107. package/dist-node/src/orchestration/workflow/effects.js +307 -0
  108. package/dist-node/src/orchestration/workflow/errors.js +13 -0
  109. package/dist-node/src/orchestration/workflow/helpers.js +154 -0
  110. package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
  111. package/dist-node/src/orchestration/workflow/job-results.js +280 -0
  112. package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
  113. package/dist-node/src/orchestration/workflow/root.js +293 -0
  114. package/dist-node/src/orchestration/workflow/start.js +99 -0
  115. package/dist-node/src/orchestration/workflow/state.js +105 -0
  116. package/dist-node/src/orchestration/workflow/types.js +1 -0
  117. package/dist-node/src/orchestration/workflow/wake.js +164 -0
  118. package/dist-node/src/prefs.js +1 -1
  119. package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
  120. package/dist-node/src/reads/messages.js +362 -0
  121. package/dist-node/src/reads/repository.js +87 -0
  122. package/dist-node/src/reads/sessions.js +165 -0
  123. package/dist-node/src/reads/types.js +1 -0
  124. package/dist-node/src/reads/workspaces.js +230 -0
  125. package/dist-node/src/reads/worktrees.js +45 -0
  126. package/dist-node/src/routes.js +5 -5
  127. package/dist-node/src/{search.js → search/coordinator.js} +4 -4
  128. package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
  129. package/dist-node/src/server.js +14 -3930
  130. package/dist-node/src/settings.js +1 -1
  131. package/dist-node/src/shared.js +10 -10
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
  133. package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
  134. package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
  135. package/dist-node/src/voice/brief.js +22 -50
  136. package/dist-node/src/voice/context.js +1 -1
  137. package/dist-node/src/voice/dates.js +38 -0
  138. package/dist-node/src/voice/history.js +14 -5
  139. package/dist-node/src/voice/prompt.js +10 -9
  140. package/dist-node/src/voice/recall.js +115 -0
  141. package/dist-node/src/{speech.js → voice/speech.js} +1 -1
  142. package/dist-node/src/voice/tools.js +92 -3
  143. package/dist-node/src/voice/webrtc.js +1 -1
  144. package/dist-node/src/wire.js +2 -2
  145. package/dist-node/src/writes/actuator.js +124 -0
  146. package/dist-node/src/writes/agent-options.js +151 -0
  147. package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
  148. package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
  149. package/dist-node/src/writes/applescript/composer.applescript +195 -0
  150. package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
  151. package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
  152. package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
  153. package/dist-node/src/writes/applescript/source.js +52 -0
  154. package/dist-node/src/writes/applescript/window.applescript +436 -0
  155. package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
  156. package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
  157. package/dist-node/src/writes/chats.js +160 -0
  158. package/dist-node/src/writes/guards.js +77 -0
  159. package/dist-node/src/writes/runner.js +67 -0
  160. package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
  161. package/dist-node/src/writes/system.js +81 -0
  162. package/dist-node/src/writes/targeting.js +121 -0
  163. package/dist-node/src/writes/types.js +1 -0
  164. package/dist-node/src/writes/ui-lock.js +229 -0
  165. package/dist-node/src/writes/workspaces.js +233 -0
  166. package/docs/voice-setup.md +34 -12
  167. package/package.json +2 -2
  168. package/dist/assets/index-ByTwVInL.css +0 -1
  169. package/dist/assets/index-LAm9X7wv.js +0 -65
  170. package/dist-node/src/conductor.applescript +0 -2279
  171. package/dist-node/src/delegations.js +0 -557
  172. package/dist-node/src/mcp-tools.js +0 -1319
  173. package/dist-node/src/orchestration-db.js +0 -2186
  174. package/dist-node/src/reads.js +0 -787
  175. package/dist-node/src/workflow-coordinator.js +0 -2119
  176. package/dist-node/src/writes.js +0 -1223
  177. /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
  178. /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
  179. /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
  180. /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
  181. /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
  182. /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
  183. /package/dist-node/src/{icons.js → files/icons.js} +0 -0
  184. /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
  185. /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
  186. /package/dist-node/src/{merge.js → git/merge.js} +0 -0
  187. /package/dist-node/src/{pr.js → git/pr.js} +0 -0
  188. /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
  189. /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
  190. /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
  191. /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
  192. /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
  193. /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
  194. /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
  195. /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
  196. /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
  197. /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
  198. /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
  199. /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
  200. /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
@@ -1,1319 +0,0 @@
1
- /**
2
- * The MCP tools, and the JSON-RPC dispatcher that serves them.
3
- *
4
- * Shared by both transports, which is the only reason they can't drift: `src/mcp.ts`
5
- * runs this over stdio as a separate process, and `src/server.ts` mounts the same
6
- * dispatcher at `POST /mcp` for clients that can only reach a URL.
7
- *
8
- * The tool set tracks `/api`, minus the routes that only exist to back a button on the
9
- * phone. An agent needs no `merge` (it holds `gh`, and `send_prompt` can ask the agent
10
- * that owns the branch), no push subscription, and no settings editor. What it does need
11
- * is everything it cannot reach any other way: the model/effort/plan/fast controls, which
12
- * live in Conductor's UI and nowhere else, the prompts the relay is holding on its behalf,
13
- * the sleep window that keeps this Mac reachable at all, and the relay's own log.
14
- *
15
- * Every tool is an HTTP call to the relay, injected as `call`, and that is the
16
- * load-bearing decision rather than a convenience. Conductor has one shared window,
17
- * so the only thing that makes writes safe is `writes.ts` ▸ `uiTurn`, backed by the
18
- * coordinator's relay-shared mutex. A tool that drove AppleScript itself would sit
19
- * outside it, and two agents focusing different workspaces would land each other's
20
- * prompts — the exact failure every fail-closed assertion cannot catch. Routed through
21
- * the relay, the phone, delivery queues and every agent share the same gate.
22
- *
23
- * The in-relay transport calls the relay's own API over loopback rather than reaching
24
- * into `reads`/`writes` directly. That is a sub-millisecond hop against a 1000-line
25
- * router it would otherwise have to be carved out of, and it keeps *one* code path:
26
- * a tool cannot behave differently over HTTP than it does over stdio.
27
- */
28
- import { chatCursor, parseChatCursor } from "./chat-cursor.js";
29
- import { routes } from "./routes.js";
30
- import { HIT_CLOSE, HIT_OPEN, isToolResult, timestampMs, workspaceTitle } from "./shared.js";
31
- /** Versions we know how to speak. The client's choice wins when we know it. */
32
- export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
33
- export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
34
- export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. list_voice_calls, search_voice_calls and read_voice_call read saved fleet-call transcripts without driving the UI. Use delegate_task for lightweight codebase agents in tracked sibling subtabs: list_roles shows the configured roles and their current models, then an ordinary chat supplies session_id, role, and a focused prompt. Choose a configured role whose instructions fit the task, usually exploration for independent code searches, investigation, and verification, including helper work requested by skills such as gstack. Use the configured role settings even when a skill names a model. Each task returns its result to its parent; several independent explorers can run concurrently without starting a planning/exploration/implementation Workflow. Continue useful local work while they run; use return_mode=steer if their result is needed during the current turn, since the default queue delivers behind it. Inside an active Workflow, only its root may delegate, using the workflow_id and current phase_capability from its private envelope. Never bypass that path through ordinary delegation or untracked splits. MCP cannot start a Workflow, edit its frozen roles, or perform recovery actions. delegate_task returns immediately; the relay later drives the real Mac UI to open, configure, and send the child. create_workspace opens its workspace link without direct UI control; requested agent settings and the first prompt are applied later through Conductor’s UI. plan_usage, dev_server status, dismiss_prompt, keep_awake and relay_logs touch no UI. Use dev_server instead of launching a long-lived development server from a shell: its start/stop actions use Conductor’s configured Run task, drive the real Mac UI and steal focus for a few seconds. send_prompt, split_chat, stop_turn, close_chat, set_agent_options, set_default_model, a live list_models call, set_workspace_status and archive_workspace also drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat or workspace they did not name. archive_workspace also deletes the worktree and stops whatever is running there.';
35
- /** Reads are quick; a UI write is measured in tens of seconds (writes.ts ▸ SEND_ATTEMPT_MS). */
36
- export const READ_TIMEOUT_MS = 10_000;
37
- export const WRITE_TIMEOUT_MS = 75_000;
38
- // ── formatting ──────────────────────────────────────────────────────────────────
39
- // Tool results are text an agent reads, so they are formatted rather than dumped as
40
- // JSON: half the tokens, and every id an agent needs to chain the next call stays
41
- // visible instead of buried in a nested object.
42
- function unmark(text) {
43
- return text.replaceAll(HIT_OPEN, '«').replaceAll(HIT_CLOSE, '»').replace(/\s+/g, ' ').trim();
44
- }
45
- function clip(text, max) {
46
- return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more chars]` : text;
47
- }
48
- /** A true output bound: unlike `clip`, the truncation marker is inside `max`. */
49
- function clipExact(text, max) {
50
- if (text.length <= max)
51
- return text;
52
- const suffix = '… [truncated]';
53
- if (max <= suffix.length)
54
- return text.slice(0, max);
55
- return `${text.slice(0, max - suffix.length)}${suffix}`;
56
- }
57
- /**
58
- * Fit a transcript inside one MCP result without dropping either side of a nearby
59
- * read. Short entries keep their full text; the longest entries share what remains.
60
- */
61
- function boundedTranscript(header, entries, maxChars) {
62
- const prefix = header.length ? `${header.join('\n')}\n\n` : '';
63
- if (!entries.length)
64
- return clipExact(prefix.trim(), maxChars);
65
- const separators = Math.max(0, entries.length - 1) * 2;
66
- const available = Math.max(1, maxChars - prefix.length - separators);
67
- const fullLength = entries.reduce((sum, entry) => sum + entry.length, 0);
68
- if (fullLength <= available)
69
- return `${prefix}${entries.join('\n\n')}`;
70
- // Find the largest per-entry ceiling whose clipped rows fit. Entries shorter than
71
- // it return their unused share to the longer ones, unlike a fixed equal split.
72
- let low = 1;
73
- let high = Math.max(...entries.map(entry => entry.length));
74
- while (low < high) {
75
- const mid = Math.ceil((low + high) / 2);
76
- const used = entries.reduce((sum, entry) => sum + Math.min(entry.length, mid), 0);
77
- if (used <= available)
78
- low = mid;
79
- else
80
- high = mid - 1;
81
- }
82
- return clipExact(`${prefix}${entries.map(entry => clipExact(entry, low)).join('\n\n')}`, maxChars);
83
- }
84
- function plural(n, one, many = `${one}s`) {
85
- return `${n} ${n === 1 ? one : many}`;
86
- }
87
- /** Wall-clock stamp for a log line. Null on continuation lines the file parser couldn't date. */
88
- function stamp(t) {
89
- if (t === null)
90
- return ' ';
91
- const d = new Date(t);
92
- const p = (n) => String(n).padStart(2, '0');
93
- return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
94
- }
95
- /** "in 42m" / "38m ago" — an absolute epoch is unreadable, and an agent's clock may differ. */
96
- function relative(at) {
97
- const mins = Math.round((at - Date.now()) / 60_000);
98
- if (mins > 0)
99
- return `in ${mins}m`;
100
- return mins < 0 ? `${-mins}m ago` : 'now';
101
- }
102
- /** Human-readable state that retains every exact Run-config ID and preview URL. */
103
- function formatDevServer(state, headline = state.running ? 'running' : 'stopped') {
104
- const summary = [headline];
105
- if (state.task)
106
- summary.push(`task ${state.task}`);
107
- if (state.port !== null)
108
- summary.push(`port ${state.port}`);
109
- if (state.forwarded)
110
- summary.push(state.url ? `forwarded ${state.url}` : 'forwarded');
111
- else if (state.running)
112
- summary.push('not forwarded');
113
- const lines = [summary.join(' · ')];
114
- if (state.error)
115
- lines.push(`! ${state.error}`);
116
- if (state.runConfigs.length) {
117
- lines.push(`run configs: ${state.runConfigs.map(config => `${config.id} (${config.name})`).join(', ')}`);
118
- }
119
- if (state.forwards.length) {
120
- lines.push('previews:');
121
- for (const forward of state.forwards) {
122
- const details = [
123
- `${forward.name}: port ${forward.port}`,
124
- forward.running ? 'running' : 'stopped',
125
- forward.forwarded ? (forward.url ? `forwarded ${forward.url}` : 'forwarded') : 'not forwarded'
126
- ];
127
- lines.push(`- ${details.join(' · ')}`);
128
- }
129
- }
130
- return lines.join('\n');
131
- }
132
- const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
133
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
134
- function need(args, key) {
135
- const v = str(args[key]);
136
- if (!v)
137
- throw new Error(`${key} is required`);
138
- return v;
139
- }
140
- function rejectUnknown(args, allowed) {
141
- const accepted = new Set(allowed);
142
- const unknown = Object.keys(args).filter(key => !accepted.has(key));
143
- if (unknown.length)
144
- throw new Error(`unknown ${unknown.length === 1 ? 'field' : 'fields'}: ${unknown.join(', ')}`);
145
- }
146
- function voiceCallHeader(call) {
147
- return [
148
- `call_id: ${call.callId}`,
149
- `${new Date(call.startedAt).toISOString()} · ${call.status} · ${call.transport} · ${call.entryCount} entries`,
150
- ...(call.hasGaps ? ['! This transcript may have gaps from an interrupted connection.'] : []),
151
- ...(call.captureError ? [`! ${call.captureError}`] : [])
152
- ];
153
- }
154
- function voiceEntryFlags(entry) {
155
- const flags = [
156
- ...(entry.transcriptionFailed ? ['audio could not be transcribed'] : entry.partial ? ['partial transcript'] : []),
157
- ...(entry.interrupted ? ['reply interrupted; generated text may include words not played'] : [])
158
- ];
159
- return flags.length ? ` (${flags.join('; ')})` : '';
160
- }
161
- /** Build the tool set against a given relay transport. */
162
- export function createTools(call) {
163
- return [
164
- {
165
- name: 'search_chats',
166
- description: 'Full-text search every Conductor chat on this Mac, archived workspaces included, and get back the workspaces that discussed it with the matching excerpts. Use this to answer "which workspace did I do X in" or "what did we decide about X". Searches the prompts the user typed, the agent replies and the agent\'s reasoning, not tool output. Each excerpt is tagged [user], [assistant] or [thinking] — a [thinking] hit is reasoning the agent never said out loud, so do not quote it back as its answer. Every excerpt carries its own session_id and cursor for a bounded nearby read_chat.',
167
- inputSchema: {
168
- type: 'object',
169
- properties: {
170
- query: {
171
- type: 'string',
172
- description: 'Plain words, ranked by relevance; wrap a "phrase in quotes" to require it word for word. Other punctuation and operators are ignored, not parsed.'
173
- },
174
- limit: { type: 'number', description: 'Max workspaces to return (default 12, max 50).' },
175
- repo: {
176
- type: 'string',
177
- description: 'Only workspaces in this repo, by the exact name list_repos prints. Ranking happens inside the repo, so a rare mention there is not buried by busier repos.'
178
- }
179
- },
180
- required: ['query']
181
- },
182
- run: async (args) => {
183
- const query = need(args, 'query');
184
- const limit = num(args.limit);
185
- const repo = typeof args.repo === 'string' && args.repo.trim() ? args.repo.trim() : null;
186
- const params = new URLSearchParams({ q: query });
187
- if (limit)
188
- params.set('limit', String(limit));
189
- if (repo)
190
- params.set('repo', repo);
191
- const data = await call(`${routes.search.path()}?${params}`);
192
- const lines = [];
193
- if (data.index.error)
194
- lines.push(`! chat index unavailable (${data.index.error}) — names matched only`);
195
- else if (!data.index.ready)
196
- lines.push(`! still indexing (${Math.round(data.index.progress * 100)}%) — older chats not searchable yet`);
197
- const scope = repo ? ` in ${repo}` : '';
198
- if (!data.results.length)
199
- return [...lines, `no workspace or chat matches ${JSON.stringify(query)}${scope}`].join('\n');
200
- for (const r of data.results) {
201
- const w = r.workspace;
202
- const tags = [w.repo_name, w.branch, w.archived ? 'ARCHIVED' : w.state].filter(Boolean).join(' · ');
203
- lines.push('');
204
- lines.push(`## ${workspaceTitle(w)}`);
205
- lines.push(`${tags}${r.byName ? ' · name match' : ''}`);
206
- lines.push(`workspace_id: ${w.id}${r.sessionId ? ` session_id: ${r.sessionId}` : ''}`);
207
- if (r.hits)
208
- lines.push(`${r.hits} matching message${r.hits === 1 ? '' : 's'}:`);
209
- for (const s of r.snippets) {
210
- lines.push(` [${s.role}] ${clip(unmark(s.text), 400)}`);
211
- lines.push(` session_id: ${s.sessionId} cursor: ${s.cursor}`);
212
- }
213
- }
214
- return lines.join('\n').trim();
215
- }
216
- },
217
- {
218
- name: 'read_chat',
219
- description: 'Read a bounded Conductor chat transcript by session_id, newest messages last. Pass a cursor from search_chats or a prior read_chat as near; before and after expand either direction or both together. For a [thinking] search hit, set include_thinking true to include the matching block. Without near, returns the trailing entries. Works for archived workspaces. Tool calls and failed tool output are summarised to one line each; prose is verbatim unless the output budget truncates it.',
220
- inputSchema: {
221
- type: 'object',
222
- properties: {
223
- session_id: { type: 'string', description: 'From search_chats or list_chats.' },
224
- near: { type: 'string', description: 'A cursor from search_chats or a prior read_chat.' },
225
- before: { type: 'number', description: 'Entries before near to return (default 6, max 100).' },
226
- after: { type: 'number', description: 'Entries after near to return (default 6, max 100).' },
227
- limit: {
228
- type: 'number',
229
- description: 'Trailing entries when near is omitted (default 12, max 400).'
230
- },
231
- max_chars: {
232
- type: 'number',
233
- description: 'Hard result-size budget (default 12000, min 1000, max 40000).'
234
- },
235
- include_thinking: { type: 'boolean', description: 'Include the agent’s reasoning (default false).' },
236
- include_tools: {
237
- type: 'boolean',
238
- description: 'Include tool calls and failed tool output (default false).'
239
- }
240
- },
241
- required: ['session_id']
242
- },
243
- run: async (args) => {
244
- const sessionId = need(args, 'session_id');
245
- const near = str(args.near);
246
- const anchor = near ? parseChatCursor(near) : null;
247
- if (near && anchor === null)
248
- throw new Error('near must be a cursor returned by search_chats');
249
- const count = (value, fallback) => Math.min(100, Math.max(0, Math.floor(num(value) ?? fallback)));
250
- const before = count(args.before, 6);
251
- const after = count(args.after, 6);
252
- const limit = Math.min(400, Math.max(1, Math.floor(num(args.limit) ?? 12)));
253
- const maxChars = Math.min(40_000, Math.max(1_000, Math.floor(num(args.max_chars) ?? 12_000)));
254
- // One flag each, because they answer different questions: tools are what the agent
255
- // *did*, thinking is why it did it. Reading both off `include_tools` meant the only
256
- // way to see the reasoning was to take the tool churn along with it.
257
- const includeTools = args.include_tools === true;
258
- const includeThinking = args.include_thinking === true;
259
- const data = await call(`${routes.messages.path(sessionId)}?after=0`, {
260
- timeoutMs: 30_000
261
- });
262
- if (anchor !== null && !data.entries.some(entry => entry.rowid === anchor)) {
263
- throw new Error('near cursor is not in that session');
264
- }
265
- const wanted = data.entries.filter(e => {
266
- if (e.role === 'thinking')
267
- return includeThinking;
268
- // A successful result carries the call's output, which is the churn `include_tools`
269
- // exists to keep out of a context window — the call above it already says what ran.
270
- // A failed one stays: one line, and it is why the turn changed course.
271
- if (isToolResult(e) && !e.error)
272
- return false;
273
- if (e.role === 'tool')
274
- return includeTools;
275
- return true;
276
- });
277
- if (!wanted.length)
278
- return `no messages in session ${sessionId}`;
279
- let selected;
280
- const head = [];
281
- if (anchor === null) {
282
- selected = wanted.slice(-limit);
283
- if (selected.length < wanted.length) {
284
- head.push(`(last ${selected.length} of ${wanted.length} entries · older_cursor: ${chatCursor(selected[0].rowid)})`);
285
- }
286
- }
287
- else {
288
- const older = wanted.filter(entry => entry.rowid < anchor);
289
- const at = wanted.filter(entry => entry.rowid === anchor);
290
- const newer = wanted.filter(entry => entry.rowid > anchor);
291
- selected = [...(before ? older.slice(-before) : []), ...at, ...newer.slice(0, after)];
292
- const parts = [
293
- `near ${near}`,
294
- `${Math.min(before, older.length)} before`,
295
- `${at.length} at`,
296
- `${Math.min(after, newer.length)} after`
297
- ];
298
- if (older.length > before && selected[0])
299
- parts.push(`older_cursor: ${chatCursor(selected[0].rowid)}`);
300
- if (newer.length > after && selected.at(-1)) {
301
- parts.push(`newer_cursor: ${chatCursor(selected.at(-1).rowid)}`);
302
- }
303
- head.push(`(${parts.join(' · ')})`);
304
- }
305
- if (!selected.length) {
306
- return boundedTranscript(head, ['(the matching entry is excluded by the current role filters)'], maxChars);
307
- }
308
- const rendered = selected.map(entry => {
309
- if (entry.role === 'tool')
310
- return `[tool ${entry.tool ?? ''}] ${clip(entry.text, 200)}${entry.detail ? ` — ${entry.detail}` : ''}`;
311
- return `[${entry.role}] ${entry.text}`;
312
- });
313
- return boundedTranscript(head, rendered, maxChars);
314
- }
315
- },
316
- {
317
- name: 'list_voice_calls',
318
- description: 'List saved fleet voice calls on this Mac, newest first, with dates, previews and call_id values for read_voice_call. These transcripts are separate from Conductor chat history. Read-only; no live call or UI interaction.',
319
- inputSchema: {
320
- type: 'object',
321
- properties: {
322
- limit: { type: 'integer', description: 'Calls per page (default 20, max 50).' },
323
- offset: {
324
- type: 'integer',
325
- description: 'Skip this many calls; use next_offset from the previous page (default 0).'
326
- }
327
- },
328
- additionalProperties: false
329
- },
330
- run: async (args) => {
331
- rejectUnknown(args, ['limit', 'offset']);
332
- const limit = Math.min(50, Math.max(1, Math.floor(num(args.limit) ?? 20)));
333
- const offset = Math.min(1_000_000, Math.max(0, Math.floor(num(args.offset) ?? 0)));
334
- const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
335
- const data = await call(`${routes.voiceHistory.path()}?${params}`);
336
- if (!data.calls.length)
337
- return 'No saved voice calls on this page. Only calls captured after transcript saving was enabled are available.';
338
- return [
339
- ...data.calls.map(item => [...voiceCallHeader(item), clip(item.preview.replace(/\s+/g, ' '), 200)].join('\n')),
340
- ...(data.hasMore ? [`next_offset: ${offset + data.calls.length}`] : [])
341
- ].join('\n\n');
342
- }
343
- },
344
- {
345
- name: 'search_voice_calls',
346
- description: 'Search caller and assistant text across saved fleet calls. Results include matching excerpts, call_id and item_id; pass those to read_voice_call as call_id and near to read surrounding decisions. Partial and interrupted text is labeled. Read-only; no UI interaction.',
347
- inputSchema: {
348
- type: 'object',
349
- properties: {
350
- query: {
351
- type: 'string',
352
- maxLength: 500,
353
- description: 'Words ranked by relevance; quote an exact phrase. Uses the same query grammar as search_chats.'
354
- },
355
- call_id: { type: 'string', description: 'Optional: search only this saved call.' },
356
- limit: { type: 'integer', description: 'Matching utterances per page (default 12, max 50).' },
357
- offset: {
358
- type: 'integer',
359
- description: 'Skip this many hits; use next_offset from the previous page (default 0).'
360
- }
361
- },
362
- required: ['query'],
363
- additionalProperties: false
364
- },
365
- run: async (args) => {
366
- rejectUnknown(args, ['query', 'call_id', 'limit', 'offset']);
367
- const query = need(args, 'query');
368
- if (query.length > 500)
369
- throw new Error('query must be at most 500 characters');
370
- const limit = Math.min(50, Math.max(1, Math.floor(num(args.limit) ?? 12)));
371
- const offset = Math.min(1_000_000, Math.max(0, Math.floor(num(args.offset) ?? 0)));
372
- const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) });
373
- if (str(args.call_id))
374
- params.set('callId', str(args.call_id));
375
- const data = await call(`${routes.voiceSearch.path()}?${params}`);
376
- if (!data.hits.length)
377
- return `No saved voice transcript matches ${JSON.stringify(query)} on this page.`;
378
- return [
379
- ...data.hits.map(hit => [
380
- ...voiceCallHeader(hit.call),
381
- `item_id: ${hit.itemId} · ${new Date(hit.at).toISOString()}`,
382
- `[${hit.role}]${voiceEntryFlags(hit)} ${clip(unmark(hit.snippet), 500)}`
383
- ].join('\n')),
384
- ...(data.hasMore ? [`next_offset: ${offset + data.hits.length}`] : [])
385
- ].join('\n\n');
386
- }
387
- },
388
- {
389
- name: 'read_voice_call',
390
- description: 'Read a saved fleet-call transcript by call_id, with caller, orchestrator and tool activity in conversation order. Pass an item_id from search_voice_calls as near for surrounding context, or use older_item/newer_item from a previous read. Without near, reads the latest entries. Text can be clipped to max_chars; narrow the window or raise that budget to read more. Interrupted replies can include generated words the caller never heard. Read-only; no UI interaction.',
391
- inputSchema: {
392
- type: 'object',
393
- properties: {
394
- call_id: { type: 'string', description: 'From list_voice_calls or search_voice_calls.' },
395
- near: { type: 'string', description: 'A transcript item_id from a search hit or previous read.' },
396
- before: { type: 'integer', description: 'Entries before near (default 6, max 100).' },
397
- after: { type: 'integer', description: 'Entries after near (default 6, max 100).' },
398
- limit: { type: 'integer', description: 'Latest entries when near is omitted (default 20, max 200).' },
399
- max_chars: { type: 'integer', description: 'Hard output budget (default 12000, min 1000, max 40000).' }
400
- },
401
- required: ['call_id'],
402
- additionalProperties: false
403
- },
404
- run: async (args) => {
405
- rejectUnknown(args, ['call_id', 'near', 'before', 'after', 'limit', 'max_chars']);
406
- const callId = need(args, 'call_id');
407
- const data = await call(routes.voiceTranscript.path(callId));
408
- const entries = data.entries.filter(entry => entry.role !== 'relay');
409
- const near = str(args.near);
410
- const anchor = near ? entries.findIndex(entry => entry.id === near) : -1;
411
- if (near && anchor === -1)
412
- throw new Error('near item is not in that saved call');
413
- const count = (value, fallback, max) => Math.min(max, Math.max(0, Math.floor(num(value) ?? fallback)));
414
- const limit = Math.max(1, count(args.limit, 20, 200));
415
- let start = near ? Math.max(0, anchor - count(args.before, 6, 100)) : Math.max(0, entries.length - limit);
416
- let end = near ? Math.min(entries.length, anchor + count(args.after, 6, 100) + 1) : entries.length;
417
- const maxChars = Math.max(1_000, count(args.max_chars, 12_000, 40_000));
418
- const heading = (entry) => `[${entry.role}] item_id: ${entry.id} · ${new Date(entry.at).toISOString()}${voiceEntryFlags(entry)}\n`;
419
- // Keep identities and caveats readable even for a tiny budget. Shrink the
420
- // window around its anchor, then advertise the omitted sides for paging.
421
- while (end - start > 1) {
422
- const minRow = Math.max(...entries.slice(start, end).map(entry => heading(entry).length)) + 80;
423
- if (voiceCallHeader(data).join('\n').length + 240 + (end - start) * (minRow + 2) <= maxChars)
424
- break;
425
- if (!near || anchor - start > end - anchor - 1)
426
- start += 1;
427
- else
428
- end -= 1;
429
- }
430
- const selected = entries.slice(start, end);
431
- const head = [
432
- ...voiceCallHeader(data),
433
- `Showing ${selected.length} of ${entries.length} entries.`,
434
- ...(start > 0 && selected[0] ? [`older_item: ${selected[0].id} (use as near with before > 0)`] : []),
435
- ...(end < entries.length && selected.at(-1)
436
- ? [`newer_item: ${selected.at(-1).id} (use as near with after > 0)`]
437
- : [])
438
- ];
439
- const rendered = selected.map(entry => `${heading(entry)}${entry.text}`);
440
- return boundedTranscript(head, rendered, maxChars);
441
- }
442
- },
443
- {
444
- name: 'list_workspaces',
445
- description: 'List the live (non-archived) Conductor workspaces with what each one is doing right now: agent status, model, branch, PR state, and any prompt the relay is still holding for it. Use this to see what is running before starting or steering anything.',
446
- inputSchema: {
447
- type: 'object',
448
- properties: {
449
- status: {
450
- type: 'string',
451
- description: 'Filter by live agent status: working | idle | error. Omit for all.'
452
- }
453
- }
454
- },
455
- run: async (args) => {
456
- const status = str(args.status);
457
- const data = await call(routes.state.path());
458
- const shown = status ? data.workspaces.filter(w => w.session_status === status) : data.workspaces;
459
- if (!shown.length)
460
- return status ? `no workspace is ${status}` : 'no live workspaces';
461
- return shown
462
- .map(w => {
463
- const assigned = w.active_session_id ? w.session_roles?.[w.active_session_id] : undefined;
464
- const lines = [
465
- `${w.session_status === 'working' ? '▶' : '·'} ${workspaceTitle(w)}`,
466
- ` ${[w.repo_name, w.branch, w.model, assigned ? `role ${assigned.role}` : null, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
467
- ` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
468
- ];
469
- // An undelivered prompt is invisible from the DB — it lives in the relay's own
470
- // queues — so an agent reading only status would call a stalled workspace idle
471
- // and send a second copy of the prompt already waiting on it (dismiss_prompt).
472
- if (w.pending_prompt) {
473
- const p = w.pending_prompt;
474
- lines.push(` ! first prompt ${p.status}: ${clip(unmark(p.text), 120)}${p.error ? ` — ${p.error}` : ''}`);
475
- }
476
- for (const p of w.parked_prompts ?? []) {
477
- lines.push(` ! prompt ${p.status} for session ${p.sessionId} (${p.reason}): ${clip(unmark(p.text), 120)}`);
478
- }
479
- for (const job of w.delegations ?? []) {
480
- lines.push(` ${job.status === 'failed' ? '!' : '→'} delegation ${job.role}: ${job.status} (${job.id})${job.failure ? ` — ${job.failure.message}` : ''}`);
481
- }
482
- if (w.delegation_warning)
483
- lines.push(` ! delegation state: ${w.delegation_warning}`);
484
- return lines.join('\n');
485
- })
486
- .join('\n');
487
- }
488
- },
489
- {
490
- name: 'list_chats',
491
- description: 'List the chat tabs in a workspace with each one’s status, model and how full its context window is. A workspace can hold several conversations, each with its own context; send_prompt and read_chat address one of them.',
492
- inputSchema: {
493
- type: 'object',
494
- properties: { workspace_id: { type: 'string' } },
495
- required: ['workspace_id']
496
- },
497
- run: async (args) => {
498
- const id = need(args, 'workspace_id');
499
- const data = await call(routes.sessions.path(id));
500
- if (!data.sessions.length)
501
- return `no chats in workspace ${id}`;
502
- // Context is per chat and only per chat — the same workspace can hold a tab at 85%
503
- // beside one at 28% — so it is printed on every row rather than summarised above.
504
- return data.sessions
505
- .map(s => {
506
- const ctx = typeof s.context_used_percent === 'number' && s.context_used_percent > 0
507
- ? ` · ${Math.round(s.context_used_percent)}% context`
508
- : '';
509
- // A chat waiting on a background task reads `idle` in `status` and will resume
510
- // itself; an agent that reads only the status would call it done and send a
511
- // second prompt into the wait.
512
- const waiting = (s.background_tasks ?? []).map(t => `\n waiting for task: ${t.description} (${Math.max(0, Math.round((Date.now() - timestampMs(t.since)) / 60_000))}m so far)`);
513
- const glyph = s.status === 'working' ? '▶' : waiting.length ? '⧗' : '·';
514
- const role = data.session_roles?.[s.id];
515
- return `${glyph} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}${role ? ` · role ${role.role}` : ''}${ctx}\n session_id: ${s.id}${waiting.join('')}`;
516
- })
517
- .join('\n');
518
- }
519
- },
520
- {
521
- name: 'list_roles',
522
- description: 'List configured models, effort, Fast mode, instructions, and validation issues before lightweight delegation. Choose a role whose current configuration fits the task. Invalid roles cannot start a child or new Workflow. Active Workflows use the frozen roles from their root envelope.',
523
- inputSchema: { type: 'object', properties: {} },
524
- run: async () => {
525
- const data = await call(routes.roles.path());
526
- const issueByRole = new Map(data.issues.map(issue => [issue.role, issue.error]));
527
- const lines = Object.entries(data.roles).map(([name, role]) => {
528
- const settings = [
529
- role.model,
530
- role.effort,
531
- role.fast === undefined ? null : role.fast ? 'fast on' : 'fast off'
532
- ]
533
- .filter(Boolean)
534
- .join(' · ');
535
- const issue = issueByRole.get(name);
536
- const instructions = role.preamble?.trim();
537
- return `${issue ? '!' : '·'} ${name}: ${settings}${issue ? ` — ${issue.code}: ${issue.message}` : ''}${instructions ? `\n Instructions: ${instructions.replaceAll('\n', '\n ')}` : ''}`;
538
- });
539
- if (data.warning)
540
- lines.unshift(`! ${data.warning}`);
541
- return lines.join('\n') || 'no delegated roles are configured';
542
- }
543
- },
544
- {
545
- name: 'delegate_task',
546
- description: 'Spawn one lightweight tracked sibling chat in a subtab. From an ordinary chat, pass session_id, a valid role from list_roles, and a focused prompt. Choose a configured role whose instructions fit the task; repeat for independent questions so children can run concurrently. This does not start a Workflow or turn the parent into a planner. The role must use a different provider than the parent. The child follows the configured preamble and assignment, receives a transcript attachment, and returns its result. Queue mode delivers behind the current turn by default; use steer if the result is needed during this turn. Keep working and integrate the result. For an active Workflow, only its root may call with workflow_id and current phase_capability from the private envelope; its frozen settings, role restrictions, transcript boundary, and Baton routing apply. Returns immediately with a delegation id; list_delegations observes progress. The relay later opens/configures/sends the child through the Mac UI. This tool cannot start or recover a Workflow.',
547
- inputSchema: {
548
- type: 'object',
549
- properties: {
550
- workflow_id: { type: 'string', description: 'Active Workflow only: id from the private root envelope.' },
551
- phase_capability: {
552
- type: 'string',
553
- description: 'Opaque current-phase capability from that same envelope; never copy it elsewhere.'
554
- },
555
- session_id: { type: 'string', description: 'Parent chat id; an active Workflow requires its root session.' },
556
- role: { type: 'string', description: 'Configured role name. Workflows allow exploration or implementation.' },
557
- prompt: {
558
- type: 'string',
559
- description: 'The independent question or implementation brief, including file ownership for edits.'
560
- },
561
- return_mode: {
562
- type: 'string',
563
- enum: ['queue', 'steer'],
564
- description: 'Ordinary chats only. Queue the Baton behind this turn (default), or steer it into a running turn.'
565
- },
566
- through: {
567
- type: 'string',
568
- description: 'Ordinary chats only. Optional read_chat cursor bounding the transcript handoff.'
569
- },
570
- include_thinking: {
571
- type: 'boolean',
572
- description: 'Ordinary chats only. Include parent reasoning in the handoff (default false).'
573
- }
574
- },
575
- required: ['session_id', 'role', 'prompt'],
576
- additionalProperties: false
577
- },
578
- run: async (args) => {
579
- if (!Object.hasOwn(args, 'workflow_id') && !Object.hasOwn(args, 'phase_capability')) {
580
- rejectUnknown(args, ['session_id', 'role', 'prompt', 'return_mode', 'through', 'include_thinking']);
581
- const sessionId = need(args, 'session_id');
582
- if (args.return_mode !== undefined && args.return_mode !== 'queue' && args.return_mode !== 'steer') {
583
- throw new Error('return_mode must be queue or steer');
584
- }
585
- if (args.include_thinking !== undefined && typeof args.include_thinking !== 'boolean') {
586
- throw new Error('include_thinking must be a boolean');
587
- }
588
- const through = args.through === undefined ? undefined : parseChatCursor(need(args, 'through'));
589
- if (through === null)
590
- throw new Error('through must be a read_chat cursor');
591
- const body = {
592
- role: need(args, 'role'),
593
- prompt: need(args, 'prompt'),
594
- ...(args.return_mode === undefined ? {} : { returnMode: args.return_mode }),
595
- ...(through === undefined ? {} : { throughRowid: through }),
596
- ...(args.include_thinking === undefined ? {} : { includeThinking: args.include_thinking })
597
- };
598
- const result = await call(routes.delegateTask.path(sessionId), {
599
- method: routes.delegateTask.method,
600
- body
601
- });
602
- if (!result.ok)
603
- throw new Error(`${result.error.code}: ${result.error.message}`);
604
- return `delegation ${result.delegationId} queued · ${result.role} · ${result.model}`;
605
- }
606
- rejectUnknown(args, ['workflow_id', 'phase_capability', 'session_id', 'role', 'prompt']);
607
- const workflowId = need(args, 'workflow_id');
608
- const sessionId = need(args, 'session_id');
609
- const role = need(args, 'role');
610
- if (role !== 'exploration' && role !== 'implementation')
611
- throw new Error('role is invalid');
612
- const body = {
613
- workflow_id: workflowId,
614
- phase_capability: need(args, 'phase_capability'),
615
- session_id: sessionId,
616
- role,
617
- prompt: need(args, 'prompt')
618
- };
619
- const result = await call(routes.workflowDelegation.path(workflowId), {
620
- method: routes.workflowDelegation.method,
621
- body
622
- });
623
- if (!result.ok)
624
- throw new Error(`${result.error.code}: ${result.error.message}`);
625
- return `delegation ${result.delegationId} queued · ${result.role} · ${result.model}`;
626
- }
627
- },
628
- {
629
- name: 'list_delegations',
630
- description: 'List active and failed delegated jobs. Successful jobs leave their chats and role chips, not a second history log.',
631
- inputSchema: {
632
- type: 'object',
633
- properties: { workspace_id: { type: 'string', description: 'Optional workspace filter.' } }
634
- },
635
- run: async (args) => {
636
- const workspaceId = str(args.workspace_id);
637
- const suffix = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : '';
638
- const data = await call(`${routes.delegations.path()}${suffix}`);
639
- if (!data.delegations.length)
640
- return 'no active or failed delegations';
641
- return data.delegations
642
- .map(job => {
643
- const child = job.childSessionId ? ` · child ${job.childSessionId}` : '';
644
- const failure = job.failure ? ` · ${job.failure.code}: ${job.failure.message}` : '';
645
- return `${job.status} · ${job.role} · ${job.resolvedRole.model} · ${job.id}${child}${failure}`;
646
- })
647
- .join('\n');
648
- }
649
- },
650
- {
651
- name: 'workspace_diff',
652
- description: 'The git diff of a live workspace against its target branch, untracked files included.',
653
- inputSchema: {
654
- type: 'object',
655
- properties: { workspace_id: { type: 'string' } },
656
- required: ['workspace_id']
657
- },
658
- run: async (args) => {
659
- const id = need(args, 'workspace_id');
660
- const data = await call(routes.diff.path(id), {
661
- timeoutMs: 30_000
662
- });
663
- if (!data.files.length)
664
- return 'no changes against the target branch';
665
- return data.files.map(f => `${f.path} +${f.added} -${f.removed}`).join('\n');
666
- }
667
- },
668
- {
669
- name: 'list_repos',
670
- description: 'The repos Conductor can create a workspace in. Use before create_workspace to get an exact repo name.',
671
- inputSchema: { type: 'object', properties: {} },
672
- run: async () => {
673
- const data = await call(routes.repos.path());
674
- return data.repos.map(r => `${r.name} (${r.default_branch ?? '?'}) ${r.root_path ?? ''}`).join('\n');
675
- }
676
- },
677
- {
678
- name: 'plan_usage',
679
- description: 'Read rolling Claude Code and Codex subscription allowances from their local CLIs: percentage used and reset time. Sends no model prompt and touches no Conductor UI. Cursor Agent and OpenCode explain why no comparable plan quota is available.',
680
- inputSchema: { type: 'object', properties: {} },
681
- run: async () => {
682
- const data = await call(routes.planUsage.path(), { timeoutMs: 15_000 });
683
- const sections = data.providers.map(provider => {
684
- const head = `## ${provider.label}${provider.plan ? ` · ${provider.plan}` : ''}`;
685
- if (provider.status !== 'available')
686
- return `${head}\n${provider.message ?? 'unavailable'}`;
687
- const lines = provider.buckets.flatMap(bucket => bucket.windows.map(window => {
688
- const bucketName = provider.buckets.length > 1 ? `${bucket.label} — ` : '';
689
- const reset = window.resetsAt === null ? 'reset unavailable' : `resets ${new Date(window.resetsAt).toISOString()}`;
690
- return `- ${bucketName}${window.label}: ${Math.round(window.usedPercent)}% used · ${reset}`;
691
- }));
692
- return `${head}\n${lines.join('\n')}`;
693
- });
694
- return `${sections.join('\n\n')}\n\nupdated ${new Date(data.fetchedAt).toISOString()}`;
695
- }
696
- },
697
- {
698
- name: 'create_workspace',
699
- description: 'Start an ordinary new Conductor workspace in a repo, optionally with a first prompt and agent settings. The workspace starts from a Conductor deep link, so creation itself needs no Accessibility. The relay applies requested model, effort, plan, and fast settings through Conductor’s UI after it creates the first chat and before it delivers the prompt. This utility cannot start or join a Workflow. Returns as soon as the workspace row exists (~2s), before the worktree is built.',
700
- inputSchema: {
701
- type: 'object',
702
- properties: {
703
- repo: { type: 'string', description: 'Exact name from list_repos.' },
704
- prompt: { type: 'string', description: 'First prompt for the new agent. Omit to open an empty workspace.' },
705
- model: {
706
- type: 'string',
707
- description: 'Picker label from list_models with no session_id. The relay applies it before the first prompt, or configures an empty workspace once its chat exists.'
708
- },
709
- effort: {
710
- type: 'string',
711
- enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
712
- description: 'Reasoning effort applied before the first prompt.'
713
- },
714
- plan: { type: 'boolean', description: 'Start the first chat in Conductor’s Plan mode.' },
715
- fast: { type: 'boolean', description: 'Set Fast mode before the first prompt.' },
716
- wait_for_send: {
717
- type: 'boolean',
718
- description: 'Block until the first prompt is actually delivered (tens of seconds, and longer behind other queued sends). Default false.'
719
- },
720
- send_immediately: {
721
- type: 'boolean',
722
- description: "Send the first prompt without waiting for the worktree to finish building, which is how Conductor's own New workspace box behaves. Default true. Pass false only when the agent's first move needs what the repo's setup script installs."
723
- }
724
- },
725
- required: ['repo'],
726
- additionalProperties: false
727
- },
728
- run: async (args) => {
729
- rejectUnknown(args, ['repo', 'prompt', 'model', 'effort', 'plan', 'fast', 'wait_for_send', 'send_immediately']);
730
- const repo = need(args, 'repo');
731
- const prompt = str(args.prompt);
732
- const model = str(args.model);
733
- const effort = str(args.effort);
734
- const plan = typeof args.plan === 'boolean' ? args.plan : undefined;
735
- const fast = typeof args.fast === 'boolean' ? args.fast : undefined;
736
- const explicitAgent = model !== undefined || effort !== undefined || plan !== undefined || fast !== undefined;
737
- const configured = explicitAgent;
738
- const send = args.wait_for_send === true;
739
- // Default on, matching the relay: an omitted flag must never mean "wait".
740
- const sendImmediately = args.send_immediately !== false;
741
- const data = await call(routes.createWorkspace.path(), {
742
- method: routes.createWorkspace.method,
743
- body: { repo, prompt, model, effort, plan, fast, send, sendImmediately },
744
- timeoutMs: send ? WRITE_TIMEOUT_MS : 30_000
745
- });
746
- const lines = [
747
- `created ${data.workspace ? workspaceTitle(data.workspace) : data.workspaceId}`,
748
- `workspace_id: ${data.workspaceId}`
749
- ];
750
- if (data.warning)
751
- lines.push(`! ${data.warning}`);
752
- else if (data.pendingPrompt && !data.sent)
753
- lines.push('the first prompt is queued — the relay delivers it, so do not send it again');
754
- else if (data.sent)
755
- lines.push('the first prompt was delivered');
756
- if (configured)
757
- lines.push(data.configured ? 'agent settings applied' : 'agent settings queued');
758
- return lines.join('\n');
759
- }
760
- },
761
- {
762
- name: 'send_prompt',
763
- description: 'Send a prompt into an existing Conductor chat, exactly as typing it on the Mac would. This DRIVES THE REAL UI: it focuses the workspace, selects the chat tab and presses Enter, so it steals focus for a few seconds. If that chat is already working, the message STEERS the running agent rather than starting a new turn — do not use it to poll or test. Ask the user before sending into a chat they did not name.',
764
- inputSchema: {
765
- type: 'object',
766
- properties: {
767
- session_id: { type: 'string', description: 'The chat to send to (list_chats / search_chats).' },
768
- workspace_id: {
769
- type: 'string',
770
- description: 'Its workspace. Strongly recommended: it is what the relay asserts against before typing.'
771
- },
772
- text: { type: 'string' }
773
- },
774
- required: ['session_id', 'text'],
775
- additionalProperties: false
776
- },
777
- run: async (args) => {
778
- rejectUnknown(args, ['session_id', 'workspace_id', 'text']);
779
- const sessionId = need(args, 'session_id');
780
- const text = need(args, 'text');
781
- const data = await call(routes.sendPrompt.path(sessionId), {
782
- method: routes.sendPrompt.method,
783
- body: { text, workspaceId: str(args.workspace_id) },
784
- timeoutMs: WRITE_TIMEOUT_MS
785
- });
786
- if (data.parked)
787
- return 'the Mac is locked — the prompt is parked and will be sent on unlock';
788
- if (!data.ok)
789
- throw new Error(data.error ?? 'the send did not land');
790
- return data.warning ? `sent (${data.warning})` : 'sent';
791
- }
792
- },
793
- {
794
- name: 'split_chat',
795
- description: 'Move a tangent out of a chat: copy that chat as a Conductor attachment and ask the new agent your question there. By default it opens a fresh tab beside the source (same files); new_workspace creates a separate Conductor workspace and carries the source worktree’s current tracked and untracked-not-ignored files into it. The transcript copy carries prose and reasoning, not tool calls, and runs to the end unless through names an earlier message. A tab fork DRIVES THE REAL UI twice (new tab, then send); a workspace fork creates through a deep link, then drives the UI for the send. Both steal focus. To split the chat you are in, find its session_id with list_chats on your own workspace.',
796
- inputSchema: {
797
- type: 'object',
798
- properties: {
799
- session_id: { type: 'string', description: 'The chat to copy (list_chats / search_chats).' },
800
- prompt: { type: 'string', description: 'What to ask in the new destination.' },
801
- workspace_id: { type: 'string', description: 'Its workspace. Resolved from the chat when omitted.' },
802
- include_thinking: {
803
- type: 'boolean',
804
- description: 'Carry the agent’s reasoning across (default true — it is usually the useful half).'
805
- },
806
- include_tools: {
807
- type: 'boolean',
808
- description: 'Carry tool calls across as one line each (default false — mostly noise, and most of the bytes).'
809
- },
810
- new_workspace: {
811
- type: 'boolean',
812
- description: 'Create a separate workspace carrying the source’s current code (default false: a new tab with the same files).'
813
- },
814
- through: {
815
- type: 'string',
816
- description: 'Stop the copy at this message, it included — a cursor from read_chat or search_chats. Default: the whole chat.'
817
- }
818
- },
819
- required: ['session_id', 'prompt']
820
- },
821
- run: async (args) => {
822
- const sessionId = need(args, 'session_id');
823
- const prompt = need(args, 'prompt');
824
- const through = str(args.through);
825
- const destination = args.new_workspace === true ? 'workspace' : 'chat';
826
- const throughRowid = through ? parseChatCursor(through) : null;
827
- if (through && throughRowid === null) {
828
- throw new Error('through must be a cursor returned by read_chat or search_chats');
829
- }
830
- const split = await call(routes.splitChat.path(sessionId), {
831
- method: routes.splitChat.method,
832
- body: {
833
- prompt,
834
- workspaceId: str(args.workspace_id),
835
- includeThinking: args.include_thinking !== false,
836
- includeTools: args.include_tools === true,
837
- throughRowid: throughRowid ?? undefined,
838
- destination
839
- },
840
- timeoutMs: WRITE_TIMEOUT_MS
841
- });
842
- if (!split.ok)
843
- throw new Error(split.error ?? 'the split did not open its destination');
844
- const { attachment: file } = split;
845
- // Name the cut. A transcript that quietly dropped half the chat reads exactly
846
- // like a complete one to whoever gets it next.
847
- const cut = [
848
- file.elided.tools ? plural(file.elided.tools, 'tool call') : '',
849
- file.elided.thinking ? plural(file.elided.thinking, 'thinking block') : '',
850
- file.elided.earlier ? plural(file.elided.earlier, 'entry before the cut', 'entries before the cut') : '',
851
- file.elided.later ? plural(file.elided.later, 'entry after the cut', 'entries after the cut') : ''
852
- ].filter(Boolean);
853
- const lines = [
854
- `copied ${plural(file.kept, 'entry', 'entries')} (${Math.round(file.bytes / 1024)}kB) to ${file.path}${cut.length ? `, without ${cut.join(' or ')}` : ''}`,
855
- ...(destination === 'workspace' ? [`workspace_id: ${split.workspaceId} (current code carried across)`] : [])
856
- ];
857
- // The destination exists either way, so every path below leaves the caller somewhere to
858
- // go rather than reporting a bare failure over work that half-happened.
859
- if (!split.sessionId) {
860
- lines.push(`the new ${destination} is open, but the relay could not read its chat id back`);
861
- lines.push('find it with list_chats, then send_prompt the question yourself');
862
- return lines.join('\n');
863
- }
864
- lines.push(`session_id: ${split.sessionId}`);
865
- const sent = await call(routes.sendPrompt.path(split.sessionId), {
866
- method: routes.sendPrompt.method,
867
- body: { text: split.text, workspaceId: split.workspaceId },
868
- timeoutMs: WRITE_TIMEOUT_MS
869
- });
870
- if (sent.parked)
871
- lines.push('the Mac is locked — the prompt is parked and lands on unlock');
872
- else if (!sent.ok)
873
- lines.push(`! the ${destination} and transcript are ready, but the prompt did not land (${sent.error ?? 'unknown'}) — retry it with send_prompt`);
874
- else
875
- lines.push(sent.warning ? `sent (${sent.warning})` : 'sent');
876
- return lines.join('\n');
877
- }
878
- },
879
- {
880
- name: 'stop_turn',
881
- description: 'Cancel the answer a chat is currently streaming — Conductor’s own "Cancel agent". Drives the real UI. A chat that already finished answers alreadyIdle, which is a success. This destroys the in-flight work of another agent, so ask the user first.',
882
- inputSchema: {
883
- type: 'object',
884
- properties: {
885
- session_id: { type: 'string' },
886
- workspace_id: {
887
- type: 'string',
888
- description: 'Required in practice — the relay asserts the pane against it before pressing.'
889
- }
890
- },
891
- required: ['session_id']
892
- },
893
- run: async (args) => {
894
- const sessionId = need(args, 'session_id');
895
- const data = await call(routes.stop.path(sessionId), {
896
- method: routes.stop.method,
897
- body: { workspaceId: str(args.workspace_id) },
898
- timeoutMs: WRITE_TIMEOUT_MS
899
- });
900
- if (data.alreadyIdle)
901
- return 'that chat had already finished — nothing to stop';
902
- if (!data.ok)
903
- throw new Error(data.error ?? 'the stop did not land');
904
- return 'stopped';
905
- }
906
- },
907
- {
908
- name: 'close_chat',
909
- description: 'Close one visible Conductor chat tab. This hides the tab but keeps its transcript; it can be reopened in Conductor with ⌘⇧T. DRIVES THE REAL UI and steals focus for a few seconds. A running chat is refused unless close_running is true, matching Conductor’s own “Close anyway” confirmation — only set it after the user confirms.',
910
- inputSchema: {
911
- type: 'object',
912
- properties: {
913
- session_id: { type: 'string', description: 'The visible chat tab to close.' },
914
- workspace_id: {
915
- type: 'string',
916
- description: 'Workspace holding the chat. The relay verifies it when provided.'
917
- },
918
- close_running: {
919
- type: 'boolean',
920
- description: 'Close even though its agent is still working. Requires explicit user confirmation.'
921
- }
922
- },
923
- required: ['session_id']
924
- },
925
- run: async (args) => {
926
- const sessionId = need(args, 'session_id');
927
- const data = await call(routes.closeChat.path(sessionId), {
928
- method: routes.closeChat.method,
929
- body: {
930
- workspaceId: str(args.workspace_id),
931
- closeRunning: args.close_running === true
932
- },
933
- timeoutMs: WRITE_TIMEOUT_MS
934
- });
935
- if (!data.ok)
936
- throw new Error(data.error ?? 'the chat tab did not close');
937
- if (data.alreadyClosed)
938
- return 'already closed';
939
- return data.activeSessionId ? `closed; active session_id: ${data.activeSessionId}` : 'closed; no tabs remain';
940
- }
941
- },
942
- {
943
- name: 'list_models',
944
- description: 'The model labels the relay knows. With no session_id this returns its stored picker labels, grouped by harness; use it before create_workspace. With a session_id it reads that chat’s live Conductor picker and refreshes the stored labels. A live read DRIVES THE REAL UI — it focuses the workspace and opens the menu — so it costs a few seconds of stolen focus.',
945
- inputSchema: {
946
- type: 'object',
947
- properties: {
948
- session_id: {
949
- type: 'string',
950
- description: 'The chat whose live picker to read. Omit to use the relay cache.'
951
- },
952
- workspace_id: {
953
- type: 'string',
954
- description: 'Workspace holding session_id. Required when session_id is set.'
955
- }
956
- }
957
- },
958
- run: async (args) => {
959
- const sessionId = str(args.session_id);
960
- if (!sessionId) {
961
- const data = await call(routes.modelCatalog.path());
962
- if (!data.groups.length)
963
- return 'no models are cached yet — read a chat’s live picker with session_id and workspace_id';
964
- return data.groups
965
- .map(group => `## ${group.agentType}\n${group.models.map(model => `- ${model}${model === data.defaultModel ? ' ★ default' : ''}`).join('\n')}`)
966
- .join('\n\n');
967
- }
968
- const workspaceId = need(args, 'workspace_id');
969
- const data = await call(`${routes.models.path(sessionId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { timeoutMs: WRITE_TIMEOUT_MS });
970
- if (!data.ok)
971
- throw new Error(data.error ?? 'could not read the model menu');
972
- return data.models?.length
973
- ? data.models.map(model => `${model}${model === data.defaultModel ? ' ★ default' : ''}`).join('\n')
974
- : 'the menu listed no models';
975
- }
976
- },
977
- {
978
- name: 'set_default_model',
979
- description: 'Star a model as Conductor’s user-wide default. This mirrors the desktop picker’s combined “set as default and select” action, so it also switches the target chat to that model for its next turn. DRIVES THE REAL UI and changes a global preference — ask the user first.',
980
- inputSchema: {
981
- type: 'object',
982
- properties: {
983
- session_id: { type: 'string', description: 'Chat whose live model picker should be used.' },
984
- workspace_id: {
985
- type: 'string',
986
- description: 'Required in practice — the relay asserts the pane against it before pressing.'
987
- },
988
- model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' }
989
- },
990
- required: ['session_id', 'workspace_id', 'model']
991
- },
992
- run: async (args) => {
993
- const sessionId = need(args, 'session_id');
994
- const workspaceId = need(args, 'workspace_id');
995
- const model = need(args, 'model');
996
- const data = await call(routes.defaultModel.path(sessionId), {
997
- method: routes.defaultModel.method,
998
- body: { workspaceId, model },
999
- timeoutMs: WRITE_TIMEOUT_MS
1000
- });
1001
- if (!data.ok)
1002
- throw new Error(data.error ?? 'the default model did not change');
1003
- return `${data.defaultModel ?? model} is now the default and selected for this chat`;
1004
- }
1005
- },
1006
- {
1007
- name: 'set_agent_options',
1008
- description: 'Change how a chat’s agent runs: model, reasoning effort, plan mode, fast mode. Conductor keeps these in its composer and nowhere else, so this is the only way to reach them — a prompt cannot. DRIVES THE REAL UI and steals focus for a few seconds. The change is confirmed against Conductor’s database before this answers. Applies to the NEXT turn, so set it before send_prompt, not during one. Ask the user before re-pointing a chat they did not name at a different model.',
1009
- inputSchema: {
1010
- type: 'object',
1011
- properties: {
1012
- session_id: { type: 'string' },
1013
- workspace_id: {
1014
- type: 'string',
1015
- description: 'Strongly recommended: it is what the relay asserts against before pressing anything.'
1016
- },
1017
- model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' },
1018
- effort: { type: 'string', enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] },
1019
- plan: { type: 'boolean', description: 'Conductor’s Plan checkbox.' },
1020
- fast: {
1021
- type: 'boolean',
1022
- description: 'Fast mode. Only some models offer it; a missing button is reported, not ignored.'
1023
- }
1024
- },
1025
- required: ['session_id']
1026
- },
1027
- run: async (args) => {
1028
- const sessionId = need(args, 'session_id');
1029
- const patch = {
1030
- model: str(args.model),
1031
- effort: str(args.effort),
1032
- plan: typeof args.plan === 'boolean' ? args.plan : undefined,
1033
- fast: typeof args.fast === 'boolean' ? args.fast : undefined,
1034
- workspaceId: str(args.workspace_id)
1035
- };
1036
- if (patch.model === undefined &&
1037
- patch.effort === undefined &&
1038
- patch.plan === undefined &&
1039
- patch.fast === undefined)
1040
- throw new Error('nothing to change — pass at least one of model, effort, plan, fast');
1041
- const data = await call(routes.agent.path(sessionId), {
1042
- method: routes.agent.method,
1043
- body: patch,
1044
- timeoutMs: WRITE_TIMEOUT_MS
1045
- });
1046
- if (!data.ok)
1047
- throw new Error(data.error ?? 'the change did not land');
1048
- // The re-read row is the receipt, so report *it* rather than what was asked for.
1049
- const s = data.session;
1050
- return s
1051
- ? `now: ${[s.model, s.claude_effort_level, s.permission_mode, s.fast_mode ? 'fast' : null].filter(Boolean).join(' · ')}`
1052
- : 'applied';
1053
- }
1054
- },
1055
- {
1056
- name: 'set_workspace_status',
1057
- description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. A collapsed section hides its rows from Accessibility, so folded sections are opened, used, and folded back — which makes this run a few seconds longer.',
1058
- inputSchema: {
1059
- type: 'object',
1060
- properties: {
1061
- workspace_id: { type: 'string' },
1062
- status: { type: 'string', enum: ['backlog', 'in-progress', 'in-review', 'done', 'canceled'] }
1063
- },
1064
- required: ['workspace_id', 'status']
1065
- },
1066
- run: async (args) => {
1067
- const id = need(args, 'workspace_id');
1068
- const status = need(args, 'status');
1069
- const data = await call(routes.workspaceStatus.path(id), {
1070
- method: routes.workspaceStatus.method,
1071
- body: { status },
1072
- timeoutMs: WRITE_TIMEOUT_MS
1073
- });
1074
- if (!data.ok)
1075
- throw new Error(data.error ?? 'the status change did not land');
1076
- return `status set to ${status}`;
1077
- }
1078
- },
1079
- {
1080
- name: 'dev_server',
1081
- description: 'Inspect, start or stop a workspace’s configured Conductor Run task and tailnet preview. Use this instead of launching a long-lived development server from a shell: Conductor supplies the workspace ports, enforces run-mode rules and owns process cleanup, while the relay avoids duplicate starts. Status is the default and touches no UI. Start and stop DRIVE THE REAL UI and can steal focus for a few seconds; confirm before controlling a workspace the user did not name. Starting requires Tailscale and, when status lists multiple Run configs, an exact run_config_id.',
1082
- inputSchema: {
1083
- type: 'object',
1084
- properties: {
1085
- workspace_id: { type: 'string', description: 'The live local workspace to control.' },
1086
- action: {
1087
- type: 'string',
1088
- enum: ['status', 'start', 'stop'],
1089
- description: 'Default status. Start and stop use Conductor’s managed Run task.'
1090
- },
1091
- run_config_id: {
1092
- type: 'string',
1093
- description: 'Exact ID printed by status. Only valid for start; required when more than one is listed.'
1094
- }
1095
- },
1096
- required: ['workspace_id']
1097
- },
1098
- run: async (args) => {
1099
- const id = need(args, 'workspace_id');
1100
- const action = str(args.action) ?? 'status';
1101
- if (action !== 'status' && action !== 'start' && action !== 'stop')
1102
- throw new Error('action is invalid');
1103
- const runConfigId = str(args.run_config_id);
1104
- if (args.run_config_id !== undefined && !runConfigId)
1105
- throw new Error('run_config_id must be a non-empty string');
1106
- if (runConfigId && action !== 'start')
1107
- throw new Error('run_config_id is only valid with action start');
1108
- if (action === 'status') {
1109
- const state = await call(routes.devServer.path(id));
1110
- return formatDevServer(state);
1111
- }
1112
- const route = action === 'start' ? routes.startDevServer : routes.stopDevServer;
1113
- const result = await call(route.path(id), {
1114
- method: route.method,
1115
- ...(action === 'start' && runConfigId ? { body: { runConfigId } } : {}),
1116
- timeoutMs: WRITE_TIMEOUT_MS
1117
- });
1118
- if (!result.ok)
1119
- throw new Error(result.error ?? `could not ${action} the dev server`);
1120
- const headline = action === 'start'
1121
- ? result.changed === false
1122
- ? 'already running'
1123
- : 'started'
1124
- : result.changed === false
1125
- ? 'already stopped'
1126
- : 'stopped';
1127
- return formatDevServer(result, headline);
1128
- }
1129
- },
1130
- {
1131
- name: 'archive_workspace',
1132
- description: 'Archive a workspace — Conductor’s own ⌘⇧A, which deletes its worktree and takes any agent still working in it down with it. The chat survives archiving and stays readable through search_chats and read_chat. Drives the real UI: it focuses the workspace on the Mac first, so confirm with the user before archiving one they did not name. A workspace with an agent working is refused unless stop_agents is true.',
1133
- inputSchema: {
1134
- type: 'object',
1135
- properties: {
1136
- workspace_id: { type: 'string' },
1137
- stop_agents: {
1138
- type: 'boolean',
1139
- description: 'Archive even though agents are still working here, ending their turns.'
1140
- }
1141
- },
1142
- required: ['workspace_id']
1143
- },
1144
- run: async (args) => {
1145
- const id = need(args, 'workspace_id');
1146
- const data = await call(routes.archiveWorkspace.path(id), {
1147
- method: routes.archiveWorkspace.method,
1148
- body: { stopAgents: args.stop_agents === true },
1149
- timeoutMs: WRITE_TIMEOUT_MS
1150
- });
1151
- if (!data.ok)
1152
- throw new Error(data.error ?? 'the archive did not land');
1153
- if (data.alreadyArchived)
1154
- return 'already archived';
1155
- return `archived ${data.workspace ? workspaceTitle(data.workspace) : id}`;
1156
- }
1157
- },
1158
- {
1159
- name: 'dismiss_prompt',
1160
- description: 'Throw away a prompt the relay is still holding — a new workspace’s first prompt waiting on setup, or one parked behind a locked Mac. Touches no UI. list_workspaces shows these; a failed one stays visible until it is dismissed, on purpose. Pass session_id for a parked prompt, workspace_id for a first prompt.',
1161
- inputSchema: {
1162
- type: 'object',
1163
- properties: {
1164
- session_id: { type: 'string', description: 'Dismiss the prompt parked for this chat.' },
1165
- workspace_id: { type: 'string', description: 'Dismiss this workspace’s undelivered first prompt.' }
1166
- }
1167
- },
1168
- run: async (args) => {
1169
- const sessionId = str(args.session_id);
1170
- const workspaceId = str(args.workspace_id);
1171
- // Both would be two deletes wearing one name, and the caller could not tell which
1172
- // half failed — so it is one or the other, never both.
1173
- if (!sessionId && !workspaceId)
1174
- throw new Error('pass session_id or workspace_id');
1175
- if (sessionId && workspaceId)
1176
- throw new Error('pass session_id or workspace_id, not both');
1177
- const route = sessionId ? routes.dismissParkedPrompt : routes.dismissFirstPrompt;
1178
- await call(route.path((sessionId ?? workspaceId)), { method: route.method });
1179
- return 'dismissed';
1180
- }
1181
- },
1182
- {
1183
- name: 'keep_awake',
1184
- description: 'Read or set the window that holds this Mac awake with the lid shut. Touches no UI. This is what keeps the relay reachable at all, so a long unattended run wants it: without it a closed lid sleeps the Mac and every agent on it stops. Needs `conductor-remote nosleep setup` to have been run once; without that the status says so and holding fails. Releasing a window while the lid is shut sleeps the Mac straight away.',
1185
- inputSchema: {
1186
- type: 'object',
1187
- properties: {
1188
- action: {
1189
- type: 'string',
1190
- enum: ['status', 'hold', 'release'],
1191
- description: 'Default status.'
1192
- },
1193
- seconds: { type: 'number', description: 'How long to hold it awake. Required for hold.' }
1194
- }
1195
- },
1196
- run: async (args) => {
1197
- const action = str(args.action) ?? 'status';
1198
- if (action === 'status') {
1199
- const s = await call(routes.nosleep.path());
1200
- if (!s.available)
1201
- return 'unavailable — run `conductor-remote nosleep setup` on the Mac first';
1202
- if (!s.armed)
1203
- return `sleeping normally; a window can be up to ${s.maxSeconds}s`;
1204
- return `awake${s.until ? `, expires ${relative(s.until)}` : ' until stopped'} (pid ${s.pid})`;
1205
- }
1206
- if (action === 'release') {
1207
- const r = await call(routes.disarmNoSleep.path(), { method: routes.disarmNoSleep.method });
1208
- if (!r.ok)
1209
- throw new Error(r.error ?? 'could not release the window');
1210
- return r.willSleep ? 'released — the lid is shut, so the Mac is going to sleep now' : 'released';
1211
- }
1212
- if (action !== 'hold')
1213
- throw new Error(`unknown action ${action}`);
1214
- const seconds = num(args.seconds);
1215
- if (seconds === undefined)
1216
- throw new Error('seconds is required for hold');
1217
- const r = await call(routes.armNoSleep.path(), {
1218
- method: routes.armNoSleep.method,
1219
- body: { seconds }
1220
- });
1221
- if (!r.ok)
1222
- throw new Error(r.error ?? 'could not hold the Mac awake');
1223
- return `awake${r.state.until ? `, expires ${relative(r.state.until)}` : ''}`;
1224
- }
1225
- },
1226
- {
1227
- name: 'relay_logs',
1228
- description: 'The relay’s own log — why a send failed, whether Conductor refused Accessibility, what the network did. Touches no UI. Default is the running relay’s captured console; `file` tails the daemon’s log on disk, which is the only place a *previous* process’s crash survives. Secrets are redacted before it leaves the relay.',
1229
- inputSchema: {
1230
- type: 'object',
1231
- properties: {
1232
- file: {
1233
- type: 'string',
1234
- enum: ['relay.log', 'relay.err.log'],
1235
- description: 'Omit for this process’s live log.'
1236
- },
1237
- limit: { type: 'number', description: 'Most recent N lines. Default 200, max 2000.' },
1238
- contains: {
1239
- type: 'string',
1240
- description: 'Keep only lines containing this (case-insensitive). Filtered here, not by the relay.'
1241
- }
1242
- }
1243
- },
1244
- run: async (args) => {
1245
- const file = str(args.file);
1246
- const limit = Math.min(2000, Math.max(1, Math.trunc(num(args.limit) ?? 200)));
1247
- const q = new URLSearchParams({ limit: String(limit) });
1248
- if (file)
1249
- q.set('file', file);
1250
- const data = await call(`${routes.logs.path()}?${q}`);
1251
- const needle = str(args.contains)?.toLowerCase();
1252
- const kept = needle ? data.entries.filter(e => e.text.toLowerCase().includes(needle)) : data.entries;
1253
- if (!kept.length)
1254
- return needle ? `no line in ${data.source} matches ${needle}` : `${data.source} is empty`;
1255
- const lines = kept.map(e => `${stamp(e.t)} ${e.level === 'info' ? '' : `${e.level.toUpperCase()} `}${clip(e.text, 2000)}`);
1256
- // Whose log this is decides what it proves: an unmanaged relay's files belong to a
1257
- // *different* process, so a clean tail there says nothing about the one just called.
1258
- const head = data.managed
1259
- ? data.source
1260
- : `${data.source} (written by the LaunchAgent, not the relay just called)`;
1261
- return [`— ${head}, ${kept.length} lines —`, ...lines].join('\n');
1262
- }
1263
- }
1264
- ];
1265
- }
1266
- const ok = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
1267
- const err = (id, code, message) => ({
1268
- jsonrpc: '2.0',
1269
- id: id ?? null,
1270
- error: { code, message }
1271
- });
1272
- /**
1273
- * Handle one JSON-RPC message. Returns null for a notification, which by spec takes
1274
- * no reply at all — stdio writes nothing and HTTP answers 202.
1275
- */
1276
- export async function handleRpc(tools, req) {
1277
- const notification = req.id === undefined || req.id === null;
1278
- switch (req.method) {
1279
- case 'initialize': {
1280
- const asked = str(req.params?.protocolVersion);
1281
- return ok(req.id, {
1282
- // Echo the client's version when we know it, else name our newest. A client
1283
- // that can't live with the answer disconnects, which is the spec's own path.
1284
- protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
1285
- capabilities: { tools: {} },
1286
- serverInfo: SERVER_INFO,
1287
- instructions: INSTRUCTIONS
1288
- });
1289
- }
1290
- case 'notifications/initialized':
1291
- case 'notifications/cancelled':
1292
- return null;
1293
- case 'ping':
1294
- return notification ? null : ok(req.id, {});
1295
- case 'tools/list':
1296
- return ok(req.id, {
1297
- tools: tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
1298
- });
1299
- case 'tools/call': {
1300
- const name = str(req.params?.name);
1301
- const tool = tools.find(t => t.name === name);
1302
- if (!tool)
1303
- return err(req.id, -32602, `unknown tool: ${name}`);
1304
- const args = req.params?.arguments ?? {};
1305
- try {
1306
- const text = await tool.run(args);
1307
- return ok(req.id, { content: [{ type: 'text', text: text || '(no output)' }] });
1308
- }
1309
- catch (e) {
1310
- // A tool failure is a result the model should see and can act on, not a
1311
- // protocol error that would hide the reason behind a transport code.
1312
- const message = e instanceof Error ? e.message : String(e);
1313
- return ok(req.id, { content: [{ type: 'text', text: message }], isError: true });
1314
- }
1315
- }
1316
- default:
1317
- return notification ? null : err(req.id, -32601, `method not found: ${req.method}`);
1318
- }
1319
- }