mixdog 0.9.67 → 0.9.69

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 (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -3,10 +3,12 @@
3
3
  // keep this module free of the runtime's path/provider constants.
4
4
  import { basename, join } from 'node:path';
5
5
  import { existsSync, readdirSync } from 'node:fs';
6
+ import { createHash } from 'node:crypto';
6
7
  import { clean } from './session-text.mjs';
7
8
  import { normalizeEffortInput } from './effort.mjs';
8
9
  import { isLikelyRawModelId } from './config-helpers.mjs';
9
10
  import { readTextSafe, readJsonSafe } from './fs-utils.mjs';
11
+ import { isHiddenAgent } from '../runtime/agent/orchestrator/internal-agents.mjs';
10
12
 
11
13
  export const WORKFLOW_ROUTE_SLOTS = ['lead', 'agent', 'explorer', 'memory'];
12
14
  export const FIXED_AGENT_SLOTS = Object.freeze([
@@ -42,6 +44,18 @@ function setAgentDefinitionCache(key, value) {
42
44
  agentDefinitionCache.set(key, value);
43
45
  }
44
46
 
47
+ // Editor writes must invalidate the definition cache or a saved AGENT.md
48
+ // stays stale for the session lifetime (keys are `${dir}\n${agentId}`).
49
+ export function clearAgentDefinitionCache(agentId = '') {
50
+ if (!agentId) {
51
+ agentDefinitionCache.clear();
52
+ return;
53
+ }
54
+ for (const key of [...agentDefinitionCache.keys()]) {
55
+ if (key.endsWith(`\n${agentId}`)) agentDefinitionCache.delete(key);
56
+ }
57
+ }
58
+
45
59
  export function workflowPresetId(slot) {
46
60
  return `workflow-${slot}`;
47
61
  }
@@ -69,6 +83,41 @@ export function normalizeWorkflowId(value, fallback = '') {
69
83
  return /^[a-z0-9][a-z0-9_.-]*$/.test(id) ? id : fallback;
70
84
  }
71
85
 
86
+ function internalIdFromName(value, fallbackPrefix) {
87
+ const name = clean(value);
88
+ const readable = name
89
+ .normalize('NFKD')
90
+ .replace(/\p{M}+/gu, '')
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9_.-]+/g, '-')
93
+ .replace(/-+/g, '-')
94
+ .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
95
+ const id = normalizeWorkflowId(readable, '');
96
+ if (id) return id;
97
+ return `${fallbackPrefix}-${createHash('sha256').update(name).digest('hex').slice(0, 10)}`;
98
+ }
99
+
100
+ export function workflowIdFromName(value) {
101
+ return internalIdFromName(value, 'workflow');
102
+ }
103
+
104
+ export function agentIdFromName(value) {
105
+ return internalIdFromName(value, 'agent');
106
+ }
107
+
108
+ export function availableWorkflowId(baseId, isTaken) {
109
+ const id = normalizeWorkflowId(baseId, '');
110
+ if (!id) return '';
111
+ if (!isTaken(id)) return id;
112
+ let suffix = 2;
113
+ while (isTaken(`${id}-${suffix}`)) suffix += 1;
114
+ return `${id}-${suffix}`;
115
+ }
116
+
117
+ export function availableAgentId(name, isTaken) {
118
+ return availableWorkflowId(agentIdFromName(name), isTaken);
119
+ }
120
+
72
121
  // A workflow/agent pack loader is created per data/root layout via
73
122
  // createWorkflowHelpers, and the config-aware route helpers via
74
123
  // createWorkflowRouteHelpers.
@@ -87,6 +136,29 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
87
136
  ];
88
137
  }
89
138
 
