mixdog 1.3.0 → 1.3.1

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 (103) hide show
  1. package/NOTICE.md +52 -9
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -1
  4. package/src/defaults/skills/setup/references/actions.md +4 -1
  5. package/src/defaults/skills/setup-guide/SKILL.md +10 -7
  6. package/src/output-styles/common.md +13 -8
  7. package/src/rules/agent-mode/AGENT_MODE.md +1 -1
  8. package/src/rules/agent-mode/MIXY.md +11 -4
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
  10. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
  12. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
  13. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
  14. package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
  18. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
  19. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
  20. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
  23. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
  24. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
  25. package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
  26. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
  27. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
  28. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
  29. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
  30. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
  31. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
  32. package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
  33. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
  34. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
  35. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
  36. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
  38. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
  39. package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
  40. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
  41. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
  42. package/src/runtime/attachments/store.mjs +6 -0
  43. package/src/runtime/effort-judge/judge-client.mjs +1 -1
  44. package/src/runtime/effort-judge/model-manifest.json +7 -7
  45. package/src/runtime/shared/agent-kind.mjs +152 -0
  46. package/src/runtime/shared/agent-route-config.d.mts +1 -0
  47. package/src/runtime/shared/agent-route-config.mjs +22 -8
  48. package/src/runtime/shared/agent-store.mjs +132 -0
  49. package/src/runtime/shared/bot-split-migration.mjs +457 -0
  50. package/src/runtime/shared/config-patch.mjs +20 -1
  51. package/src/runtime/shared/connector-catalog.d.mts +1 -2
  52. package/src/runtime/shared/connector-catalog.mjs +2 -4
  53. package/src/runtime/shared/connector-triggers.mjs +2 -0
  54. package/src/runtime/shared/err-text.mjs +3 -1
  55. package/src/runtime/shared/error-presentation.mjs +91 -6
  56. package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
  57. package/src/runtime/shared/handoff-artifacts.mjs +61 -0
  58. package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
  59. package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
  60. package/src/runtime/shared/model-route-fallback.mjs +79 -7
  61. package/src/runtime/shared/plain-preview.mjs +22 -17
  62. package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
  63. package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
  64. package/src/runtime/shared/usage-readout-totals.mjs +15 -13
  65. package/src/runtime/shared/user-data-guard.mjs +5 -2
  66. package/src/session-runtime/agent-home-delivery.mjs +423 -56
  67. package/src/session-runtime/agent-room-delivery.mjs +45 -4
  68. package/src/session-runtime/agent-task-sessions.mjs +299 -39
  69. package/src/session-runtime/boot/apis.mjs +13 -0
  70. package/src/session-runtime/boot/providers.mjs +14 -4
  71. package/src/session-runtime/config-lifecycle.mjs +43 -2
  72. package/src/session-runtime/handoff-attachments.mjs +593 -0
  73. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
  74. package/src/session-runtime/model-route/route-persist.mjs +2 -2
  75. package/src/session-runtime/model-route/route-tuning.mjs +7 -1
  76. package/src/session-runtime/model-route/set-route.mjs +21 -1
  77. package/src/session-runtime/native-web-search.mjs +2 -2
  78. package/src/session-runtime/new-session-config.mjs +6 -1
  79. package/src/session-runtime/route-resolution.mjs +5 -11
  80. package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
  81. package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
  82. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  83. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
  84. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  85. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  86. package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
  87. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
  88. package/src/session-runtime/tool-policy-refresh.mjs +13 -2
  89. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
  90. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
  91. package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
  92. package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
  93. package/src/standalone/daemon-session-control.mjs +13 -5
  94. package/src/standalone/daemon.mjs +16 -4
  95. package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
  96. package/src/standalone/session-service/agent-tree.mjs +4 -3
  97. package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
  98. package/src/tui/dist/index.mjs +70 -9
  99. package/src/tui/session/notice-surface.mjs +3 -4
  100. package/src/tui/session/session-api/routes.mjs +14 -3
  101. package/src/tui/session/transcript-intake.mjs +2 -0
  102. package/src/tui/session/turn-live-call.mjs +11 -3
  103. package/src/tui/session/turn.mjs +13 -3
@@ -10,9 +10,12 @@
10
10
  import {
11
11
  normalizeRoomRow,
12
12
  roomIdOfSession,
13
+ roomMemberOfSession,
13
14
  roomRowItem,
14
15
  roomRowMessage,
15
16
  } from '../runtime/agent/orchestrator/session/agent-room.mjs';
17
+ import { artifactRecords } from '../runtime/shared/handoff-artifacts.mjs';
18
+ import { collectTurnArtifacts, sessionRoots, turnSlice } from './handoff-attachments.mjs';
16
19
  import {
17
20
  attachmentsForIntake,
18
21
  isAttachmentReference,
@@ -46,14 +49,52 @@ async function storedRow(row) {
46
49
  };
47
50
  }
48
51
 
