mixdog 0.9.52 → 0.9.53

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 (111) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench-run.mjs +2 -2
  3. package/scripts/compact-pressure-test.mjs +104 -0
  4. package/scripts/desktop-session-bridge-test.mjs +704 -0
  5. package/scripts/freevar-smoke.mjs +7 -4
  6. package/scripts/lifecycle-api-test.mjs +65 -4
  7. package/scripts/max-output-recovery-test.mjs +31 -0
  8. package/scripts/memory-core-input-test.mjs +10 -0
  9. package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
  10. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  11. package/scripts/parent-abort-link-test.mjs +24 -0
  12. package/scripts/process-lifecycle-test.mjs +80 -22
  13. package/scripts/provider-contract-test.mjs +257 -0
  14. package/scripts/provider-toolcall-test.mjs +172 -10
  15. package/scripts/session-orphan-sweep-test.mjs +27 -1
  16. package/src/lib/keychain-cjs.cjs +36 -23
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  18. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  19. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
  22. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
  24. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
  27. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  28. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  29. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  30. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
  41. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  42. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  43. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  44. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  45. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
  46. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  47. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  48. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  50. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  53. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  55. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  59. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  62. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  63. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  64. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  65. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  66. package/src/runtime/channels/backends/discord.mjs +6 -6
  67. package/src/runtime/channels/lib/config.mjs +15 -2
  68. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  69. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  70. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  72. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  73. package/src/runtime/memory/index.mjs +24 -198
  74. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  75. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  76. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  77. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  78. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  79. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  80. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  81. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  82. package/src/runtime/shared/config.mjs +58 -13
  83. package/src/runtime/shared/llm/cost.mjs +14 -4
  84. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  85. package/src/runtime/shared/process-lifecycle.mjs +92 -19
  86. package/src/runtime/shared/process-shutdown.mjs +6 -0
  87. package/src/runtime/shared/resource-admission.mjs +7 -2
  88. package/src/session-runtime/channel-config-api.mjs +7 -7
  89. package/src/session-runtime/config-lifecycle.mjs +20 -17
  90. package/src/session-runtime/cwd-plugins.mjs +9 -7
  91. package/src/session-runtime/env.mjs +1 -2
  92. package/src/session-runtime/hitch-profile.mjs +45 -0
  93. package/src/session-runtime/lifecycle-api.mjs +36 -9
  94. package/src/session-runtime/mcp-glue.mjs +6 -11
  95. package/src/session-runtime/provider-init-key.mjs +17 -0
  96. package/src/session-runtime/runtime-core.mjs +44 -103
  97. package/src/session-runtime/runtime-paths.mjs +20 -0
  98. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  99. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  100. package/src/session-runtime/tool-catalog.mjs +15 -89
  101. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  102. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  103. package/src/standalone/agent-tool.mjs +12 -162
  104. package/src/standalone/channel-admin.mjs +29 -0
  105. package/src/tui/App.jsx +11 -5
  106. package/src/tui/dist/index.mjs +202 -65
  107. package/src/tui/engine/session-api-ext.mjs +37 -4
  108. package/src/tui/index.jsx +1 -0
  109. package/src/tui/lib/voice-setup.mjs +5 -5
  110. package/scripts/_devtools-stub.mjs +0 -1
  111. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -28,16 +28,6 @@ import { providerInputExcludesCache } from '../../providers/registry.mjs';
28
28
  // the recall-fasttrack injection cap uses (loop.mjs recallTokenCap). One
29
29
  // number governs every "share of model context" budget.
30
30
 
