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.
- package/README.md +10 -8
- package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +10 -10
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-ByTwVInL.css +0 -1
- package/dist/assets/index-LAm9X7wv.js +0 -65
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /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
|
+
}
|