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
@@ -13,6 +13,7 @@ import { _queueSessionSummaryUpsert, _queueSessionSummaryRemoval } from '../summ
13
13
  import { deleteSession } from '../session-delete.mjs';
14
14
  import { markSessionClosed } from '../lifecycle-barriers.mjs';
15
15
  import { BLANK_SCRATCH_MAX_AGE_MS, RUNNING_STALL_MS, SWEEP_RECORD_NO_DATA, readSweepRecord } from './sweep-record.mjs';
16
+ import { subAgentMember } from './sub-agent-retention.mjs';
16
17
  import { isAgentHomeSessionId } from '../../agent-home.mjs';
17
18
  import { isRoomThreadSessionId } from '../../agent-room.mjs';
18
19
 
@@ -28,6 +29,14 @@ const effectiveFields = (actual, row) => ({
28
29
  createdAt: Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : row.createdAt || 0,
29
30
  });
30
31
 
32
+ /** Every sub-agent record, open or closed, fresh or not, for the tree
33
+ * retention its caller applies (sub-agent-retention.mjs). */
34
+ function noteSubAgent(ctx, id, doc) {
35
+ if (!ctx.plan.retainSubAgents) return;
36
+ const member = subAgentMember(id, doc);
37
+ if (member) ctx.tally.subAgents.push(member);
38
+ }
39
+
31
40
  /** A closed record: a mature tombstone is deleted; otherwise a stale summary
32
41
  * row that still claims the session open is repaired and the row is kept. */