31
- function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
32
- // Types are hard-locked (agent -> semantic, main/user -> recall-fasttrack).
33
- // Semantic must always be available as a compact path or agent sessions
34
- // would have none (loop.mjs throws when no type is available). Env/cfg
35
- // off-switches no longer apply.
36
- void sessionRef;
37
- void cfg;
38
- return true;
39
- }
40
-
41
31
  function resolveCompactTypeSetting(sessionRef, cfg = {}) {
42
32
  // Agent-owned sessions are ALWAYS semantic. recall-fasttrack rebuilds
43
33
  // context from Memory recall, which is scoped to the user's main-session
@@ -171,7 +161,7 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
171
161
  ? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
172
162
  : null,
173
163
  autoCompactTokenLimit: explicitAutoCompactTokenLimit,
174
- semantic: compactTypeIsSemantic(compactType) && resolveSemanticCompactSetting(sessionRef, cfg),
164
+ semantic: compactTypeIsSemantic(compactType),
175
165
  recallFastTrack: compactTypeIsRecallFastTrack(compactType),
176
166
  semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_AGENT_COMPACT_TIMEOUT_MS') || 30_000,
177
167
  tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
@@ -191,15 +181,15 @@ function compactPressureTokens(messageTokensEst, policy) {
191
181
 
192
182
  function providerPressureTokens(sessionRef, usage) {
193
183
  if (!usage || typeof usage !== 'object') return 0;
194
- const input = Math.max(0, Number(usage.inputTokens) || 0);
195
- const cachedRead = Math.max(0, Number(usage.cachedTokens) || 0);
196
- const cacheWrite = Math.max(0, Number(usage.cacheWriteTokens) || 0);
197
- const explicitPrompt = Math.max(0, Number(usage.promptTokens) || 0);
184
+ const input = Math.max(0, Number(usage.mainInputTokens ?? usage.inputTokens) || 0);
185
+ const cachedRead = Math.max(0, Number(usage.mainCachedTokens ?? usage.cachedTokens) || 0);
186
+ const cacheWrite = Math.max(0, Number(usage.mainCacheWriteTokens ?? usage.cacheWriteTokens) || 0);
187
+ const explicitPrompt = Math.max(0, Number(usage.mainPromptTokens ?? usage.promptTokens) || 0);
198
188
  const normalizedPrompt = providerInputExcludesCache(sessionRef?.provider)
199
189
  ? input + cachedRead + cacheWrite
200
190
  : input;
201
191
  const prompt = Math.max(explicitPrompt, normalizedPrompt);
202
- const output = Math.max(0, Number(usage.outputTokens) || 0);
192
+ const output = Math.max(0, Number(usage.mainOutputTokens ?? usage.outputTokens) || 0);
203
193
  return Math.max(0, Math.round(prompt + output));
204
194
  }
205
195
 
@@ -213,10 +203,14 @@ export function recordProviderContextBaseline(sessionRef, messages, usage, {
213
203
  sendTools = sessionRef?.tools,
214
204
  } = {}) {
215
205
  if (!sessionRef || !Array.isArray(messages)) return false;
206
+ if (usage?.mainUsageAvailable === false) {
207
+ invalidateProviderContextBaseline(sessionRef);
208
+ return false;
209
+ }
216
210
  const tokens = providerPressureTokens(sessionRef, usage);
217
211
  if (!tokens) return false;
218
212
  sessionRef.contextPressureBaselineTokens = tokens;
219
- sessionRef.contextPressureBaselineOutputTokens = Math.max(0, Math.round(Number(usage?.outputTokens) || 0));
213
+ sessionRef.contextPressureBaselineOutputTokens = Math.max(0, Math.round(Number(usage?.mainOutputTokens ?? usage?.outputTokens) || 0));
220
214
  sessionRef.contextPressureBaselineMessageCount = messages.length;
221
215
  sessionRef.contextPressureBaselinePrefixSignature = contextMessagesSignature(messages);
222
216
  sessionRef.contextPressureBaselineProvider = sessionRef.provider || null;
@@ -37,6 +37,7 @@ import {
37
37
  _touchRuntime,
38
38
  _unlinkParentAbortListener,
39
39
  _getRuntimeEntry,
40
+ _evictTerminalSessionRuntime,
40
41
  } from './runtime-liveness.mjs';
41
42
  import { SessionClosedError } from './session-errors.mjs';
42
43
  import { acquireSessionLock } from './session-lock.mjs';
@@ -378,11 +379,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
378
379
  // tool results/steering.
379
380
  if (d?.source === 'provider_send') {
380
381
  recordProviderContextBaseline(session, outgoing, {
381
- inputTokens: d.deltaInput,
382
- outputTokens: d.deltaOutput,
383
- promptTokens: d.deltaPrompt,
384
- cachedTokens: d.deltaCachedRead,
385
- cacheWriteTokens: d.deltaCacheWrite,
382
+ mainUsageAvailable: d.contextUsageAvailable,
383
+ inputTokens: d.contextInputTokens ?? d.deltaInput,
384
+ outputTokens: d.contextOutputTokens ?? d.deltaOutput,
385
+ promptTokens: d.contextPromptTokens ?? d.deltaPrompt,
386
+ cachedTokens: d.contextCachedReadTokens ?? d.deltaCachedRead,
387
+ cacheWriteTokens: d.contextCacheWriteTokens ?? d.deltaCacheWrite,
386
388
  }, { boundary: 'request', sendTools: d.sendTools });
387
389
  }
388
390
  try { askOpts?.onUsageDelta?.(d); } catch {}
@@ -747,6 +749,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
747
749
  // Detach the live session reference; ask is over.
748
750
  entry.session = null;
749
751
  }
752
+ // Final-stage runtime diagnostics are useful only while the turn is
753
+ // unwinding. Once its controller is detached, retaining the full entry
754
+ // (and any accidental references hanging from it) for the host lifetime
755
+ // turns one-shot agent traffic into an unbounded manager Map.
756
+ _evictTerminalSessionRuntime(sessionId);
750
757
  unlock();
751
758
  }
752
759
  }
