conductor-remote 1.129.0 → 1.130.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 (30) hide show
  1. package/dist/assets/AutoModelSettings-DkKribgn.js +1 -0
  2. package/dist/assets/{PierrePatch-DBoBtHa-.js → PierrePatch-DWOOY6WI.js} +1 -1
  3. package/dist/assets/index-6ha0PmVR.css +1 -0
  4. package/dist/assets/index-BPB0gbIv.js +67 -0
  5. package/dist/index.html +2 -2
  6. package/dist/sw.js +1 -1
  7. package/dist-node/src/agents/auto-model/config.js +153 -0
  8. package/dist-node/src/agents/auto-model/decision.js +101 -0
  9. package/dist-node/src/agents/auto-model/provider.js +228 -0
  10. package/dist-node/src/agents/auto-model/queue.js +266 -0
  11. package/dist-node/src/agents/auto-model/types.js +1 -0
  12. package/dist-node/src/contracts/agent-inputs.js +2 -0
  13. package/dist-node/src/http/router.js +2 -0
  14. package/dist-node/src/http/routes/auto-model.js +26 -0
  15. package/dist-node/src/http/routes/create-workspace.js +36 -1
  16. package/dist-node/src/http/routes/prompts.js +56 -4
  17. package/dist-node/src/http/routes/sessions.js +8 -0
  18. package/dist-node/src/http/routes/state.js +3 -2
  19. package/dist-node/src/http/routes/workflows.js +7 -0
  20. package/dist-node/src/http/routes/workspaces.js +3 -1
  21. package/dist-node/src/http/services/auto-model.js +108 -0
  22. package/dist-node/src/http/services/delivery.js +7 -2
  23. package/dist-node/src/http/services.js +3 -0
  24. package/dist-node/src/mcp/tools/workspaces.js +2 -0
  25. package/dist-node/src/prefs.js +2 -0
  26. package/dist-node/src/routes.js +2 -0
  27. package/dist-node/src/server.js +2 -1
  28. package/package.json +1 -1
  29. package/dist/assets/index-D1kDyIDN.js +0 -67
  30. package/dist/assets/index-q-Z5Zz9y.css +0 -1