33
42
  function sweepClosedRecord(ctx, row, actual, jsonPath) {
@@ -153,6 +162,7 @@ function sweepOpenRecord(ctx, row, record, { jsonMtime }) {
153
162
  tally.remaining++;
154
163
  return;
155
164
  }
165
+ noteSubAgent(ctx, row.id, record.doc);
156
166
  const eff = effectiveFields(record.doc, row);
157
167
  const ownerNamed = typeof eff.owner === 'string' && eff.owner.length > 0;
158
168
  // Freshness gate — recently-touched open sessions are skipped cheaply here.
@@ -277,7 +287,10 @@ export function sweepRow(ctx, row) {
277
287
  return;
278
288
  }
279
289
  if (record.closed === true || actual.status === 'closed') {
290
+ const cleanedBefore = ctx.tally.tombstonesCleaned;
280
291
  sweepClosedRecord(ctx, row, actual, jsonPath);
292
+ // A tombstone this sweep just deleted is gone, not a member.
293
+ if (ctx.tally.tombstonesCleaned === cleanedBefore) noteSubAgent(ctx, row.id, actual);
281
294
  return;
282
295
  }
283
296
  sweepOpenRecord(ctx, row, record, { jsonMtime: jsonProbe.mtimeMs || 0 });
@@ -12,6 +12,7 @@
12
12
  import { readdirSync, readFileSync } from 'node:fs';
13
13
  import { stat } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
+ import { performance } from 'node:perf_hooks';
15
16
  // Leaf helpers only: the three-way present/absent/unreadable classification
16
17
  // and the strict record parser the authoritative store uses, so the catalog
17
18
  // cannot disagree with it.
@@ -384,6 +385,66 @@ function scanSessionEntries(directory, indexRowsById, forceRead) {
384
385
  return { entries };
385
386
  }
386
387
 
388
+ // The async rescan yields a turn once a slice of reconciles has run this long:
389
+ // a full authoritative rescan strict-reads every session file (≈4 s for 1.8k
390
+ // files / 1.3 GB), which must not hold the daemon loop in one piece.
391
+ let scanSliceMs = 25;
392
+
393
+ /** Test seam: the async rescan's slice budget (0 yields after every file). */
394
+ export function _setCatalogScanSliceMsForTest(ms) {
395
+ scanSliceMs = Math.max(0, Number(ms) || 0);
396
+ }
397
+
398
+ const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
399
+
400
+ /** The entry a file still has: `entry` when the file's stamp is unchanged
401
+ * since it was reconciled, else a fresh reconcile (null once it is gone). */
402
+ function settledEntry(directory, storageId, entry, retained, forceRead) {
403
+ if (entry?.stamp) {
404
+ let stamp = null;
405
+ try {
406
+ stamp = statSessionStamp(join(directory, `${storageId}.json`));
407
+ } catch {
408
+ // Gone or unstatable: the reconcile below decides which.
409
+ }
410
+ if (stamp && sameSessionStamp(entry.stamp, stamp)) return entry;
411
+ }
412
+ return reconcileSessionFile(directory, storageId, retained, forceRead);
413
+ }
414
+
415
+ /** scanSessionEntries in slices, yielding the loop between them. Files can
416
+ * change while it yields, so the answer is settled in one synchronous pass at
417
+ * the end: the directory is listed again, a file whose stamp moved since its
418
+ * read (or that appeared) is reconciled again, and a file that is no longer
419
+ * listed is dropped — the entries match storage when they are returned, as
420
+ * the one-piece scan's do. That pass is a stat per file (≈20 ms for 1.5k). */
421
+ async function scanSessionEntriesAsync(directory, indexRowsById, forceRead, onSettle) {
422
+ const sessionIds = listSessionIds(directory);
423
+ if (!sessionIds) {
424
+ onSettle?.();
425
+ return null;
426
+ }
427
+ const scanned = new Map();
428
+ let sliceStart = performance.now();
429
+ for (const storageId of sessionIds) {
430
+ if (performance.now() - sliceStart >= scanSliceMs) {
431
+ await nextTurn();
432
+ sliceStart = performance.now();
433
+ }
434
+ const entry = reconcileSessionFile(directory, storageId, indexRowsById.get(storageId), forceRead);
435
+ if (entry) scanned.set(storageId, entry);
436
+ }
437
+ onSettle?.();
438
+ const settledIds = listSessionIds(directory);
439
+ if (!settledIds) return null;
440
+ const entries = new Map();
441
+ for (const storageId of settledIds) {
442
+ const entry = settledEntry(directory, storageId, scanned.get(storageId), indexRowsById.get(storageId), forceRead);
443
+ if (entry) entries.set(storageId, entry);
444
+ }
445
+ return { entries };
446
+ }
447
+
387
448
  /** The row an entry contributes, or null. */
388
449
  function entryRow(entry, id, indexRowsById) {
389
450
  if (!entry || entry.kind === 'none') return null;
@@ -419,6 +480,21 @@ export function scanSessionFiles(indexRowsById, forceRead) {
419
480
  return scan && assembleCatalog(scan.entries, indexRowsById);
420
481
  }
421
482
 
483
+ /** scanSessionFiles with the same answer, its file reconciles run in slices
484
+ * that yield the event loop (the daemon's session.list storage refresh).
485
+ * `onSettle` runs once, when the answer's final synchronous part begins: from
486
+ * then on storage changes are no longer seen by this scan. */
487
+ export async function scanSessionFilesAsync(indexRowsById, forceRead, { onSettle } = {}) {
488
+ const directory = join(dataDir(), 'sessions');
489
+ const dirProbe = probePath(directory);
490
+ if (dirProbe.state !== PROBE_PRESENT) {
491
+ onSettle?.();
492
+ return dirProbe.state === PROBE_ABSENT ? [] : null;
493
+ }
494
+ const scan = await scanSessionEntriesAsync(directory, indexRowsById, forceRead, onSettle);
495
+ return scan && assembleCatalog(scan.entries, indexRowsById);
496
+ }
497
+
422
498
  /** Listing from the index rows alone, or null without a valid index. */
423
499
  export function indexListing(index) {
424
500
  return index.rows && leadRows(index.rows.map((row) => ({ ...row }))).sort(byCatalogOrder);
@@ -20,6 +20,7 @@ import {
20
20
  indexListing,
21
21
  observeStamp,
22
22
  scanSessionFiles,
23
+ scanSessionFilesAsync,
23
24
  stampedDerivation,
24
25
  } from './store-summary-catalog.mjs';
25
26
  import { createStoredTranscriptCache, nextProjectionStamp } from './store-transcript-cache.mjs';
@@ -155,6 +156,30 @@ async function catalogDbListing(page) {
155
156
  return answer;
156
157
  }
157
158
 
159
+ // The running storage refresh a new caller may still join: only until its
160
+ // final synchronous settle begins, since a caller arriving after that asked
161
+ // after the answer was fixed (a scan that never yields settles before its own
162
+ // caller awaits it).
163
+ let storageRefreshFlight = null;
164
+
165
+ async function refreshFromStorageOnce(onSettle) {
166
+ const index = ordinaryCatalog.loadIndex();
167
+ return (await scanSessionFilesAsync(index.rowsById, true, { onSettle })) ?? indexListing(index) ?? [];
168
+ }
169
+
170
+ function storageRefresh() {
171
+ if (storageRefreshFlight?.joinable) return storageRefreshFlight.rows;
172
+ const flight = { joinable: true, rows: null };
173
+ storageRefreshFlight = flight;
174
+ flight.rows = refreshFromStorageOnce(() => {
175
+ flight.joinable = false;
176
+ }).finally(() => {
177
+ // A newer flight is never cleared by an older one's end.
178
+ if (storageRefreshFlight === flight) storageRefreshFlight = null;
179
+ });
180
+ return flight.rows;
181
+ }
182
+
158
183
  /**
159
184
  * listStoredSessionSummaries for async callers (the daemon's session views).
160
185
  * In the daemon the ordinary listing comes from the session catalog DB
@@ -163,7 +188,14 @@ async function catalogDbListing(page) {
163
188
  * JSON/file path.
164
189
  */
165
190
  export async function listStoredSessionSummariesAsync(options = {}) {
166
- if (options.refreshFromStorage === true || options.rebuildIfMissing === false) {
191
+ if (options.refreshFromStorage === true) {
192
+ // The same authoritative rescan as the sync path, in slices that yield.
193
+ // Concurrent refreshes share one scan while it can still see their
194
+ // request (it settles against storage when it ends); each caller gets its
195
+ // own rows.
196
+ return (await storageRefresh()).map((row) => ({ ...row }));
197
+ }
198
+ if (options.rebuildIfMissing === false) {
167
199
  return listStoredSessionSummaries(options);
168
200
  }
169
201
  const answer = await catalogDbListing(null);
@@ -0,0 +1,5 @@
1
+ export function isRootLeadSession(value: unknown): boolean;
2
+ export function sessionVisibility(value: unknown): 'ordinary' | 'agent-only';
3
+ export function isAgentOnlySession(value: unknown): boolean;
4
+ export function agentParentIds(value: unknown): string[];
5
+ export function agentDescendantIds(rows: readonly unknown[], rootId: unknown): string[];
@@ -49,3 +49,40 @@ export function sessionVisibility(value) {
49
49
  export function isAgentOnlySession(value) {
50
50
  return sessionVisibility(value) === SESSION_VISIBILITY_AGENT_ONLY;
51
51
  }
52
+
53
+ /** Every session a record links itself to as its owner or parent (both links,
54
+ * never itself): the links the descendant walk and the sub-agent retention
55
+ * trees follow. */
56
+ export function agentParentIds(value) {
57
+ const id = clean(value?.id);
58
+ return [...new Set([clean(value?.ownerSessionId), clean(value?.parentSessionId)])].filter(
59
+ (parent) => parent && parent !== id
60
+ );
61
+ }
62
+
63
+ /** The agent-only sessions under `rootId` at any depth, from session rows. A
64
+ * catalog row links a sub-agent to its root Lead (`ownerSessionId`) or, on a
65
+ * legacy row, to its parent, so the walk follows every link a row names. */
66
+ export function agentDescendantIds(rows, rootId) {
67
+ const root = clean(rootId);
68
+ const children = new Map();
69
+ for (const row of Array.isArray(rows) ? rows : []) {
70
+ if (!isAgentOnlySession(row)) continue;
71
+ const id = clean(row.id);
72
+ if (!id) continue;
73
+ for (const parent of agentParentIds(row)) {
74
+ if (!children.has(parent)) children.set(parent, []);
75
+ children.get(parent).push(id);
76
+ }
77
+ }
78
+ const found = new Set();
79
+ const queue = [root];
80
+ while (queue.length) {
81
+ for (const child of children.get(queue.pop()) || []) {
82
+ if (child === root || found.has(child)) continue;
83
+ found.add(child);
84
+ queue.push(child);
85
+ }
86
+ }
87
+ return [...found];
88
+ }
@@ -722,4 +722,5 @@ export { fenceSessionWrites } from './store/live-state.mjs';
722
722
  // session-store entry point.
723
723
  export { listStoredSessions, listStoredSessionSummaries, getStoredSessionsRaw } from './store/listing.mjs';
724
724
  export { sweepStaleSessions, sweepStaleSessionsCooperative } from './store/sweep/stale-sweep.mjs';
725
+ export { applySubAgentRetention } from './store/sweep/sub-agent-retention.mjs';
725
726
  export { _savePending } from './store/pending-saves.mjs';
@@ -3,10 +3,11 @@
3
3
  // clean up after crashed holders. Pair with the in-process withPathLock for
4
4
  // the same target: in-process serialises async callers in this Node, advisory
5
5
  // lock serialises across Node processes.
6
- import { openSync, closeSync, writeSync, readFileSync, unlinkSync, mkdirSync, realpathSync } from 'node:fs';
6
+ import { openSync, closeSync, writeSync, readFileSync, unlinkSync, realpathSync } from 'node:fs';
7
7
  import { compareCodePoints } from '../../../../shared/code-point-order.mjs';
8
- import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
8
+ import { ensurePrivateRuntimeRoot } from '../../../../shared/runtime-root.mjs';
9
9
  import { sleep } from '../../../../shared/sleep.mjs';
10
+ import { homedir } from 'node:os';
10
11
  import { dirname, basename, join, resolve } from 'node:path';
11
12
  import { createHash, randomBytes } from 'node:crypto';
12
13
 
@@ -28,14 +29,24 @@ function canonicalLockTarget(targetPath) {
28
29
  }
29
30
  }
30
31
 
31
- // Lockfiles live in the local data dir, keyed by the target's canonical path,
32
- // never beside the target: sync-drive folders (e.g. Naver MYBOX) refuse
33
- // dot-prefixed sibling files, and the pid liveness check only spans this host.
32
+ // External files can be shared by profiles, so neither MIXDOG_HOME/DATA_DIR
33
+ // nor MIXDOG_RUNTIME_ROOT may choose this namespace. Temp directories can
34
+ // differ between processes too. Keep locks in the OS user's local home,
35
+ // never beside the target: sync drives such as MYBOX refuse sibling dotfiles.
36
+ // The pid liveness check coordinates only processes on this host.
37
+ function lockDirectory() {
38
+ const root =
39
+ process.platform === 'win32'
40
+ ? join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'mixdog')
41
+ : join(homedir(), '.mixdog');
42
+ return join(root, 'locks');
43
+ }
44
+
34
45
  function lockFileFor(targetPath) {
35
46
  const canonical = canonicalLockTarget(targetPath);
36
47
  const key = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
37
48
  const hash = createHash('sha256').update(key).digest('hex');
38
- return join(resolvePluginData(), 'locks', `${hash}.mixdog-lock`);
49
+ return join(lockDirectory(), `${hash}.mixdog-lock`);
39
50
  }
40
51
 
41
52
  function isPidAlive(pid) {
@@ -112,7 +123,7 @@ function tryCreateLock(lockFile, token) {
112
123
 
113
124
  async function acquireAdvisoryLock(targetPath, { timeoutMs = 5000, pollMs = 25 } = {}) {
114
125
  const lockFile = lockFileFor(targetPath);
115
- mkdirSync(dirname(lockFile), { recursive: true });
126
+ ensurePrivateRuntimeRoot(dirname(lockFile));
116
127
  const token = `${process.pid}.${randomBytes(8).toString('hex')}`;
117
128
  const deadline = Date.now() + timeoutMs;
118
129
  let stalePostDeadlineRetryUsed = false;
@@ -19,6 +19,7 @@ export function shellTaskIdentity(command, options) {
19
19
  callerSessionId: options?.callerSessionId || options?.sessionId || null,
20
20
  routingSessionId: options?.routingSessionId || options?.sessionId || null,
21
21
  clientHostPid: options?.clientHostPid,
22
+ hop: options?.hop,
22
23
  },
23
24
  };
24
25
  }
@@ -61,6 +62,7 @@ export function renderBackgroundedResult({ result, command, cwd, options, starte
61
62
  callerSessionId: options?.callerSessionId || options?.sessionId,
62
63
  routingSessionId: options?.routingSessionId || options?.sessionId,
63
64
  clientHostPid: options?.clientHostPid,
65
+ hop: options?.hop,
64
66
  });
65
67
  } catch {
66
68
  /* best effort */
@@ -1,3 +1,4 @@
1
+ import { isUtf8 } from 'node:buffer';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
@@ -8,6 +9,10 @@ import { withBuiltinPathLocks } from './path-locks.mjs';
8
9
  import { withAdvisoryLocks } from './advisory-lock.mjs';
9
10
  import { gitRepoLockKey, withGitWorktreeWriteLock } from './git-repo-rw-lock.mjs';
10
11
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
12
+ import { tryAdmitWorkspaceActivity } from '../../../../shared/worktrees/activity-gate.mjs';
13
+ import { WORKTREE_SNAPSHOT_KIND } from '../../../../shared/worktrees/contract.mjs';
14
+ import { isWithin } from '../../../../shared/worktrees/paths.mjs';
15
+ import { readRegistrySync } from '../../../../shared/worktrees/registry.mjs';
11
16
  import { runAbortable } from '../../../../shared/abort-race.mjs';
12
17
  import { sleep } from '../../../../shared/sleep.mjs';
13
18
  import { capMarker } from '../../../../shared/tool-output-marker.mjs';
@@ -26,6 +31,12 @@ import {
26
31
  diffSnapshotMatches,
27
32
  getDiffSnapshot,
28
33
  } from './git-partial-stage.mjs';
34
+ import {
35
+ executeRawWorktree,
36
+ executeWorktreeAction,
37
+ WORKTREE_INPUT_FIELDS,
38
+ WORKTREE_TOOL_ACTIONS,
39
+ } from './git-worktree-tool.mjs';
29
40
 
30
41
  const DEFAULT_TIMEOUT_MS = 120_000;
31
42
  const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
@@ -84,14 +95,15 @@ export const GIT_TOOL_DEF = {
84
95
  compressible: true,
85
96
  },
86
97
  description:
87
- 'Run Git here, never through shell. An array (max 10) runs in order and stops on failure; batch read-only commands in one. diff for known changes, status to discover them; history only when needed. git diff or git diff -- <paths> includes untracked files. Set include_stage_ids:true to select changes, then action:stage with diff_id/change_ids. Staging rejects stale or cross-Project diffs and returns per-file staged counts.',
98
+ 'Run Git here, never through shell. An array (max 10) runs in order and stops on failure; batch read-only commands in one. diff for known changes, status to discover them; history only when needed. git diff or git diff -- <paths> includes untracked files. Set include_stage_ids:true to select changes, then action:stage with diff_id/change_ids. Staging rejects stale or cross-Project diffs and returns per-file staged counts. Worktrees are managed only: action:worktree (Lead only) moves a session into/out of its managed worktree, merges, removes, lists, prunes; git worktree add/remove/list/prune map to managed operations (add -b keeps the session in place; add --detach makes a read-only snapshot worktree, removed when its turn ends; list --porcelain stays raw git; move/lock/unlock/repair are refused). Merge or remove only when the user asks; carry and large-size answers come from the user; force only on explicit request.',
88
99
  inputSchema: {
89
100
  type: 'object',
90
101
  properties: {
91
102
  action: {
92
103
  type: 'string',
93
- enum: ['command', 'stage'],
94
- description: 'Default command. stage uses diff_id/change_ids instead of command.',
104
+ enum: ['command', 'stage', 'worktree'],
105
+ description:
106
+ 'Default command. stage uses diff_id/change_ids instead of command. worktree uses worktree_action and its fields.',
95
107
  },
96
108
  command: {
97
109
  anyOf: [
@@ -118,6 +130,29 @@ export const GIT_TOOL_DEF = {
118
130
  anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 }],
119
131
  description: 'stage: exact change ID or IDs to stage, including new files.',
120
132
  },
133
+ worktree_action: {
134
+ type: 'string',
135
+ enum: [...WORKTREE_TOOL_ACTIONS],
136
+ description:
137
+ 'enter: create or re-enter the managed worktree (branch: that branch, create: new); return: back to the project folder, worktree kept; merge: into its merge target; remove; list; prune.',
138
+ },
139
+ session_id: {
140
+ type: 'string',
141
+ description: 'enter/return/merge/remove: another session (it must be idle); default the caller.',
142
+ },
143
+ name: { type: 'string', description: 'enter: the new worktree name (branch <prefix><name>).' },
144
+ branch: { type: 'string', description: 'enter: use the worktree of this branch instead.' },
145
+ create: { type: 'boolean', description: 'enter with branch: create that branch.' },
146
+ carry: {
147
+ type: 'string',
148
+ enum: ['leave', 'carry'],
149
+ description: 'enter: the user answer to WORKTREE_DIRTY_CHOICE (uncommitted project-folder changes).',
150
+ },
151
+ confirm_large: {
152
+ type: 'boolean',
153
+ description: 'enter or git worktree add: the user accepted the WORKTREE_LARGE_REPO_CONFIRM size.',
154
+ },
155
+ force: { type: 'boolean', description: 'remove: discard dirty/unmerged work; only on explicit user request.' },
121
156
  },
122
157
  additionalProperties: false,
123
158
  },
@@ -136,6 +171,9 @@ function ok(data = {}) {
136
171
  });
137
172
  }
138
173
 
174
+ const NOT_UTF8_STAGE_NOTE =
175
+ 'staging ids unavailable: this diff has bytes that are not UTF-8 (a file in another encoding), and staging its text would replace them; stage those files whole with git add -- <paths>';
176
+
139
177
  function fail(message) {
140
178
  return `error: ${message}`;
141
179
  }
@@ -398,6 +436,9 @@ async function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_B
398
436
  ...(optionalLocks ? {} : { GIT_OPTIONAL_LOCKS: '0' }),
399
437
  },
400
438
  outputLimit: maxBytes,
439
+ // Bytes as git wrote them, decoded once by the caller: a multibyte
440
+ // character split across two pipe reads must not become U+FFFD.
441
+ rawOutput: true,
401
442
  },
402
443
  });