139
+ // Custom agents (user-authored roles beyond FIXED_AGENT_SLOTS): every
140
+ // agents/<id>/AGENT.md directory whose id is not a fixed role. Mixdog-managed
141
+ // hidden roles (defaults/agents.json: scheduler-task, webhook-handler, …) ship
142
+ // the same directory layout, so they are excluded here — at the data source —
143
+ // instead of per-surface, or the TUI /agents list exposes internal roles the
144
+ // desktop UI happens to filter out.
145
+ function listCustomAgentIds(dir) {
146
+ const ids = new Set();
147
+ for (const root of [join(dir || dataDir, 'agents'), join(rootDir, 'agents')]) {
148
+ if (!existsSync(root)) continue;
149
+ let entries = [];
150
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { entries = []; }
151
+ for (const entry of entries) {
152
+ if (!entry.isDirectory()) continue;
153
+ const id = normalizeWorkflowId(entry.name);
154
+ if (!id || AGENT_ROLE_IDS.has(id) || isHiddenAgent(id)) continue;
155
+ if (!existsSync(join(root, entry.name, 'AGENT.md'))) continue;
156
+ ids.add(id);
157
+ }
158
+ }
159
+ return [...ids].sort();
160
+ }
161
+
90
162
  function readWorkflowPackFromDir(dir, source = 'built-in', dirName = '') {
91
163
  const entry = 'WORKFLOW.md';
92
164
  const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
@@ -184,21 +256,10 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
184
256
  setAgentDefinitionCache(cacheKey, definition);
185
257
  return definition;
186
258
  }
187
- const legacyDoc = readMarkdownDocument(readTextSafe(join(rootDir, 'agents', `${agentId}.md`)));
188
- if (!legacyDoc.body) {
189
- setAgentDefinitionCache(cacheKey, null);
190
- return null;
191
- }
192
- const definition = {
193
- id: agentId,
194
- name: FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
195
- description: '',
196
- permission: normalizeAgentPermissionOrNone(legacyDoc.frontmatter.permission),
197
- frontmatter: legacyDoc.frontmatter,
198
- body: legacyDoc.body,
199
- };
200
- setAgentDefinitionCache(cacheKey, definition);
201
- return definition;
259
+ // Every shipped and user role lives at agents/<id>/AGENT.md; there is no
260
+ // flat agents/<id>.md layout left to fall back to.
261
+ setAgentDefinitionCache(cacheKey, null);
262
+ return null;
202
263
  }
203
264
 
204
265
  function workflowContextBlock(config, dir) {
@@ -210,7 +271,10 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
210
271
  const lines = [`# Active Workflow: ${pack.name}`];
211
272
  if (pack.description) lines.push(pack.description);
212
273
  lines.push(pack.body);
213
- const agentIds = pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id);
274
+ // A hand-edited pack may name a hidden role in its `agents:` frontmatter;
275
+ // internal roles are never delegatable, so they never enter the catalog.
276
+ const agentIds = (pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id))
277
+ .filter((id) => !isHiddenAgent(id));
214
278
  const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
215
279
  if (agentBlocks.length) {
216
280
  lines.push('# Available Agents');
@@ -241,6 +305,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
241
305
  workflowSummary,
242
306
  activeWorkflowSummary,
243
307
  loadAgentDefinition,
308
+ listCustomAgentIds,
244
309
  workflowContextBlock,
245
310
  activeWorkflowContext,
246
311
  };
@@ -349,7 +414,9 @@ export function createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset
349
414
  }
350
415
 