@@ -0,0 +1,266 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { atomicJson } from "./config.js";
5
+ const pending = (job) => ['selecting', 'waiting', 'failed'].includes(job.status);
6
+ /** One durable file per target; a process lease also keeps dev and installed relays from routing twice. */
7
+ export class AutoModelQueue {
8
+ running = false;
9
+ now;
10
+ directory;
11
+ deps;
12
+ constructor(directory, deps) {
13
+ this.directory = directory;
14
+ this.deps = deps;
15
+ this.now = deps.now ?? Date.now;
16
+ fs.mkdirSync(directory, { recursive: true });
17
+ }
18
+ file(job) {
19
+ return path.join(this.directory, `${job.id}.json`);
20
+ }
21
+ list() {
22
+ return fs
23
+ .readdirSync(this.directory)
24
+ .filter(name => /^[a-f0-9]{64}\.json$/.test(name))
25
+ .map(name => {
26
+ const job = JSON.parse(fs.readFileSync(path.join(this.directory, name), 'utf8'));
27
+ if (!job.id || !job.config || typeof job.text !== 'string')
28
+ throw new Error('An Auto submission could not be read.');
29
+ return job;
30
+ });
31
+ }
32
+ get(sessionId, workspaceId) {
33
+ return this.list().find(job => (sessionId && job.sessionId === sessionId) || (workspaceId && job.workspaceId === workspaceId && !job.sessionId));
34
+ }
35
+ state(sessionId, workspaceId) {
36
+ const job = this.get(sessionId, workspaceId);
37
+ return (job && { status: job.status, decision: job.status === 'cancelled' ? undefined : job.decision, error: job.error });
38
+ }
39
+ pending() {
40
+ return this.list()
41
+ .filter(pending)
42
+ .map(job => ({
43
+ workspaceId: job.workspaceId,
44
+ sessionId: job.sessionId,
45
+ text: job.text,
46
+ createdAt: job.createdAt,
47
+ status: job.status === 'failed' ? 'failed' : 'waiting',
48
+ attempts: job.attempts,
49
+ reason: job.reason,
50
+ error: job.error,
51
+ autoModel: true,
52
+ agent: job.decision && { model: job.decision.model, effort: job.decision.effort, fast: job.decision.fast }
53
+ }));
54
+ }
55
+ accept(input) {
56
+ const existing = this.get(input.sessionId, input.workspaceId);
57
+ if (existing && existing.status !== 'draft' && existing.status !== 'cancelled') {
58
+ if (existing.text !== input.text)
59
+ throw new Error('This chat already has an Auto submission. Dismiss it before sending another.');
60
+ if (existing.status === 'failed') {
61
+ existing.status = 'waiting';
62
+ existing.attempts = 0;
63
+ existing.earlyAttempts = 0;
64
+ existing.lastAttemptAt = undefined;
65
+ existing.error = undefined;
66
+ existing.reason = 'Retrying with the saved model choice';
67
+ this.save(existing);
68
+ }
69
+ return existing;
70
+ }
71
+ const job = {
72
+ ...input,
73
+ config: structuredClone(input.config),
74
+ id: existing?.id ??
75
+ crypto
76
+ .createHash('sha256')
77
+ .update(`${input.workspaceId}:${input.sessionId ?? 'first'}`)
78
+ .digest('hex'),
79
+ attachmentIds: input.attachmentIds ?? [],
80
+ sendImmediately: input.sendImmediately !== false,
81
+ createdAt: Math.max(this.now(), (existing?.createdAt ?? 0) + 1),
82
+ attempts: 0,
83
+ earlyAttempts: 0,
84
+ status: input.text ? 'waiting' : 'draft',
85
+ reason: 'Choosing a model…'
86
+ };
87
+ if (existing)
88
+ this.save(job);
89
+ else {
90
+ try {
91
+ fs.writeFileSync(this.file(job), JSON.stringify(job), { flag: 'wx', mode: 0o600 });
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'EEXIST')
95
+ return this.accept(input);
96
+ throw error;
97
+ }
98
+ }
99
+ return job;
100
+ }
101
+ cancel(sessionId, workspaceId) {
102
+ const job = this.get(sessionId, workspaceId);
103
+ if (!job || !['draft', 'selecting', 'waiting', 'failed'].includes(job.status))
104
+ return false;
105
+ const lease = this.readLease(job);
106
+ if (lease?.dispatching && this.alive(lease.pid))
107
+ throw new Error('Auto is sending this prompt now. Wait for its receipt before dismissing it.');
108
+ job.status = 'cancelled';
109
+ job.reason = 'Auto cancelled';
110
+ this.save(job);
111
+ return true;
112
+ }
113
+ save(job) {
114
+ atomicJson(this.file(job), job);
115
+ }
116
+ current(job) {
117
+ const saved = JSON.parse(fs.readFileSync(this.file(job), 'utf8'));
118
+ return saved.createdAt === job.createdAt && saved.text === job.text && pending(saved) && saved.status !== 'failed';
119
+ }
120
+ alive(pid) {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ }
125
+ catch (error) {
126
+ return error.code !== 'ESRCH';
127
+ }
128
+ }
129
+ readLease(job) {
130
+ try {
131
+ return JSON.parse(fs.readFileSync(`${this.file(job)}.lock`, 'utf8'));
132
+ }
133
+ catch (error) {
134
+ if (error.code !== 'ENOENT')
135
+ throw error;
136
+ }
137
+ }
138
+ lease(job) {
139
+ const existing = this.readLease(job);
140
+ if (existing) {
141
+ if (this.alive(existing.pid))
142
+ return false;
143
+ fs.rmSync(`${this.file(job)}.lock`, { force: true });
144
+ }
145
+ try {
146
+ fs.writeFileSync(`${this.file(job)}.lock`, JSON.stringify({ pid: process.pid, dispatching: false }), {
147
+ flag: 'wx',
148
+ mode: 0o600
149
+ });
150
+ return true;
151
+ }
152
+ catch (error) {
153
+ if (error.code === 'EEXIST')
154
+ return false;
155
+ throw error;
156
+ }
157
+ }
158
+ async tick() {
159
+ if (this.running)
160
+ return;
161
+ this.running = true;
162
+ try {
163
+ for (const candidate of this.list()) {
164
+ if (!pending(candidate) || candidate.status === 'failed' || !this.lease(candidate))
165
+ continue;
166
+ const job = JSON.parse(fs.readFileSync(this.file(candidate), 'utf8'));
167
+ try {
168
+ if (this.current(job))
169
+ await this.step(job);
170
+ }
171
+ catch (error) {
172
+ if (this.current(job)) {
173
+ job.status = 'failed';
174
+ job.error = error instanceof Error ? error.message : 'Auto could not deliver the prompt.';
175
+ this.save(job);
176
+ }
177
+ }
178
+ finally {
179
+ fs.rmSync(`${this.file(job)}.lock`, { force: true });
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ this.running = false;
185
+ }
186
+ }
187
+ async step(job) {
188
+ if (job.sessionId && job.cursor && this.deps.received(job)) {
189
+ job.status = 'delivered';
190
+ this.save(job);
191
+ return;
192
+ }
193
+ if (await this.deps.locked()) {
194
+ if (!this.current(job))
195
+ return;
196
+ job.reason = 'Waiting for the Mac to unlock';
197
+ job.lastAttemptAt = this.now();
198
+ this.save(job);
199
+ return;
200
+ }
201
+ if (!this.current(job))
202
+ return;
203
+ const target = this.deps.inspect(job);
204
+ if (target.obsolete) {
205
+ job.status = 'cancelled';
206
+ job.reason = 'The chat started before Auto could send.';
207
+ this.save(job);
208
+ return;
209
+ }
210
+ if (target.error)
211
+ throw new Error(target.error);
212
+ if (!target.sessionId || !target.worktree || (!target.ready && (!job.sendImmediately || job.earlyAttempts >= 2))) {
213
+ if (this.now() - (job.lastAttemptAt ?? job.createdAt) > 15 * 60_000)
214
+ throw new Error('The workspace did not become ready for Auto. Retry when setup finishes.');
215
+ return;
216
+ }
217
+ if (job.lastAttemptAt && this.now() - job.lastAttemptAt < 5000)
218
+ return;
219
+ job.sessionId = target.sessionId;
220
+ this.deps.materialize(job, target.worktree);
221
+ job.cursor ??= this.deps.cursor(job.sessionId);
222
+ this.save(job);
223
+ if (!job.decision) {
224
+ job.status = 'selecting';
225
+ job.reason = 'Choosing a model…';
226
+ this.save(job);
227
+ const decision = await this.deps.choose(job, target.worktree);
228
+ if (!this.current(job))
229
+ return;
230
+ job.decision = decision;
231
+ job.status = 'waiting';
232
+ job.reason = `${decision.model} selected · ${decision.reason}`;
233
+ this.save(job); // Decision is durable before any UI write.
234
+ }
235
+ const result = await this.deps.deliver(job, () => this.current(job), () => {
236
+ atomicJson(`${this.file(job)}.lock`, { pid: process.pid, dispatching: true });
237
+ });
238
+ if (!this.current(job))
239
+ return;
240
+ if (result.cancelled)
241
+ job.status = 'cancelled';
242
+ else if (result.ok)
243
+ job.status = 'delivered';
244
+ else if (result.blocked)
245
+ job.reason = 'Waiting for the Mac to unlock';
246
+ else {
247
+ if (target.ready)
248
+ job.attempts++;
249
+ else
250
+ job.earlyAttempts++;
251
+ job.error = result.error ?? 'The prompt was not accepted.';
252
+ job.reason = job.error;
253
+ if (job.attempts >= 3)
254
+ job.status = 'failed';
255
+ }
256
+ job.lastAttemptAt = this.now();
257
+ this.save(job);
258
+ }
259
+ start() {
260
+ const tick = () => void this.tick().catch(error => console.error('[auto-model] queue failed:', error));
261
+ const timer = setInterval(tick, 1000);
262
+ timer.unref();
263
+ tick();
264
+ return () => clearInterval(timer);
265
+ }
266
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -19,6 +19,7 @@ export const agentPatchSchema = z.object({
19
19
  });
20
20
  export const setAgentOptionsSchema = agentPatchSchema.extend({ workspaceId: workspaceIdSchema });
21
21
  export const sendPromptSchema = z.object({
22
+ auto: z.boolean().optional().describe('Select a configured model from this pristine chat’s first message.'),
22
23
  text: z.string({ error: 'prompt must be a string' }).trim().min(1, 'empty prompt'),
23
24
  workspaceId: workspaceIdSchema,
24
25
  agent: agentPatchSchema.optional(),
@@ -26,6 +27,7 @@ export const sendPromptSchema = z.object({
26
27
  queue: z.boolean().default(false)
27
28
  });
28
29
  export const createWorkspaceSchema = agentPatchSchema.extend({
30
+ auto: z.boolean().optional().describe('Use Auto to select the initial model. Omit manual agent settings.'),
29
31
  repo: z.string().trim().min(1).optional().describe('Exact name from list_repos.'),
30
32
  prompt: optionalText.describe('First prompt for the new agent. Omit to open an empty workspace.'),
31
33
  send: z
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { InputError } from "../contracts/validation.js";
3
3
  import { UiBusyError, uiQueueDepth, withUiPriority } from "../writes/ui-lock.js";
4
4
  import { NOT_HANDLED } from "./router-types.js";
5
+ import { createAutoModelRoutes } from "./routes/auto-model.js";
5
6
  import { createCreateWorkspaceRoutes } from "./routes/create-workspace.js";
6
7
  import { createFilesRoutes } from "./routes/files.js";
7
8
  import { createPromptsRoutes } from "./routes/prompts.js";
@@ -14,6 +15,7 @@ import { createWorkspacesRoutes } from "./routes/workspaces.js";
14
15
  export function createRelayServer(services) {
15
16
  const { handleMcpHttp, serveStatic, authed, json, PayloadTooLargeError, workflowHttpError } = services;
16
17
  const handlers = [
18
+ createAutoModelRoutes(services),
17
19
  createStateRoutes(services),
18
20
  createWorkflowsRoutes(services),
19
21
  createVoiceRoutes(services),
@@ -0,0 +1,26 @@
1
+ import { autoModelIssues, decodeAutoModelConfig } from "../../agents/auto-model/config.js";
2
+ import { isRoute, routes } from "../../routes.js";
3
+ import { NOT_HANDLED } from "../router-types.js";
4
+ export function createAutoModelRoutes(services) {
5
+ const { autoModelConfig, modelCache, readBody, json } = services;
6
+ return async (req, res, url) => {
7
+ if (isRoute(routes.autoModelConfig, req.method, url.pathname)) {
8
+ const config = autoModelConfig.read();
9
+ return json(req, res, 200, { config, issues: autoModelIssues(config, modelCache.list()) });
10
+ }
11
+ if (isRoute(routes.updateAutoModelConfig, req.method, url.pathname)) {
12
+ try {
13
+ const config = decodeAutoModelConfig(JSON.parse(await readBody(req)));
14
+ const issues = autoModelIssues(config, modelCache.list());
15
+ if (issues.length)
16
+ return json(req, res, 400, { error: issues.join(' ') });
17
+ autoModelConfig.write(config);
18
+ return json(req, res, 200, { config, issues: [] });
19
+ }
20
+ catch {
21
+ return json(req, res, 400, { error: 'Invalid Auto settings. Check the profiles, fallback, and router.' });
22
+ }
23
+ }
24
+ return NOT_HANDLED;
25
+ };
26
+ }
@@ -1,3 +1,4 @@
1
+ import { freezeAutoModelConfig } from "../../agents/auto-model/config.js";
1
2
  import { createWorkspaceSchema, hasAgentSettings } from "../../contracts/agent-inputs.js";
2
3
  import { parseInput } from "../../contracts/validation.js";
3
4
  import { stagedAttachments } from "../../files/staged-attachments.js";
@@ -17,6 +18,21 @@ export function createCreateWorkspaceRoutes(services) {
17
18
  const body = parseInput(createWorkspaceSchema, input);
18
19
  const { attachmentIds, model, effort, plan, fast } = body;
19
20
  const requestedAgent = { model, effort, plan, fast };
21
+ if (body.auto && [body.model, body.effort, body.fast, body.plan].some(value => value !== undefined))
22
+ return json(req, res, 400, {
23
+ error: 'Auto chooses the agent settings. Omit manual model, effort, Fast, and Plan.'
24
+ });
25
+ let autoConfig;
26
+ if (body.auto) {
27
+ if (!services.autoModels || !services.autoModelConfig || !services.modelCache)
28
+ return json(req, res, 503, { error: 'Auto is unavailable.' });
29
+ try {
30
+ autoConfig = freezeAutoModelConfig(services.autoModelConfig.read(), services.modelCache.list());
31
+ }
32
+ catch (error) {
33
+ return json(req, res, 409, { error: error instanceof Error ? error.message : 'Invalid Auto settings.' });
34
+ }
35
+ }
20
36
  const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
21
37
  if (!attachments)
22
38
  return json(req, res, 409, { error: 'an attached file is no longer available; add it again' });
@@ -37,7 +53,7 @@ export function createCreateWorkspaceRoutes(services) {
37
53
  return json(req, res, 404, { error: `unknown repo ${body.repo}` });
38
54
  if (repo && !repo.root_path)
39
55
  return json(req, res, 409, { error: `${repo.name} has no checkout path` });
40
- const { result, created } = await createWorkspaceAndRead(prompt, repo?.root_path ?? null, repo?.name);
56
+ const { result, created } = await createWorkspaceAndRead(prompt, repo?.root_path ?? null, repo?.name, !!body.auto);
41
57
  if (!result.ok)
42
58
  return json(req, res, 502, result);
43
59
  if (!created) {
@@ -47,6 +63,25 @@ export function createCreateWorkspaceRoutes(services) {
47
63
  error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
48
64
  });
49
65
  }
66
+ if (autoConfig && services.autoModels) {
67
+ const sessions = reads.listSessions(created.id);
68
+ services.autoModels.accept({
69
+ workspaceId: created.id,
70
+ sessionId: sessions.length === 1 ? sessions[0].id : undefined,
71
+ text: prompt,
72
+ repo: created.repo_name ?? body.repo ?? '',
73
+ config: autoConfig,
74
+ attachmentIds,
75
+ sendImmediately: body.sendImmediately
76
+ });
77
+ return json(req, res, 200, {
78
+ ok: true,
79
+ workspaceId: created.id,
80
+ workspace: created,
81
+ pendingPrompt: prompt || undefined,
82
+ sent: false
83
+ });
84
+ }
50
85
  // Return as soon as the row exists (~2s) — waiting for delivery would block the
51
86
  // request through Conductor's whole setup, measured at 30s+ on a real repo and
52
87
  // past any budget a phone should hold a request open for. The queue delivers on
@@ -1,3 +1,4 @@
1
+ import { freezeAutoModelConfig } from "../../agents/auto-model/config.js";
1
2
  import { hasAgentSettings, sendPromptSchema } from "../../contracts/agent-inputs.js";
2
3
  import { parseInput } from "../../contracts/validation.js";
3
4
  import { attachmentPrompt, writeAttachment } from "../../files/attachments.js";
@@ -71,6 +72,47 @@ export function createPromptsRoutes(services) {
71
72
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
72
73
  if (!ws)
73
74
  return json(req, res, 404, { error: 'workspace for session not found' });
75
+ const autoJob = services.autoModels?.get(sessionId, ws.id);
76
+ const useAuto = body.auto === true ||
77
+ (body.auto !== false && autoJob?.status === 'draft') ||
78
+ (!!autoJob && ['waiting', 'selecting', 'failed'].includes(autoJob.status));
79
+ if (useAuto) {
80
+ if (!services.autoModels || !services.autoModelConfig || !services.inspectAutoTarget)
81
+ return json(req, res, 503, { error: 'Auto is unavailable.' });
82
+ if (body.queue || requestedAgent)
83
+ return json(req, res, 400, {
84
+ error: 'Auto selects settings for the first message. Omit manual settings and queue mode.'
85
+ });
86
+ try {
87
+ if (!autoJob || ['draft', 'cancelled'].includes(autoJob.status)) {
88
+ const target = services.inspectAutoTarget({ workspaceId: ws.id, sessionId });
89
+ if (target.error)
90
+ return json(req, res, 409, { error: target.error });
91
+ }
92
+ const job = services.autoModels.accept({
93
+ workspaceId: ws.id,
94
+ sessionId,
95
+ text: rawText,
96
+ repo: ws.repo_name ?? '',
97
+ config: autoJob && !['draft', 'cancelled'].includes(autoJob.status)
98
+ ? autoJob.config
99
+ : freezeAutoModelConfig(services.autoModelConfig.read(), modelCache.list())
100
+ });
101
+ return json(req, res, job.status === 'delivered' ? 200 : 202, {
102
+ ok: job.status === 'delivered',
103
+ parked: job.status !== 'delivered',
104
+ strategy: actuator.name,
105
+ queued: services.autoModels.pending().find(p => p.sessionId === sessionId)
106
+ });
107
+ }
108
+ catch (error) {
109
+ return json(req, res, 409, {
110
+ error: error instanceof Error ? error.message : 'Auto could not accept this prompt.'
111
+ });
112
+ }
113
+ }
114
+ if (autoJob?.status === 'draft' && body.auto === false)
115
+ services.autoModels?.cancel(sessionId, ws.id);
74
116
  // One deadline for the whole request: settings eat into the send's budget
75
117
  // rather than extending it past what the phone said it would wait.
76
118
  const deadline = Date.now() + sendBudget(req);
@@ -338,16 +380,26 @@ export function createPromptsRoutes(services) {
338
380
  const forgetFirst = routeParam(routes.dismissFirstPrompt, req.method, pathname);
339
381
  if (forgetFirst) {
340
382
  const workspaceId = forgetFirst;
341
- if (!firstPrompts.forget(workspaceId))
342
- return json(req, res, 404, { error: 'no pending prompt' });
383
+ try {
384
+ if (!services.autoModels?.cancel(undefined, workspaceId) && !firstPrompts.forget(workspaceId))
385
+ return json(req, res, 404, { error: 'no pending prompt' });
386
+ }
387
+ catch (error) {
388
+ return json(req, res, 409, { error: error instanceof Error ? error.message : 'Auto is sending.' });
389
+ }
343
390
  return json(req, res, 200, { ok: true });
344
391
  }
345
392
  // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
346
393
  const forgetParked = routeParam(routes.dismissParkedPrompt, req.method, pathname);
347
394
  if (forgetParked) {
348
395
  const sessionId = forgetParked;
349
- if (!parkedPrompts.forgetSession(sessionId))
350
- return json(req, res, 404, { error: 'no parked prompt' });
396
+ try {
397
+ if (!services.autoModels?.cancel(sessionId) && !parkedPrompts.forgetSession(sessionId))
398
+ return json(req, res, 404, { error: 'no parked prompt' });
399
+ }
400
+ catch (error) {
401
+ return json(req, res, 409, { error: error instanceof Error ? error.message : 'Auto is sending.' });
402
+ }
351
403
  return json(req, res, 200, { ok: true });
352
404
  }
353
405
  return NOT_HANDLED;
@@ -12,6 +12,14 @@ export function createSessionsRoutes(services) {
12
12
  const { json, reads, workflowFrozenError, locateChat, modelCache, readBody, applyAgentPatch, actuator, sleep, attachmentHeaderName, readAttachmentBody } = services;
13
13
  return async (req, res, url) => {
14
14
  const { pathname } = url;
15
+ const changingAgent = routeParam(routes.agent, req.method, pathname) ?? routeParam(routes.defaultModel, req.method, pathname);
16
+ if (changingAgent) {
17
+ const auto = services.autoModels?.get(changingAgent);
18
+ if (auto && ['selecting', 'waiting', 'failed'].includes(auto.status))
19
+ return json(req, res, 409, {
20
+ error: 'Auto owns the initial settings until this submission is delivered or dismissed.'
21
+ });
22
+ }
15
23
  // PWA presentation only: preserve both real tabs and all their original messages.
16
24
  const joinHistoryOf = routeParam(routes.joinChatHistory, req.method, pathname);
17
25
  if (joinHistoryOf) {
@@ -29,9 +29,10 @@ export function createStateRoutes(services) {
29
29
  // Prompts parked for the lock screen ride the same way, one list per workspace,
30
30
  // each entry naming its chat (src/delivery/parked.ts).
31
31
  const parked = parkedPrompts.list();
32
+ const auto = services.autoModels?.pending() ?? [];
32
33
  for (const ws of workspaces) {
33
- ws.pending_prompt = firstPrompts.get(ws.id);
34
- const mine = parked.filter(p => p.workspaceId === ws.id);
34
+ ws.pending_prompt = firstPrompts.get(ws.id) ?? auto.find(p => p.workspaceId === ws.id && !p.sessionId);
35
+ const mine = [...parked, ...auto.filter((p) => !!p.sessionId)].filter(p => p.workspaceId === ws.id);
35
36
  if (mine.length)
36
37
  ws.parked_prompts = mine;
37
38
  }
@@ -47,6 +47,13 @@ export function createWorkflowsRoutes(services) {
47
47
  throw new WorkflowCoordinatorError('workflow_incompatible_relay', `Workflow is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
48
48
  }
49
49
  const request = parseStartWorkflowRequest(await workflowRequestBody(req));
50
+ if (request.target.kind === 'existing_session') {
51
+ const auto = services.autoModels?.get(request.target.sessionId, request.target.workspaceId);
52
+ if (auto && ['waiting', 'selecting', 'failed'].includes(auto.status))
53
+ return json(req, res, 409, { error: 'Dismiss the Auto submission before starting Workflow.' });
54
+ if (auto?.status === 'draft')
55
+ services.autoModels?.cancel(request.target.sessionId, request.target.workspaceId);
56
+ }
50
57
  const replay = orchestration.getIdempotentMutation('start_workflow', request.clientId, {
51
58
  objective: request.objective,
52
59
  target: request.target
@@ -28,7 +28,9 @@ export function createWorkspacesRoutes(services) {
28
28
  attachWorkflowState([enriched]);
29
29
  const sessionRoles = { ...(roles?.sessions ?? {}), ...(enriched?.session_roles ?? {}) };
30
30
  return json(req, res, 200, {
31
- sessions: reads.listSessions(listSessionsIn),
31
+ sessions: reads
32
+ .listSessions(listSessionsIn)
33
+ .map(session => ({ ...session, auto_model: services.autoModels?.state(session.id, listSessionsIn) })),
32
34
  chat_history: services.chatHistory.forWorkspace(listSessionsIn),
33
35
  ...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
34
36
  });
@@ -0,0 +1,108 @@
1
+ import path from 'node:path';
2
+ import { AutoModelConfigStore } from "../../agents/auto-model/config.js";
3
+ import { chooseAutoModel, routingInput } from "../../agents/auto-model/decision.js";
4
+ import { runRouter } from "../../agents/auto-model/provider.js";
5
+ import { AutoModelQueue } from "../../agents/auto-model/queue.js";
6
+ import { roleModelIssues } from "../../agents/roles.js";
7
+ import { stateDir } from "../../config.js";
8
+ import { materializeStagedAttachments } from "../../files/staged-attachments.js";
9
+ import { modelAgentType } from "../../shared.js";
10
+ import { lockBlocked, screenLocked } from "../../writes/guards.js";
11
+ import { uiTurn, withUiPriority } from "../../writes/ui-lock.js";
12
+ export function createAutoModelServices(services) {
13
+ const { reads, modelCache, delegationStore, workflowOwningSession, firstPrompts, parkedPrompts, applyAgentPatch, deliverPrompt, STAGED_ATTACHMENTS_DIR } = services;
14
+ const autoModelConfig = new AutoModelConfigStore(path.join(stateDir(), 'auto-model.json'));
15
+ const received = (job) => !!(job.sessionId &&
16
+ job.cursor &&
17
+ reads.deliveryReceiptSince(job.sessionId, job.text, {
18
+ rowid: job.cursor.rowid,
19
+ outboxIds: new Set(job.cursor.outboxIds)
20
+ }));
21
+ function inspect(job) {
22
+ const ws = reads.getWorkspace(job.workspaceId);
23
+ if (!ws)
24
+ return { ready: false, error: 'The Auto workspace is no longer available.' };
25
+ const sessions = reads.listSessions(ws.id);
26
+ const session = job.sessionId
27
+ ? sessions.find(s => s.id === job.sessionId)
28
+ : sessions.length === 1
29
+ ? sessions[0]
30
+ : undefined;
31
+ const target = { ready: ws.state === 'ready', worktree: ws.worktree, sessionId: session?.id };
32
+ if (!session)
33
+ return {
34
+ ...target,
35
+ error: sessions.length > 1 || job.sessionId ? 'The Auto chat is missing or ambiguous.' : undefined
36
+ };
37
+ const roles = delegationStore(ws)?.sessionRoles();
38
+ if (workflowOwningSession(session.id) || roles?.sessions[session.id] || roles?.warning) {
39
+ return { ...target, error: 'Auto is unavailable in a chat owned by a Workflow or delegation.' };
40
+ }
41
+ if (firstPrompts.get(ws.id) || parkedPrompts.list().some(p => p.sessionId === session.id)) {
42
+ return { ...target, error: 'Resolve this chat’s pending prompt before using Auto.' };
43
+ }
44
+ if (session.last_user_message_at ||
45
+ (session.status && session.status !== 'idle') ||
46
+ session.background_tasks.length ||
47
+ reads.deliveryCursor(session.id).outboxIds.size ||
48
+ reads.getMessages(session.id).entries.some(entry => entry.role === 'user')) {
49
+ return { ...target, obsolete: true, error: 'Auto is available only before the first message in an idle chat.' };
50
+ }
51
+ return target;
52
+ }
53
+ const autoModels = new AutoModelQueue(path.join(stateDir(), 'auto-model-prompts'), {
54
+ inspect,
55
+ received,
56
+ locked: async () => (await screenLocked()) === true,
57
+ cursor: sessionId => {
58
+ const cursor = reads.deliveryCursor(sessionId);
59
+ return { rowid: cursor.rowid, outboxIds: [...cursor.outboxIds] };
60
+ },
61
+ materialize: (job, worktree) => materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, worktree, job.attachmentIds),
62
+ choose: (job, worktree) => chooseAutoModel(job.config, routingInput(job.text, job.repo, worktree), (prompt, images, signal) => runRouter(job.config.router, prompt, images, signal)),
63
+ deliver: (job, current, dispatch) => withUiPriority('background', () => uiTurn(async () => {
64
+ if (!current())
65
+ return { ok: false, error: 'Auto cancelled.' };
66
+ if (received(job))
67
+ return { ok: true };
68
+ const target = inspect(job);
69
+ if (target.error)
70
+ return { ok: false, error: target.error, cancelled: target.obsolete };
71
+ const ws = reads.getWorkspace(job.workspaceId);
72
+ if (!ws || !job.sessionId || !job.decision || !job.cursor)
73
+ return { ok: false, error: 'Auto’s saved target is incomplete.' };
74
+ const { model, effort, fast } = job.decision;
75
+ // A vanished selected profile must remain a visible failure, never a silent reroute.
76
+ const issues = roleModelIssues({ version: 1, roles: { selected: { model, effort, fast } } }, modelCache.list());
77
+ if (issues.length)
78
+ return {
79
+ ok: false,
80
+ error: 'The saved Auto model is no longer available. Dismiss this submission and choose another model.'
81
+ };
82
+ dispatch();
83
+ const applied = await applyAgentPatch(ws, job.sessionId, {
84
+ model,
85
+ effort,
86
+ fast,
87
+ ...(modelAgentType(model) === 'claude' ? { plan: false } : {})
88
+ });
89
+ if (!applied.ok)
90
+ return { ok: false, error: applied.error, blocked: lockBlocked(applied.error) };
91
+ if (received(job))
92
+ return { ok: true };
93
+ const fresh = inspect(job);
94
+ if (fresh.error || !current())
95
+ return { ok: false, error: fresh.error ?? 'Auto cancelled.', cancelled: fresh.obsolete };
96
+ const result = await deliverPrompt(ws, job.sessionId, job.text, undefined, false, {
97
+ rowid: job.cursor.rowid,
98
+ outboxIds: new Set(job.cursor.outboxIds)
99
+ });
100
+ return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
101
+ }))
102
+ });
103
+ services.setAdditionalStagedReferences(() => autoModels
104
+ .list()
105
+ .filter(job => !['delivered', 'cancelled'].includes(job.status))
106
+ .flatMap(job => job.attachmentIds));
107
+ return { autoModels, autoModelConfig, inspectAutoTarget: inspect };
108
+ }
@@ -292,8 +292,12 @@ export function createDeliveryServices(services) {
292
292
  /** Unreferenced pre-workspace uploads get one week for an offline device to reconnect. */
293
293
  const STAGED_ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
294
294
  const STAGED_ATTACHMENT_SWEEP_MS = 6 * 60 * 60 * 1000;
295
+ let additionalStagedReferences = () => [];
296
+ function setAdditionalStagedReferences(read) {
297
+ additionalStagedReferences = read;
298
+ }
295
299
  function referencedStagedAttachments() {
296
- const referenced = new Set();
300
+ const referenced = new Set(additionalStagedReferences());
297
301
  for (const draft of Object.values(readPrefs().drafts)) {
298
302
  if (draft.deleted)
299
303
  continue;
@@ -422,6 +426,7 @@ export function createDeliveryServices(services) {
422
426
  sendOnce,
423
427
  PARKED_ERROR,
424
428
  sweepStagedAttachments,
425
- STAGED_ATTACHMENT_SWEEP_MS
429
+ STAGED_ATTACHMENT_SWEEP_MS,
430
+ setAdditionalStagedReferences
426
431
  };
427
432
  }