403
444
  if (!native?.child)
@@ -561,9 +602,13 @@ function prepare(plan) {
561
602
  return { argv: [operation, ...args] };
562
603
  }
563
604
 
605
+ // A stage applies the text of this diff, so a patch with bytes that are not
606
+ // UTF-8 (a legacy-encoded file) would be applied with U+FFFD in their place.
607
+ // `notUtf8` marks such a diff: it is shown, but never staged by id.
564
608
  async function runStageableDiff(plan, argv, repo, signal) {
565
609
  const result = await runGit(plan, argv, { signal, readOnly: true });
566
610
  if (!succeeded(result)) return result;
611
+ result.notUtf8 = !isUtf8(result.stdout);
567
612
  result.stdout = String(result.stdout);
568
613
  result.stderr = String(result.stderr);
569
614
  const paths = plan.args.slice(1);
@@ -593,6 +638,7 @@ async function runStageableDiff(plan, argv, repo, signal) {
593
638
  );
594
639
  // --no-index uses exit 1 for a successfully produced patch.
595
640
  if (!succeeded({ ...added, exitCode: added.exitCode === 1 ? 0 : added.exitCode })) return added;
641
+ if (!isUtf8(added.stdout)) result.notUtf8 = true;
596
642
  result.stdout = appendText(result.stdout, String(added.stdout));
597
643
  result.stderr = appendText(result.stderr, String(added.stderr));
598
644
  }
