mixdog 1.3.3 → 1.3.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
@@ -153,7 +153,7 @@
153
153
  "arm64"
154
154
  ],
155
155
  "dependencies": {
156
- "@anthropic-ai/sdk": "^0.105.0",
156
+ "@anthropic-ai/sdk": "^0.135.0",
157
157
  "@formulajs/formulajs": "^4.6.1",
158
158
  "@google/generative-ai": "^0.24.1",
159
159
  "@huggingface/transformers": "^4.3.1",
@@ -10,7 +10,7 @@ import { createNpmVersionSource, maxSemver } from './npm-cli-version.mjs';
10
10
  // source release just to advance the user-agent version.
11
11
  // Claude Opus 5.5 requires 2.1.280 or newer. The release workflow raises this
12
12
  // to the published CLI version (scripts/sync-client-version-floors.mjs).
13
- export const DEFAULT_CLI_VERSION = '2.1.308';
13
+ export const DEFAULT_CLI_VERSION = '2.1.309';
14
14
  export const CLAUDE_CLI_NPM_PACKAGE = '@anthropic-ai/claude-code';
15
15
 
16
16
  const CACHE_SCHEMA_VERSION = 1;
@@ -30,7 +30,7 @@ function hosts() {
30
30
  * resolves the turn's end status ('done' when it finished).
31
31
  * - `stop(roomSessionId)` → boolean: the room's stop button: the speaking
32
32
  * member's turn is cancelled, queued calls settle cancelled, and no one else
33
- * gets a turn; true when an exchange was running.
33
+ * gets a turn; true when an exchange or incoming request was cancelled.
34
34
  * `hop`: the send's or report's place in a bot-to-bot chain (bot-hops.mjs);
35
35
  * the member's turn carries it, so the sends it makes count on from there.
36
36
  * `follow(submissionId)` (both): the turn that answers is submitted under
@@ -1,78 +1,15 @@
1
1
  /**
2
- * Durable per-day usage rollup (provider × model).
3
- *
4
- * The gateway usage store keeps one raw event per turn, but it is bounded to a
5
- * recent window (TTL + row cap) because it exists to answer "what is happening
6
- * right now". Totals for a month ago cannot be recovered from it once the cap
7
- * evicts those rows, so every turn is ALSO folded into a per-day bucket here.
8
- * A bucket is a few numbers, which keeps years of history in one small file.
9
- *
10
- * Most turns on this machine are NOT the user typing: background runners
11
- * (memory cycles, schedules, webhooks) spend tokens on their own and outnumber
12
- * conversation turns several times over. Every bucket therefore carries a
13
- * `conversation` sub-total beside the full one, so "what did I spend" can be
14
- * answered without the background drowning it.
15
- *
16
- * Session token totals are kept id-keyed only while a day is recent; older days
17
- * keep the anonymous per-session totals alone. Only conversation sessions are
18
- * tracked: a background runner mints a fresh id per cycle, which would turn
19
- * "how many sessions" into a count of cycles.
20
- *
21
- * `firstTs`/`lastTs` bound the turns already folded into a day. A reader that
22
- * also holds the raw event list adds ONLY the events outside that span, which
23
- * is what lets both stores be read together without counting a turn twice.
24
- *
25
- * Shape:
26
- * {
27
- * version, updatedAt,
28
- * days: {
29
- * "2026-08-12": {
30
- * turns, input, output, cacheRead, cacheWrite,
31
- * costUsd, costBilled, costEstimated, costKnownTurns,
32
- * durationMs, durationTurns, firstTs, lastTs,
33
- * conversation: { …the same turn totals, user-driven turns only },
34
- * conversationPartial,
35
- * models: { "<provider>/<model>": {
36
- * provider, model, kind, turns, input, output, cacheRead, cacheWrite,
37
- * costUsd, conversation: { …route totals }
38
- * } },
39
- * sessions: { "<sessionId>": <tokens> },
40
- * sessionTokens: [<tokens>, …],
41
- * sessionsDropped
42
- * }
43
- * }
44
- * }
2
+ * Day keys, conversation-source classification and numeric helpers shared by the
3
+ * usage ledger and the stats model. The daily JSON rollup these helpers were
4
+ * written for is no longer produced; its past day records are still imported
5
+ * by usage-ledger-import.mjs.
45
6
  */
46
- import { billableInputTokensForProvider } from './cost.mjs';
47
-
48
- const USAGE_ROLLUP_VERSION = 2;
49
-
50
- // A day costs well under a kilobyte, so a long retention stays cheap while
51
- // making "all time" mean something closer to the install date.
52
- const RETENTION_DAYS = 400;
53
- // Session ids are the only identifying values in the file; drop them once the
54
- // day is old enough that per-session drill-down no longer matters.
55
- const SESSION_DETAIL_DAYS = 45;
56
- const MAX_MODELS_PER_DAY = 80;
57
- const MAX_SESSION_IDS_PER_DAY = 400;
58
- const MAX_SESSION_TOKENS_PER_DAY = 400;
59
- const DAY_MS = 24 * 60 * 60 * 1000;
60
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
61
7
 
62
8
  export function num(value) {
63
9
  const n = Number(value);
64
10
  return Number.isFinite(n) && n > 0 ? n : 0;
65
11
  }
66
12
 
67
- export function cleanId(value) {
68
- const text = typeof value === 'string' ? value.trim() : '';
69
- return text && !UNSAFE_KEYS.has(text) ? text.slice(0, 200) : '';
70
- }
71
-
72
- export function round6(value) {
73
- return Math.round(value * 1e6) / 1e6;
74
- }
75
-
76
13
  /** Local calendar day, so "today" in the UI means the user's today. */
77
14
  export function usageRollupDayKey(ts) {
78
15
  const date = new Date(Number.isFinite(Number(ts)) ? Number(ts) : Date.now());
@@ -92,324 +29,8 @@ export function isConversationUsageSource(sourceType) {
92
29
  return !BACKGROUND_SOURCES.has(id);
93
30
  }
94
31
 
95
- function emptyRouteTotals() {
96
- return {
97
- turns: 0,
98
- input: 0,
99
- output: 0,
100
- cacheRead: 0,
101
- cacheWrite: 0,
102
- costUsd: 0,
103
- costBilled: 0,
104
- costEstimated: 0,
105
- costKnownTurns: 0,
106
- };
107
- }
108
-
109
- function emptyTurnTotals() {
110
- return {
111
- ...emptyRouteTotals(),
112
- durationMs: 0,
113
- durationTurns: 0,
114
- };
115
- }
116
-
117
- function addRouteTotals(target, turn, { priced, billed } = {}) {
118
- target.turns += turn.turns;
119
- target.input += turn.input;
120
- target.output += turn.output;
121
- target.cacheRead += turn.cacheRead;
122
- target.cacheWrite += turn.cacheWrite;
123
- target.costUsd = round6(target.costUsd + turn.costUsd);
124
- if (priced) {
125
- target.costKnownTurns += 1;
126
- if (billed) target.costBilled = round6(target.costBilled + turn.costUsd);
127
- else target.costEstimated = round6(target.costEstimated + turn.costUsd);
128
- }
129
- }
130
-
131
- function addTurnTotals(target, turn, pricing) {
132
- addRouteTotals(target, turn, pricing);
133
- const { durationMs } = pricing;
134
- if (durationMs > 0) {
135
- target.durationMs += durationMs;
136
- target.durationTurns += 1;
137
- }
138
- }
139
-
140
- function emptyModel(provider, model) {
141
- return {
142
- provider,
143
- model,
144
- kind: '',
145
- ...emptyRouteTotals(),
146
- conversation: emptyRouteTotals(),
147
- };
148
- }
149
-
150
- function emptyDay() {
151
- return {
152
- ...emptyTurnTotals(),
153
- firstTs: 0,
154
- lastTs: 0,
155
- conversation: emptyTurnTotals(),
156
- conversationPartial: false,
157
- // Rebuilt from session transcripts rather than recorded turn by turn. The
158
- // flag travels with the bucket so a surface can keep saying so long after
159
- // the transcripts it came from are gone.
160
- restored: false,
161
- // Set once a day's pre-classification session ids have been cleared, which
162
- // is what tells a reader the remaining ids can be trusted.
163
- sessionsPurged: true,
164
- models: {},
165
- sessions: {},
166
- sessionTokens: [],
167
- sessionsDropped: 0,
168
- };
169
- }
170
-
171
- function readRouteTotals(raw, target) {
172
- target.turns = num(raw?.turns);
173
- target.input = num(raw?.input);
174
- target.output = num(raw?.output);
175
- target.cacheRead = num(raw?.cacheRead);
176
- target.cacheWrite = num(raw?.cacheWrite);
177
- target.costUsd = round6(num(raw?.costUsd));
178
- target.costBilled = round6(num(raw?.costBilled));
179
- target.costEstimated = round6(
180
- raw?.costEstimated == null ? Math.max(0, target.costUsd - target.costBilled) : num(raw.costEstimated)
181
- );
182
- if (raw?.costKnownTurns != null) target.costKnownTurns = num(raw.costKnownTurns);
183
- else target.costKnownTurns = target.costUsd > 0 ? target.turns : 0;
184
- return target;
185
- }
186
-
187
32
  /** Turns with a known cost: the recorded count, else every turn when the usage carries any cost at all. */
188
33
  export function knownCostTurns(usage, amount = num) {
189
34
  if (usage.costKnownTurns != null) return amount(usage.costKnownTurns);
190
35
  return amount(usage.costUsd) > 0 ? amount(usage.turns) : 0;
191
36
  }
192
-
193
- function readTurnTotals(raw, target) {
194
- readRouteTotals(raw, target);
195
- target.costEstimated = round6(num(raw?.costEstimated));
196
- target.costKnownTurns = num(raw?.costKnownTurns);
197
- target.durationMs = num(raw?.durationMs);
198
- target.durationTurns = num(raw?.durationTurns);
199
- return target;
200
- }
201
-
202
- function normalizeModel(raw, key) {
203
- const slash = typeof key === 'string' ? key.indexOf('/') : -1;
204
- const bucket = emptyModel(
205
- cleanId(raw?.provider) || (slash > 0 ? key.slice(0, slash) : ''),
206
- cleanId(raw?.model) || (slash > 0 ? key.slice(slash + 1) : '')
207
- );
208
- bucket.kind = cleanId(raw?.kind);
209
- readRouteTotals(raw, bucket);
210
- // A bucket written before turns carried a source has no split to read.
211
- // `null` means unknown, which a reader must not quietly treat as zero.
212
- bucket.conversation =
213
- raw?.conversation && typeof raw.conversation === 'object'
214
- ? readRouteTotals(raw.conversation, emptyRouteTotals())
215
- : null;
216
- return bucket;
217
- }
218
-
219
- function normalizeDay(raw) {
220
- const day = emptyDay();
221
- readTurnTotals(raw, day);
222
- day.firstTs = num(raw?.firstTs);
223
- day.lastTs = num(raw?.lastTs);
224
- day.conversation =
225
- raw?.conversation && typeof raw.conversation === 'object'
226
- ? readTurnTotals(raw.conversation, emptyTurnTotals())
227
- : null;
228
- day.conversationPartial = raw?.conversationPartial === true;
229
- day.restored = raw?.restored === true;
230
- // A day that never held unclassified turns needs no purge; one that did is
231
- // trusted only once it says the old ids were cleared.
232
- day.sessionsPurged = !day.conversationPartial || raw?.sessionsPurged === true;
233
- day.sessionsDropped = num(raw?.sessionsDropped);
234
- const models = raw?.models && typeof raw.models === 'object' ? raw.models : {};
235
- for (const [key, value] of Object.entries(models)) {
236
- const id = cleanId(key);
237
- if (!id) continue;
238
- day.models[id] = normalizeModel(value, id);
239
- }
240
- const sessions = raw?.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
241
- for (const [key, value] of Object.entries(sessions)) {
242
- const id = cleanId(key);
243
- if (!id) continue;
244
- day.sessions[id] = num(value);
245
- }
246
- if (Array.isArray(raw?.sessionTokens)) {
247
- day.sessionTokens = raw.sessionTokens.map(num).slice(-MAX_SESSION_TOKENS_PER_DAY);
248
- }
249
- return day;
250
- }
251
-
252
- /** Defensive read: a hand-edited or partially written file must not throw. */
253
- export function normalizeUsageRollup(raw) {
254
- const days = {};
255
- const source = raw?.days && typeof raw.days === 'object' ? raw.days : {};
256
- for (const [key, value] of Object.entries(source)) {
257
- if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
258
- days[key] = normalizeDay(value);
259
- }
260
- return {
261
- version: USAGE_ROLLUP_VERSION,
262
- updatedAt: num(raw?.updatedAt),
263
- days,
264
- };
265
- }
266
-
267
- /** Retention + identifier aging. Both bounds are evaluated against `now`. */
268
- function pruneUsageRollup(rollup, now = Date.now()) {
269
- const dropBefore = usageRollupDayKey(now - RETENTION_DAYS * DAY_MS);
270
- const anonymizeBefore = usageRollupDayKey(now - SESSION_DETAIL_DAYS * DAY_MS);
271
- for (const [key, day] of Object.entries(rollup.days)) {
272
- if (key < dropBefore) {
273
- delete rollup.days[key];
274
- continue;
275
- }
276
- if (key >= anonymizeBefore) continue;
277
- const ids = Object.keys(day.sessions);
278
- if (!ids.length) continue;
279
- const archived = day.sessionTokens.concat(ids.map((id) => day.sessions[id]));
280
- day.sessionTokens = archived.slice(-MAX_SESSION_TOKENS_PER_DAY);
281
- day.sessions = {};
282
- }
283
- return rollup;
284
- }
285
-
286
- /**
287
- * Fold one gateway usage summary into the rollup. Pure: the caller owns the
288
- * document, which is what makes this testable without touching disk.
289
- */
290
- export function foldUsageRollup(current, summary, now = Date.now()) {
291
- const rollup = normalizeUsageRollup(current);
292
- const provider = cleanId(summary?.provider);
293
- const model = cleanId(summary?.model);
294
- // A turn that cannot be attributed to a route would silently inflate every
295
- // total it lands in, so it is dropped rather than bucketed as "unknown".
296
- if (!provider || !model) return pruneUsageRollup(rollup, now);
297
-
298
- const at = Number(summary?.ts ?? summary?.at);
299
- const ts = Number.isFinite(at) && at > 0 ? at : now;
300
- const key = usageRollupDayKey(ts);
301
- const day = rollup.days[key] || emptyDay();
302
-
303
- const cacheRead = num(summary?.cacheReadTokens);
304
- const cacheWrite = num(summary?.cacheWriteTokens);
305
- const turn = {
306
- turns: 1,
307
- // Providers disagree about what `inputTokens` counts: several report the
308
- // whole prompt with the cached part inside it. Stripping it where it is
309
- // already included is what keeps one cache hit from being counted twice.
310
- input: billableInputTokensForProvider(provider, num(summary?.inputTokens), cacheRead, cacheWrite),
311
- output: num(summary?.outputTokens),
312
- cacheRead,
313
- cacheWrite,
314
- costUsd: num(summary?.costUsd),
315
- };
316
- const tokens = turn.input + turn.output + turn.cacheRead + turn.cacheWrite;
317
- const costSource = cleanId(summary?.costSource);
318
- // costSource 'none' means neither the provider nor the catalog priced this
319
- // turn; counting it keeps the surface honest about partial cost coverage.
320
- const pricing = {
321
- priced:
322
- summary?.costUsd != null &&
323
- Number.isFinite(Number(summary.costUsd)) &&
324
- !['', 'none', 'unpriced'].includes(costSource),
325
- billed: costSource === 'provider',
326
- durationMs: num(summary?.durationMs),
327
- };
328
- const conversation = isConversationUsageSource(summary?.sourceType);
329
-
330
- addTurnTotals(day, turn, pricing);
331
- day.firstTs = day.firstTs > 0 ? Math.min(day.firstTs, ts) : ts;
332
- day.lastTs = Math.max(day.lastTs, ts);
333
- if (conversation) {
334
- if (!day.conversation) {
335
- // A day carried over from before turns were classified: what is already
336
- // in it stays unattributed, and the flag lets a reader say so.
337
- day.conversation = emptyTurnTotals();
338
- day.conversationPartial = true;
339
- // Those ids were collected while background cycles still counted, and
340
- // most of them are cycles. Keeping them would report cycles as sessions.
341
- day.sessions = {};
342
- day.sessionTokens = [];
343
- day.sessionsPurged = true;
344
- }
345
- addTurnTotals(day.conversation, turn, pricing);
346
- }
347
-
348
- const modelKey = `${provider}/${model}`;
349
- const known = Object.hasOwn(day.models, modelKey);
350
- if (known || Object.keys(day.models).length < MAX_MODELS_PER_DAY) {
351
- const bucket = known ? day.models[modelKey] : emptyModel(provider, model);
352
- bucket.kind = cleanId(summary?.providerKind) || bucket.kind;
353
- addRouteTotals(bucket, turn, pricing);
354
- if (conversation) {
355
- if (!bucket.conversation) bucket.conversation = emptyRouteTotals();
356
- addRouteTotals(bucket.conversation, turn, pricing);
357
- }
358
- day.models[modelKey] = bucket;
359
- }
360
-
361
- // Only conversation sessions are kept: a background runner mints a new id per
362
- // cycle, so counting those would report cycles as sessions.
363
- const sessionId = conversation ? cleanId(summary?.sessionId) : '';
364
- if (sessionId) {
365
- if (Object.hasOwn(day.sessions, sessionId)) {
366
- day.sessions[sessionId] += tokens;
367
- } else if (Object.keys(day.sessions).length < MAX_SESSION_IDS_PER_DAY) {
368
- day.sessions[sessionId] = tokens;
369
- } else {
370
- day.sessionsDropped += 1;
371
- }
372
- }
373
-
374
- rollup.days[key] = day;
375
- rollup.updatedAt = now;
376
- return pruneUsageRollup(rollup, now);
377
- }
378
-
379
- /**
380
- * Write transcript-rebuilt days into the rollup, so they outlive the sessions
381
- * they were derived from.
382
- *
383
- * The session store prunes: ephemeral sessions past 14 days or beyond the
384
- * newest 300 are deleted, which silently takes their tokens out of any figure
385
- * derived from them. The rollup is a few numbers per day kept for 400 days, so
386
- * once a rebuilt day lands here it stops depending on the transcripts at all.
387
- *
388
- * Recording always wins. A day the rollup already holds is left exactly as it
389
- * is — including a day already frozen this way, because re-deriving it from a
390
- * half-pruned store would quietly shrink it.
391
- *
392
- * Pure: the caller owns the document.
393
- */
394
- export function freezeRestoredDays(current, historyDays, now = Date.now()) {
395
- const rollup = normalizeUsageRollup(current);
396
- const source = historyDays && typeof historyDays === 'object' ? historyDays : {};
397
- const dropBefore = usageRollupDayKey(now - RETENTION_DAYS * DAY_MS);
398
- const today = usageRollupDayKey(now);
399
- let frozen = 0;
400
- for (const [key, day] of Object.entries(source)) {
401
- if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
402
- if (Object.hasOwn(rollup.days, key)) continue;
403
- if (key < dropBefore) continue;
404
- // Today is still being recorded turn by turn; freezing it now would pin a
405
- // partial rebuild and lock out the rest of the day's real events.
406
- if (key >= today) continue;
407
- const bucket = normalizeDay(day);
408
- if (bucket.turns <= 0) continue;
409
- bucket.restored = true;
410
- rollup.days[key] = bucket;
411
- frozen += 1;
412
- }
413
- if (frozen > 0) rollup.updatedAt = now;
414
- return { rollup, frozen };
415
- }
@@ -4,12 +4,57 @@
4
4
  * status for the facade.
5
5
  */
6
6
 
7
- export function createRoutedAgentTool({ rt, agentTool, executeAgentControl = null }) {
7
+ import { clean } from '../runtime/shared/clean.mjs';
8
+
9
+ const lower = (value) => clean(value).toLowerCase();
10
+
11
+ // A pinned model named by its catalog display name ("GPT-6.1-Sol") is not a
12
+ // wire id. Canonicalize it only on an unambiguous EXACT match: the id itself,
13
+ // or a display name / label, case-insensitively, within the requested provider
14
+ // (or across providers when none is given and exactly one row matches).
15
+ // Anything else — no match, several matches, an empty or partial catalog —
16
+ // returns the args unchanged: the catalog never refuses a model and never
17
+ // substitutes a near one.
18
+ export function canonicalizePinnedModel(args, rows) {
19
+ const model = clean(args?.model);
20
+ if (!model || !Array.isArray(rows)) return args;
21
+ const provider = lower(args.provider);
22
+ const pool = rows.filter((row) => row && (!provider || lower(row.provider) === provider));
23
+ const wanted = model.toLowerCase();
24
+ const exactId = pool.filter((row) => row.id === model);
25
+ // A partial catalog may lack the real id a display name collides with, so
26
+ // only an exact id can pass there; aliases wait for a complete catalog.
27
+ const partial = pool.some((row) => row.catalogPartial === true);
28
+ const hits = exactId.length
29
+ ? exactId
30
+ : partial
31
+ ? []
32
+ : pool.filter((row) => lower(row.id) === wanted || lower(row.display) === wanted || lower(row.label) === wanted);
33
+ const distinct = [...new Map(hits.map((row) => [`${row.provider}\n${row.id}`, row])).values()];
34
+ if (distinct.length !== 1) return args;
35
+ const [hit] = distinct;
36
+ if (hit.id === model && hit.provider === args.provider) return args;
37
+ return { ...args, provider: hit.provider, model: hit.id };
38
+ }
39
+
40
+ // `listProviderModels` is this runtime's provider catalog. The agent control
41
+ // behind `executeAgentControl` (the daemon's canonical agent) has none, so a
42
+ // display-name model is canonicalized here before it is forwarded. Only the
43
+ // cached/quick catalog is read, so a spawn never waits on provider listings.
44
+ export function createRoutedAgentTool({ rt, agentTool, executeAgentControl = null, listProviderModels = null }) {
8
45
  const routedAgentTool = {
9
46
  ...agentTool,
10
- execute(args, context = {}) {
11
- if (typeof executeAgentControl === 'function') return executeAgentControl(args, context);
12
- return agentTool.execute(args, context);
47
+ async execute(args, context = {}) {
48
+ if (typeof executeAgentControl !== 'function') return agentTool.execute(args, context);
49
+ let routedArgs = args;
50
+ if (typeof listProviderModels === 'function' && clean(args?.model) && (clean(args.type) || 'spawn') === 'spawn') {
51
+ try {
52
+ routedArgs = canonicalizePinnedModel(args, await listProviderModels({ quick: true }));
53
+ } catch {
54
+ routedArgs = args;
55
+ }
56
+ }
57
+ return executeAgentControl(routedArgs, context);
13
58
  },
14
59
  closeAll(reason, scope = {}) {
15
60
  if (typeof executeAgentControl !== 'function') {
@@ -98,6 +98,8 @@ function wireAgent(boot) {
98
98
  rt,
99
99
  agentTool,
100
100
  executeAgentControl: params.executeAgentControl,
101
+ // Lazy, as above.
102
+ listProviderModels: (options) => boot.collectProviderModels(options),
101
103
  });
102
104
  bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
103
105
  Object.assign(boot, { agentTool, routedAgentTool, agentStatusState });
@@ -5,7 +5,7 @@ import {
5
5
  listBackgroundTasks,
6
6
  taskIdFromArgs,
7
7
  } from '../../../runtime/shared/background-tasks.mjs';
8
- import { agentScope, clean, clearAgentStatuslineRoute } from './helpers.mjs';
8
+ import { agentScope, clean, clearAgentStatuslineRoute, sessionMatchesContext } from './helpers.mjs';
9
9
  import { isLeadPoolAgent } from './worker-rows.mjs';
10
10
  import { createCloseAll } from './close-flow/close-all.mjs';
11
11
  import { closeManagedSession } from '../../lifecycle/shared.mjs';
@@ -20,6 +20,16 @@ export function createCloseFlow({ mgr, registry, views }) {
20
20
  // Stale local metadata only: resolveTag found no session in this
21
21
  // terminal/scope, so there is no sessionId-safe worker row to delete. Never
22
22
  // turn it into a tag-wide persisted-row removal.
23
+ // `tags`/`tagAgents` are shared by every caller on the canonical agent, so a
24
+ // binding alone does not prove the caller owns it: only a live session or a
25
+ // persisted row inside the caller's scope does. Another session's tag must
26
+ // stay untouched.
27
+ function tagBoundByCaller(target, scopedContext) {
28
+ const live = registry.getLiveSession(tags.get(target));
29
+ if (live) return sessionMatchesContext(live, scopedContext);
30
+ return registry.readWorkerRows(scopedContext).some((row) => clean(row.tag) === target);
31
+ }
32
+
23
33
  function forgetStaleLocalTag(target, task) {
24
34
  registry.unbindTag(target);
25
35
  if (task?.taskId) cancelBackgroundTask(task.taskId, CLOSE_REASON);
@@ -55,7 +65,9 @@ export function createCloseFlow({ mgr, registry, views }) {
55
65
  }
56
66
  const sessionId = registry.resolveTag(target, scopedContext, { scanSessions: registry.wantsSessionScan(args) });
57
67
  if (!sessionId) {
58
- if (!target.startsWith('sess_') && tagAgents.has(target)) return forgetStaleLocalTag(target, task);
68
+ if (!target.startsWith('sess_') && tagAgents.has(target) && tagBoundByCaller(target, scopedContext)) {
69
+ return forgetStaleLocalTag(target, task);
70
+ }
59
71
  throw new Error(`agent close: target "${target}" not found`);
60
72
  }
61
73
  if (isLeadPoolAgent(registry.getLiveSession(sessionId)?.agent)) {
@@ -67,7 +67,12 @@ export function createAgentExecute({
67
67
  }
68
68
  // Targetless status is the same compact overview as list.
69
69
  return renderResult({
70
- workers: views.list({ scanSessions: registry.wantsSessionScan(args), context: scopedContext }),
70
+ // Idle workers stay open for follow-ups; the model must see them to close them.
71
+ workers: views.list({
72
+ scanSessions: registry.wantsSessionScan(args),
73
+ context: scopedContext,
74
+ includeIdle: true,
75
+ }),
71
76
  jobs: views.listJobs(scopedContext),
72
77
  });
73
78
  case 'read':
@@ -25,6 +25,14 @@ export function isActiveWorkerRow(row = {}) {
25
25
  return !isTerminalWorkerStatus(effective);
26
26
  }
27
27
 
28
+ // A finished worker that is still open: it takes follow-ups and can be closed.
29
+ function isOpenIdleRow(row = {}) {
30
+ const status = String(row.status ?? '').trim().toLowerCase();
31
+ const stage = String(row.stage ?? '').trim().toLowerCase();
32
+ if (CANCELLED_WORKER_STATUS.test(stage) || CANCELLED_WORKER_STATUS.test(status)) return false;
33
+ return (status || stage) === 'idle';
34
+ }
35
+
28
36
  function rowState(session, runtime) {
29
37
  const status = session.closed === true ? 'closed' : session.status || 'idle';
30
38
  const stage =
@@ -78,7 +86,7 @@ function agentRow({ tag, session, runtime, progress, now, indexRow = null }) {
78
86
  }
79
87
 
80
88
  export function createAgentList({ mgr, refreshTagsFromSessions, agentSessionEntries, progress }) {
81
- return function list({ scanSessions = false, context = {}, includeTerminal = false } = {}) {
89
+ return function list({ scanSessions = false, context = {}, includeTerminal = false, includeIdle = false } = {}) {
82
90
  refreshTagsFromSessions({ scanSessions, context });
83
91
  const now = Date.now();
84
92
  const rows = [];
@@ -89,7 +97,8 @@ export function createAgentList({ mgr, refreshTagsFromSessions, agentSessionEntr
89
97
  // loads the worker's stored session, and a Lead's status pulse (every
90
98
  // 2 s) would otherwise read every worker it ever ran, whole transcripts
91
99
  // on first sight, synchronously on the daemon loop.
92
- if (!includeTerminal && !isActiveWorkerRow(rowState(session, runtime))) continue;
100
+ const state = rowState(session, runtime);
101
+ if (!includeTerminal && !isActiveWorkerRow(state) && !(includeIdle && isOpenIdleRow(state))) continue;
93
102
  rows.push(
94
103
  agentRow({
95
104
  tag,
@@ -156,9 +156,11 @@ export function renderResult(value, { includeDiagnostics = false } = {}) {
156
156
  if (typeof value === 'string') return value;
157
157
  if (value && typeof value === 'object') {
158
158
  if (Array.isArray(value.workers) || Array.isArray(value.jobs)) return renderRoster(value);
159
+ // close-flow results carry `closed` plus task_id: render the close outcome.
160
+ // close-flow results carry `closed` plus task_id: render the close outcome.
161
+ if (value.closed !== undefined) return renderClosed(value);
159
162
  if (value.task_id) return renderTask(value, includeDiagnostics);
160
163
  if (value.queued) return renderQueued(value);
161
- if (value.closed !== undefined) return renderClosed(value);
162
164
  if (value.content !== undefined) return renderContent(value);
163
165
  }
164
166
  return JSON.stringify(value, null, 2);
@@ -28,7 +28,7 @@ export const AGENT_TOOL = {
28
28
  agentHidden: false,
29
29
  },
30
30
  description:
31
- 'Run scoped agent work as background tasks. spawn/send return task_id immediately. Reuse one tag for one scope for its whole lifetime; give distinct tags only to independent scopes. Never mint a new tag because a session expired or was lost — same-tag spawn respawns it with the full brief. Wait for completion notifications; use status/read only for manual recovery.',
31
+ 'Run scoped agent work as background tasks. spawn/send return task_id immediately. Reuse one tag for one scope for its whole lifetime; give distinct tags only to independent scopes. Never mint a new tag because a session expired or was lost — same-tag spawn respawns it with the full brief. Wait for completion notifications; use status/read only for manual recovery. Close finished agents yourself.',
32
32
  inputSchema: {
33
33
  type: 'object',
34
34
  properties: {
@@ -203,7 +203,7 @@ export function createUsageStatsApi({
203
203
  // beyond a clock captured before the import started.
204
204
  const now = Date.now();
205
205
  const period = usagePeriodFor(options, now);
206
- // The rollup query runs off the event loop (usage-rollup-worker.mjs).
206
+ // The rollup query runs off the event loop (usage-ledger-worker.mjs).
207
207
  const rollup = await store.rollupAsync(usageRollupQuery(period));
208
208
  // The ledger returns the same rollup object while nothing was committed
209
209
  // (and while a rolling window lost no record), so identical back-to-back