@@ -4,7 +4,12 @@
4
4
  // from session-close.mjs (one-way dependency, no cycle).
5
5
  import { sweepStaleSessions } from '../store.mjs';
6
6
  import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
- import { _getRuntimeEntry, _clearSessionRuntime, _runtimeEntries } from './runtime-liveness.mjs';
7
+ import {
8
+ _getRuntimeEntry,
9
+ _clearSessionRuntime,
10
+ _runtimeEntries,
11
+ _sweepTerminalSessionRuntimes,
12
+ } from './runtime-liveness.mjs';
8
13
  import { _closeBashSessionLazy } from './runtime-loaders.mjs';
9
14
  import { closeSession } from './session-close.mjs';
10
15
  import { nonNegativeIntEnv } from './env-utils.mjs';
@@ -36,10 +41,11 @@ function _previewIds(items, limit = 5) {
36
41
  return ` (${ids.join(', ')}${more})`;
37
42
  }
38
43
 
39
- function sweepIdleSessions({ includeTombstones = true } = {}) {
44
+ function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
40
45
  const startedAt = Date.now();
41
46
  try {
42
47
  const result = sweepStaleSessions({
48
+ sweepIdle,
43
49
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
44
50
  isSessionLive: _isSessionLive,
45
51
  });
@@ -71,7 +77,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
71
77
  }
72
78
  if (tombstonesCleaned > 0) {
73
79
  for (const d of tombstoneDetails) {
74
- if (d?.id) _clearSessionRuntime(d.id);
80
+ if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
75
81
  }
76
82
  process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
77
83
  }
@@ -105,9 +111,10 @@ export function sweepTombstones() {
105
111
  const { tombstonesCleaned = 0, tombstoneDetails = [], tombstoneErrors = [] } = sweepStaleSessions({
106
112
  sweepIdle: false,
107
113
  tombstoneMaxAgeMs: TOMBSTONE_MAX_AGE_MS,
114
+ isSessionLive: _isSessionLive,
108
115
  });
109
116
  for (const d of tombstoneDetails) {
110
- if (d?.id) _clearSessionRuntime(d.id);
117
+ if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
111
118
  }
112
119
  if (tombstonesCleaned > 0) {
113
120
  process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
@@ -133,7 +140,16 @@ function hasActiveRuntimeWork() {
133
140
  }
134
141
 
135
142
  function _runCleanupCycle() {
136
- if (hasActiveRuntimeWork()) return;
143
+ // Drain every settled runtime entry on each pass, not just the one or two
144
+ // sessions whose on-disk idle TTL happened to expire in this interval.
145
+ _sweepTerminalSessionRuntimes();
146
+ // A busy host may always have some unrelated agent in flight. Tombstones
147
+ // are already closed and can still be batch-reclaimed safely; only the
148
+ // open-session idle sweep remains gated while active work exists.
149
+ if (hasActiveRuntimeWork()) {
150
+ sweepIdleSessions({ includeTombstones: true, sweepIdle: false });
151
+ return;
152
+ }
137
153
  sweepOrphanedPendingMessages();
138
154
  sweepIdleSessions({ includeTombstones: true });
139
155
  }
@@ -1,48 +1,12 @@
1
1
  // manager/prefetch-bridge.mjs
2
2
  // Explicit-prefetch bridge extracted verbatim from manager.mjs. Runs Lead-
3
- // supplied files[]/callers[]/references[] prefetch (outside the agent loop),
4
- // with a pass-through permission guard for API compatibility.
5
- import { createRequire } from 'module';
6
- import { fileURLToPath } from 'url';
3
+ // supplied files[]/callers[]/references[] prefetch outside the agent loop.
7
4
  import { isAgentOwner } from '../../agent-owner.mjs';
8
5
  import { executeInternalTool } from '../../internal-tools.mjs';
9
6
  import { classifyResultKind } from '../result-classification.mjs';
10
7
  import { tryPrefetchCached, setPrefetchCached } from '../read-dedup.mjs';
11
8
  import { _executeCodeGraphToolLazy } from './runtime-loaders.mjs';
12
9
 
13
- // ── Prefetch permission guard ─────────────────────────────────────────────────
14
- // Runs the shared permission evaluator for tool calls that originate in the
15
- // prefetch path (outside the agent loop). Permission enforcement is disabled
16
- // (the evaluator always returns allow), so this is effectively a pass-through
17
- // kept for API compatibility. Returns an error string if blocked, or null.
18
- const _permEvalForPrefetch = (() => {
19
- const _req = createRequire(import.meta.url);
20
- try {
21
- const { dirname: _pdir, resolve: _pres } = _req('path');
22
- const _hooksLib = _pres(_pdir(fileURLToPath(import.meta.url)), '../../../../../hooks/lib/permission-evaluator.cjs');
23
- return _req(_hooksLib).evaluatePermission;
24
- } catch { return null; }
25
- })();
26
- function _guardedPrefetchTool(toolName, toolArgs, session) {
27
- if (!_permEvalForPrefetch) return null;
28
- // When no explicit mode is attached to the session, run the evaluator
29
- // under 'default'. The evaluator now always allows, so this never blocks.
30
- const permissionMode = session?.permissionMode || 'default';
31
- const projectDir = session?.cwd || undefined;
32
- const userCwd = session?.cwd || undefined;
33
- const MCP_PFX = 'mcp__plugin_mixdog_mixdog__';
34
- const fullName = toolName.startsWith(MCP_PFX) || toolName.startsWith('mcp__') ? toolName : `${MCP_PFX}${toolName}`;
35
- try {
36
- const { decision, reason } = _permEvalForPrefetch({ toolName: fullName, toolInput: toolArgs || {}, permissionMode, projectDir, userCwd });
37
- if (decision === 'deny' || decision === 'ask') {
38
- return `Error: prefetch tool "${toolName}" blocked (decision=${decision}): ${reason}`;
39
- }
40
- } catch (e) {
41
- process.stderr.write(`[prefetch-guard] evaluator error: ${e?.message}\n`);
42
- }
43
- return null;
44
- }
45
-
46
10
  export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
47
11
  if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
48
12
  if (!isAgentOwner(session)) return null;
@@ -85,12 +49,6 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
85
49
  }
86
50
  }
87
51
  if (files.length > 0) {
88
- const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
89
- if (_pfGuard) {
90
- process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
91
- failed.push(...files);
92
- totalEntries.push(...files);
93
- } else {
94
52
  totalEntries.push(...files);
95
53
  // R20: per-file prefetch cache (cross-dispatch, process-local).
96
54
  // Try each file from cache first; batch misses into one disk read.
@@ -183,7 +141,6 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
183
141
  session.prefetchStats.miss += fileMisses.length;
184
142
  session.prefetchStats.failed += failed.length;
185
143
  }
186
- }
187
144
  }
