mixdog 1.3.2 → 1.3.3

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 (171) hide show
  1. package/package.json +7 -7
  2. package/scripts/verify-runtime-dependency-versions.mjs +158 -0
  3. package/src/agents/reviewer/AGENT.md +5 -7
  4. package/src/agents/reviewer/agent.json +1 -1
  5. package/src/defaults/shipped-history.json +4 -0
  6. package/src/defaults/skills/browser-use/SKILL.md +6 -3
  7. package/src/defaults/skills/local-provider/SKILL.md +33 -6
  8. package/src/rules/lead/LEAD.md +5 -8
  9. package/src/rules/shared/45-destructive.md +5 -1
  10. package/src/rules/shared/60-verification.md +6 -4
  11. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +4 -1
  12. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +38 -11
  13. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +15 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +17 -4
  16. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  17. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  18. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +10 -0
  21. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +13 -1
  22. package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +5 -0
  23. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +10 -1
  24. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +40 -5
  25. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +32 -11
  26. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -3
  27. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +13 -0
  29. package/src/runtime/agent/orchestrator/session/agent-room.mjs +68 -12
  30. package/src/runtime/agent/orchestrator/session/compact/continuation.mjs +15 -6
  31. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +31 -8
  32. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +44 -0
  33. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +117 -19
  34. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/session/human-gate/delegation.mjs +147 -137
  37. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +37 -74
  38. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +0 -2
  39. package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +13 -7
  41. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +5 -0
  42. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/empty-nudge.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/output-limit.mjs +62 -13
  45. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +22 -6
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -0
  47. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +12 -20
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/call-prep.mjs +5 -0
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +11 -5
  50. package/src/runtime/agent/orchestrator/session/manager/agent-listing-notice.mjs +102 -0
  51. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +2 -0
  52. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +13 -1
  53. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +24 -15
  54. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +8 -0
  55. package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +1 -0
  56. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +32 -1
  57. package/src/runtime/agent/orchestrator/session/manager/orchestration-mode-switch-notice.mjs +17 -1
  58. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +6 -0
  59. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +36 -0
  60. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -9
  61. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +7 -5
  63. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +7 -3
  64. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +10 -1
  65. package/src/runtime/agent/orchestrator/session/routine-result.mjs +7 -4
  66. package/src/runtime/agent/orchestrator/session/runtime-user-context.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +11 -2
  68. package/src/runtime/agent/orchestrator/session/store/sweep/stale-sweep.mjs +5 -4
  69. package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +140 -0
  70. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-plan.mjs +5 -0
  71. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-record.mjs +7 -2
  72. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-row.mjs +13 -0
  73. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +76 -0
  74. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +33 -1
  75. package/src/runtime/agent/orchestrator/session/store-summary-visibility.d.mts +5 -0
  76. package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +37 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +18 -7
  79. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +2 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +127 -20
  81. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +557 -0
  82. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +6 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +8 -2
  84. package/src/runtime/attachments/legacy-office.mjs +110 -0
  85. package/src/runtime/attachments/prompt-restore.mjs +70 -0
  86. package/src/runtime/attachments/store.mjs +60 -3
  87. package/src/runtime/effort-judge/judge-client.mjs +61 -14
  88. package/src/runtime/effort-judge/model-install.mjs +34 -2
  89. package/src/runtime/memory/lib/memory-chunk-quality.mjs +14 -2
  90. package/src/runtime/office/portable/portable-soffice.mjs +132 -30
  91. package/src/runtime/shared/agent-avatar-sprites.mjs +23 -8
  92. package/src/runtime/shared/agent-avatar.mjs +3 -2
  93. package/src/runtime/shared/agent-room-host.d.mts +31 -0
  94. package/src/runtime/shared/agent-room-host.mjs +58 -0
  95. package/src/runtime/shared/bot-hops.mjs +16 -11
  96. package/src/runtime/shared/redact-directories.mjs +18 -0
  97. package/src/runtime/shared/remove-directory.mjs +28 -0
  98. package/src/runtime/shared/session-sidebar-host.d.mts +11 -2
  99. package/src/runtime/shared/session-sidebar-host.mjs +16 -3
  100. package/src/runtime/shared/tool-execution-contract.mjs +4 -0
  101. package/src/runtime/shared/tool-work-units.mjs +12 -9
  102. package/src/runtime/shared/turn-snapshot.mjs +233 -122
  103. package/src/runtime/shared/user-data-guard.mjs +51 -19
  104. package/src/runtime/shared/worktrees/activity-gate.mjs +209 -227
  105. package/src/runtime/shared/worktrees/contract.mjs +11 -3
  106. package/src/runtime/shared/worktrees/maintenance.mjs +11 -2
  107. package/src/runtime/shared/worktrees/manager.mjs +419 -52
  108. package/src/runtime/shared/worktrees/registry.mjs +3 -1
  109. package/src/runtime/shared/worktrees/tool-host.mjs +39 -0
  110. package/src/runtime/tidy/process.mjs +41 -8
  111. package/src/runtime/tidy/resolve.mjs +10 -7
  112. package/src/session-runtime/agent-home-delivery.mjs +73 -91
  113. package/src/session-runtime/agent-profile-changes.mjs +16 -0
  114. package/src/session-runtime/agent-room-delivery.mjs +2 -63
  115. package/src/session-runtime/agent-task-sessions.mjs +194 -64
  116. package/src/session-runtime/boot/apis.mjs +3 -0
  117. package/src/session-runtime/boot/facade.mjs +5 -6
  118. package/src/session-runtime/boot/tools.mjs +21 -1
  119. package/src/session-runtime/held-requests.mjs +20 -25
  120. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +5 -22
  121. package/src/session-runtime/model-recency.mjs +1 -0
  122. package/src/session-runtime/provider-models/row-hydration.mjs +8 -1
  123. package/src/session-runtime/quick-model-rows/model-meta.mjs +2 -0
  124. package/src/session-runtime/quick-model-rows/provider-rows.mjs +7 -1
  125. package/src/session-runtime/runtime-review-api.mjs +4 -1
  126. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +34 -19
  127. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +3 -0
  128. package/src/session-runtime/services/provider-admin.mjs +12 -2
  129. package/src/session-runtime/services/worktree-access.mjs +27 -0
  130. package/src/session-runtime/session-hooks.mjs +9 -17
  131. package/src/session-runtime/settings-builtin-tools-api.mjs +2 -0
  132. package/src/session-runtime/setup-tool/executor.mjs +4 -0
  133. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -0
  134. package/src/session-runtime/tool-policy/agent-profile-fold.mjs +98 -14
  135. package/src/session-runtime/tool-policy/policy-stage.mjs +23 -5
  136. package/src/session-runtime/tool-policy-refresh.mjs +1 -0
  137. package/src/session-runtime/tool-surface.mjs +9 -5
  138. package/src/session-runtime/turn/session-ops.mjs +8 -0
  139. package/src/session-runtime/turn/turn-run.mjs +5 -1
  140. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +10 -2
  141. package/src/session-runtime/workflow-agents-api/agent-route.mjs +39 -17
  142. package/src/session-runtime/workspace/transition-steps.mjs +1 -18
  143. package/src/session-runtime/workspace/transitions.mjs +279 -67
  144. package/src/session-runtime/workspace/worktree-tool.mjs +194 -0
  145. package/src/standalone/daemon-lag-profiler.mjs +3 -1
  146. package/src/standalone/daemon-session-control.mjs +140 -23
  147. package/src/standalone/daemon-telemetry.mjs +9 -0
  148. package/src/standalone/daemon.mjs +5 -0
  149. package/src/standalone/local-session-runtime.mjs +11 -2
  150. package/src/standalone/session-protocol.mjs +1 -0
  151. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +2 -0
  152. package/src/standalone/session-service/agent-route-follow.mjs +69 -0
  153. package/src/standalone/session-service/session-calls/session-turns.mjs +33 -4
  154. package/src/standalone/session-service.mjs +10 -0
  155. package/src/standalone/session-tool-output.mjs +1 -0
  156. package/src/standalone/staged-import.mjs +47 -0
  157. package/src/tui/App.jsx +2 -0
  158. package/src/tui/app/message-selector.mjs +15 -2
  159. package/src/tui/app/model-options.mjs +16 -2
  160. package/src/tui/app/model-picker.mjs +2 -1
  161. package/src/tui/app/route-pickers.mjs +3 -1
  162. package/src/tui/dist/index.mjs +92 -40
  163. package/src/tui/paste-attachments.mjs +0 -1
  164. package/src/tui/paste-text-policy.mjs +4 -5
  165. package/src/tui/session/agent-job-feed/execution-delivery.mjs +4 -1
  166. package/src/tui/session/notice-surface.mjs +3 -0
  167. package/src/tui/session/session-api/intake/abort.mjs +33 -17
  168. package/src/tui/session/session-api/intake/rewind.mjs +39 -4
  169. package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
  170. package/src/tui/session/tool-approval.mjs +11 -48
  171. package/src/session-runtime/room-member-gate.mjs +0 -61
