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
@@ -0,0 +1,186 @@
1
+ import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "../../agents/conductor-settings.js";
2
+ import { decodeRoles, roleModelIssues } from "../../agents/roles.js";
3
+ import { attachRunActivity } from "../../dev-server/run-activity.js";
4
+ import { attachChangeStats } from "../../git/change-stats.js";
5
+ import { attachPrStatus } from "../../git/pr.js";
6
+ import { updateStatus } from "../../host/autoupdate.js";
7
+ import { isRoute, routes } from "../../routes.js";
8
+ import { foldHits, queryTokens } from "../../search/coordinator.js";
9
+ import { scrubWorkflowSecrets } from "../../shared.js";
10
+ import { isToolUsageRange } from "../../usage/tool-usage.js";
11
+ import { describeActuator } from "../../writes/actuator.js";
12
+ import { NOT_HANDLED } from "../router-types.js";
13
+ export function createStateRoutes(services) {
14
+ const { reads, attachDelegationState, attachWorkflowState, wireUiQuarantine, parkedPrompts, firstPrompts, json, orchestration, orchestrationUnavailableReason, actuator, search, modelCache, readBody, planUsage, toolUsage, roleStore } = services;
15
+ return async (req, res, url) => {
16
+ const { pathname } = url;
17
+ // GET /api/state — workspace list with active-session status
18
+ if (isRoute(routes.state, req.method, pathname)) {
19
+ const update = updateStatus();
20
+ const workspaces = reads.listWorkspaces();
21
+ attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
22
+ attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
23
+ attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
24
+ attachDelegationState(workspaces);
25
+ const workflows = attachWorkflowState(workspaces);
26
+ const uiQuarantine = wireUiQuarantine();
27
+ // An undelivered first prompt rides along with its workspace: the phone renders it
28
+ // in that chat rather than tracking delivery itself (see src/delivery/firstprompt.ts).
29
+ // Prompts parked for the lock screen ride the same way, one list per workspace,
30
+ // each entry naming its chat (src/delivery/parked.ts).
31
+ const parked = parkedPrompts.list();
32
+ for (const ws of workspaces) {
33
+ ws.pending_prompt = firstPrompts.get(ws.id);
34
+ const mine = parked.filter(p => p.workspaceId === ws.id);
35
+ if (mine.length)
36
+ ws.parked_prompts = mine;
37
+ }
38
+ return json(req, res, 200, {
39
+ workspaces,
40
+ workflows,
41
+ ...(uiQuarantine ? { uiQuarantine } : {}),
42
+ ...(orchestration.writable
43
+ ? {}
44
+ : { workflowWarning: scrubWorkflowSecrets(orchestrationUnavailableReason()).slice(0, 500) }),
45
+ actuator: await describeActuator(actuator),
46
+ version: update.current,
47
+ update
48
+ });
49
+ }
50
+ // GET /api/search?q= — find a workspace by its name or by what was said in its chats.
51
+ //
52
+ // Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
53
+ // and wins ties, because someone who types a name wants that workspace and not the
54
+ // twelve chats that mention it. The transcript index answers the harder question —
55
+ // "which workspace did I do this in" — and is the only one that can, since the
56
+ // words you remember are usually the agent's, not the branch's.
57
+ //
58
+ // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
59
+ // archived, so a search limited to the live sidebar would miss almost everything.
60
+ //
61
+ // `repo=` (repeatable) and `archived=0` scope both halves. They are resolved to
62
+ // chat ids and pushed *into* the FTS query rather than applied to its top 300
63
+ // chunks, or excluded work would fill every slot (src/search/coordinator.ts ▸ search).
64
+ if (isRoute(routes.search, req.method, pathname)) {
65
+ const q = url.searchParams.get('q') ?? '';
66
+ const repos = [...new Set(url.searchParams.getAll('repo').filter(Boolean))];
67
+ // Archived search predates the toggle and stays the default for cached PWAs and MCP.
68
+ const includeArchived = url.searchParams.get('archived') !== '0';
69
+ // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
70
+ // and past the first screenful nobody scrolls — they retype instead.
71
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
72
+ const index = search.status();
73
+ const tokens = queryTokens(q);
74
+ if (!tokens.length)
75
+ return json(req, res, 200, { query: q, repos, results: [], index });
76
+ const scoped = repos.length > 0 || !includeArchived;
77
+ const scope = scoped
78
+ ? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
79
+ : {};
80
+ const hits = await search.search(q, scope);
81
+ const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
82
+ const fromChats = foldHits(hits, sid => {
83
+ const workspace = targets.get(sid)?.workspace ?? null;
84
+ return !includeArchived && workspace?.archived ? null : workspace;
85
+ });
86
+ const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
87
+ const merged = [];
88
+ for (const workspace of reads.findWorkspacesByName(tokens, limit, repos.length ? repos : undefined, includeArchived)) {
89
+ const evidence = remaining.get(workspace.id);
90
+ remaining.delete(workspace.id);
91
+ // Keep the chat evidence when there is any: the snippet is what tells you this
92
+ // is the right "fix-lamp-thing" out of three with similar names.
93
+ merged.push(evidence
94
+ ? { ...evidence, byName: true }
95
+ : { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
96
+ }
97
+ merged.push(...remaining.values());
98
+ return json(req, res, 200, {
99
+ query: q,
100
+ repos,
101
+ index,
102
+ results: merged.slice(0, limit).map(r => ({
103
+ ...r,
104
+ sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
105
+ }))
106
+ });
107
+ }
108
+ // GET /api/repos — repos a new workspace can be created in
109
+ if (isRoute(routes.repos, req.method, pathname)) {
110
+ return json(req, res, 200, { repos: reads.listRepos() });
111
+ }
112
+ // GET /api/models — prior live picker reads, without activating Conductor. A
113
+ // new workspace has no chat yet, so this is its only safe source of choices.
114
+ if (isRoute(routes.modelCatalog, req.method, pathname)) {
115
+ return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
116
+ }
117
+ // GET/PATCH /api/models/defaults — the live user-wide effort defaults.
118
+ // These are file-backed settings, not the stale rows conductor.db still carries.
119
+ if (isRoute(routes.modelDefaults, req.method, pathname)) {
120
+ return json(req, res, 200, { defaultEfforts: readDefaultEfforts() });
121
+ }
122
+ if (isRoute(routes.updateModelDefaults, req.method, pathname)) {
123
+ const body = JSON.parse((await readBody(req)) || '{}');
124
+ const patch = {};
125
+ if (body.claude !== undefined) {
126
+ if (!isDefaultEffortLevel(body.claude))
127
+ return json(req, res, 400, { error: 'unknown Claude effort level' });
128
+ patch.claude = body.claude;
129
+ }
130
+ if (body.codex !== undefined) {
131
+ if (!isDefaultEffortLevel(body.codex))
132
+ return json(req, res, 400, { error: 'unknown Codex effort level' });
133
+ patch.codex = body.codex;
134
+ }
135
+ if (Object.keys(patch).length === 0)
136
+ return json(req, res, 400, { error: 'nothing to change' });
137
+ return json(req, res, 200, { defaultEfforts: writeDefaultEfforts(patch) });
138
+ }
139
+ // GET /api/usage — structured subscription limits from the CLIs Conductor
140
+ // itself bundles. Both reads are prompt-free and cached; `refresh=1` is the
141
+ // explicit user action in the sheet, never a background poll.
142
+ if (isRoute(routes.planUsage, req.method, pathname)) {
143
+ return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
144
+ }
145
+ if (isRoute(routes.toolUsage, req.method, pathname)) {
146
+ const range = url.searchParams.get('range') ?? '24h';
147
+ if (!isToolUsageRange(range))
148
+ return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
149
+ return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
150
+ }
151
+ if (isRoute(routes.roles, req.method, pathname)) {
152
+ const stored = roleStore.read();
153
+ return json(req, res, 200, {
154
+ ...stored.config,
155
+ issues: roleModelIssues(stored.config, modelCache.list()),
156
+ ...(stored.warning ? { warning: stored.warning } : {})
157
+ });
158
+ }
159
+ if (isRoute(routes.updateRoles, req.method, pathname)) {
160
+ let config;
161
+ try {
162
+ config = decodeRoles(JSON.parse((await readBody(req)) || '{}'));
163
+ }
164
+ catch (err) {
165
+ const error = {
166
+ code: 'invalid_request',
167
+ message: err instanceof Error ? err.message : String(err),
168
+ retryable: false
169
+ };
170
+ return json(req, res, 400, { ok: false, error });
171
+ }
172
+ const issues = roleModelIssues(config, modelCache.list());
173
+ if (issues.length)
174
+ return json(req, res, 409, { ok: false, error: issues[0].error, issues });
175
+ const written = roleStore.write(config);
176
+ if (!written.ok) {
177
+ return json(req, res, 500, {
178
+ ok: false,
179
+ error: { code: 'state_invalid', message: written.error, retryable: true }
180
+ });
181
+ }
182
+ return json(req, res, 200, { ok: true, config: written.config });
183
+ }
184
+ return NOT_HANDLED;
185
+ };
186
+ }
@@ -0,0 +1,220 @@
1
+ import { discardStagedAttachment, stageAttachment } from "../../files/staged-attachments.js";
2
+ import { isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "../../host/logbuf.js";
3
+ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState } from "../../host/nosleep.js";
4
+ import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "../../host/wifi.js";
5
+ import { notifyDevice, pushConfig, subscribeDevice, unsubscribeDevice } from "../../notifications/notify.js";
6
+ import { readPrefs, writePrefs } from "../../prefs.js";
7
+ import { isRoute, routeParam, routes } from "../../routes.js";
8
+ import { readSettings, writeSettings } from "../../settings.js";
9
+ import { screenLocked } from "../../writes/guards.js";
10
+ import { restartConductorApp } from "../../writes/system.js";
11
+ import { NOT_HANDLED } from "../router-types.js";
12
+ export function createSystemRoutes(services) {
13
+ const { json, readBody, cfg, reads, attachmentHeaderName, readAttachmentBody, STAGED_ATTACHMENTS_DIR } = services;
14
+ return async (req, res, url) => {
15
+ const { pathname } = url;
16
+ // GET /api/settings — relay preferences plus what the phone needs to edit them:
17
+ // the SSIDs this Mac already holds credentials for, so the picker offers a choice
18
+ // instead of asking someone to type a network name from memory on a phone keyboard.
19
+ // `ssid` is best-effort and often null (macOS gates it behind Location Services).
20
+ if (isRoute(routes.settings, req.method, pathname)) {
21
+ // Five subprocesses, all concurrent: this is the one route that shells out more
22
+ // than once, and serialising them would put the phone's polls behind the sum.
23
+ const [known, current, autoJoinHotspot, nosleep, locked] = await Promise.all([
24
+ preferredNetworks(),
25
+ currentSsid(),
26
+ // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
27
+ // your phone unprompted, which no amount of relay code can substitute for.
28
+ autoJoinHotspotMode(),
29
+ nosleepState(),
30
+ // Read here rather than polled: this sheet is where someone goes when the Mac
31
+ // stopped answering, and a keep-awake window that says "automatic screen lock
32
+ // is off" beside a Mac that is locked right now reads as the relay lying. The
33
+ // assertion blocks the *idle* lock; it cannot lift one already up, and a lid
34
+ // close or a manual lock puts one up whatever it holds.
35
+ screenLocked()
36
+ ]);
37
+ return json(req, res, 200, {
38
+ settings: readSettings(),
39
+ wifi: {
40
+ current,
41
+ known,
42
+ // A guess from the name, never a fact — see src/host/wifi.ts. It only sorts the picker.
43
+ likelyHotspots: known.filter(looksLikeHotspot),
44
+ autoJoinHotspot
45
+ },
46
+ nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS },
47
+ screenLocked: locked
48
+ });
49
+ }
50
+ // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
51
+ if (isRoute(routes.updateSettings, req.method, pathname)) {
52
+ const body = JSON.parse((await readBody(req)) || '{}');
53
+ const patch = {};
54
+ if (Array.isArray(body.fallbackSsids))
55
+ patch.fallbackSsids = body.fallbackSsids;
56
+ if (typeof body.autoRejoin === 'boolean')
57
+ patch.autoRejoin = body.autoRejoin;
58
+ if (Object.keys(patch).length === 0)
59
+ return json(req, res, 400, { error: 'nothing to change' });
60
+ return json(req, res, 200, { settings: writeSettings(patch) });
61
+ }
62
+ // PWA state remains local-first; this host copy survives origin changes and
63
+ // reconciles phones. PATCH accepts a full client snapshot and merges per key.
64
+ if (isRoute(routes.prefs, req.method, pathname)) {
65
+ return json(req, res, 200, { prefs: readPrefs() });
66
+ }
67
+ if (isRoute(routes.updatePrefs, req.method, pathname)) {
68
+ const raw = JSON.parse((await readBody(req)) || '{}');
69
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
70
+ return json(req, res, 400, { error: 'preferences must be an object' });
71
+ }
72
+ const body = raw;
73
+ if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
74
+ return json(req, res, 400, { error: 'nothing to sync' });
75
+ }
76
+ return json(req, res, 200, { prefs: writePrefs(body) });
77
+ }
78
+ // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
79
+ if (isRoute(routes.nosleep, req.method, pathname)) {
80
+ return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
81
+ }
82
+ // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
83
+ // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
84
+ // rule; without it there is no way for a TTY-less daemon to reach root, and the
85
+ // response says so rather than failing vaguely.
86
+ if (isRoute(routes.armNoSleep, req.method, pathname)) {
87
+ const body = JSON.parse((await readBody(req)) || '{}');
88
+ const seconds = Number(body.seconds);
89
+ // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
90
+ // truncates to 0 — an unbounded window from a request that looked bounded.
91
+ if (!Number.isInteger(seconds) || seconds < 1)
92
+ return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
93
+ const result = await armNoSleep(seconds, cfg.preventScreenLock);
94
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
95
+ }
96
+ // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
97
+ if (isRoute(routes.disarmNoSleep, req.method, pathname)) {
98
+ const result = await disarmNoSleep();
99
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
100
+ }
101
+ // POST /api/conductor/restart { stopAgents? } — quit Conductor and start it again.
102
+ //
103
+ // The lever exists in the actuator already, but only as activateConductor's last
104
+ // resort, which fires exclusively for a *windowless* Conductor. This is for the
105
+ // other shape: window up, prompts landing as rows, and no agent output behind any
106
+ // of it (measured 2026-09-02 — 2h35m of user rows after the last agent frame).
107
+ // The running agents are counted from the DB before the UI is touched and refused
108
+ // unless the caller meant it, the same way archiving is: quitting ends every turn
109
+ // in flight. The lock screen is the actuator's own gate, since only it can ask.
110
+ if (isRoute(routes.restartConductor, req.method, pathname)) {
111
+ const body = JSON.parse((await readBody(req)) || '{}');
112
+ const working = reads.listSessionStates().filter(state => state.status === 'working').length;
113
+ if (working > 0 && body.stopAgents !== true) {
114
+ return json(req, res, 409, {
115
+ ok: false,
116
+ agentsRunning: true,
117
+ working,
118
+ error: `${working} chat${working === 1 ? ' is' : 's are'} mid-turn. Restarting Conductor ends ${working === 1 ? 'it' : 'them'}.`
119
+ });
120
+ }
121
+ const startedAt = Date.now();
122
+ const result = await restartConductorApp();
123
+ const ms = Date.now() - startedAt;
124
+ if (!result.ok) {
125
+ console.warn(`[restart] Conductor restart failed after ${(ms / 1000).toFixed(1)}s: ${result.error}`);
126
+ return json(req, res, 502, { ok: false, ms, error: result.error });
127
+ }
128
+ console.log(`[restart] quit Conductor and relaunched it in ${(ms / 1000).toFixed(1)}s`);
129
+ return json(req, res, 200, { ok: true, ms });
130
+ }
131
+ // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
132
+ // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
133
+ // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
134
+ // process's crash survives. Everything is redacted: the startup banner prints the token.
135
+ if (isRoute(routes.logs, req.method, pathname)) {
136
+ const file = url.searchParams.get('file');
137
+ if (file && !LOG_FILE_NAMES.includes(file)) {
138
+ return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
139
+ }
140
+ const asked = Number(url.searchParams.get('limit') ?? 300);
141
+ const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
142
+ let entries;
143
+ try {
144
+ entries = file ? tailLogFile(file, limit) : recentLogs(limit);
145
+ }
146
+ catch (err) {
147
+ // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
148
+ return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
149
+ }
150
+ return json(req, res, 200, {
151
+ source: file ?? 'live',
152
+ // False → the files below are some *other* (daemon) process's output, not this relay's.
153
+ managed: isManaged(),
154
+ startedAt: processStartedAt(),
155
+ now: Date.now(),
156
+ files: logFiles(),
157
+ entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
158
+ });
159
+ }
160
+ // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
161
+ if (isRoute(routes.push, req.method, pathname)) {
162
+ return json(req, res, 200, pushConfig());
163
+ }
164
+ // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
165
+ // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
166
+ // lost its store, or a subscription the browser silently renewed.
167
+ if (isRoute(routes.pushSubscribe, req.method, pathname)) {
168
+ const body = JSON.parse((await readBody(req)) || '{}');
169
+ const sub = body.subscription;
170
+ if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
171
+ return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
172
+ }
173
+ // An endpoint is a URL we will POST to — never accept a non-HTTPS one.
174
+ if (!/^https:\/\//i.test(sub.endpoint))
175
+ return json(req, res, 400, { error: 'endpoint must be https' });
176
+ const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
177
+ return json(req, res, 200, { ok: true, ...registered });
178
+ }
179
+ // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
180
+ if (isRoute(routes.pushUnsubscribe, req.method, pathname)) {
181
+ const body = JSON.parse((await readBody(req)) || '{}');
182
+ if (!body.endpoint)
183
+ return json(req, res, 400, { error: 'need the endpoint' });
184
+ return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
185
+ }
186
+ // POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
187
+ if (isRoute(routes.pushTest, req.method, pathname)) {
188
+ const body = JSON.parse((await readBody(req)) || '{}');
189
+ if (!body.id)
190
+ return json(req, res, 400, { error: 'need the device id' });
191
+ const result = await notifyDevice(body.id, {
192
+ title: 'Conductor Remote',
193
+ body: 'Notifications are working. You’ll get one when an agent finishes.',
194
+ tag: 'test',
195
+ url: '/',
196
+ kind: 'test',
197
+ ts: Date.now()
198
+ });
199
+ return json(req, res, result.ok ? 200 : 502, result);
200
+ }
201
+ // POST /api/attachments — hold one phone file until its new workspace has a worktree.
202
+ if (isRoute(routes.stageAttachment, req.method, pathname)) {
203
+ const name = attachmentHeaderName(req);
204
+ if (!name)
205
+ return json(req, res, 400, { error: 'missing attachment name' });
206
+ const bytes = await readAttachmentBody(req);
207
+ if (!bytes.length)
208
+ return json(req, res, 400, { error: 'empty attachment' });
209
+ const attachment = stageAttachment(STAGED_ATTACHMENTS_DIR, name, bytes);
210
+ return json(req, res, 201, { ok: true, attachment });
211
+ }
212
+ // DELETE /api/attachments/:id — an upload cancelled before it became a synced draft attachment.
213
+ const stagedAttachment = routeParam(routes.discardStagedAttachment, req.method, pathname);
214
+ if (stagedAttachment)
215
+ return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
216
+ ok: true
217
+ });
218
+ return NOT_HANDLED;
219
+ };
220
+ }
@@ -0,0 +1,117 @@
1
+ import { isRoute, routeParam, routes } from "../../routes.js";
2
+ import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES } from "../../shared.js";
3
+ import { openAIOriginForSipHost } from "../../voice/config.js";
4
+ import { parseVoiceCallTarget, readVoiceChatContext, VoiceContextError } from "../../voice/context.js";
5
+ import { MAX_VOICE_SEARCH_CHARS } from "../../voice/history.js";
6
+ import { mintSipTicket, missingTicketConfig } from "../../voice/ticket.js";
7
+ import { createWebRtcCall, MAX_SDP_CHARS } from "../../voice/webrtc.js";
8
+ import { NOT_HANDLED } from "../router-types.js";
9
+ export function createVoiceRoutes(services) {
10
+ const { json, voiceHistory, voiceConfig, voiceBroker, readBody, reads, voiceSafetyIdentifier } = services;
11
+ return async (req, res, url) => {
12
+ const { pathname } = url;
13
+ const voiceTranscript = routeParam(routes.voiceTranscript, req.method, pathname);
14
+ if (isRoute(routes.voiceHistory, req.method, pathname) ||
15
+ isRoute(routes.voiceSearch, req.method, pathname) ||
16
+ voiceTranscript) {
17
+ try {
18
+ if (isRoute(routes.voiceSearch, req.method, pathname)) {
19
+ const query = url.searchParams.get('q')?.trim() ?? '';
20
+ if (!query || query.length > MAX_VOICE_SEARCH_CHARS)
21
+ return json(req, res, 400, { error: `q must contain 1–${MAX_VOICE_SEARCH_CHARS} characters` });
22
+ return json(req, res, 200, voiceHistory.search(query, {
23
+ limit: Math.max(1, Math.min(50, Number(url.searchParams.get('limit')) || 12)),
24
+ offset: Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0)),
25
+ callId: url.searchParams.get('callId') || undefined
26
+ }));
27
+ }
28
+ if (!voiceTranscript) {
29
+ const limit = Math.max(1, Math.min(100, Number(url.searchParams.get('limit')) || 30));
30
+ const offset = Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0));
31
+ return json(req, res, 200, voiceHistory.list(Math.floor(limit), Math.floor(offset)));
32
+ }
33
+ const call = url.searchParams.get('summary') === '1'
34
+ ? voiceHistory.status(voiceTranscript)
35
+ : voiceHistory.read(voiceTranscript);
36
+ return call ? json(req, res, 200, call) : json(req, res, 404, { error: 'Saved voice call not found' });
37
+ }
38
+ catch (error) {
39
+ console.warn('[voice] could not read call history:', error);
40
+ return json(req, res, 503, { error: 'Call history is unavailable. Check the relay logs.' });
41
+ }
42
+ }
43
+ // POST /api/voice/ticket — the native app presents the same relay bearer as
44
+ // the PWA, and receives only a two-minute SIP URI. The OpenAI key, webhook
45
+ // secret and marker key never leave this Mac.
46
+ if (isRoute(routes.voiceTicket, req.method, pathname)) {
47
+ const missing = missingTicketConfig(voiceConfig);
48
+ if (missing.length || !voiceBroker) {
49
+ return json(req, res, 503, {
50
+ error: 'voice calls are not fully configured on this relay',
51
+ missing
52
+ });
53
+ }
54
+ return json(req, res, 200, mintSipTicket(voiceConfig));
55
+ }
56
+ // POST /api/voice/calls — the PWA sends its SDP offer to this authenticated
57
+ // relay. The relay loads the selected chat context or the fleet session and
58
+ // keeps OpenAI's permanent key and every function tool on the Mac.
59
+ if (isRoute(routes.voiceCall, req.method, pathname)) {
60
+ if (!voiceConfig.openaiKey || !voiceBroker)
61
+ return json(req, res, 503, { error: 'voice needs an OpenAI API key on this relay' });
62
+ const raw = await readBody(req);
63
+ if (raw.length > MAX_SDP_CHARS * 2)
64
+ return json(req, res, 413, { error: 'WebRTC offer is too large' });
65
+ const body = JSON.parse(raw || '{}');
66
+ if (typeof body.sdp !== 'string' || !body.sdp.trim())
67
+ return json(req, res, 400, { error: 'WebRTC offer is required' });
68
+ if (body.sdp.length > MAX_SDP_CHARS)
69
+ return json(req, res, 413, { error: 'WebRTC offer is too large' });
70
+ if (!isOpenAIRealtimeVoice(body.voice))
71
+ return json(req, res, 400, { error: `voice must be one of ${OPENAI_REALTIME_VOICES.join(', ')}` });
72
+ if (!isVoiceLanguage(body.language))
73
+ return json(req, res, 400, { error: 'unsupported voice language' });
74
+ try {
75
+ const target = parseVoiceCallTarget(body.target);
76
+ const context = target ? readVoiceChatContext(reads, target) : undefined;
77
+ const call = await createWebRtcCall(voiceConfig.openaiKey, openAIOriginForSipHost(voiceConfig.sipHost), body.sdp, {
78
+ model: voiceConfig.model,
79
+ voice: body.voice,
80
+ language: body.language,
81
+ context
82
+ }, voiceSafetyIdentifier);
83
+ voiceBroker.registerWebRtc(call.callId, { voice: body.voice, language: body.language });
84
+ return json(req, res, 200, call);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof VoiceContextError)
88
+ return json(req, res, err.status, { error: err.message });
89
+ console.warn('[voice] could not create WebRTC orchestrator call:', err);
90
+ return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice call failed' });
91
+ }
92
+ }
93
+ const readyVoiceCall = routeParam(routes.voiceCallReady, req.method, pathname);
94
+ if (readyVoiceCall) {
95
+ if (!voiceBroker)
96
+ return json(req, res, 503, { error: 'voice is not configured on this relay' });
97
+ if (!voiceBroker.beginWebRtc(readyVoiceCall))
98
+ return json(req, res, 404, { error: 'voice call not found' });
99
+ return json(req, res, 200, { ok: true });
100
+ }
101
+ const endedVoiceCall = routeParam(routes.voiceCallEnd, req.method, pathname);
102
+ if (endedVoiceCall) {
103
+ if (!voiceBroker)
104
+ return json(req, res, 503, { error: 'voice is not configured on this relay' });
105
+ try {
106
+ if (!(await voiceBroker.hangupWebRtc(endedVoiceCall)))
107
+ return json(req, res, 404, { error: 'voice call not found' });
108
+ return json(req, res, 200, { ok: true });
109
+ }
110
+ catch (err) {
111
+ console.warn('[voice] could not hang up WebRTC orchestrator call:', err);
112
+ return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice hangup failed' });
113
+ }
114
+ }
115
+ return NOT_HANDLED;
116
+ };
117
+ }