@@ -763,26 +809,14 @@ function creationTarget(plan) {
763
809
  // --prune=now as unsafe beside a concurrent writer), so these hold the whole
764
810
  // repository.
765
811
  const REPO_WIDE_OPERATIONS = new Set(['gc', 'maintenance', 'prune', 'prune-packed', 'repack']);
766
- const WORKTREE_VALUE_FLAGS = new Set(['-b', '-B', '--reason']);
767
812
 
768
813
  // The worktrees a mutating plan touches; null holds the whole repository. A
769
- // write changes its cwd's worktree, except that `git worktree add/remove/move`
770
- // change the target worktree (plus the shared admin dir, which git itself
771
- // guards), so writes on different worktrees never wait for each other.
814
+ // write changes its cwd's worktree, so writes on different worktrees never
815
+ // wait for each other. (`git worktree` never runs as a git write here: it
816
+ // maps to managed operations, which take the repository lock themselves.)
772
817
  function writeScopes(plan, root) {
773
818
  if (REPO_WIDE_OPERATIONS.has(plan.operation)) return null;
774
- if (plan.operation !== 'worktree') return [root];
775
- const [action, ...paths] = optionFreePositionals(plan.args, WORKTREE_VALUE_FLAGS);
776
- const target = (path) => resolve(plan.cwd, path);
777
- // git also names a worktree by a unique path suffix, which no cwd-relative
778
- // path resolves; an argument that is not a worktree here holds the whole
779
- // repository instead of guessing (so do prune and an argument-less repair).
780
- const existing = (path) => (existsSync(join(target(path), '.git')) ? target(path) : null);
781
- let scopes = null;
782
- if (action === 'add') scopes = paths.slice(0, 1).map(target);
783
- else if (action === 'move' && paths.length === 2) scopes = [existing(paths[0]), target(paths[1])];
784
- else if (['remove', 'lock', 'unlock', 'repair'].includes(action)) scopes = paths.map(existing);
785
- return scopes?.length && scopes.every(Boolean) ? scopes : null;
819
+ return [root];
786
820
  }
787
821
 
788
822
  // A mutating git call holds the in-process and cross-process locks on its
@@ -804,6 +838,34 @@ function withGitWriteLocks(lockKey, scopes, root, signal, callback) {
804
838
  );
805
839
  }