52
+ /** A member's speech with the files its turn produced taken in: `artifactsFrom`
53
+ * `{ sessionId, since }` names the member's own session in this room and when
54
+ * its turn started; the files inside that session's folders are stored and
55
+ * ride on the row as references (handoff-attachments.mjs). */
56
+ function speechWithArtifacts(row, roomId, loadSession, log) {
57
+ const from =
58
+ row?.kind === 'speech' && row.artifactsFrom && typeof row.artifactsFrom === 'object' ? row.artifactsFrom : null;
59
+ if (!from) return row;
60
+ const { artifactsFrom: _from, ...rest } = row;
61
+ const member = roomMemberOfSession(String(from.sessionId || ''));
62
+ if (!member || member.roomId !== roomId || member.agentId !== row.speaker) return rest;
63
+ try {
64
+ const session = loadSession(from.sessionId);
65
+ const messages = turnSlice(session?.messages, { since: Number(from.since) || 0 });
66
+ const { files } = collectTurnArtifacts(messages, { cwd: session?.cwd, roots: sessionRoots(session) });
67
+ const stored = files.filter((file) => file.id);
68
+ if (!stored.length) return rest;
69
+ return {
70
+ ...rest,
71
+ parts: stored.map((file) => ({
72
+ type: file.kind === 'image' ? 'image' : file.kind === 'text' ? 'text' : 'file',
73
+ attachmentRef: file.id,
74
+ ...(file.kind === 'text' ? { textKind: 'file' } : {}),
75
+ mimeType: file.mimeType,
76
+ filename: file.name,
77
+ sizeBytes: file.size,
78
+ })),
79
+ attachments: artifactRecords(stored),
80
+ };
81
+ } catch (error) {
82
+ log(`the files of ${from.sessionId} could not be taken in: ${error?.message || error}`);
83
+ return rest;
84
+ }
85
+ }
86
+
49
87
  export function createAgentRoomDelivery({ loadSession, saveSession, live = agentHomeLiveSurface, log = () => {} }) {
50
88
  /** Append `rows` to the log of `sessionId`; resolves `{ added, rows }`: how
51
89
  * many were new, and every row as stored (files as references, no bytes). */
52
90
  async function append(sessionId, rows) {
53
- if (!roomIdOfSession(sessionId)) throw new TypeError('not a group-room session');
54
- const kept = (await Promise.all((Array.isArray(rows) ? rows : []).map(storedRow))).map((row) =>
55
- normalizeRoomRow(row)
56
- );
91
+ const roomId = roomIdOfSession(sessionId);
92
+ if (!roomId) throw new TypeError('not a group-room session');
93
+ const kept = (
94
+ await Promise.all(
95
+ (Array.isArray(rows) ? rows : []).map((row) => storedRow(speechWithArtifacts(row, roomId, loadSession, log)))
96
+ )
97
+ ).map((row) => normalizeRoomRow(row));
57
98
  const messages = kept.map((row) => roomRowMessage(row));
58
99
  const session = loadSession(sessionId);
59
100
  if (!session || session.closed === true) throw new Error(`session ${sessionId} is not available`);
@@ -9,11 +9,19 @@
9
9
  * home (agent-home-delivery.mjs), which posts it and wakes the agent once to
10
10
  * relay it. A turn blocked on the user's answer or approval posts one faint
11
11
  * line instead; the card stays in the task session for the user.
12
+ *
13
+ * An agent's send to any other session reports the same way, by an origin for
14
+ * that send only (reportOrigin). Every reporting turn is also watched durably
15
+ * (agent-home-delivery.mjs `watches`) until its end is queued, so a daemon
16
+ * restart before that re-attaches it (recover): an ended turn reports once
17
+ * from the target's record, a queued or running one is watched to its end.
12
18
  */
13
19
  import { randomUUID } from 'node:crypto';
14
20
  import { realpathSync, statSync } from 'node:fs';
15
21
  import { basename, resolve } from 'node:path';
16
22
  import { clean } from '../runtime/agent/orchestrator/runtime-core/session-text.mjs';
23
+ import { collectTurnArtifacts, handoffContent, sessionRoots, turnSlice } from './handoff-attachments.mjs';
24
+ import { roomMemberOfSession } from '../runtime/agent/orchestrator/session/agent-room.mjs';
17
25
 
18
26
  const TITLE_LIMIT = 60;
19
27
 
@@ -51,9 +59,72 @@ export function resolveTaskProject(project, { projects = [], cwd = process.cwd()
51
59
  const turnStatus = (detail) =>
52
60
  ({ done: 'completed', completed: 'completed', failed: 'failed' })[detail?.status] || 'cancelled';
53
61
 
62
+ const INTERRUPTED = /^\[Request interrupted/;
63
+
64
+ const messageText = (content) =>
65
+ typeof content === 'string'
66
+ ? content
67
+ : (Array.isArray(content) ? content : [])
68
+ .map((part) => (typeof part === 'string' ? part : part?.type === 'text' ? part.text || '' : ''))
69
+ .join('');
70
+
71
+ /** The submissions a stored user message delivered: a turn's prompt
72
+ * (`checkpointId`, `submissionIds` of a merged batch) or a steered message. */
73
+ export function submissionIdsOf(message) {
74
+ if (message?.role !== 'user') return [];
75
+ const meta = message.meta || {};
76
+ const transcript = meta.transcript || {};
77
+ return [
78
+ transcript.checkpointId,
79
+ ...(Array.isArray(transcript.submissionIds) ? transcript.submissionIds : []),
80
+ ...(Array.isArray(meta.submissionIds) ? meta.submissionIds : []),
81
+ ];
82
+ }
83
+
84
+ /** The end of the turn that took submission `runId`, from a session's stored
85
+ * `messages`: `{ status, text }` once that turn recorded its end (its
86
+ * completion, an interruption marker, or the next turn's prompt), 'open'
87
+ * while it has none, null when no turn took it. */
88
+ export function submissionOutcome(messages, runId) {
89
+ const list = Array.isArray(messages) ? messages : [];
90
+ const start = list.findIndex((message) => submissionIdsOf(message).includes(runId));
91
+ if (start < 0) return null;
92
+ for (let index = start; index < list.length; index += 1) {
93
+ const message = list[index];
94
+ const completion = message?.meta?.transcript?.completion;
95
+ if (completion) {
96
+ const status = turnStatus(completion);
97
+ const text = status === 'failed' ? completion.detail || '' : message.role === 'assistant' ? message.content : '';
98
+ return { status, text: messageText(text) };
99
+ }
100
+ if (index === start || message?.role !== 'user') continue;
101
+ if (INTERRUPTED.test(messageText(message.content)) || message.meta?.transcript?.checkpointId) {
102
+ return { status: 'cancelled', text: '' };
103
+ }
104
+ }
105
+ return 'open';
106
+ }
107
+
108
+ const LOST_TEXT = 'The message never ran: the app restarted before its turn started.';
109
+ const GONE_TEXT = 'The session no longer exists.';
110
+
111
+ // The last-used model search stops after this many catalog pages.
112
+ const LAST_USED_ROUTE_MAX_PAGES = 20;
113
+
54
114
  export function createAgentTaskSessions({
55
115
  getSessionService,
56
116
  deliver = async (agentId, report) => (await import('./agent-home-delivery.mjs')).deliverAgentTask(agentId, report),
117
+ // The durable watch of each reporting turn until its end is queued.
118
+ watches = {
119
+ add: async (watch) => (await import('./agent-home-delivery.mjs')).watchAgentTask(watch),
120
+ list: async () => (await import('./agent-home-delivery.mjs')).agentTaskWatches(),
121
+ remove: async (runId) => (await import('./agent-home-delivery.mjs')).unwatchAgentTask(runId),
122
+ },
123
+ // Submissions still queued (durably) for a session after a restart.
124
+ queuedSubmissionIds = async (sessionId) =>
125
+ (await (await import('../tui/session/tui-steering-persist.mjs')).readTuiSteeringPersist(sessionId))
126
+ .map((row) => row?.submissionId)
127
+ .filter(Boolean),
57
128
  loadSession = async (sessionId) =>
58
129
  (await import('../runtime/agent/orchestrator/session/store.mjs')).loadSession(sessionId),
59
130
  listProjects = async () => (await import('../runtime/shared/projects.mjs')).listProjects(),
@@ -73,8 +144,56 @@ export function createAgentTaskSessions({
73
144
  if (!preset) throw new Error(`model "${ref}" is neither a provider/model route nor a known preset name`);
74
145
  return modelRouteFields(preset);
75
146
  },
147
+ // The Lead (Main) route of the config — what a desktop New task starts on.
148
+ // Null while none is chosen.
149
+ leadRoute = async () => {
150
+ const [{ modelRouteFields }, { resolveRoute }, { loadConfig }] = await Promise.all([
151
+ import('../runtime/shared/schedule-model-ref.mjs'),
152
+ import('./boot/shared.mjs'),
153
+ import('../runtime/agent/orchestrator/config.mjs'),
154
+ ]);
155
+ const route = resolveRoute(loadConfig({ secrets: false }), {});
156
+ return clean(route?.provider) && clean(route?.model) ? modelRouteFields(route) : null;
157
+ },
158
+ // The model the user last used in a coding session (the runtime's stand-in
159
+ // for the desktop's last New-task model): the first row that has one in the
160
+ // catalog, which lists newest activity first. Pages on (bounded) until a
161
+ // row qualifies; null when none does.
162
+ lastUsedRoute = async (sessionService) => {
163
+ let cursor = null;
164
+ for (let page = 0; page < LAST_USED_ROUTE_MAX_PAGES; page += 1) {
165
+ const listed = await sessionService.listSessionCatalog({ page: { limit: 50, cursor } });
166
+ const used = (Array.isArray(listed?.sessions) ? listed.sessions : []).find(
167
+ (row) => row?.sessionMode !== 'agent' && clean(row?.provider) && clean(row?.model)
168
+ );
169
+ if (used) return { provider: clean(used.provider), model: clean(used.model) };
170
+ cursor = listed?.nextCursor || null;
171
+ if (!cursor) return null;
172
+ }
173
+ return null;
174
+ },
175
+ // The files turn `runId` of `sessionId` produced (handoff-attachments.mjs),
176
+ // read from its live runtime when one is given, else its stored record.
177
+ turnArtifacts = async (sessionId, runId, runtime = null) => {
178
+ const stored = await loadSession(sessionId);
179
+ const live = typeof runtime?.readModelMessages === 'function' ? await runtime.readModelMessages(0) : null;
180
+ const messages = turnSlice(Array.isArray(live?.messages) ? live.messages : stored?.messages, { runId });
181
+ return collectTurnArtifacts(messages, { cwd: stored?.cwd, roots: sessionRoots(stored) });
182
+ },
76
183
  log = (line) => process.stderr.write(`mixdog task sessions: ${line}\n`),
77
184
  }) {
185
+ /** The report fields of turn `runId`'s files; none when it produced none. */
186
+ const artifactFields = async (sessionId, runId, runtime = null) => {
187
+ try {
188
+ const { files, omitted } = await turnArtifacts(sessionId, runId, runtime);
189
+ return files.length ? { files, ...(omitted ? { omitted } : {}) } : {};
190
+ } catch (error) {
191
+ log(`collecting the files of ${sessionId} failed: ${error?.message || error}`);
192
+ return {};
193
+ }
194
+ };
195
+ // Marks this process's watches: recovery re-attaches only an earlier one's.
196
+ const processToken = randomUUID();
78
197
  const service = () => {
79
198
  const value = getSessionService();
80
199
  if (!value) throw new Error('session: session service is unavailable');
@@ -85,53 +204,165 @@ export function createAgentTaskSessions({
85
204
  * user in it) reports to the agent's home. Resolves on intake. */
86
205
  async function agentTurn(sessionId, prompt, origin, id = `atask_${randomUUID()}`, beforeReport = null) {
87
206
  const sessions = service();
207
+ const target = origin.target === 'session' ? { target: 'session' } : {};
208
+ // The watch is durable before the turn can start, so no end goes
209
+ // unreported across a restart; a turn whose watch could not be stored
210
+ // runs without a report (reportsBack false) rather than claim one.
211
+ let notify = false;
212
+ if (origin.notify !== false) {
213
+ try {
214
+ await watches.add({
215
+ runId: id,
216
+ agentId: origin.agentId,
217
+ homeSessionId: origin.sessionId,
218
+ taskSessionId: sessionId,
219
+ title: origin.title,
220
+ ...target,
221
+ ...(origin.textOnly ? { textOnly: true } : {}),
222
+ owner: processToken,
223
+ at: Date.now(),
224
+ });
225
+ notify = true;
226
+ } catch (error) {
227
+ log(`watch of ${sessionId} failed, sending without a report: ${error?.message || error}`);
228
+ }
229
+ }
230
+ const dropWatch = () =>
231
+ watches.remove(id).catch((error) => log(`dropping the watch of ${sessionId} failed: ${error?.message || error}`));
232
+ let runtime = null;
88
233
  const report = (status, text = '') =>
89
234
  Promise.resolve(beforeReport)
90
- .then(() =>
235
+ .then(async () =>
91
236
  deliver(origin.agentId, {
92
237
  runId: `${id}_${status === 'blocked' ? 'blocked' : 'end'}`,
93
238
  taskSessionId: sessionId,
94
239
  title: origin.title,
95
240
  status,
96
241
  text,
242
+ ...target,
243
+ // A turn's end brings back the files it produced, when the agent
244
+ // may receive them (reportOrigin `textOnly`).
245
+ ...(status === 'blocked'
246
+ ? {}
247
+ : { watchId: id, ...(origin.textOnly ? {} : await artifactFields(sessionId, id, runtime)) }),
97
248
  })
98
249
  )
99
250
  .catch((error) => log(`report of ${sessionId} failed: ${error?.message || error}`));
100
- const notify = origin.notify !== false;
101
251
  let unwatch = () => {};
102
- if (notify) {
103
- const runtime = await sessions.materializeSession(sessionId);
104
- let blocked = false;
105
- unwatch =
106
- runtime?.subscribe?.(() => {
107
- if (blocked || !runtime.getState?.()?.toolApproval) return;
108
- blocked = true;
109
- void report('blocked');
110
- }) || unwatch;
252
+ let result;
253
+ try {
254
+ if (notify) {
255
+ runtime = await sessions.materializeSession(sessionId);
256
+ let blocked = false;
257
+ unwatch =
258
+ runtime?.subscribe?.(() => {
259
+ if (blocked || !runtime.getState?.()?.toolApproval) return;
260
+ blocked = true;
261
+ void report('blocked');
262
+ }) || unwatch;
263
+ }
264
+ result = await sessions.submitSession({
265
+ sessionId,
266
+ prompt,
267
+ options: {
268
+ id,
269
+ mode: 'prompt',
270
+ transcriptMeta: { sender: 'lead' },
271
+ ...(notify
272
+ ? {
273
+ onSettled: (detail) => {
274
+ unwatch();
275
+ void report(turnStatus(detail), detail?.result?.content || detail?.error || '');
276
+ },
277
+ }
278
+ : {}),
279
+ },
280
+ });
281
+ } catch (error) {
282
+ unwatch();
283
+ if (notify) await dropWatch();
284
+ throw error;
285
+ }
286
+ const accepted = result?.accepted === true;
287
+ if (!accepted) {
288
+ unwatch();
289
+ if (notify) await dropWatch();
111
290
  }
112
- const result = await sessions.submitSession({
113
- sessionId,
114
- prompt,
115
- options: {
116
- id,
117
- mode: 'prompt',
118
- transcriptMeta: { sender: 'lead' },
119
- ...(notify
120
- ? {
121
- onSettled: (detail) => {
122
- unwatch();
123
- void report(turnStatus(detail), detail?.result?.content || detail?.error || '');
124
- },
291
+ return { accepted, reportsBack: accepted && notify };
292
+ }
293
+
294
+ /** Re-attach one watch a stopped daemon left: report the turn's end once
295
+ * from the target's record, or watch it until it ends. */
296
+ async function rearm(watch) {
297
+ const sessionId = watch.taskSessionId;
298
+ const finish = async (status, text = '') =>
299
+ deliver(watch.agentId, {
300
+ runId: `${watch.runId}_end`,
301
+ taskSessionId: sessionId,
302
+ title: watch.title,
303
+ status,
304
+ text,
305
+ ...(watch.target === 'session' ? { target: 'session' } : {}),
306
+ watchId: watch.runId,
307
+ ...(watch.textOnly || text === GONE_TEXT || text === LOST_TEXT
308
+ ? {}
309
+ : await artifactFields(sessionId, watch.runId)),
310
+ });
311
+ if (!(await loadSession(sessionId))) return finish('cancelled', GONE_TEXT);
312
+ const runtime = await service().materializeSession(sessionId);
313
+ let done = false;
314
+ let checking = false;
315
+ let again = false;
316
+ let unsubscribe = () => {};
317
+ const check = async () => {
318
+ if (done) return;
319
+ if (checking) {
320
+ again = true;
321
+ return;
322
+ }
323
+ checking = true;
324
+ try {
325
+ do {
326
+ again = false;
327
+ const outcome = submissionOutcome((await loadSession(sessionId))?.messages, watch.runId);
328
+ const state = runtime?.getState?.() || {};
329
+ let end = outcome && outcome !== 'open' ? outcome : null;
330
+ if (!end && !state.busy) {
331
+ const queued = (state.queued || []).some((entry) => entry?.id === watch.runId);
332
+ if (outcome === 'open' && !queued) end = { status: 'cancelled', text: '' };
333
+ else if (!outcome && !queued && !(await queuedSubmissionIds(sessionId)).includes(watch.runId)) {
334
+ end = { status: 'cancelled', text: LOST_TEXT };
125
335
  }
126
- : {}),
127
- },
128
- });
129
- if (result?.accepted !== true) unwatch();
130
- return result?.accepted === true;
336
+ }
337
+ if (end) {
338
+ done = true;
339
+ unsubscribe();
340
+ await finish(end.status, end.text);
341
+ }
342
+ } while (again && !done);
343
+ } finally {
344
+ checking = false;
345
+ }
346
+ };
347
+ const failed = (error) => log(`re-attaching ${sessionId} failed: ${error?.message || error}`);
348
+ unsubscribe = runtime?.subscribe?.(() => void check().catch(failed)) || unsubscribe;
349
+ await check();
350
+ }
351
+
352
+ /** Daemon start: re-attach every reporting turn an earlier process watched.
353
+ * Resolves once each is reported or watched. */
354
+ async function recover() {
355
+ const pending = (await watches.list()).filter((watch) => watch?.owner !== processToken);
356
+ await Promise.all(
357
+ pending.map((watch) =>
358
+ rearm(watch).catch((error) => log(`re-attaching ${watch?.taskSessionId} failed: ${error?.message || error}`))
359
+ )
360
+ );
361
+ return pending.length;
131
362
  }
132
363
 
133
364
  /** Create the task session for `brief` and start it; returns at once. */
134
- async function create(args = {}, { callerSessionId, callerAgentId, callerCwd } = {}) {
365
+ async function create(args = {}, { callerSessionId, callerAgentId, callerCwd, attachmentParts = [] } = {}) {
135
366
  if (!callerAgentId) throw new Error('session: create is only for an agent conversation');
136
367
  const brief = typeof args.brief === 'string' ? args.brief.trim() : '';
137
368
  if (!brief) throw new Error('session: brief is required for action=create');
@@ -145,7 +376,13 @@ export function createAgentTaskSessions({
145
376
  title,
146
377
  notify: args.notify !== false,
147
378
  };
148
- const route = clean(args.model) ? await modelRoute(clean(args.model)) : {};
379
+ // A task session runs in coding mode under Lead: an explicit model wins,
380
+ // else the Lead route, else the last-used model. Never a model-less
381
+ // session — the composer would show no model and its first turn would fail.
382
+ const route = clean(args.model)
383
+ ? await modelRoute(clean(args.model))
384
+ : (await leadRoute()) || (await lastUsedRoute(service()));
385
+ if (!route) throw new Error('session: no model to start the task session on. Choose a Lead model first.');
149
386
  const sessions = service();
150
387
  const created = await sessions.createSession({
151
388
  ...route,
@@ -175,9 +412,11 @@ export function createAgentTaskSessions({
175
412
  const ready = new Promise((resolveReady) => {
176
413
  started = resolveReady;
177
414
  });
178
- const accepted = await agentTurn(sessionId, brief, taskOrigin, requestId, ready);
415
+ // The brief carries the files the agent forwarded (workspace-tools.mjs).
416
+ const prompt = handoffContent(brief, attachmentParts);
417
+ const { accepted, reportsBack } = await agentTurn(sessionId, prompt, taskOrigin, requestId, ready);
179
418
  if (!accepted) throw new Error(`session: task session ${sessionId} did not accept the brief`);
180
- if (taskOrigin.notify) {
419
+ if (reportsBack) {
181
420
  await deliver(callerAgentId, {
182
421
  runId: `${requestId}_started`,
183
422
  taskSessionId: sessionId,
@@ -186,15 +425,36 @@ export function createAgentTaskSessions({
186
425
  }).catch((error) => log(`start line of ${sessionId} failed: ${error?.message || error}`));
187
426
  }
188
427
  started();
189
- return { session_id: sessionId, title, project: where.project?.path ?? null };
428
+ return {
429
+ session_id: sessionId,
430
+ title,
431
+ project: where.project?.path ?? null,
432
+ ...(taskOrigin.notify && !reportsBack ? { reportsBack: false } : {}),
433
+ };
190
434
  }
191
435
 
192
- /** The origin of a task session, when `agentId` created it. */
193
- async function originFor(sessionId, agentId) {
436
+ /** The origin a `send` of agent `agentId` (from its home `callerSessionId`)
437
+ * reports to: the stored one of a task session it created, else one for
438
+ * this send only — never stored on the target, whose own origin and owner
439
+ * stay as they are. Null for a caller that is no agent conversation. */
440
+ async function reportOrigin(sessionId, agentId, callerSessionId) {
194
441
  if (!agentId) return null;
195
- const origin = (await loadSession(sessionId))?.taskOrigin;
196
- return origin?.agentId === agentId ? origin : null;
442
+ const target = await loadSession(sessionId);
443
+ const origin = target?.taskOrigin;
444
+ if (origin?.agentId === agentId) return origin;
445
+ const title = (clean(target?.title) || sessionId).slice(0, TITLE_LIMIT);
446
+ // Files come back only from sessions the agent created (above) or its own
447
+ // room member threads; any other session reports its text alone.
448
+ const own = roomMemberOfSession(sessionId)?.agentId === agentId;
449
+ return {
450
+ agentId,
451
+ sessionId: callerSessionId,
452
+ title,
453
+ notify: true,
454
+ target: 'session',
455
+ ...(own ? {} : { textOnly: true }),
456
+ };
197
457
  }
198
458
 
199
- return { create, originFor, send: (sessionId, prompt, origin) => agentTurn(sessionId, prompt, origin) };
459
+ return { create, reportOrigin, recover, send: (sessionId, prompt, origin) => agentTurn(sessionId, prompt, origin) };
200
460
  }
@@ -67,6 +67,7 @@ import { createConversationImportApi } from '../conversation-import/service.mjs'
67
67
  import { importError } from '../conversation-import/discovery.mjs';
68
68
  import { bootProfile } from '../boot-profile.mjs';
69
69
  import { STANDALONE_DATA_DIR } from '../runtime-paths.mjs';
70
+ import { registerBotRouteStore } from '../../runtime/shared/agent-store.mjs';
70
71
  import {
71
72
  dataDirOf,
72
73
  resolveRoute,
@@ -124,7 +125,16 @@ export function wireApis(boot) {
124
125
  } catch (error) {
125
126
  bootProfile('connector-policy:migrate-failed', { error: error?.message || String(error) });
126
127
  }
128
+ // Bots get their own store (bots/) once, before any catalog read; a
129
+ // failed split leaves agents/ read as before (bot-split-migration.mjs).
130
+ try {
131
+ boot.workflowAgentsApi.migrateBotStore();
132
+ } catch (error) {
133
+ bootProfile('bot-split:migrate-failed', { error: error?.message || String(error) });
134
+ }
127
135
  }
136
+ // Route reads ask a store bot's own folder first (agent-store.mjs).
137
+ registerBotRouteStore(() => dataDirOf(boot.cfgMod));
128
138
  boot.workspaceApi = workspaceApiFor(boot);
129
139
  boot.sessionTurnApi = sessionTurnApiFor(boot);
130
140
  const getFacadeSessionId = () => boot.rt.session?.id || boot.rt.reservedSessionId || null;
@@ -538,6 +548,8 @@ function modelRouteApiFor(boot) {
538
548
  adoptConfig: boot.adoptConfig,
539
549
  saveConfigAndAdopt: boot.saveConfigAndAdopt,
540
550
  ensureFullConfig: boot.ensureFullConfig,
551
+ reloadFullConfig: boot.reloadFullConfig,
552
+ confirmLeadFill: boot.confirmLeadFill,
541
553
  awaitKeychainPrewarm: boot.awaitKeychainPrewarm,
542
554
  ensureProvidersReady: boot.ensureProvidersReady,
543
555
  persistLeadRoute: boot.persistLeadRoute,
@@ -567,6 +579,7 @@ function workflowAgentsApiFor(boot) {
567
579
  lookupModelMeta: boot.lookupModelMeta,
568
580
  adoptConfig: boot.adoptConfig,
569
581
  saveConfigAndAdopt: boot.saveConfigAndAdopt,
582
+ reloadFullConfig: boot.reloadFullConfig,
570
583
  ensureProvidersReady: boot.ensureProvidersReady,
571
584
  displayConfig: boot.displayConfig,
572
585
  mcpStatus: () => boot.mcpStatus(),
@@ -11,7 +11,10 @@ import { LOCAL_PROVIDER_ID, configureLocalProviderIdleTtl } from '../../runtime/
11
11
  import { resolve } from 'node:path';
12
12
  import { clean } from '../../runtime/agent/orchestrator/runtime-core/session-text.mjs';
13
13
  import { LAZY_SECRET_PROVIDERS } from '../../runtime/agent/orchestrator/runtime-core/model-capabilities.mjs';
14
- import { ensureProviderEnabled, normalizeSystemShellConfig } from '../../runtime/agent/orchestrator/runtime-core/config-helpers.mjs';
14
+ import {
15
+ ensureProviderEnabled,
16
+ normalizeSystemShellConfig,
17
+ } from '../../runtime/agent/orchestrator/runtime-core/config-helpers.mjs';
15
18
  import {
16
19
  workflowPresetId,
17
20
  normalizeWebSearchProviderId,
@@ -33,7 +36,7 @@ import { createProviderModels } from '../provider-models.mjs';
33
36
  import { createProviderUsage } from '../provider-usage.mjs';
34
37
  import { bootProfile } from '../boot-profile.mjs';
35
38
  import { STANDALONE_DATA_DIR } from '../runtime-paths.mjs';
36
- import { outputStyleStatus, webSearchCapableFor } from './shared.mjs';
39
+ import { outputStyleStatus, resolveRoute, webSearchCapableFor } from './shared.mjs';
37
40
 
38
41
  export function wireProviders(boot) {
39
42
  wireConfigLifecycle(boot);
@@ -63,6 +66,10 @@ function wireConfigLifecycle(boot) {
63
66
  rt.configHasSecrets = next;
64
67
  },
65
68
  getRoute: () => rt.route,
69
+ setRouteState: (next) => {
70
+ rt.route = next;
71
+ },
72
+ resolveRoute,
66
73
  cfgMod,
67
74
  sharedCfgMod,
68
75
  setConfiguredShell,
@@ -82,6 +89,7 @@ function wireConfigLifecycle(boot) {
82
89
  seedOutputStyleStatusCache,
83
90
  adoptConfig,
84
91
  saveConfigAndAdopt,
92
+ confirmLeadFill,
85
93
  scheduleSkillsSave,
86
94
  flushSkillsSave,
87
95
  scheduleOutputStyleSave,
@@ -98,6 +106,7 @@ function wireConfigLifecycle(boot) {
98
106
  seedOutputStyleStatusCache,
99
107
  adoptConfig,
100
108
  saveConfigAndAdopt,
109
+ confirmLeadFill,
101
110
  scheduleSkillsSave,
102
111
  flushSkillsSave,
103
112
  scheduleOutputStyleSave,
@@ -106,13 +115,14 @@ function wireConfigLifecycle(boot) {
106
115
  ensureFullConfig,
107
116
  displayConfig,
108
117
  ensureConfigForRouteProvider,
109
- persistLeadRoute: (routeLike) => {
118
+ persistLeadRoute: (routeLike, { onlyIfAbsent = false } = {}) => {
110
119
  const leadRoute = normalizeWorkflowRoute(routeLike);
111
120
  if (!leadRoute) return null;
112
121
  const nextConfig = { ...(rt.config || {}) };
113
122
  nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, 'lead', leadRoute);
114
123
  nextConfig.default = workflowPresetId('lead');
115
- saveConfigAndAdopt(nextConfig);
124
+ // A conditional fill reaches the file only while no Lead route is stored.
125
+ saveConfigAndAdopt(nextConfig, { leadFill: onlyIfAbsent });
116
126
  return leadRoute;
117
127
  },
118
128
  });
@@ -79,6 +79,9 @@ export function createConfigLifecycle({
79
79
  getConfigHasSecrets,
80
80
  setConfigHasSecrets,
81
81
  getRoute,
82
+ // Main route sync on reload (both optional: a lifecycle without them never syncs)
83
+ setRouteState,
84
+ resolveRoute,
82
85
  // shared modules / helpers
83
86
  cfgMod,
84
87
  sharedCfgMod,
@@ -121,16 +124,42 @@ export function createConfigLifecycle({
121
124
  // separate from adoptConfig: model tuning is adopted before its route save.
122
125
  let configSaveBaseline = structuredClone(getConfig());
123
126
 
124
- function saveConfigAndAdopt(nextConfig, { hasSecrets = getConfigHasSecrets() } = {}) {
127
+ // The Lead route lives in these two stored keys; a conditional fill writes
128
+ // them only while `default` is still unset on disk, judged under the config
129
+ // file lock when the patch is applied (config-patch.mjs `unlessSet`).
130
+ const LEAD_FILL_KEYS = new Set(['default', 'presets']);
131
+
132
+ function saveConfigAndAdopt(nextConfig, { hasSecrets = getConfigHasSecrets(), leadFill = false } = {}) {
125
133
  // In-memory adopt is synchronous and first so callers that read back the
126
134
  // value immediately (e.g. setProfile -> getProfile) see the new state.
127
135
  const adopted = adoptConfig(nextConfig, { hasSecrets });
128
- const changes = cfgMod.createConfigPatch(configSaveBaseline, adopted);
136
+ const changes = cfgMod
137
+ .createConfigPatch(configSaveBaseline, adopted)
138
+ .map((change) =>
139
+ leadFill && LEAD_FILL_KEYS.has(change.path[0]) ? { ...change, unlessSet: ['default'] } : change
140
+ );
129
141
  configSaveBaseline = structuredClone(adopted);
130
142
  if (changes.length) writers.queueConfigChanges([...changes, ...seedChanges(adopted, changes)]);
131
143
  return adopted;
132
144
  }
133
145
 
146
+ const sameModel = (a, b) => clean(a?.provider) === clean(b?.provider) && clean(a?.model) === clean(b?.model);
147
+
148
+ /** After a conditional Lead fill adopted `filled`: persist it now and, when
149
+ * the file already held another Lead route (written by another process after
150
+ * the fill's re-read, so the patch skipped it), adopt that one instead.
151
+ * Answers whether the runtime route was replaced. A route the user has moved
152
+ * off `filled` meanwhile stays. */
153
+ async function confirmLeadFill(filled) {
154
+ await writers.flushConfigSaveAsync();
155
+ reloadFullConfig();
156
+ if (!setRouteState || !resolveRoute || !sameModel(getRoute(), filled)) return false;
157
+ const winner = resolveRoute(getConfig(), {});
158
+ if (!clean(winner?.model) || sameModel(winner, filled)) return false;
159
+ setRouteState(winner);
160
+ return true;
161
+ }
162
+
134
163
  // A fresh profile's install markers (an empty `builtins`) equal the
135
164
  // defaults, so no diff ever carries them — yet on disk their absence reads
136
165
  // as a grandfathered profile. The first write seeds them where the stored
@@ -177,9 +206,20 @@ export function createConfigLifecycle({
177
206
  }
178
207
  const adopted = adoptConfig(next, { hasSecrets: true });
179
208
  configSaveBaseline = structuredClone(adopted);
209
+ syncEmptyMainRoute(adopted);
180
210
  return adopted;
181
211
  }
182
212
 
213
+ // A Main route the runtime still has no model for takes the Lead route a
214
+ // reload brings in (edited on disk or by another process), so a conditional
215
+ // backfill sees it and never overwrites it. A live route that has a model is
216
+ // the user's selection and stays.
217
+ function syncEmptyMainRoute(config) {
218
+ if (!setRouteState || !resolveRoute || clean(getRoute()?.model)) return;
219
+ const resolved = resolveRoute(config, {});
220
+ if (clean(resolved?.model)) setRouteState(resolved);
221
+ }
222
+
183
223
  function ensureFullConfig() {
184
224
  if (getConfigHasSecrets()) return getConfig();
185
225
  return reloadFullConfig();
@@ -211,6 +251,7 @@ export function createConfigLifecycle({
211
251
  // adopt / save
212
252
  adoptConfig,
213
253
  saveConfigAndAdopt,
254
+ confirmLeadFill,
214
255
  // Skills publication also drains older config edits first.
215
256
  flushSkillsSave: writers.flushConfigSaveAsync,
216
257
  scheduleSkillsSave,