@@ -18,8 +18,13 @@
18
18
  *
19
19
  * A group-room member (its member session `sess_rmem_<room>_<agent>`) opens
20
20
  * and drives task sessions the same way; its reports carry `from` (the member
21
- * session), so they land in the ROOM's log as that member's row and in its own
22
- * member session, never in its home (agent-home-delivery.mjs enqueueTask).
21
+ * session), so they land in that member session alone, never in its home or
22
+ * the room's log, and an ended turn gives the member a turn in its room to
23
+ * relay it (agent-home-delivery.mjs enqueueTask).
24
+ *
25
+ * A send to a group room's log goes to one member through the desktop room
26
+ * host (daemon-session-control.mjs): the member's turn in its member session
27
+ * is the reporting turn, taken by the room (`submit`), not by a prompt.
23
28
  */
24
29
  import { randomUUID } from 'node:crypto';
25
30
  import { realpathSync, statSync } from 'node:fs';
@@ -30,6 +35,9 @@ import { roomMemberOfSession } from '../runtime/agent/orchestrator/session/agent
30
35
  import { wrapAgentMessage } from '../runtime/shared/agent-message-envelope.mjs';
31
36
  import { sessionsWithPendingWork } from '../runtime/shared/pending-agent-work.mjs';
32
37
  import { agentTaskIdOfExecution } from './services/agent-tool/restart-notify.mjs';
38
+ import { worktreeError, worktreeNameError } from '../runtime/shared/worktrees/contract.mjs';
39
+ import { samePath } from '../runtime/shared/worktrees/paths.mjs';
40
+ import { assertWorktreeMutationAllowed } from './services/worktree-access.mjs';
33
41
 
34
42
  const TITLE_LIMIT = 60;
35
43
 
@@ -293,7 +301,7 @@ const WATCH_CAP_REASON =
293
301
  const LAST_USED_ROUTE_MAX_PAGES = 20;
294
302
 
295
303
  /** A report's `from` when its origin is a group-room member session: the
296
- * report goes to that member's room instead of the agent's home. */
304
+ * report goes to that member session instead of the agent's home. */
297
305
  const roomFrom = (originSessionId) => (roomMemberOfSession(originSessionId) ? { from: originSessionId } : {});
298
306
 