806
840
 
841
+ // The managed snapshot worktree `repo` lies in, else null. A registry that
842
+ // cannot be read names none: commands elsewhere run as before.
843
+ function snapshotContaining(repo) {
844
+ let entries;
845
+ try {
846
+ entries = readRegistrySync().entries;
847
+ } catch {
848
+ return null;
849
+ }
850
+ return (
851
+ Object.values(entries).find(
852
+ (record) => record?.kind === WORKTREE_SNAPSHOT_KIND && typeof record.path === 'string' && isWithin(record.path, repo)
853
+ ) ?? null
854
+ );
855
+ }
856
+
857
+ // A command run in a snapshot worktree is one of its consumers: the workspace
858
+ // lease it holds (activity-gate, the same as a background shell's) keeps the
859
+ // snapshot from being removed in any process until it ends, and a snapshot
860
+ // whose removal is under way refuses it. Returns the release.
861
+ function admitSnapshotCommand(repo, sessionId) {
862
+ const snapshot = snapshotContaining(repo);
863
+ if (!snapshot) return () => {};
864
+ const leave = tryAdmitWorkspaceActivity(sessionId || 'git-tool', repo, { kind: 'command' });
865
+ if (!leave) throw new Error(`the snapshot worktree ${snapshot.path} is being removed; nothing ran`);
866
+ return leave;
867
+ }
868
+
807
869
  function invalidateAfterGitMutation() {
808
870
  invalidateBuiltinResultCache();
809
871
  drainCodeGraphCache();
@@ -873,6 +935,7 @@ async function executeGitStage(input, workDir, options = {}) {
873
935
  };
874
936
  const current = await runStageableDiff(snapshot.plan, snapshot.argv, repo, signal);
875
937
  if (!succeeded(current)) return noted(commandFailure(snapshot.plan, current, limit));
938
+ if (current.notUtf8) return noted(fail(NOT_UTF8_STAGE_NOTE));
876
939
  const raw = current.stdout;
877
940
  if (!diffSnapshotMatches(snapshot, raw)) {
878
941
  return answer({
@@ -919,6 +982,15 @@ async function executeSingleGitTool(input, workDir, options = {}) {
919
982
  } catch (error) {
920
983
  return rejected(error.message);
921
984
  }
985
+ // No unmanaged worktree is ever made here: worktree commands run as their
986
+ // managed operations (git-worktree-tool.mjs); only `list --porcelain` stays git.
987
+ if (plan.operation === 'worktree') {
988
+ const mapped = await executeRawWorktree(plan, input, options);
989
+ if (mapped) {
990
+ if (!isReadOnly(plan)) invalidateAfterGitMutation();
991
+ return mapped;
992
+ }
993
+ }
922
994
  if (
923
995
  plan.operation === 'archive' &&
924
996
  !plan.args.some((value) => value === '-o' || value === '--output' || value.startsWith('--output='))
@@ -954,6 +1026,23 @@ async function executeSingleGitTool(input, workDir, options = {}) {
954
1026
  const missing = probe ?? (await runGit(plan, ['rev-parse', '--show-toplevel'], { signal }));
955
1027
  return commandResult(plan, missing, limit);
956
1028
  }
1029
+ // A command in a snapshot worktree uses it like a worker would: it holds a
1030
+ // workspace lease there while it runs, so the snapshot is never removed
1031
+ // under it, and a snapshot being removed refuses it.
1032
+ let leaveSnapshot;
1033
+ try {
1034
+ leaveSnapshot = admitSnapshotCommand(repo, options.sessionId);
1035
+ } catch (error) {
1036
+ return rejected(error.message);
1037
+ }
1038
+ try {
1039
+ return await runInRepository(input, plan, { repo, lockKey, limit, signal, workDir });
1040
+ } finally {
1041
+ leaveSnapshot();
1042
+ }
1043
+ }
1044
+
1045
+ async function runInRepository(input, plan, { repo, lockKey, limit, signal, workDir }) {
957
1046
  const prepared = prepare(plan);
958
1047
  const stageableRequest = plan.operation === 'diff' && (plan.args.length === 0 || plan.args[0] === '--');
959
1048
  if (stageableRequest) prepared.argv.splice(1, 0, '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/');
@@ -965,6 +1054,11 @@ async function executeSingleGitTool(input, workDir, options = {}) {
965
1054
  : await runGit(plan, prepared.argv, { signal, readOnly: true });
966
1055
  if (!succeeded(result)) return commandResult(plan, result, limit);
967
1056
  if (stageableRequest && input.include_stage_ids === true) {
1057
+ if (result.notUtf8) {
1058
+ const rendered = commandResult(plan, result, limit);
1059
+ rendered.text = appendText(rendered.text, NOT_UTF8_STAGE_NOTE);
1060
+ return rendered;
1061
+ }
968
1062
  const snapshot = createDiffSnapshot({
969
1063
  repo,
970
1064
  scope: resolve(workDir || process.cwd()),
@@ -977,6 +1071,9 @@ async function executeSingleGitTool(input, workDir, options = {}) {
977
1071
  return commandResult(plan, result, limit);
978
1072
  }
979
1073
  return withGitWriteLocks(lockKey, writeScopes(plan, repo), repo, signal, async (waitedMs) => {
1074
+ // A managed removal holds the same folder lock while it deletes: once it
1075
+ // is ours, a folder that is gone was removed, and nothing runs there.
1076
+ if (!existsSync(repo)) return rejected(`the worktree folder ${repo} was removed meanwhile; nothing ran`);
980
1077
  const result = await runGitWrite(plan, prepared.argv, { signal });
981
1078
  invalidateAfterGitMutation();
982
1079
  const rendered = commandResult(plan, result, limit);
@@ -1064,13 +1161,23 @@ export async function executeGitTool(rawInput, workDir, options = {}) {
1064
1161
  if (rawInput.change_ids !== undefined) input.change_ids = parseStringifiedArray(rawInput.change_ids);
1065
1162
  if (rawInput.command === undefined) delete input.command;
1066
1163
  const action = input.action ?? 'command';
1164
+ if (action === 'worktree') {
1165
+ const text = await executeWorktreeAction(input, workDir, options);
1166
+ if (input.worktree_action !== 'list') invalidateAfterGitMutation();
1167
+ return text;
1168
+ }
1169
+ const worktreeField = WORKTREE_INPUT_FIELDS.find((field) => input[field] !== undefined);
1170
+ if (worktreeField) return fail(`git ${worktreeField} requires action:worktree`);
1171
+ if (input.confirm_large !== undefined && action !== 'command') {
1172
+ return fail('git confirm_large applies to action:worktree or a git worktree add command');
1173
+ }
1067
1174
  if (action === 'stage') {
1068
1175
  if (input.command !== undefined || input.include_stage_ids !== undefined) {
1069
1176
  return fail('git action:stage accepts diff_id/change_ids, not command/include_stage_ids');
1070
1177
  }
1071
1178
  return executeGitStage(input, workDir, options);
1072
1179
  }
1073
- if (action !== 'command') return fail('git action must be command or stage');
1180
+ if (action !== 'command') return fail('git action must be command, stage or worktree');
1074
1181
  if (input.diff_id !== undefined || input.change_ids !== undefined) {
1075
1182
  return fail('git diff_id/change_ids require action:stage');
1076
1183
  }