351
416
  function agentRouteFromConfig(config, agentId, _dataDir) {
352
- const id = normalizeAgentId(agentId);
417
+ // Custom agents (user-authored roles) pass through as workflow-style ids;
418
+ // their routes live in config.agents[<id>] like the fixed roles.
419
+ const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
353
420
  if (!id) return null;
354
421
  // Read/interpret path: inject config.defaultProvider (then DEFAULT_PROVIDER)
355
422
  // when a stored route omits its provider.
@@ -0,0 +1,287 @@
1
+ // Worker-index persistence for the agent tool: the on-disk row store (tag →
2
+ // session), its mtime-keyed parse cache, the batched atomic writer and the
3
+ // tag-map projection. Extracted from agent-tool.mjs, which now owns spawn /
4
+ // dispatch flow only. The tag Maps are passed in by reference so the facade
5
+ // and this store observe the same live state.
6
+ import { readFileSync, statSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+
9
+ import { updateJsonAtomicSync } from '../../runtime/shared/atomic-file.mjs';
10
+ import { resolveAgentTerminalReapMs } from '../../session-runtime/config-helpers.mjs';
11
+ import { WORKER_INDEX_FILE } from './tool-def.mjs';
12
+ import { agentTagOf, clean, positiveInt, rowMatchesContext } from './helpers.mjs';
13
+ import {
14
+ applyWorkerRowUpsert,
15
+ isTerminalWorkerStatus,
16
+ normalizeTagTombstones,
17
+ tagTombstoneKey,
18
+ workerRowKey,
19
+ workerRowTime,
20
+ } from './worker-rows.mjs';
21
+
22
+ export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCwds }) {
23
+ const pendingMutators = [];
24
+ let flushTimer = null;
25
+ // Mtime-keyed parse cache. A single spawn calls refreshTagsFromSessions /
26
+ // resolveTag / nextTag, which each re-read and re-parse this file; across a
27
+ // parallel fanout that is O(spawns^2) synchronous reads of the same bytes.
28
+ let cache = null; // { mtimeMs, size, rows, tombstones }
29
+ let cacheDirty = true;
30
+
31
+ function workerIndexPath() {
32
+ return dataDir ? resolve(dataDir, WORKER_INDEX_FILE) : null;
33
+ }
34
+
35
+ // A terminal row ages out after the configured reap window so the index does
36
+ // not grow without bound; live rows are always kept.
37
+ function keepWorkerRow(row = {}) {
38
+ if (!clean(row.tag) || !clean(row.sessionId)) return false;
39
+ const t = workerRowTime(row);
40
+ if (!t) return true;
41
+ if (!isTerminalWorkerStatus(row.status || row.stage)) return true;
42
+ const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), row.provider);
43
+ return reapMs == null || Date.now() - t < reapMs;
44
+ }
45
+
46
+ function normalizeWorkerRows(value) {
47
+ const source = Array.isArray(value?.workers)
48
+ ? value.workers
49
+ : (value?.workers && typeof value.workers === 'object'
50
+ ? Object.values(value.workers)
51
+ : (Array.isArray(value) ? value : []));
52
+ return source
53
+ .filter((row) => row && typeof row === 'object')
54
+ .map((row) => ({
55
+ tag: clean(row.tag),
56
+ sessionId: clean(row.sessionId),
57
+ agent: clean(row.agent) || null,
58
+ provider: clean(row.provider) || null,
59
+ model: clean(row.model) || null,
60
+ preset: clean(row.preset) || null,
61
+ effort: clean(row.effort) || null,
62
+ fast: row.fast === true ? true : (row.fast === false ? false : null),
63
+ status: clean(row.status) || 'idle',
64
+ stage: clean(row.stage) || clean(row.status) || 'idle',
65
+ createdAt: clean(row.createdAt) || null,
66
+ updatedAt: clean(row.updatedAt) || null,
67
+ lastUsedAt: clean(row.lastUsedAt) || null,
68
+ finishedAt: clean(row.finishedAt) || null,
69
+ clientHostPid: positiveInt(row.clientHostPid),
70
+ cwd: clean(row.cwd) || null,
71
+ task_id: clean(row.task_id || row.taskId) || null,
72
+ error: clean(row.error) || null,
73
+ permission: clean(row.permission) || null,
74
+ toolPermission: clean(row.toolPermission) || null,
75
+ messages: positiveInt(row.messages) || 0,
76
+ tools: positiveInt(row.tools) || 0,
77
+ }))
78
+ .filter(keepWorkerRow);
79
+ }
80
+
81
+ function invalidateCache() {
82
+ cacheDirty = true;
83
+ }
84
+
85
+ function readAllWorkerRows() {
86
+ const file = workerIndexPath();
87
+ if (!file) return [];
88
+ let st = null;
89
+ try { st = statSync(file); } catch { cache = null; return []; }
90
+ if (!cacheDirty && cache && cache.mtimeMs === st.mtimeMs && cache.size === st.size) {
91
+ return cache.rows;
92
+ }
93
+ let rows = [];
94
+ let tombstones = [];
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
97
+ rows = normalizeWorkerRows(parsed);
98
+ tombstones = normalizeTagTombstones(parsed);
99
+ } catch {
100
+ rows = [];
101
+ tombstones = [];
102
+ }
103
+ cache = { mtimeMs: st.mtimeMs, size: st.size, rows, tombstones };
104
+ cacheDirty = false;
105
+ return rows;
106
+ }
107
+
108
+ function readAllTagTombstones() {
109
+ readAllWorkerRows();
110
+ return cache?.tombstones || [];
111
+ }
112
+
113
+ function readTagTombstones(context = {}) {
114
+ return readAllTagTombstones().filter((row) => rowMatchesContext(row, context));
115
+ }
116
+
117
+ function readWorkerRows(context = {}) {
118
+ const rows = readAllWorkerRows();
119
+ if (rows.length === 0) return rows;
120
+ return rows.filter((row) => rowMatchesContext(row, context));
121
+ }
122
+
123
+ // Single writer path: every mutation re-reads under the file lock, applies
124
+ // the caller's mutator over keyed maps, then republishes rows + tombstones.
125
+ function writeWorkerRows(mutator) {
126
+ const file = workerIndexPath();
127
+ if (!file || typeof mutator !== 'function') return null;
128
+ try {
129
+ const result = updateJsonAtomicSync(file, (cur) => {
130
+ const byKey = new Map();
131
+ for (const row of normalizeWorkerRows(cur)) {
132
+ const key = workerRowKey(row);
133
+ if (key) byKey.set(key, row);
134
+ }
135
+ const tombstonesByKey = new Map();
136
+ for (const row of normalizeTagTombstones(cur, { cap: false })) {
137
+ tombstonesByKey.set(tagTombstoneKey(row), row);
138
+ }
139
+ const priorityTombstoneKeys = new Set();
140
+ mutator(byKey, tombstonesByKey, priorityTombstoneKeys);
141
+ const workers = {};
142
+ for (const row of [...byKey.values()].filter(keepWorkerRow)) {
143
+ const key = workerRowKey(row);
144
+ if (key) workers[key] = row;
145
+ }
146
+ const tombstones = {};
147
+ for (const row of normalizeTagTombstones(
148
+ { tombstones: [...tombstonesByKey.values()] },
149
+ { priorityKeys: priorityTombstoneKeys },
150
+ )) {
151
+ tombstones[tagTombstoneKey(row)] = row;
152
+ }
153
+ return { version: 2, updatedAt: new Date().toISOString(), workers, tombstones };
154
+ }, { lock: true });
155
+ // This process just rewrote the index; force the next read to re-parse
156
+ // even if the new mtime/size happen to collide with the cached stat.
157
+ invalidateCache();
158
+ return result;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // Spawn-path writes are batched onto one microtask so a parallel fanout pays
165
+ // a single locked rewrite instead of one per worker.
166
+ function flushWorkerIndexMutations() {
167
+ if (flushTimer) {
168
+ try { clearImmediate(flushTimer); } catch { /* already fired */ }
169
+ flushTimer = null;
170
+ }
171
+ if (pendingMutators.length === 0) return;
172
+ const batch = pendingMutators.splice(0, pendingMutators.length);
173
+ writeWorkerRows((byKey) => {
174
+ for (const mutator of batch) {
175
+ try { mutator(byKey); } catch { /* one bad row never drops the batch */ }
176
+ }
177
+ });
178
+ }
179
+
180
+ function queueWorkerIndexMutation(mutator) {
181
+ if (typeof mutator !== 'function') return false;
182
+ if (!workerIndexPath()) return false;
183
+ pendingMutators.push(mutator);
184
+ if (!flushTimer) {
185
+ flushTimer = setImmediate(flushWorkerIndexMutations);
186
+ flushTimer.unref?.();
187
+ }
188
+ return true;
189
+ }
190
+
191
+ function workerRowFromSession(session, fallbackTag = '', extra = {}) {
192
+ const tag = agentTagOf(session) || clean(fallbackTag) || clean(extra.tag);
193
+ const sessionId = clean(session?.id || extra.sessionId);
194
+ if (!tag || !sessionId) return null;
195
+ const runtime = mgr.getSessionRuntime?.(sessionId);
196
+ const status = clean(extra.status) || (session?.closed === true ? 'closed' : clean(session?.status) || 'idle');
197
+ const stage = clean(extra.stage) || clean(runtime?.stage) || status;
198
+ const nowIso = new Date().toISOString();
199
+ return {
200
+ tag,
201
+ sessionId,
202
+ agent: clean(extra.agent) || clean(session?.agent) || null,
203
+ provider: clean(extra.provider) || clean(session?.provider) || null,
204
+ model: clean(extra.model) || clean(session?.model) || null,
205
+ preset: clean(extra.preset) || clean(session?.presetName) || null,
206
+ effort: clean(extra.effort) || clean(session?.effort) || null,
207
+ fast: extra.fast === true || extra.fast === false ? extra.fast : (session?.fast === true ? true : null),
208
+ status,
209
+ stage,
210
+ createdAt: clean(session?.createdAt) || clean(extra.createdAt) || nowIso,
211
+ updatedAt: clean(extra.updatedAt) || nowIso,
212
+ lastUsedAt: clean(session?.lastUsedAt) || null,
213
+ finishedAt: clean(extra.finishedAt) || null,
214
+ clientHostPid: positiveInt(extra.clientHostPid) || positiveInt(session?.clientHostPid),
215
+ cwd: clean(session?.cwd) || clean(extra.cwd) || null,
216
+ task_id: clean(extra.task_id || extra.taskId) || null,
217
+ error: clean(extra.error) || null,
218
+ permission: clean(session?.permission) || null,
219
+ toolPermission: clean(session?.toolPermission) || null,
220
+ messages: Array.isArray(session?.messages) ? session.messages.length : 0,
221
+ tools: Array.isArray(session?.tools) ? session.tools.length : 0,
222
+ };
223
+ }
224
+
225
+ // Every upsert also projects the row into the in-memory tag maps so tag
226
+ // lookups never wait on a disk round-trip.
227
+ function upsertWorkerRow(row, { defer = false } = {}) {
228
+ const normalized = normalizeWorkerRows({ workers: [row] })[0];
229
+ if (!normalized) return false;
230
+ tags.set(normalized.tag, normalized.sessionId);
231
+ if (normalized.agent) tagAgents.set(normalized.tag, normalized.agent);
232
+ if (normalized.cwd) tagCwds.set(normalized.tag, normalized.cwd);
233
+ if (defer) return queueWorkerIndexMutation((byKey) => applyWorkerRowUpsert(byKey, normalized));
234
+ writeWorkerRows((byKey) => { applyWorkerRowUpsert(byKey, normalized); });
235
+ return true;
236
+ }
237
+
238
+ function upsertWorkerSession(session, fallbackTag = '', extra = {}) {
239
+ return upsertWorkerRow(workerRowFromSession(session, fallbackTag, extra));
240
+ }
241
+
242
+ function upsertWorkerSessionDeferred(session, fallbackTag = '', extra = {}) {
243
+ return upsertWorkerRow(workerRowFromSession(session, fallbackTag, extra), { defer: true });
244
+ }
245
+
246
+ function removeWorkerRow({ tag = '', sessionId = '' } = {}) {
247
+ const targetTag = clean(tag);
248
+ const targetSessionId = clean(sessionId);
249
+ flushWorkerIndexMutations();
250
+ writeWorkerRows((byKey) => {
251
+ for (const [key, row] of [...byKey.entries()]) {
252
+ if ((targetSessionId && row.sessionId === targetSessionId) || (targetTag && row.tag === targetTag)) {
253
+ byKey.delete(key);
254
+ }
255
+ }
256
+ });
257
+ }
258
+
259
+ function refreshTagsFromIndex(context = {}) {
260
+ const rows = readWorkerRows(context);
261
+ for (const row of rows) {
262
+ if (!row.tag || !row.sessionId) continue;
263
+ tags.set(row.tag, row.sessionId);
264
+ if (row.agent) tagAgents.set(row.tag, row.agent);
265
+ if (row.cwd) tagCwds.set(row.tag, row.cwd);
266
+ }
267
+ return rows;
268
+ }
269
+
270
+ return {
271
+ workerIndexPath,
272
+ invalidateWorkerRowsCache: invalidateCache,
273
+ readAllWorkerRows,
274
+ readAllTagTombstones,
275
+ readTagTombstones,
276
+ readWorkerRows,
277
+ writeWorkerRows,
278
+ flushWorkerIndexMutations,
279
+ queueWorkerIndexMutation,
280
+ workerRowFromSession,
281
+ upsertWorkerRow,
282
+ upsertWorkerSession,
283
+ upsertWorkerSessionDeferred,
284
+ removeWorkerRow,
285
+ refreshTagsFromIndex,
286
+ };
287
+ }