299
307
  /** The route of `modelRef`: a provider/model[@effort…] ref (a superseded
@@ -514,20 +522,68 @@ async function deliverWithRetries({ deliver, retryDelaysMs, log }, agentId, sess
514
522
  }
515
523
  }
516
524
 
517
- /** Start task session `sessionId` in its own worktree; a repo that cannot
518
- * have one keeps the Project folder. */
519
- async function requestTaskWorktree(sessions, sessionId, title, requestId) {
525
+ /** Where create's `worktree` option starts the task session: `{ mode }` is
526
+ * 'default' (the user's New task setting), 'branch' (the Project folder on its
527
+ * current branch) or 'worktree' (its own new one, `name` optional). */
528
+ function taskLocation(option) {
529
+ if (option === undefined || option === null) return { mode: 'default' };
530
+ if (typeof option !== 'object' || Array.isArray(option) || !['branch', 'worktree'].includes(option.mode)) {
531
+ throw new Error('session: worktree.mode must be "branch" or "worktree"');
532
+ }
533
+ if (option.name === undefined) return { mode: option.mode };
534
+ if (option.mode !== 'worktree') throw new Error('session: worktree.name applies only to mode "worktree"');
535
+ const reason = worktreeNameError(option.name);
536
+ if (reason) throw new Error(`session: worktree.name is invalid: ${reason}`);
537
+ return { mode: 'worktree', name: option.name };
538
+ }
539
+
540
+ const megabytes = (bytes) => `${Math.round(Number(bytes) / (1024 * 1024))} MB`;
541
+
542
+ /** A typed worktree failure as the model should read it: its code and, for a
543
+ * large repository, the size to put to the user. */
544
+ function taskWorktreeFailure(error) {
545
+ const details = error?.details;
546
+ const size =
547
+ error?.code === 'WORKTREE_LARGE_REPO_CONFIRM' && Number.isFinite(details?.estimateBytes)
548
+ ? ` The repository is about ${megabytes(details.estimateBytes)} (warning threshold ${megabytes(details.thresholdBytes)}). Ask the user whether to create the worktree anyway; only if they confirm, call create again with confirm_large: true.`
549
+ : '';
550
+ return Object.assign(new Error(`session: ${error.code}: ${error.message}${size}`), {
551
+ code: error.code,
552
+ ...(details ? { details } : {}),
553
+ });
554
+ }
555
+
556
+ /** Start task session `sessionId` in its own worktree and return its workspace
557
+ * value. A New task never carries the Project folder's changes. Unless
558
+ * `explicit`, a repo that cannot have one keeps the Project folder (null).
559
+ * The session must end up bound: the brief goes out only after it. */
560
+ async function requestTaskWorktree(sessions, sessionId, { title, requestId, name, confirmLarge, explicit }) {
520
561
  try {
521
- await sessions.configureSession(
562
+ const result = await sessions.configureSession(
522
563
  {
523
564
  sessionId,
524
565
  action: 'requestWorktree',
525
- args: [{ provision: true, carryChanges: 'leave', prompt: title, txId: requestId }],
566
+ args: [
567
+ {
568
+ provision: true,
569
+ carryChanges: 'leave',
570
+ prompt: title,
571
+ txId: requestId,
572
+ ...(name ? { name } : {}),
573
+ ...(confirmLarge ? { confirmLarge: true } : {}),
574
+ },
575
+ ],
526
576
  },
527
577
  { revision: 1 }
528
578
  );
579
+ const value = result?.value;
580
+ if (value?.pending !== false || typeof value.workspace?.worktreeId !== 'string') {
581
+ throw worktreeError('WORKTREE_NOT_IMPLEMENTED', 'The session did not enter its worktree before the brief.');
582
+ }
583
+ return value;
529
584
  } catch (error) {
530
- if (error?.code !== 'WORKTREE_UNSUPPORTED_REPO') throw error;
585
+ if (!explicit && error?.code === 'WORKTREE_UNSUPPORTED_REPO') return null;
586
+ throw typeof error?.code === 'string' && error.code.startsWith('WORKTREE_') ? taskWorktreeFailure(error) : error;
531
587
  }
532
588
  }
533
589
 
@@ -565,6 +621,9 @@ async function rearmTaskWatch(
565
621
  watch
566
622
  ) {
567
623
  const sessionId = watch.taskSessionId;
624
+ // The turn that answers: the watched submission's own, or the one it
625
+ // follows (a room member's turn under another submission id).
626
+ const turnId = watch.follows || watch.runId;
568
627
  const finish = async (status, text = '') =>
569
628
  deliver(watch.agentId, {
570
629
  runId: `${watch.runId}_end`,
@@ -578,7 +637,7 @@ async function rearmTaskWatch(
578
637
  watchId: watch.runId,
579
638
  ...(watch.textOnly || text === GONE_TEXT || text === LOST_TEXT
580
639
  ? {}
581
- : await artifactFields(sessionId, watch.runId)),
640
+ : await artifactFields(sessionId, turnId)),
582
641
  });
583
642
  if (!(await loadSession(sessionId))) return finish('cancelled', GONE_TEXT);
584
643
  let runtime;
@@ -607,20 +666,20 @@ async function rearmTaskWatch(
607
666
  try {
608
667
  do {
609
668
  again = false;
610
- const outcome = submissionOutcome((await loadSession(sessionId))?.messages, watch.runId);
669
+ const outcome = submissionOutcome((await loadSession(sessionId))?.messages, turnId);
611
670
  const state = runtime?.getState?.() || {};
612
671
  let end = outcome && outcome !== 'open' ? outcome : null;
613
672
  if (!end && !state.busy) {
614
- const queued = (state.queued || []).some((entry) => entry?.id === watch.runId);
673
+ const queued = (state.queued || []).some((entry) => entry?.id === turnId);
615
674
  if (outcome === 'open' && !queued) end = { status: 'cancelled', text: '' };
616
- else if (!outcome && !queued && !(await queuedSubmissionIds(sessionId)).includes(watch.runId)) {
675
+ else if (!outcome && !queued && !(await queuedSubmissionIds(sessionId)).includes(turnId)) {
617
676
  end = { status: 'cancelled', text: LOST_TEXT };
618
677
  }
619
678
  }
620
679
  if (end?.status === 'completed') {
621
680
  follow = followToFinal({
622
681
  sessionId,
623
- runId: watch.runId,
682
+ runId: turnId,
624
683
  runtime,
625
684
  wakeOwed: true,
626
685
  progress: (text) =>
@@ -664,6 +723,8 @@ export function createAgentTaskSessions({
664
723
  add: async (watch) => (await import('./agent-home-delivery.mjs')).watchAgentTask(watch),
665
724
  list: async () => (await import('./agent-home-delivery.mjs')).agentTaskWatches(),
666
725
  remove: async (runId) => (await import('./agent-home-delivery.mjs')).unwatchAgentTask(runId),
726
+ follow: async (runId, submissionId) =>
727
+ (await import('./agent-home-delivery.mjs')).followAgentTask(runId, submissionId),
667
728
  },
668
729
  // Submissions still queued (durably) for a session after a restart.
669
730
  queuedSubmissionIds = async (sessionId) =>
@@ -736,8 +797,11 @@ export function createAgentTaskSessions({
736
797
  );
737
798
 
738
799
  /** Submit `prompt` as the origin agent's turn; its end (and a wait for the
739
- * user in it) reports to the agent's home. Resolves on intake. */
740
- async function agentTurn(sessionId, prompt, origin, id = `atask_${randomUUID()}`, beforeReport = null) {
800
+ * user in it) reports to the agent's home. Resolves on intake. `submit`
801
+ * takes the turn in another way than a prompt to `sessionId` (a message
802
+ * into a group room, whose member answers in `sessionId`): given `{ id,
803
+ * onSettled }`, it resolves `{ accepted }`. */
804
+ async function agentTurn(sessionId, prompt, origin, id = `atask_${randomUUID()}`, beforeReport = null, submit = null) {
741
805
  const sessions = service();
742
806
  const target = origin.target === 'session' ? { target: 'session' } : {};
743
807
  const { notify, noReport } = await watchTurn({ watches, owner: processToken, log }, sessionId, origin, id);
@@ -789,48 +853,49 @@ export function createAgentTaskSessions({
789
853
  const sent = origin.envelopeName
790
854
  ? wrapAgentMessage(prompt, { name: origin.envelopeName, agentId: origin.agentId })
791
855
  : prompt;
792
- result = await sessions.submitSession({
793
- sessionId,
794
- prompt: sent,
795
- options: {
796
- id,
797
- mode: 'prompt',
798
- // `fromAgent`: the sending bot, recorded when it writes into another
799
- // bot's home chat (otherwise the row would read as the user's);
800
- // `hop`: its place in a bot-to-bot chain (bot-hops.mjs).
801
- transcriptMeta: {
802
- sender: 'lead',
803
- ...(origin.envelopeName ? { fromAgent: origin.agentId } : {}),
804
- ...(origin.hop > 0 ? { hop: origin.hop } : {}),
805
- },
806
- ...(notify
807
- ? {
808
- onSettled: (detail) => {
809
- const status = turnStatus(detail);
810
- const text = detail?.result?.content || detail?.error || '';
811
- // A failed or cancelled turn reports at once; a completed one
812
- // reports the task's final answer (followToFinal).
813
- if (status !== 'completed') {
814
- unwatch();
815
- void report(status, text);
816
- return;
817
- }
818
- follow = followToFinal({
819
- sessionId,
820
- runId: id,
821
- runtime,
822
- immediate: { status, text },
823
- finish: async (endStatus, endText) => {
824
- unwatch();
825
- await report(endStatus, endText);
826
- },
827
- progress: (progressText) => report('working', progressText),
828
- });
829
- follow.check();
830
- },
831
- }
832
- : {}),
856
+ const take = submit ?? ((options) => sessions.submitSession({ sessionId, prompt: sent, options }));
857
+ result = await take({
858
+ id,
859
+ mode: 'prompt',
860
+ // `fromAgent`: the sending bot, recorded when it writes into another
861
+ // bot's home chat (otherwise the row would read as the user's);
862
+ // `hop`: its place in a bot-to-bot chain (bot-hops.mjs).
863
+ transcriptMeta: {
864
+ sender: 'lead',
865
+ ...(origin.envelopeName ? { fromAgent: origin.agentId } : {}),
866
+ ...(origin.hop > 0 ? { hop: origin.hop } : {}),
833
867
  },
868
+ ...(notify
869
+ ? {
870
+ // A room answers this send in a turn under another submission
871
+ // id (one turn for several sends, or a fresh turn for one cut
872
+ // short): the watch follows that turn, recorded before it runs.
873
+ follow: (submissionId) => watches.follow?.(id, submissionId),
874
+ onSettled: (detail) => {
875
+ const status = turnStatus(detail);
876
+ const text = detail?.result?.content || detail?.error || '';
877
+ // A failed or cancelled turn reports at once; a completed one
878
+ // reports the task's final answer (followToFinal).
879
+ if (status !== 'completed') {
880
+ unwatch();
881
+ void report(status, text);
882
+ return;
883
+ }
884
+ follow = followToFinal({
885
+ sessionId,
886
+ runId: id,
887
+ runtime,
888
+ immediate: { status, text },
889
+ finish: async (endStatus, endText) => {
890
+ unwatch();
891
+ await report(endStatus, endText);
892
+ },
893
+ progress: (progressText) => report('working', progressText),
894
+ });
895
+ follow.check();
896
+ },
897
+ }
898
+ : {}),
834
899
  });
835
900
  } catch (error) {
836
901
  unwatch();
@@ -882,15 +947,31 @@ export function createAgentTaskSessions({
882
947
  return pending.length;
883
948
  }
884
949
 
885
- /** Create the task session for `brief` and start it; returns at once. */
950
+ /** Create the task session for `brief` and start it; returns at once.
951
+ * `callerScope` is the caller's trusted worktree scope (worktree-access.mjs
952
+ * worktreeCallerScope): provisioning a worktree is a mutation only an
953
+ * attended conversation may make, so an explicit worktree from any other
954
+ * caller is refused and the New task default keeps it in the Project folder. */
886
955
  async function create(
887
956
  args = {},
888
- { callerSessionId, callerAgentId, callerCwd, callerHop = 0, attachmentParts = [] } = {}
957
+ { callerSessionId, callerAgentId, callerCwd, callerHop = 0, callerScope, attachmentParts = [] } = {}
889
958
  ) {
890
959
  if (!callerAgentId) throw new Error('session: create is only for an agent conversation');
891
960
  const brief = typeof args.brief === 'string' ? args.brief.trim() : '';
892
961
  if (!brief) throw new Error('session: brief is required for action=create');
893
- const where = resolveTaskProject(args.project, { projects: await listProjects(), cwd: callerCwd || process.cwd() });
962
+ const location = taskLocation(args.worktree);
963
+ if (location.mode === 'worktree') assertWorktreeMutationAllowed(callerScope, 'session: create with a worktree');
964
+ const projects = await listProjects();
965
+ const where = resolveTaskProject(args.project, { projects, cwd: callerCwd || process.cwd() });
966
+ // An explicit worktree is never silently dropped: it needs a registered Project.
967
+ const registered = where.project && projects.some((entry) => samePath(entry.path, where.project.path));
968
+ if (location.mode === 'worktree' && !registered) {
969
+ throw taskWorktreeFailure(
970
+ worktreeError('WORKTREE_PROJECT_UNKNOWN', 'A worktree needs a registered Project; run list-projects.', {
971
+ projectPath: where.project?.path ?? null,
972
+ })
973
+ );
974
+ }
894
975
  const requestId = `atask_${randomUUID()}`;
895
976
  const title = (clean(args.title) || clean(brief.split('\n')[0])).slice(0, TITLE_LIMIT);
896
977
  const taskOrigin = {
@@ -908,6 +989,16 @@ export function createAgentTaskSessions({
908
989
  : (await leadRoute()) || (await lastUsedRoute(service()));
909
990
  if (!route) throw new Error('session: no model to start the task session on. Choose a Lead model first.');
910
991
  const sessions = service();
992
+ // Where it starts: the `worktree` option, else the user's New task default
993
+ // for a caller that may provision one. Decided before the session exists,
994
+ // so a failure here leaves nothing behind.
995
+ const explicit = location.mode === 'worktree';
996
+ const inWorktree =
997
+ explicit ||
998
+ (location.mode === 'default' &&
999
+ where.project &&
1000
+ callerScope?.attended === true &&
1001
+ (await newTaskDefault()) === 'worktree');
911
1002
  const created = await sessions.createSession({
912
1003
  ...route,
913
1004
  cwd: where.cwd,
@@ -916,9 +1007,43 @@ export function createAgentTaskSessions({
916
1007
  });
917
1008
  const sessionId = String(created?.sessionId || '');
918
1009
  if (!sessionId) throw new Error('session: task session creation returned no session id');
919
- // The user's New task default: a Project task starts in its own worktree.
920
- if (where.project && (await newTaskDefault()) === 'worktree') {
921
- await requestTaskWorktree(sessions, sessionId, title, requestId);
1010
+ if (inWorktree) {
1011
+ try {
1012
+ await requestTaskWorktree(sessions, sessionId, {
1013
+ title,
1014
+ requestId,
1015
+ name: location.name,
1016
+ confirmLarge: args.confirm_large === true,
1017
+ explicit,
1018
+ });
1019
+ } catch (error) {
1020
+ // Nothing was sent: drop the empty session so a retry starts clean.
1021
+ let dropFailure = null;
1022
+ try {
1023
+ const dropped = await sessions.configureSession(
1024
+ { sessionId, action: 'deleteSession', args: [sessionId] },
1025
+ { revision: 1 }
1026
+ );
1027
+ // A resolved `false` is a refusal, not a deletion.
1028
+ if (dropped?.value === false) dropFailure = new Error('the session service refused the deletion');
1029
+ } catch (deleteError) {
1030
+ dropFailure = deleteError;
1031
+ }
1032
+ if (dropFailure) log(`dropping ${sessionId} failed: ${dropFailure?.message || dropFailure}`);
1033
+ if (!dropFailure) throw error;
1034
+ // The model must know the empty session is still there to delete.
1035
+ throw Object.assign(
1036
+ new Error(
1037
+ `${error?.message || error} The empty task session ${sessionId} could not be deleted either (${dropFailure?.message || dropFailure}); delete it with session action=delete session_id=${sessionId}.`
1038
+ ),
1039
+ {
1040
+ ...(typeof error?.code === 'string' ? { code: error.code } : {}),
1041
+ ...(error?.details ? { details: error.details } : {}),
1042
+ orphanSessionId: sessionId,
1043
+ cause: error,
1044
+ }
1045
+ );
1046
+ }
922
1047
  }
923
1048
  // "Started" lands in the home before any report of the brief's turn.
924
1049
  let started = () => {};
@@ -958,5 +1083,10 @@ export function createAgentTaskSessions({
958
1083
  const reportOrigin = (sessionId, agentId, callerSessionId) =>
959
1084
  sendReportOrigin({ loadSession, agentName }, sessionId, agentId, callerSessionId);
960
1085
 
961
- return { create, reportOrigin, recover, send: (sessionId, prompt, origin) => agentTurn(sessionId, prompt, origin) };
1086
+ return {
1087
+ create,
1088
+ reportOrigin,
1089
+ recover,
1090
+ send: (sessionId, prompt, origin, submit = null) => agentTurn(sessionId, prompt, origin, undefined, null, submit),
1091
+ };
962
1092
  }
@@ -167,6 +167,7 @@ function wireToolPolicyRefresh(boot) {
167
167
  applyPendingSessionSwitches,
168
168
  refreshAgentProfile,
169
169
  applyPendingAgentProfile,
170
+ currentAgents,
170
171
  } = createToolPolicyRefresh({
171
172
  getSession: () => rt.session,
172
173
  getRoute: () => rt.route,
@@ -193,6 +194,7 @@ function wireToolPolicyRefresh(boot) {
193
194
  boot.applyPendingSessionSwitches = applyPendingSessionSwitches;
194
195
  boot.refreshAgentProfile = refreshAgentProfile;
195
196
  boot.applyPendingAgentProfile = applyPendingAgentProfile;
197
+ boot.currentAgents = currentAgents;
196
198
  // A saved bot reaches this runtime's thread when its prompt embeds it (it
197
199
  // runs as that bot, or lists it); the rebuilt prompt is saved with the session.
198
200
  const profileChanges = subscribeAgentProfileChanges(async () => {
@@ -660,6 +662,7 @@ function sessionTurnApiFor(boot) {
660
662
  return createSessionTurnApi({
661
663
  applyPendingRoute: () => boot.modelRouteApi.applyPendingRoute(),
662
664
  applyPendingAgentProfile: boot.applyPendingAgentProfile,
665
+ currentAgents: boot.currentAgents,
663
666
  applyPendingSessionSwitches: boot.applyPendingSessionSwitches,
664
667
  prepareTurnWorkspace: boot.workspaceApi.prepareTurnWorkspace,
665
668
  admitTurnWorkspace: boot.workspaceApi.admitTurnWorkspace,
@@ -7,6 +7,7 @@ import { createRuntimeFacade } from '../runtime-facade.mjs';
7
7
  import { bootProfile } from '../boot-profile.mjs';
8
8
  import { assertAdmissionOpen, bindSessionAddress } from '../session-address.mjs';
9
9
  import { dataDirOf, workflowHelpers } from './shared.mjs';
10
+ import { publicWorkspaceMethods } from '../workspace/transitions.mjs';
10
11
 
11
12
  export function buildFacade(boot) {
12
13
  const { rt, setupTool } = boot;
@@ -41,16 +42,14 @@ export function buildFacade(boot) {
41
42
  getLiveWorkspace: boot.workspaceApi.getLiveWorkspace,
42
43
  getBranchMenu: boot.workspaceApi.getBranchMenu,
43
44
  getProjectBranchMenu: boot.workspaceApi.getProjectBranchMenu,
44
- selectBranch: boot.workspaceApi.selectBranch,
45
45
  selectProjectBranch: boot.workspaceApi.selectProjectBranch,
46
46
  mergeProjectBranch: boot.workspaceApi.mergeProjectBranch,
47
47
  inspectProjectBranch: boot.workspaceApi.inspectProjectBranch,
48
48
  manageProjectBranch: boot.workspaceApi.manageProjectBranch,
49
- requestWorktree: boot.workspaceApi.requestWorktree,
50
- mergeWorkspace: boot.workspaceApi.mergeWorkspace,
51
- removeWorktree: boot.workspaceApi.removeWorktree,
52
- listWorktrees: boot.workspaceApi.listWorktrees,
53
- pruneWorktrees: boot.workspaceApi.pruneWorktrees,
49
+ // selectBranch, requestWorktree, mergeWorkspace, removeWorktree,
50
+ // listWorktrees, pruneWorktrees: the public arity only, so no external
51
+ // caller (desktop IPC, the daemon's configure lane) reaches `ownTurn`.
52
+ ...publicWorkspaceMethods(boot.workspaceApi),
54
53
  onWorktreeSetup: boot.workspaceApi.onWorktreeSetup,
55
54
  claimSetupRequest: setupTool.claimSetupRequest,
56
55
  isSetupRequestActive: setupTool.isSetupRequestActive,
@@ -34,6 +34,8 @@ import {
34
34
  createContextStatus,
35
35
  } from '../../runtime/agent/orchestrator/runtime-core/context-status.mjs';
36
36
  import { createInternalToolExecutor } from '../internal-tool-executor.mjs';
37
+ import { registerWorktreeToolHost } from '../../runtime/shared/worktrees/tool-host.mjs';
38
+ import { createWorktreeToolHost } from '../workspace/worktree-tool.mjs';
37
39
  import { STANDALONE_ROOT, standaloneDataDir } from '../runtime-paths.mjs';
38
40
  import { dataDirOf, workflowHelpers } from './shared.mjs';
39
41
 
@@ -261,5 +263,23 @@ function wireInternalTools(boot) {
261
263
  executeSessionControl: boot.params.executeSessionControl,
262
264
  }),
263
265
  });
264
- Object.assign(boot, { setupTool, disposeInternalTools });
266
+ // The git tool's managed-worktree operations reach this Lead's workspace
267
+ // authority (late-bound like the internal tools) through this host.
268
+ const disposeWorktreeToolHost = registerWorktreeToolHost({
269
+ sessionId: () => rt.session?.id || null,
270
+ run: createWorktreeToolHost({
271
+ getSession: () => rt.session,
272
+ getWorkspaceApi: () => boot.workspaceApi || null,
273
+ executeSessionControl: boot.params.executeSessionControl || null,
274
+ }).run,
275
+ });
276
+ Object.assign(boot, {
277
+ setupTool,
278
+ disposeInternalTools: () => {
279
+ disposeWorktreeToolHost();
280
+ // Its turn snapshot cleanups stop with the runtime (maintenance finishes them).
281
+ boot.workspaceApi?.dispose();
282
+ disposeInternalTools();
283
+ },
284
+ });
265
285
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Unattended runs. A schedule or webhook run never asks (its saved routine is
3
- * the user's grant; human-gate/run-policy.mjs), so only group-room members
4
- * still use held requests: an approval-needing call is HELD instead of shown as
2
+ * Held requests. A schedule or webhook run never asks and never holds (its
3
+ * saved routine is the user's grant; human-gate/run-policy.mjs), so only group
4
+ * room members use held requests: with no
5
+ * approval UI attached, an approval-needing call is HELD instead of shown as
5
6
  * a card: the run pauses on it, a structured held-request row lands in the
6
7
  * owning agent's home thread (agent-home-delivery.mjs queue), a phone push
7
8
  * says only "<agent>가 승인을 기다립니다" (no tool, arguments or text), and the
@@ -24,8 +25,8 @@ import {
24
25
  setUnattendedApprovalPort,
25
26
  } from '../runtime/agent/orchestrator/session/human-gate/gate.mjs';
26
27
  import { normalizeHumanResponse } from '../runtime/agent/orchestrator/session/human-gate/requests.mjs';
28
+ import { runStampOf } from '../runtime/agent/orchestrator/session/human-gate/delegation.mjs';
27
29
  import { normalizeHeldRequest } from '../runtime/agent/orchestrator/session/human-gate/held-message.mjs';
28
- import { delegationOf } from '../runtime/agent/orchestrator/session/human-gate/delegation.mjs';
29
30
  import {
30
31
  notifyHeldRequest,
31
32
  recordRunOwner,
@@ -49,28 +50,21 @@ export function markUnattendedRun(sessionId, { kind, agentId, name = '' }) {
49
50
  };
50
51
  }
51
52
 
52
- /** The unattended run `sessionId` belongs to, or null for an attended session.
53
- * Work delegated from a run or a room (a sub-agent at any depth, a task
54
- * session the bot created) belongs to it by the origin recorded on its own
55
- * session record when it was created (human-gate/delegation.mjs) — however
56
- * long ago that run ended, across restarts: its approvals are held like the
57
- * run's own. */
53
+ /** The unattended run `sessionId` is, or null for an attended session: a
54
+ * marked schedule/webhook run, a room member's own session, or work a
55
+ * schedule/webhook run started (a sub-agent at any depth, a task session) by
56
+ * the `run` stamped on its own record when it was created
57
+ * (human-gate/delegation.mjs) — however long ago that run ended, across
58
+ * restarts. Work a bot's attended chat or a room member started is no run. */
58
59
  export function unattendedRunOf(sessionId) {
59
60
  const run = runs.get(sessionId);
60
61
  if (run) return run;
61
62
  const member = roomMemberOfSession(sessionId);
62
63
  if (member) return { kind: 'room', agentId: member.agentId, name: member.roomId };
63
- const delegation = delegationOf(sessionId);
64
- if (delegation.state !== 'resolved') return null;
65
- if (delegation.via === 'run') {
66
- return {
67
- kind: delegation.run.kind === 'webhook' ? 'webhook' : 'schedule',
68
- agentId: delegation.rootAgentId,
69
- name: String(delegation.run.name || ''),
70
- };
71
- }
72
- const root = delegation.via === 'room' ? roomMemberOfSession(delegation.rootSessionId) : null;
73
- return root ? { kind: 'room', agentId: root.agentId, name: root.roomId } : null;
64
+ const stamped = runStampOf(sessionId);
65
+ return stamped && (stamped.kind === 'schedule' || stamped.kind === 'webhook') && stamped.agentId
66
+ ? { kind: stamped.kind, agentId: String(stamped.agentId), name: String(stamped.name || '') }
67
+ : null;
74
68
  }
75
69
 
76
70
  /**
@@ -134,6 +128,8 @@ export function createHeldRequests({
134
128
  /** Hold `request` (the approval hook's payload) until answered or the run is cancelled. */
135
129
  function hold(request = {}, { signal } = {}) {
136
130
  const run = runOf(request.sessionId) || { kind: 'schedule', agentId: MIXY_AGENT_ID, name: '' };
131
+ // The scopes the gate offered (gate.mjs): the bot's own grant, or once.
132
+ const remember = Array.isArray(request.remember) && request.remember.length ? request.remember : ['once'];
137
133
  const id = `held_${randomUUID()}`;
138
134
  const held = normalizeHeldRequest({
139
135
  id,
@@ -152,13 +148,12 @@ export function createHeldRequests({
152
148
  details: request.outbound?.details,
153
149
  }
154
150
  : {}),
155
- // An unattended run never remembers: its held row offers once only.
156
- remember: ['once'],
151
+ remember,
157
152
  run: { kind: run.kind, name: run.name, sessionId: request.sessionId },
158
153
  });
159
- const shape = { kind: held.kind, outbound: request.outbound, remember: ['once'] };
154
+ const shape = { kind: held.kind, outbound: request.outbound, remember };
160
155
  const gate = {
161
- remember: ['once'],
156
+ remember,
162
157
  agentId: run.agentId,
163
158
  kind: held.kind,
164
159
  ...(held.action ? { action: held.action } : {}),
@@ -9,6 +9,7 @@ import { AGENT_MODE, resolveSessionMode } from '../../runtime/agent/orchestrator
9
9
  import { agentIdOfHomeSession } from '../../runtime/agent/orchestrator/session/agent-home.mjs';
10
10
  import { turnHopOf } from '../../runtime/shared/bot-hops.mjs';
11
11
  import { scheduleCallerScope } from '../services/schedule-access.mjs';
12
+ import { worktreeCallerScope } from '../services/worktree-access.mjs';
12
13
  import { resolveHandoffAttachments, sessionRoots } from '../handoff-attachments.mjs';
13
14
 
14
15
  const USER_TITLE_ERROR = 'session: the user already titled this session; the title was not changed';
@@ -44,9 +45,6 @@ export function createWorkspaceToolHandlers({
44
45
  getLifecycleApi = () => null,
45
46
  executeSessionControl = null,
46
47
  }) {
47
- // The Lead's own worktree, on the user's request: the same authority the
48
- // branch chip menu drives, tolerating the calling turn but nothing else
49
- // running (workers, background shells).
50
48
  const assertLead = (action, callerCtx) => {
51
49
  const callerSessionId = callerCtx?.callerSessionId || rt.session?.id || null;
52
50
  if (!rt.session?.id || callerSessionId !== rt.session.id) {
@@ -55,24 +53,6 @@ export function createWorkspaceToolHandlers({
55
53
  return rt.session.id;
56
54
  };
57
55
 
58
- const worktreeAction = async (action, args, callerCtx) => {
59
- const workspace = getWorkspaceApi();
60
- if (!workspace) throw new Error(`session: ${action} is unavailable in this runtime`);
61
- assertLead(action, callerCtx);
62
- let result;
63
- if (action === 'merge-worktree') {
64
- result = await workspace.mergeWorkspace({ confirmed: true }, { ownTurn: true });
65
- } else if (action === 'remove-worktree') {
66
- result = await workspace.removeWorktree({ force: args?.force === true }, { ownTurn: true });
67
- } else if (action === 'list-worktrees') {
68
- result = await workspace.listWorktrees();
69
- } else {
70
- result = await workspace.pruneWorktrees();
71
- }
72
- return JSON.stringify({ action, cwd: rt.currentCwd, result }, null, 2);
73
- };
74
-
75
- const WORKTREE_ACTIONS = new Set(['merge-worktree', 'remove-worktree', 'list-worktrees', 'prune-worktrees']);
76
56
  const SIDEBAR_ACTIONS = new Set(['favorite', 'archive']);
77
57
 
78
58
  // Sidebar flags only (the desktop's favorite/archive metadata).
@@ -127,6 +107,8 @@ export function createWorkspaceToolHandlers({
127
107
  callerAgentId,
128
108
  callerCwd,
129
109
  ...(action === 'send' || action === 'create' ? { callerHop: turnHopOf(rt.session) } : {}),
110
+ // Whether a create may provision a worktree (worktree-access.mjs).
111
+ ...(action === 'create' ? { callerScope: worktreeCallerScope(rt.session) } : {}),
130
112
  // Which bots and sessions it may see (bot-status.mjs).
131
113
  ...(action === 'bots' || action === 'list' ? { callerScope: scheduleCallerScope(rt.session) } : {}),
132
114
  ...(attachmentParts.length ? { attachmentParts } : {}),
@@ -137,7 +119,6 @@ export function createWorkspaceToolHandlers({
137
119
  if (target && target !== assertLead(action, callerCtx)) {
138
120
  throw new Error(`session: action=${action} only applies to the current session`);
139
121
  }
140
- if (WORKTREE_ACTIONS.has(action)) return worktreeAction(action, args, callerCtx);
141
122
  if (SIDEBAR_ACTIONS.has(action)) return sidebarAction(action, args, callerCtx);
142
123
  let currentCwd = callerCwd;
143
124
  if (action === 'status' || action === 'list-projects' || action === 'set-project') assertLead(action, callerCtx);
@@ -265,6 +246,8 @@ export function createWorkspaceToolHandlers({
265
246
  clientHostPid: callerCtx?.clientHostPid || rt.session?.clientHostPid || process.pid,
266
247
  signal: callerCtx?.signal,
267
248
  notifyFn: notifyFnForSession(callerSessionId),
249
+ // The jobs it starts report back at this turn's bot-chain hop (bot-hops.mjs).
250
+ ...(turnHopOf(rt.session) > 0 ? { hop: turnHopOf(rt.session) } : {}),
268
251
  // The jobs this call starts are recorded under it (agent-task-starts.mjs).
269
252
  ...(callerCtx?.toolCallId ? { toolCallId: callerCtx.toolCallId } : {}),
270
253
  });
@@ -11,6 +11,7 @@ function customRowFields(name, m) {
11
11
  if (!isCustomProviderName(name)) return {};
12
12
  return {
13
13
  ...(typeof m.name === 'string' && m.name ? { name: m.name } : {}),
14
+ ...(typeof m.providerName === 'string' && m.providerName ? { providerName: m.providerName } : {}),
14
15
  ...(typeof m.hidden === 'boolean' ? { hidden: m.hidden } : {}),
15
16
  ...(typeof m.manual === 'boolean' ? { manual: m.manual } : {}),
16
17
  ...(m.customReasoning && typeof m.customReasoning === 'object' ? { customReasoning: m.customReasoning } : {}),
@@ -45,8 +45,15 @@ export function hydrateProviderModelRow(cfg, row) {
45
45
  const modelParameterOptions = addedOptions.length
46
46
  ? [...existingOptions.filter((option) => !addedOptions.some((added) => added.id === option?.id)), ...addedOptions]
47
47
  : row.modelParameterOptions;
48
+ const isCustom = row.provider?.startsWith?.('custom-');
49
+ const customName = isCustom ? String(cfg?.providers?.[row.provider]?.name || '').trim() : '';
50
+ // A custom provider's name always comes from the current configuration; a
51
+ // row's own (possibly renamed or removed) name is dropped.
52
+ const base = { ...row };
53
+ if (isCustom) delete base.providerName;
48
54
  return {
49
- ...row,
55
+ ...base,
56
+ ...(customName ? { providerName: customName } : {}),
50
57
  ...(alias ? { display: alias, displayAlias: alias } : {}),
51
58
  ...(modelParameterOptions ? { modelParameterOptions } : {}),
52
59
  effortOptions: effortItemsFor(row.provider, row, null),