188
145
  // callers[]
189
146
  const callers = Array.isArray(explicitPrefetch.callers) ? explicitPrefetch.callers.filter(c => c && typeof c.symbol === 'string') : [];
@@ -193,11 +150,6 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
193
150
  if (file) cgArgs.file = file;
194
151
  if (session?.cwd) cgArgs.cwd = session.cwd;
195
152
  totalEntries.push(symbol);
196
- const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
197
- if (blocked) {
198
- process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
199
- return Promise.resolve({ symbol, out: null, blocked: true });
200
- }
201
153
  return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
202
154
  .then(out => ({ symbol, out }))
203
155
  .catch(e => {
@@ -207,8 +159,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
207
159
  });
208
160
  const callerResults = await Promise.allSettled(callerTasks);
209
161
  for (const r of callerResults) {
210
- const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
211
- if (blocked) { failed.push(symbol); continue; }
162
+ const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
212
163
  if (out && classifyResultKind(String(out)) !== 'error') {
213
164
  parts.push(`### prefetch callers ${symbol}\n${out}`);
214
165
  } else {
@@ -224,11 +175,6 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
224
175
  if (file) cgArgs.file = file;
225
176
  if (session?.cwd) cgArgs.cwd = session.cwd;
226
177
  totalEntries.push(symbol);
227
- const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
228
- if (blocked) {
229
- process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
230
- return Promise.resolve({ symbol, out: null, blocked: true });
231
- }
232
178
  return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
233
179
  .then(out => ({ symbol, out }))
234
180
  .catch(e => {
@@ -238,8 +184,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
238
184
  });
239
185
  const refResults = await Promise.allSettled(refTasks);
240
186
  for (const r of refResults) {
241
- const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
242
- if (blocked) { failed.push(symbol); continue; }
187
+ const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
243
188
  if (out && classifyResultKind(String(out)) !== 'error') {
244
189
  parts.push(`### prefetch references ${symbol}\n${out}`);
245
190
  } else {
@@ -34,6 +34,7 @@ const _toolActivityHeartbeats = new Map();
34
34
  const VALID_STAGES = new Set([
35
35
  'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
36
36
  ]);
37
+ const TERMINAL_STAGES = new Set(['done', 'error']);
37
38
 
38
39
  // Injected deps that would otherwise pull manager.mjs's store/provider surface
39
40
  // back into this module (circular). Wired once from manager.mjs at load time.
@@ -502,6 +503,29 @@ export function _clearSessionRuntime(id) {
502
503
  }
503
504
  }
504
505
 
506
+ /**
507
+ * Evict a settled terminal runtime entry without ever touching an in-flight
508
+ * controller. askSession calls this after detaching its controller; the
509
+ * periodic cleanup also uses it to drain any backlog left by older paths.
510
+ */
511
+ export function _evictTerminalSessionRuntime(id) {
512
+ if (!id) return false;
513
+ const entry = _runtimeState.get(id);
514
+ if (!entry) return false;
515
+ if (entry.controller && !entry.controller.signal?.aborted) return false;
516
+ if (entry.closed !== true && !TERMINAL_STAGES.has(entry.stage)) return false;
517
+ _clearSessionRuntime(id);
518
+ return true;
519
+ }
520
+
521
+ export function _sweepTerminalSessionRuntimes() {
522
+ let cleaned = 0;
523
+ for (const [id] of _runtimeState) {
524
+ if (_evictTerminalSessionRuntime(id)) cleaned++;
525
+ }
526
+ return cleaned;
527
+ }
528
+
505
529
  // Direct-Map accessors for manager.mjs internals (askSession / closeSession /
506
530
  // idle-sweep) that mutate entries in place. Kept as thin exports so the Map
507
531
  // stays private to this module.
@@ -258,11 +258,22 @@ export function applyAskTerminalUsageTotals(session, result, options = {}) {
258
258
  session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
259
259
  }
260
260
  const _lastTurn = result.lastTurnUsage || result.usage || {};
261
- const _lastInputTokens = _lastTurn.inputTokens || 0;
262
- const _lastCachedReadTokens = _lastTurn.cachedTokens || 0;
263
- const _lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
261
+ if (_lastTurn.mainUsageAvailable === false) {
262
+ session.lastInputTokens = null;
263
+ session.lastOutputTokens = null;
264
+ session.lastCachedReadTokens = null;
265
+ session.lastCacheWriteTokens = null;
266
+ session.lastUncachedInputTokens = null;
267
+ session.lastContextTokens = null;
268
+ session.lastContextTokensUpdatedAt = Date.now();
269
+ session.lastContextTokensStaleAfterCompact = true;
270
+ return;
271
+ }
272
+ const _lastInputTokens = _lastTurn.mainInputTokens ?? _lastTurn.inputTokens ?? 0;
273
+ const _lastCachedReadTokens = _lastTurn.mainCachedTokens ?? _lastTurn.cachedTokens ?? 0;
274
+ const _lastCacheWriteTokens = _lastTurn.mainCacheWriteTokens ?? _lastTurn.cacheWriteTokens ?? 0;
264
275
  session.lastInputTokens = _lastInputTokens;
265
- session.lastOutputTokens = _lastTurn.outputTokens || 0;
276
+ session.lastOutputTokens = _lastTurn.mainOutputTokens ?? _lastTurn.outputTokens ?? 0;
266
277
  session.lastCachedReadTokens = _lastCachedReadTokens;
267
278
  session.lastCacheWriteTokens = _lastCacheWriteTokens;
268
279
  session.lastUncachedInputTokens = uncachedInputTokensForProvider(
@@ -286,7 +297,20 @@ export function applyAskTerminalUsageTotals(session, result, options = {}) {
286
297
  */
287
298
  export async function persistIterationMetrics(delta) {
288
299
  if (!delta || !delta.sessionId) return;
289
- const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
300
+ const {
301
+ sessionId,
302
+ iterationIndex,
303
+ deltaInput,
304
+ deltaOutput,
305
+ deltaCachedRead,
306
+ deltaCacheWrite,
307
+ contextInputTokens = deltaInput,
308
+ contextOutputTokens = deltaOutput,
309
+ contextCachedReadTokens = deltaCachedRead,
310
+ contextCacheWriteTokens = deltaCacheWrite,
311
+ contextUsageAvailable = true,
312
+ ts,
313
+ } = delta;
290
314
  const runtimeEntry = _getRuntimeEntry(sessionId);
291
315
  const session = runtimeEntry?.session ?? loadSession(sessionId);
292
316
  if (!session || session.closed) return;
@@ -320,23 +344,40 @@ export async function persistIterationMetrics(delta) {
320
344
  // Window snapshot updated per iteration so agent type=list reflects the
321
345
  // most-recent provider-reported input size even for short dispatches
322
346
  // that finish before askSession's terminal save lands.
323
- session.lastInputTokens = deltaInput || 0;
324
- session.lastOutputTokens = deltaOutput || 0;
325
- session.lastCachedReadTokens = deltaCachedRead || 0;
326
- session.lastCacheWriteTokens = deltaCacheWrite || 0;
327
- session.lastUncachedInputTokens = deltaUncachedInput;
347
+ if (contextUsageAvailable === false) {
348
+ session.lastInputTokens = null;
349
+ session.lastOutputTokens = null;
350
+ session.lastCachedReadTokens = null;
351
+ session.lastCacheWriteTokens = null;
352
+ session.lastUncachedInputTokens = null;
353
+ session.lastContextTokens = null;
354
+ session.lastContextTokensUpdatedAt = ts || Date.now();
355
+ session.lastContextTokensStaleAfterCompact = true;
356
+ } else {
357
+ const contextUncachedInput = uncachedInputTokensForProvider(
358
+ session.provider,
359
+ contextInputTokens,
360
+ contextCachedReadTokens,
361
+ contextCacheWriteTokens,
362
+ );
363
+ session.lastInputTokens = contextInputTokens || 0;
364
+ session.lastOutputTokens = contextOutputTokens || 0;
365
+ session.lastCachedReadTokens = contextCachedReadTokens || 0;
366
+ session.lastCacheWriteTokens = contextCacheWriteTokens || 0;
367
+ session.lastUncachedInputTokens = contextUncachedInput;
328
368
  // Normalized last-call context footprint: how many prompt tokens the
329
369
  // model actually saw on the most-recent send, comparable ACROSS
330
370
  // providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
331
371
  // is a separate field), so the cached portion must be added back to
332
372
  // reflect real context size; openai/grok/gemini already fold cached
333
373
  // tokens INTO the input count, so input alone is the footprint.
334
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
335
- session.lastContextTokens = _inputExcludesCache
336
- ? (deltaInput || 0) + (deltaCachedRead || 0) + (deltaCacheWrite || 0)
337
- : (deltaInput || 0);
338
- session.lastContextTokensUpdatedAt = ts || Date.now();
339
- session.lastContextTokensStaleAfterCompact = false;
374
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
375
+ session.lastContextTokens = _inputExcludesCache
376
+ ? (contextInputTokens || 0) + (contextCachedReadTokens || 0) + (contextCacheWriteTokens || 0)
377
+ : (contextInputTokens || 0);
378
+ session.lastContextTokensUpdatedAt = ts || Date.now();
379
+ session.lastContextTokensStaleAfterCompact = false;
380
+ }
340
381
  }
341
382
  session.lastIterationIndex = iterationIndex;
342
383
  session.updatedAt = ts || Date.now();
@@ -10,8 +10,8 @@ import { _saveSessionSync } from './store.mjs';
10
10
 
11
11
  parentPort.on('message', ({ session, opts, reqId }) => {
12
12
  try {
13
- _saveSessionSync(session, opts);
14
- parentPort.postMessage({ ok: true, reqId });
13
+ const saved = _saveSessionSync(session, opts);
14
+ parentPort.postMessage({ ok: true, saved, reqId });
15
15
  } catch (err) {
16
16
  parentPort.postMessage({ ok: false, error: err?.message ?? String(err), reqId });
17
17
  }
@@ -13,9 +13,15 @@ import { isOutputLimitStopReason } from './loop/termination.mjs';
13
13
 
14
14
  function normalizedIncompleteUsage(raw) {
15
15
  if (!raw || typeof raw !== 'object') return undefined;
16
- const inputTokens = Number(raw.promptTokenCount ?? raw.prompt_token_count ?? raw.input_tokens ?? raw.prompt_tokens ?? 0) || 0;
16
+ const directInputTokens = Number(raw.promptTokenCount ?? raw.prompt_token_count ?? raw.input_tokens ?? raw.prompt_tokens ?? 0) || 0;
17
17
  const candidateTokens = Number(raw.candidatesTokenCount ?? raw.candidates_token_count ?? 0) || 0;
18
18
  const thoughtTokens = Number(raw.thoughtsTokenCount ?? raw.thoughts_token_count ?? 0) || 0;
19
+ const totalTokens = Number(raw.totalTokenCount ?? raw.total_token_count ?? 0) || 0;
20
+ const hasExplicitGeminiPromptTokens = Object.prototype.hasOwnProperty.call(raw, 'promptTokenCount')
21
+ || Object.prototype.hasOwnProperty.call(raw, 'prompt_token_count');
22
+ const inputTokens = hasExplicitGeminiPromptTokens || directInputTokens > 0
23
+ ? directInputTokens
24
+ : Math.max(0, totalTokens - candidateTokens - thoughtTokens);
19
25
  const outputFallback = Number(raw.output_tokens ?? raw.completion_tokens ?? 0) || 0;
20
26
  const cachedTokens = Number(raw.cachedContentTokenCount ?? raw.cached_content_token_count ?? raw.cached_tokens ?? 0) || 0;
21
27
  return {
@@ -0,0 +1,52 @@
1
+ import { existsSync, mkdirSync, unlinkSync } from 'fs';
2
+ import * as fsp from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { getPluginData } from '../../config.mjs';
5
+
6
+ export function getStoreDir() {
7
+ const dir = join(getPluginData(), 'sessions');
8
+ if (!existsSync(dir))
9
+ mkdirSync(dir, { recursive: true });
10
+ return dir;
11
+ }
12
+ export function sessionPath(id) {
13
+ // Enforce minted session-id shape before using it in a path to prevent
14
+ // `../` traversal. session IDs are generated by createSession as
15
+ // `sess_<timestamp>_<hex>` — reject anything that doesn't match.
16
+ if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
17
+ throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
18
+ }
19
+ return join(getStoreDir(), `${id}.json`);
20
+ }
21
+
22
+ // ── Heartbeat publish ─────────────────────────────────────
23
+ // Lightweight per-session timestamp file (`<id>.hb`) consumed by the
24
+ // status aggregator for fresh-session detection. Decoupled from the
25
+ // full session JSON save so it can fire at a tight cadence (≤5s)
26
+ // without serialising the whole payload. The .hb file holds a single
27
+ // ASCII line: `<msTimestamp>\n`. Aggregator scans the same sessions/
28
+ // directory and matches `<id>.hb` to `<id>.json`.
29
+ const _HEARTBEAT_THROTTLE_MS = 5_000;
30
+ const _hbLastAt = new Map();
31
+
32
+ function _heartbeatPath(id) {
33
+ if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
34
+ throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
35
+ }
36
+ return join(getStoreDir(), `${id}.hb`);
37
+ }
38
+
39
+ export function publishHeartbeat(id, ts) {
40
+ if (!id) return;
41
+ const now = ts || Date.now();
42
+ const last = _hbLastAt.get(id) || 0;
43
+ if (now - last < _HEARTBEAT_THROTTLE_MS) return;
44
+ const target = _heartbeatPath(id);
45
+ _hbLastAt.set(id, now);
46
+ void fsp.writeFile(target, `${now}\n`, 'utf8').catch(() => {});
47
+ }
48
+
49
+ export function deleteHeartbeat(id) {
50
+ try { unlinkSync(_heartbeatPath(id)); } catch { /* ignore */ }
51
+ _hbLastAt.delete(id);
52
+ }
@@ -0,0 +1,62 @@
1
+ /** Module-level map tracking per-session cancellation generations and commit locks. */
2
+ const _writeControls = new Map();
3
+
4
+ export function writeControl(id) {
5
+ let control = _writeControls.get(id);
6
+ if (!control) {
7
+ // [0] cancellation generation, [1] commit lock
8
+ control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2));
9
+ _writeControls.set(id, control);
10
+ }
11
+ return control;
12
+ }
13
+
14
+ export function guardedSaveOptions(id, opts) {
15
+ const control = writeControl(id);
16
+ return {
17
+ ...(opts || {}),
18
+ _sessionWriteGuard: { buffer: control.buffer, version: Atomics.load(control, 0) },
19
+ };
20
+ }
21
+
22
+ export function cancelSessionWrites(id) {
23
+ Atomics.add(writeControl(id), 0, 1);
24
+ }
25
+
26
+ export function isCancelledWrite(opts) {
27
+ const guard = opts?._sessionWriteGuard;
28
+ if (!guard?.buffer || !Number.isInteger(guard.version)) return false;
29
+ try {
30
+ return Atomics.load(new Int32Array(guard.buffer), 0) !== guard.version;
31
+ } catch {
32
+ return true;
33
+ }
34
+ }
35
+
36
+ export function acquireWriteCommit(opts) {
37
+ const guard = opts?._sessionWriteGuard;
38
+ if (!guard?.buffer) return null;
39
+ const control = new Int32Array(guard.buffer);
40
+ while (Atomics.compareExchange(control, 1, 0, 1) !== 0) {
41
+ Atomics.wait(control, 1, 1, 25);
42
+ }
43
+ if (isCancelledWrite(opts)) {
44
+ Atomics.store(control, 1, 0);
45
+ Atomics.notify(control, 1);
46
+ return false;
47
+ }
48
+ return control;
49
+ }
50
+
51
+ export function releaseWriteCommit(control) {
52
+ if (!control) return;
53
+ Atomics.store(control, 1, 0);
54
+ Atomics.notify(control, 1);
55
+ }
56
+
57
+ export function waitForWriteCommit(id) {
58
+ const control = writeControl(id);
59
+ while (Atomics.load(control, 1) !== 0) {
60
+ Atomics.wait(control, 1, 1, 25);
61
+ }
62
+ }