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,363 @@
1
+ import { attachmentPrompt, writeAttachment } from "../../files/attachments.js";
2
+ import { discardStagedAttachment, materializeStagedAttachments, stageAttachment } from "../../files/staged-attachments.js";
3
+ import { captureForkWorkspace, materializeForkWorkspace, releaseForkWorkspace } from "../../git/fork-workspace.js";
4
+ import { acceptDelegation, delegationHttpStatus } from "../../orchestration/delegation/intake.js";
5
+ import { WorkflowCoordinatorError } from "../../orchestration/workflow/errors.js";
6
+ import { routeParam, routes } from "../../routes.js";
7
+ import { renderTranscript, transcriptMessage, transcriptThrough } from "../../transcript/parser.js";
8
+ import { EFFORT_LABELS } from "../../writes/agent-options.js";
9
+ import { lockBlocked } from "../../writes/guards.js";
10
+ import { NOT_HANDLED } from "../router-types.js";
11
+ export function createPromptsRoutes(services) {
12
+ const { orchestration, orchestrationUnavailableReason, readBody, json, workflowOwningSession, reads, roleStore, modelCache, delegationStore, delegationQueue, workflowFrozenError, sendBudget, sendOnce, firstPrompts, actuator, STAGED_ATTACHMENTS_DIR, applyAgentPatch, parkedPrompts, PARKED_ERROR, deliverPrompt, createWorkspaceAndRead, sleep, openChat } = services;
13
+ return async (req, res, url) => {
14
+ const { pathname } = url;
15
+ const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
16
+ if (delegateFrom) {
17
+ if (!orchestration.writable) {
18
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', orchestrationUnavailableReason(), {
19
+ status: 409
20
+ });
21
+ }
22
+ let body;
23
+ try {
24
+ body = JSON.parse((await readBody(req)) || '{}');
25
+ }
26
+ catch {
27
+ return json(req, res, 400, {
28
+ ok: false,
29
+ error: { code: 'invalid_request', message: 'delegation must be valid JSON', retryable: false }
30
+ });
31
+ }
32
+ const result = acceptDelegation(delegateFrom, body, {
33
+ ownsSession: sessionId => workflowOwningSession(sessionId) !== null,
34
+ sessionWorkspaceId: sessionId => reads.sessionWorkspaceId(sessionId),
35
+ getSession: sessionId => reads.getSession(sessionId),
36
+ getWorkspace: workspaceId => reads.getWorkspace(workspaceId),
37
+ getMessages: sessionId => reads.getMessages(sessionId).entries,
38
+ readRoles: () => roleStore.read(),
39
+ models: () => modelCache.list(),
40
+ enqueue: (workspace, job) => {
41
+ const store = delegationStore(workspace);
42
+ if (!store)
43
+ throw new Error('worktree path unresolved');
44
+ delegationQueue.enqueue(store, job);
45
+ }
46
+ });
47
+ return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
48
+ }
49
+ // POST /api/sessions/:id/prompt { text, agent? } — ordinary staged settings
50
+ // are applied before the prompt so the two cannot come apart.
51
+ const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
52
+ if (promptTo) {
53
+ const sessionId = promptTo;
54
+ const body = JSON.parse((await readBody(req)) || '{}');
55
+ if ('workflow' in body) {
56
+ return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
57
+ }
58
+ if (body.text !== undefined && typeof body.text !== 'string') {
59
+ return json(req, res, 400, { error: 'prompt must be a string' });
60
+ }
61
+ const rawText = (body.text ?? '').trim();
62
+ if (!rawText)
63
+ return json(req, res, 400, { error: 'empty prompt' });
64
+ if (body.agent !== undefined && (!body.agent || typeof body.agent !== 'object' || Array.isArray(body.agent))) {
65
+ return json(req, res, 400, { error: 'agent must be a settings object' });
66
+ }
67
+ const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
68
+ const frozen = workflowFrozenError(sessionId);
69
+ if (frozen &&
70
+ requestedAgent &&
71
+ (requestedAgent.model !== undefined || requestedAgent.effort !== undefined || requestedAgent.fast !== undefined)) {
72
+ return json(req, res, 409, frozen);
73
+ }
74
+ const ws = body.workspaceId
75
+ ? reads.getWorkspace(body.workspaceId)
76
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
77
+ if (!ws)
78
+ return json(req, res, 404, { error: 'workspace for session not found' });
79
+ // One deadline for the whole request: settings eat into the send's budget
80
+ // rather than extending it past what the phone said it would wait.
81
+ const deadline = Date.now() + sendBudget(req);
82
+ const queue = body.queue === true;
83
+ if (requestedAgent?.effort && !EFFORT_LABELS[requestedAgent.effort]) {
84
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
85
+ }
86
+ // One prompt per intent (src/delivery/sendonce.ts). Everything that can *say something
87
+ // to Conductor* sits inside, so an answer the phone never heard is replayed
88
+ // rather than re-performed — including the parked branches, since parking the
89
+ // same intent twice queues the same prompt twice for the unlock.
90
+ if (body.clientId && sendOnce.recall(body.clientId)) {
91
+ console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
92
+ }
93
+ const answer = await sendOnce.run(body.clientId, async () => {
94
+ const text = rawText;
95
+ const agent = requestedAgent;
96
+ // A failed first-prompt entry offers the same Retry button as an ordinary
97
+ // prompt. If staging had been the failure, put its files in place before
98
+ // that retry reaches the attachment tokens.
99
+ const first = firstPrompts.get(ws.id);
100
+ if (first?.attachmentIds?.length && first.text === text) {
101
+ if (!ws.worktree)
102
+ return {
103
+ status: 409,
104
+ body: { ok: false, strategy: actuator.name, error: 'worktree path unresolved' }
105
+ };
106
+ try {
107
+ materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, ws.worktree, first.attachmentIds);
108
+ }
109
+ catch (err) {
110
+ return {
111
+ status: 409,
112
+ body: {
113
+ ok: false,
114
+ strategy: actuator.name,
115
+ error: err instanceof Error ? err.message : 'the attached files could not be copied'
116
+ }
117
+ };
118
+ }
119
+ }
120
+ if (agent) {
121
+ const applied = await applyAgentPatch(ws, sessionId, agent);
122
+ if (!applied.ok) {
123
+ if (lockBlocked(applied.error)) {
124
+ const queued = parkedPrompts.park(ws.id, sessionId, text, agent, queue);
125
+ return {
126
+ status: 202,
127
+ body: { ok: false, parked: true, queued, strategy: actuator.name, error: PARKED_ERROR }
128
+ };
129
+ }
130
+ return { status: 502, body: { ok: false, strategy: actuator.name, error: applied.error } };
131
+ }
132
+ }
133
+ // Retries live inside deliverPrompt, confirmed against the transcript each time,
134
+ // and inside the deadline this phone told us it would wait.
135
+ const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now(), queue);
136
+ if (result.ok) {
137
+ // Whatever a queue was still holding has now been said by hand — the first
138
+ // prompt (including a failed entry retried from the chat), and any parked
139
+ // copy of this exact text, which delivering again would double.
140
+ firstPrompts.forget(ws.id);
141
+ parkedPrompts.forgetDelivered(sessionId, text);
142
+ return { status: 200, body: result };
143
+ }
144
+ if (lockBlocked(result.error)) {
145
+ // Settings (if any) already stuck, so the entry parks without them.
146
+ const queued = parkedPrompts.park(ws.id, sessionId, text, undefined, queue);
147
+ return {
148
+ status: 202,
149
+ body: { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR }
150
+ };
151
+ }
152
+ return { status: 502, body: result };
153
+ });
154
+ return json(req, res, answer.status, answer.body);
155
+ }
156
+ // POST /api/sessions/:id/split
157
+ // { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid?, destination? }
158
+ //
159
+ // Conductor's own tab fork resumes the agent's real session. This copies the
160
+ // conversation instead, as a Conductor attachment, which is the cut that survives
161
+ // being read by a *different* agent: prose and reasoning, no tool churn. Its
162
+ // destination can be another tab over the same files, or a new workspace whose
163
+ // Git layers are restored from the source's current worktree snapshot.
164
+ // Two reasons it exists at all. A tangent asked inside a running chat leaves three
165
+ // conversations interleaved in one tab, which reads badly for everyone afterwards;
166
+ // and Conductor's fork lives on a hover menu over one message, which an agent
167
+ // cannot reach and which the relay would have to find by walking a transcript that
168
+ // gets more expensive the longer the chat is.
169
+ //
170
+ // It stops before sending. The composed prompt goes out through the ordinary send
171
+ // route so it inherits the retry loop, the transcript confirm and the parked queue.
172
+ // For a tab, that also keeps ⌘T plus a send from becoming two UI turns inside one
173
+ // request (28s + 55s against the MCP client's 75s); for a workspace it leaves the
174
+ // staged handoff as the same editable draft the phone already presents for a tab.
175
+ const splitFrom = routeParam(routes.splitChat, req.method, pathname);
176
+ if (splitFrom) {
177
+ const sessionId = splitFrom;
178
+ const body = JSON.parse((await readBody(req)) || '{}');
179
+ // `active_session_id` is how every other route resolves this, and it would only
180
+ // ever find the tab on screen. Splitting a chat you are not looking at is the
181
+ // normal case here, so the session's own column decides.
182
+ const workspaceId = body.workspaceId ?? reads.sessionWorkspaceId(sessionId);
183
+ const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
184
+ if (!ws)
185
+ return json(req, res, 404, { error: 'workspace for session not found' });
186
+ if (!ws.worktree)
187
+ return json(req, res, 409, { error: 'worktree path unresolved' });
188
+ const source = reads.listSessions(ws.id).find(s => s.id === sessionId);
189
+ if (!source)
190
+ return json(req, res, 404, { error: 'chat not found in that workspace' });
191
+ const destination = body.destination ?? 'chat';
192
+ if (destination !== 'chat' && destination !== 'workspace') {
193
+ return json(req, res, 400, { error: 'destination must be chat or workspace' });
194
+ }
195
+ const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
196
+ const { entries } = reads.getMessages(sessionId);
197
+ const through = body.throughRowid;
198
+ const only = body.onlyRowid;
199
+ if (through !== undefined && (!Number.isSafeInteger(through) || through < 1)) {
200
+ return json(req, res, 400, { error: 'throughRowid must be a positive integer' });
201
+ }
202
+ if (only !== undefined && (!Number.isSafeInteger(only) || only < 1)) {
203
+ return json(req, res, 400, { error: 'onlyRowid must be a positive integer' });
204
+ }
205
+ if (through !== undefined && only !== undefined) {
206
+ return json(req, res, 400, { error: 'throughRowid and onlyRowid cannot be combined' });
207
+ }
208
+ const cut = only !== undefined
209
+ ? transcriptMessage(entries, only)
210
+ : through === undefined
211
+ ? { entries, earlier: 0, later: 0 }
212
+ : transcriptThrough(entries, through);
213
+ if (!cut)
214
+ return json(req, res, 409, { error: 'that message is not in this chat' });
215
+ const rendered = renderTranscript(cut.entries, format);
216
+ const elided = { ...rendered.elided, earlier: 'earlier' in cut ? cut.earlier : 0, later: cut.later };
217
+ if (!rendered.kept)
218
+ return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
219
+ // Conductor's own name for a copied transcript, so the chip reads the same as one
220
+ // saved by hand. The header states the cut, because a transcript that silently
221
+ // drops half a chat is worse than one that admits to it.
222
+ const title = source.title?.trim() || 'chat';
223
+ const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
224
+ carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
225
+ const stops = only !== undefined
226
+ ? ['The copy contains only the selected source message; all earlier and later messages are omitted.']
227
+ : cut.later
228
+ ? [
229
+ `The copy stops partway through: ${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} not in it.`
230
+ ]
231
+ : [];
232
+ const header = [
233
+ `# Transcript of ${title}`,
234
+ '',
235
+ `${[ws.repo_name, ws.branch].filter(Boolean).join(' · ')}`,
236
+ `Copied from the Conductor chat \`${sessionId}\` by conductor-remote. ${carried.join(', ')}.`,
237
+ ...stops,
238
+ '',
239
+ ''
240
+ ].join('\n');
241
+ const transcript = header + rendered.text;
242
+ if (destination === 'workspace') {
243
+ if (!(ws.repo_name && ws.repo_root)) {
244
+ return json(req, res, 409, { error: 'the source workspace has no repository checkout to fork' });
245
+ }
246
+ let snapshot;
247
+ try {
248
+ snapshot = await captureForkWorkspace(ws.worktree);
249
+ }
250
+ catch (err) {
251
+ const reason = err instanceof Error ? err.message : 'Git could not capture the worktree';
252
+ return json(req, res, 502, { error: `Could not snapshot the source workspace: ${reason}` });
253
+ }
254
+ let staged;
255
+ let materialized = false;
256
+ let created;
257
+ try {
258
+ staged = stageAttachment(STAGED_ATTACHMENTS_DIR, `Transcript of ${title}.md`, Buffer.from(transcript));
259
+ const creation = await createWorkspaceAndRead('', ws.repo_root, ws.repo_name);
260
+ if (!creation.result.ok)
261
+ return json(req, res, 502, creation.result);
262
+ created = creation.created;
263
+ if (!created) {
264
+ return json(req, res, 502, {
265
+ error: 'Conductor didn’t create the fork workspace — check it’s running and not showing a dialog.'
266
+ });
267
+ }
268
+ // The DB row can precede `.git` by a tick. Install the snapshot at the
269
+ // first verified worktree path, before Conductor starts the new agent.
270
+ let target = reads.getWorkspace(created.id) ?? created;
271
+ for (let attempt = 0; attempt < 20 && !target.worktree; attempt++) {
272
+ await sleep(250);
273
+ target = reads.getWorkspace(created.id) ?? target;
274
+ }
275
+ if (!target.worktree)
276
+ throw new Error('the new workspace worktree path never became available');
277
+ await materializeForkWorkspace(snapshot, target.worktree);
278
+ materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, target.worktree, [staged.stageId]);
279
+ materialized = true;
280
+ discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
281
+ let destinationSession = reads.listSessions(created.id)[0];
282
+ for (let attempt = 0; attempt < 12 && !destinationSession; attempt++) {
283
+ await sleep(250);
284
+ destinationSession = reads.listSessions(created.id)[0];
285
+ }
286
+ return json(req, res, 200, {
287
+ ok: true,
288
+ destination,
289
+ sessionId: destinationSession?.id ?? null,
290
+ workspaceId: created.id,
291
+ text: attachmentPrompt(staged.token, body.prompt),
292
+ attachment: {
293
+ name: staged.name,
294
+ path: staged.path,
295
+ bytes: staged.bytes,
296
+ kept: rendered.kept,
297
+ elided
298
+ }
299
+ });
300
+ }
301
+ catch (err) {
302
+ const reason = err instanceof Error ? err.message : 'the current files could not be copied';
303
+ return json(req, res, 502, {
304
+ error: created
305
+ ? `Workspace ${created.id} was created, but its code fork failed: ${reason}`
306
+ : `Could not create the code fork: ${reason}`
307
+ });
308
+ }
309
+ finally {
310
+ if (staged && !materialized)
311
+ discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
312
+ await releaseForkWorkspace(snapshot).catch(err => {
313
+ console.warn(`[relay] could not release fork snapshot ${snapshot.ref}: ${err instanceof Error ? err.message : err}`);
314
+ });
315
+ }
316
+ }
317
+ const attachment = writeAttachment(ws.worktree, `Transcript of ${title}.md`, transcript);
318
+ const opened = await openChat(ws);
319
+ if ('error' in opened) {
320
+ return json(req, res, 502, {
321
+ ...opened.result,
322
+ destination,
323
+ attachment: { ...attachment, ...rendered, elided }
324
+ });
325
+ }
326
+ // The token is what Conductor turns into the attachment chip and supplies to the
327
+ // receiving agent. Do not repeat `attachment.relPath` in prose: that renders a
328
+ // second link to the same transcript in the new chat.
329
+ const text = attachmentPrompt(attachment.token, body.prompt);
330
+ return json(req, res, 200, {
331
+ ok: true,
332
+ destination,
333
+ sessionId: opened.sessionId,
334
+ workspaceId: ws.id,
335
+ text,
336
+ attachment: {
337
+ name: attachment.name,
338
+ path: attachment.relPath,
339
+ bytes: attachment.bytes,
340
+ kept: rendered.kept,
341
+ elided
342
+ }
343
+ });
344
+ }
345
+ // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
346
+ const forgetFirst = routeParam(routes.dismissFirstPrompt, req.method, pathname);
347
+ if (forgetFirst) {
348
+ const workspaceId = forgetFirst;
349
+ if (!firstPrompts.forget(workspaceId))
350
+ return json(req, res, 404, { error: 'no pending prompt' });
351
+ return json(req, res, 200, { ok: true });
352
+ }
353
+ // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
354
+ const forgetParked = routeParam(routes.dismissParkedPrompt, req.method, pathname);
355
+ if (forgetParked) {
356
+ const sessionId = forgetParked;
357
+ if (!parkedPrompts.forgetSession(sessionId))
358
+ return json(req, res, 404, { error: 'no parked prompt' });
359
+ return json(req, res, 200, { ok: true });
360
+ }
361
+ return NOT_HANDLED;
362
+ };
363
+ }
@@ -0,0 +1,279 @@
1
+ import { writeAttachment } from "../../files/attachments.js";
2
+ import { noteViewing } from "../../notifications/notify.js";
3
+ import { routeParam, routes } from "../../routes.js";
4
+ import { VIEWING_HEADER } from "../../shared.js";
5
+ import { EFFORT_LABELS, listAgentModels, setDefaultModel } from "../../writes/agent-options.js";
6
+ import { closeChat, restoreChat, stopTurn } from "../../writes/chats.js";
7
+ import { NOT_HANDLED } from "../router-types.js";
8
+ export function createSessionsRoutes(services) {
9
+ const { json, reads, workflowFrozenError, locateChat, modelCache, readBody, applyAgentPatch, actuator, sleep, attachmentHeaderName, readAttachmentBody } = services;
10
+ return async (req, res, url) => {
11
+ const { pathname } = url;
12
+ // GET /api/sessions/:id/messages?after=<rowid>
13
+ const messagesOf = routeParam(routes.messages, req.method, pathname);
14
+ if (messagesOf) {
15
+ // The phone's 1s transcript poll doubles as its "I am reading this chat" heartbeat,
16
+ // which is what keeps a turn ending on screen from also buzzing the lock screen
17
+ // (src/notifications/notify.ts). Only this route is a claim: it is the one read that runs for the
18
+ // chat on screen and for no other.
19
+ const device = req.headers[VIEWING_HEADER];
20
+ if (typeof device === 'string' && device)
21
+ noteViewing(device, messagesOf);
22
+ const after = Number(url.searchParams.get('after') ?? 0);
23
+ return json(req, res, 200, reads.getMessages(messagesOf, Number.isFinite(after) ? after : 0));
24
+ }
25
+ // GET /api/sessions/:id/context — expensive enough to stay off the session poll.
26
+ const contextOf = routeParam(routes.context, req.method, pathname);
27
+ if (contextOf) {
28
+ const breakdown = reads.getContextBreakdown(contextOf);
29
+ if (!breakdown)
30
+ return json(req, res, 404, { error: 'chat not found' });
31
+ return json(req, res, 200, breakdown);
32
+ }
33
+ // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
34
+ const modelsOf = routeParam(routes.models, req.method, pathname);
35
+ if (modelsOf) {
36
+ const sessionId = modelsOf;
37
+ const frozen = workflowFrozenError(sessionId);
38
+ if (frozen)
39
+ return json(req, res, 409, frozen);
40
+ const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
41
+ if (!ws)
42
+ return json(req, res, 404, { error: 'workspace for session not found' });
43
+ const located = locateChat(ws, sessionId);
44
+ if ('error' in located)
45
+ return json(req, res, 409, { error: located.error });
46
+ const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
47
+ if (result.ok && result.models)
48
+ modelCache.remember(located.session?.agent_type, result.models, result.defaultModel);
49
+ return json(req, res, result.ok ? 200 : 502, result);
50
+ }
51
+ // POST /api/sessions/:id/default-model { model, workspaceId? }
52
+ // The picker star is a combined "set default and select" action, so this
53
+ // changes both the user-wide default and this chat's model exactly as the
54
+ // desktop control does.
55
+ const defaultModelOf = routeParam(routes.defaultModel, req.method, pathname);
56
+ if (defaultModelOf) {
57
+ const sessionId = defaultModelOf;
58
+ const frozen = workflowFrozenError(sessionId);
59
+ if (frozen)
60
+ return json(req, res, 409, frozen);
61
+ const body = JSON.parse((await readBody(req)) || '{}');
62
+ if (typeof body.model !== 'string' || !body.model.trim()) {
63
+ return json(req, res, 400, { error: 'model must be a picker label' });
64
+ }
65
+ const ws = typeof body.workspaceId === 'string'
66
+ ? reads.getWorkspace(body.workspaceId)
67
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
68
+ if (!ws)
69
+ return json(req, res, 404, { error: 'workspace for session not found' });
70
+ const located = locateChat(ws, sessionId);
71
+ if ('error' in located)
72
+ return json(req, res, 409, { error: located.error });
73
+ const result = await setDefaultModel({ workspace: ws, sessionId, tab: located.tab }, body.model.trim());
74
+ if (!result.ok || !result.model) {
75
+ return json(req, res, 502, { ok: false, error: result.error ?? 'the default model did not change' });
76
+ }
77
+ const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
78
+ modelCache.rememberModel(session?.agent_type, result.model);
79
+ modelCache.rememberDefault(result.model);
80
+ return json(req, res, 200, { ok: true, defaultModel: result.model, session });
81
+ }
82
+ // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
83
+ // Drives the composer's own model/effort/plan/fast controls for one chat.
84
+ const agentOf = routeParam(routes.agent, req.method, pathname);
85
+ if (agentOf) {
86
+ const sessionId = agentOf;
87
+ const body = JSON.parse((await readBody(req)) || '{}');
88
+ if (body.effort && !EFFORT_LABELS[body.effort]) {
89
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
90
+ }
91
+ const frozen = workflowFrozenError(sessionId);
92
+ if (frozen && (body.model !== undefined || body.effort !== undefined || body.fast !== undefined)) {
93
+ return json(req, res, 409, frozen);
94
+ }
95
+ const ws = body.workspaceId
96
+ ? reads.getWorkspace(body.workspaceId)
97
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
98
+ if (!ws)
99
+ return json(req, res, 404, { error: 'workspace for session not found' });
100
+ const applied = await applyAgentPatch(ws, sessionId, body);
101
+ if (!applied.ok)
102
+ return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
103
+ return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
104
+ }
105
+ // POST /api/sessions/:id/stop — the desktop app's stop button, for one chat.
106
+ const stopOf = routeParam(routes.stop, req.method, pathname);
107
+ if (stopOf) {
108
+ const sessionId = stopOf;
109
+ const body = JSON.parse((await readBody(req)) || '{}');
110
+ const ws = body.workspaceId
111
+ ? reads.getWorkspace(body.workspaceId)
112
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
113
+ if (!ws)
114
+ return json(req, res, 404, { error: 'workspace for session not found' });
115
+ const located = locateChat(ws, sessionId);
116
+ if ('error' in located)
117
+ return json(req, res, 409, { error: located.error });
118
+ // Nothing running is a success, not an error: the phone shows Stop the moment it
119
+ // sends (the optimistic hint) and a turn that ends on its own a beat before the tap
120
+ // is the common case, not a mistake worth a red banner. It also keeps the one
121
+ // keystroke this route presses off an idle chat entirely — Conductor's own
122
+ // composer has no stop button to mis-tap there either.
123
+ const before = reads.listSessions(ws.id).find(s => s.id === sessionId);
124
+ if (before?.status !== 'working') {
125
+ return json(req, res, 200, { ok: true, alreadyIdle: true, session: before });
126
+ }
127
+ const result = await stopTurn({ workspace: ws, sessionId, tab: located.tab });
128
+ if (!result.ok)
129
+ return json(req, res, 502, result);
130
+ // The DB is the receipt, exactly as it is for agent settings: the keystroke is
131
+ // fire-and-forget, so what counts is `status` leaving `working`. Conductor writes
132
+ // that a beat after it tears the turn down.
133
+ let observed = before.status;
134
+ for (let i = 0; i < 20 && observed === 'working'; i++) {
135
+ await sleep(300);
136
+ observed = reads.listSessions(ws.id).find(s => s.id === sessionId)?.status ?? observed;
137
+ }
138
+ if (observed === 'working') {
139
+ return json(req, res, 502, {
140
+ ok: false,
141
+ strategy: result.strategy,
142
+ error: 'Conductor took the stop but the agent is still working. Try again, or stop it on your Mac.'
143
+ });
144
+ }
145
+ return json(req, res, 200, {
146
+ ok: true,
147
+ strategy: result.strategy,
148
+ session: reads.listSessions(ws.id).find(s => s.id === sessionId)
149
+ });
150
+ }
151
+ const restoreChatId = routeParam(routes.restoreChat, req.method, pathname);
152
+ if (restoreChatId) {
153
+ const body = JSON.parse((await readBody(req)) || '{}');
154
+ const ownerId = reads.sessionWorkspaceId(restoreChatId);
155
+ if (!ownerId)
156
+ return json(req, res, 404, { error: 'chat not found' });
157
+ if (body.workspaceId && body.workspaceId !== ownerId) {
158
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
159
+ }
160
+ if (!reads.getWorkspace(ownerId)) {
161
+ return json(req, res, 409, { error: 'Restore this workspace in Conductor before restoring its tabs.' });
162
+ }
163
+ const result = await restoreChat(ownerId, restoreChatId, () => reads.getSession(restoreChatId) !== null);
164
+ if (!result.ok)
165
+ return json(req, res, 502, result);
166
+ return json(req, res, 200, { ...result, session: reads.getSession(restoreChatId) });
167
+ }
168
+ // DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
169
+ // reversible Close tab action (Command-W). A running chat gets the same
170
+ // explicit "Close anyway" gate as the desktop app.
171
+ const closeChatId = routeParam(routes.closeChat, req.method, pathname);
172
+ if (closeChatId) {
173
+ const sessionId = closeChatId;
174
+ const body = JSON.parse((await readBody(req)) || '{}');
175
+ const ownerId = reads.sessionWorkspaceId(sessionId);
176
+ if (!ownerId)
177
+ return json(req, res, 404, { error: 'chat not found' });
178
+ if (body.workspaceId && body.workspaceId !== ownerId) {
179
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
180
+ }
181
+ const ws = reads.getWorkspace(ownerId);
182
+ if (!ws)
183
+ return json(req, res, 404, { error: 'workspace for session not found' });
184
+ const before = reads.listSessions(ws.id);
185
+ const session = before.find(s => s.id === sessionId);
186
+ const visibleActiveSession = () => {
187
+ const visible = reads.listSessions(ws.id);
188
+ const activeId = reads.getWorkspace(ws.id)?.active_session_id;
189
+ return visible.some(s => s.id === activeId) ? (activeId ?? null) : (visible[0]?.id ?? null);
190
+ };
191
+ // Closing is a soft delete. A repeat whose first response was lost is already
192
+ // in the requested state, so it is success rather than a stale-link error.
193
+ if (!session) {
194
+ return json(req, res, 200, {
195
+ ok: true,
196
+ alreadyClosed: true,
197
+ activeSessionId: visibleActiveSession()
198
+ });
199
+ }
200
+ if ((session.status === 'working' || session.background_tasks.length > 0) && body.closeRunning !== true) {
201
+ return json(req, res, 409, {
202
+ ok: false,
203
+ agentRunning: true,
204
+ error: 'The agent is still working in this chat. Confirm closing it anyway.'
205
+ });
206
+ }
207
+ const located = locateChat(ws, sessionId);
208
+ if ('error' in located)
209
+ return json(req, res, 409, { error: located.error });
210
+ const result = await closeChat({ workspace: ws, sessionId, tab: located.tab }, body.closeRunning === true);
211
+ if (!result.ok) {
212
+ // A turn can start after the status read above. The script dismisses
213
+ // Conductor's surprise dialog instead of accepting it, and this sends the
214
+ // caller back through the same explicit confirmation path.
215
+ if (body.closeRunning !== true && result.error?.includes('needs confirmation')) {
216
+ return json(req, res, 409, {
217
+ ok: false,
218
+ agentRunning: true,
219
+ error: 'The agent is still working in this chat. Confirm closing it anyway.'
220
+ });
221
+ }
222
+ return json(req, res, 502, result);
223
+ }
224
+ // Command-W is fire-and-forget. The durable receipt is the same flag all tab
225
+ // reads filter on: this id disappearing from listSessions means Conductor set
226
+ // sessions.is_hidden, not merely that a keystroke happened.
227
+ let visible = true;
228
+ for (let i = 0; i < 20 && visible; i++) {
229
+ await sleep(300);
230
+ visible = reads.listSessions(ws.id).some(s => s.id === sessionId);
231
+ }
232
+ if (visible) {
233
+ return json(req, res, 502, {
234
+ ok: false,
235
+ strategy: result.strategy,
236
+ error: 'Conductor took the close but the chat tab is still open. Try again, or close it on your Mac.'
237
+ });
238
+ }
239
+ return json(req, res, 200, {
240
+ ok: true,
241
+ strategy: result.strategy,
242
+ activeSessionId: visibleActiveSession()
243
+ });
244
+ }
245
+ // POST /api/sessions/:id/attachments?workspaceId= — raw bytes from the phone.
246
+ // Conductor derives an attachment from this on-disk layout plus the composer
247
+ // token, so its own database stays read-only from this relay's point of view.
248
+ const uploadTo = routeParam(routes.uploadAttachment, req.method, pathname);
249
+ if (uploadTo) {
250
+ const sessionId = uploadTo;
251
+ const ownerId = reads.sessionWorkspaceId(sessionId);
252
+ const workspaceId = url.searchParams.get('workspaceId') ?? ownerId;
253
+ const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
254
+ if (!ws)
255
+ return json(req, res, 404, { error: 'workspace for session not found' });
256
+ if (!ownerId || ownerId !== ws.id)
257
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
258
+ if (!ws.worktree)
259
+ return json(req, res, 409, { error: 'worktree path unresolved' });
260
+ const name = attachmentHeaderName(req);
261
+ if (!name)
262
+ return json(req, res, 400, { error: 'missing attachment name' });
263
+ const bytes = await readAttachmentBody(req);
264
+ if (!bytes.length)
265
+ return json(req, res, 400, { error: 'empty attachment' });
266
+ const attachment = writeAttachment(ws.worktree, name, bytes);
267
+ return json(req, res, 200, {
268
+ ok: true,
269
+ attachment: {
270
+ name: attachment.name,
271
+ path: attachment.relPath,
272
+ bytes: attachment.bytes,
273
+ token: attachment.token
274
+ }
275
+ });
276
+ }
277
+ return NOT_HANDLED;
278
+ };
279
+ }