mixdog 0.9.55 → 0.9.59
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 +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
import { getProvider } from '../../providers/registry.mjs';
|
|
8
8
|
import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
|
|
9
|
-
import { loadSession, saveSessionAsync, saveSessionAsyncDeferred } from '../store.mjs';
|
|
9
|
+
import { loadSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
|
|
10
10
|
import { createAbortController } from '../../../../shared/abort-controller.mjs';
|
|
11
11
|
import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
|
|
12
12
|
import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
@@ -59,6 +59,70 @@ import { _getAgentLoop } from './runtime-loaders.mjs';
|
|
|
59
59
|
import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
|
|
60
60
|
import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
|
|
61
61
|
|
|
62
|
+
export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
|
|
63
|
+
if (!value || typeof value !== 'object') return null;
|
|
64
|
+
const candidateAt = Number(value.assistantAt);
|
|
65
|
+
const assistantAt = Number.isFinite(candidateAt) && candidateAt > 0 ? candidateAt : fallbackAt;
|
|
66
|
+
value.assistantAt = assistantAt;
|
|
67
|
+
return {
|
|
68
|
+
at: assistantAt,
|
|
69
|
+
...(typeof value.model === 'string' && value.model ? { model: value.model } : {}),
|
|
70
|
+
...(typeof value.provider === 'string' && value.provider ? { provider: value.provider } : {}),
|
|
71
|
+
...(typeof value.agent === 'string' && value.agent ? { agent: value.agent } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function attachAssistantTranscriptCompletion(messages, completion, turnStartedAt = 0) {
|
|
76
|
+
if (!Array.isArray(messages) || !completion || typeof completion !== 'object') return false;
|
|
77
|
+
const elapsedMs = Math.max(0, Number(completion.elapsedMs || 0));
|
|
78
|
+
const status = typeof completion.status === 'string' && completion.status
|
|
79
|
+
? completion.status
|
|
80
|
+
: 'done';
|
|
81
|
+
const verb = typeof completion.verb === 'string' && completion.verb
|
|
82
|
+
? completion.verb
|
|
83
|
+
: 'Thought';
|
|
84
|
+
let turnStart = -1;
|
|
85
|
+
const expectedAt = Number(turnStartedAt || 0);
|
|
86
|
+
if (expectedAt > 0) {
|
|
87
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
88
|
+
const message = messages[index];
|
|
89
|
+
if (message?.role !== 'user') continue;
|
|
90
|
+
if (Number(message?.meta?.transcript?.at || 0) !== expectedAt) continue;
|
|
91
|
+
turnStart = index;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (let index = messages.length - 1; index > turnStart; index -= 1) {
|
|
96
|
+
const message = messages[index];
|
|
97
|
+
if (message?.role !== 'assistant') continue;
|
|
98
|
+
const content = message.content;
|
|
99
|
+
const hasVisibleText = typeof content === 'string'
|
|
100
|
+
? Boolean(content.trim())
|
|
101
|
+
: Array.isArray(content) && content.some((part) => {
|
|
102
|
+
if (typeof part === 'string') return Boolean(part.trim());
|
|
103
|
+
if (!part || typeof part !== 'object') return false;
|
|
104
|
+
return Boolean(String(part.text || part.content || '').trim());
|
|
105
|
+
});
|
|
106
|
+
if (!hasVisibleText) continue;
|
|
107
|
+
const meta = message.meta && typeof message.meta === 'object' ? message.meta : {};
|
|
108
|
+
const transcript = meta.transcript && typeof meta.transcript === 'object'
|
|
109
|
+
? meta.transcript
|
|
110
|
+
: {};
|
|
111
|
+
messages[index] = {
|
|
112
|
+
...message,
|
|
113
|
+
meta: {
|
|
114
|
+
...meta,
|
|
115
|
+
transcript: {
|
|
116
|
+
...transcript,
|
|
117
|
+
completion: { status, verb, elapsedMs },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
62
126
|
/**
|
|
63
127
|
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
64
128
|
* the wrapper settles with a SessionClosedError even if the underlying promise
|
|
@@ -102,6 +166,20 @@ export async function _api_call_with_interrupt(sessionId, fn) {
|
|
|
102
166
|
|
|
103
167
|
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
|
|
104
168
|
const _askStartedAt = Date.now();
|
|
169
|
+
const _rawTranscriptMeta = askOpts?.transcriptMeta;
|
|
170
|
+
const _transcriptMeta = _rawTranscriptMeta && typeof _rawTranscriptMeta === 'object'
|
|
171
|
+
? {
|
|
172
|
+
...(Number.isFinite(Number(_rawTranscriptMeta.at)) ? { at: Number(_rawTranscriptMeta.at) } : {}),
|
|
173
|
+
...(typeof _rawTranscriptMeta.model === 'string' && _rawTranscriptMeta.model ? { model: _rawTranscriptMeta.model } : {}),
|
|
174
|
+
...(typeof _rawTranscriptMeta.provider === 'string' && _rawTranscriptMeta.provider ? { provider: _rawTranscriptMeta.provider } : {}),
|
|
175
|
+
...(typeof _rawTranscriptMeta.agent === 'string' && _rawTranscriptMeta.agent ? { agent: _rawTranscriptMeta.agent } : {}),
|
|
176
|
+
}
|
|
177
|
+
: null;
|
|
178
|
+
const _takeAssistantTranscriptMetadata = () => {
|
|
179
|
+
const metadata = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
|
|
180
|
+
if (_rawTranscriptMeta && typeof _rawTranscriptMeta === 'object') delete _rawTranscriptMeta.assistantAt;
|
|
181
|
+
return metadata;
|
|
182
|
+
};
|
|
105
183
|
const _promptSrc = 'prompt';
|
|
106
184
|
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
107
185
|
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
@@ -188,6 +266,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
188
266
|
// recover the stale transient stage before the loop's pre-send compact
|
|
189
267
|
// path runs (it will overwrite lastStage with real telemetry).
|
|
190
268
|
normalizeStaleCompactingStage(preSession);
|
|
269
|
+
// Split-brain re-adoption: another surface (e.g. desktop click-through
|
|
270
|
+
// of a session still actively owned by this process) may have
|
|
271
|
+
// resumed-and-detached this session, bumping the ON-DISK generation
|
|
272
|
+
// while the conversation kept going here. Every save from this process
|
|
273
|
+
// would then be silently dropped by _shouldDrop's ownership rule and
|
|
274
|
+
// the on-disk transcript would freeze at the last landed save. A new
|
|
275
|
+
// ask on a NON-closed session is an explicit ownership claim: adopt
|
|
276
|
+
// the disk generation so this turn's commits land and heal the file.
|
|
277
|
+
const diskLifecycle = readSessionLifecycleFromDisk(sessionId);
|
|
278
|
+
if (diskLifecycle && diskLifecycle.closed !== true
|
|
279
|
+
&& diskLifecycle.generation > (typeof preSession.generation === 'number' ? preSession.generation : 0)) {
|
|
280
|
+
preSession.generation = diskLifecycle.generation;
|
|
281
|
+
}
|
|
191
282
|
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
192
283
|
const runtime = _touchRuntime(sessionId);
|
|
193
284
|
// Preserve any parent-abort link agent-dispatch established BEFORE we
|
|
@@ -299,7 +390,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
299
390
|
session.sessionStartMetaInjected = true;
|
|
300
391
|
}
|
|
301
392
|
cancelledUserTurnContent = _userTurnContent;
|
|
302
|
-
const outgoing = [...historyMessages, {
|
|
393
|
+
const outgoing = [...historyMessages, {
|
|
394
|
+
role: 'user',
|
|
395
|
+
content: _userTurnContent,
|
|
396
|
+
...(_transcriptMeta ? { meta: { transcript: _transcriptMeta } } : {}),
|
|
397
|
+
}];
|
|
303
398
|
_turnOutgoing = outgoing;
|
|
304
399
|
// Expose the in-flight working transcript so contextStatus() can
|
|
305
400
|
// estimate the LIVE context footprint mid-turn. agentLoop mutates
|
|
@@ -403,6 +498,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
403
498
|
onTextReset: _trackTextReset,
|
|
404
499
|
onReasoningDelta: _trackReasoningDelta,
|
|
405
500
|
onAssistantText: _trackAssistantText,
|
|
501
|
+
takeAssistantTranscriptMetadata: _takeAssistantTranscriptMetadata,
|
|
406
502
|
onAssistantMessageCommitted: () => _turnInterruption.markAssistantMessageCommitted(),
|
|
407
503
|
onAssistantToolCallObserved: (call, detail) => _turnInterruption.recordToolCalls([call], detail),
|
|
408
504
|
onProviderSendStarted: () => _turnInterruption.markProviderSendStarted(),
|
|
@@ -503,6 +599,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
503
599
|
// Turn committed into session.messages; drop the live-turn alias so
|
|
504
600
|
// contextStatus() reverts to the authoritative committed transcript.
|
|
505
601
|
session.liveTurnMessages = null;
|
|
602
|
+
const _assistantTranscriptMeta = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
|
|
506
603
|
if (result.content || result.reasoningContent) {
|
|
507
604
|
// Max-output recovery returns the complete concatenated text to
|
|
508
605
|
// callers/TUI, while outgoing already contains prior partial
|
|
@@ -518,6 +615,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
518
615
|
// if any, are swapped for a placeholder only at disk write
|
|
519
616
|
// time inside the session store (store.mjs _sessionForDisk).
|
|
520
617
|
content: persistedAssistantContent,
|
|
618
|
+
...(_assistantTranscriptMeta ? { meta: { transcript: _assistantTranscriptMeta } } : {}),
|
|
521
619
|
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
522
620
|
? { reasoningContent: result.reasoningContent }
|
|
523
621
|
: {}),
|
|
@@ -553,6 +651,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
553
651
|
role: 'assistant',
|
|
554
652
|
content: '',
|
|
555
653
|
emptyFinal: true,
|
|
654
|
+
...(_assistantTranscriptMeta ? { meta: { transcript: _assistantTranscriptMeta } } : {}),
|
|
556
655
|
stopReason: _emptyStop,
|
|
557
656
|
iterations: result?.iterations ?? null,
|
|
558
657
|
toolCallsTotal: result?.toolCallsTotal ?? null,
|
|
@@ -668,6 +767,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
668
767
|
// replay; duplicate memory/spool copies share the same id.
|
|
669
768
|
recordPendingMessageDelivery(session, _turnPendingEntries);
|
|
670
769
|
_pwstTurnDrained = drainPendingMessages(sessionId);
|
|
770
|
+
if (_pwstTurnDrained.length === 0) {
|
|
771
|
+
const turnStartedAt = Number(_rawTranscriptMeta?.at || _askStartedAt);
|
|
772
|
+
attachAssistantTranscriptCompletion(session.messages, {
|
|
773
|
+
status: 'done',
|
|
774
|
+
verb: _rawTranscriptMeta?.completionVerb,
|
|
775
|
+
elapsedMs: Date.now() - turnStartedAt,
|
|
776
|
+
}, turnStartedAt);
|
|
777
|
+
}
|
|
671
778
|
let terminalRelayed = false;
|
|
672
779
|
if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
|
|
673
780
|
terminalRelayed = true;
|
|
@@ -200,6 +200,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
|
|
|
200
200
|
limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
|
|
201
201
|
includeMembers: true,
|
|
202
202
|
includeRaw: true,
|
|
203
|
+
compactDigest: true,
|
|
203
204
|
}, callerCtx, memoryTimeoutMs);
|
|
204
205
|
recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
|
|
205
206
|
} catch (err) {
|
|
@@ -270,7 +271,7 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
270
271
|
const targetBudgetTokens = alignedPolicy
|
|
271
272
|
? (compactTargetBudgetForPolicy({ ...alignedPolicy, force }) || boundary)
|
|
272
273
|
: boundary;
|
|
273
|
-
const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
274
|
+
const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || [], { provider: session.provider });
|
|
274
275
|
const beforeTokens = pressureTokens;
|
|
275
276
|
// Manual /compact may explicitly request the original semantic path:
|
|
276
277
|
// summarize THIS session's transcript directly without hydrating/searching
|
|
@@ -322,6 +323,7 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
322
323
|
recallText: recallPayload.recallText,
|
|
323
324
|
query: recallPayload.query,
|
|
324
325
|
querySha: recallPayload.querySha,
|
|
326
|
+
cwd: session.cwd,
|
|
325
327
|
// Ingest just ran on the live transcript, so an empty recall dump
|
|
326
328
|
// means the memory pipeline is broken — do NOT erase history
|
|
327
329
|
// behind an empty summary shell. Empty recall now throws and is
|
|
@@ -352,12 +354,13 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
352
354
|
semanticCompactResult = await semanticCompactMessages(
|
|
353
355
|
provider,
|
|
354
356
|
messages,
|
|
355
|
-
opts.model || session.model,
|
|
357
|
+
opts.model || resolveSemanticSummaryModel(session, { budgetTokens: budget }) || session.model,
|
|
356
358
|
budget,
|
|
357
359
|
{
|
|
358
360
|
reserveTokens,
|
|
359
361
|
providerName: session.provider || provider?.name || null,
|
|
360
362
|
sessionId: opts.sessionId || session.id || null,
|
|
363
|
+
cwd: session.cwd,
|
|
361
364
|
signal: opts.signal || null,
|
|
362
365
|
promptCacheKey: session.promptCacheKey || null,
|
|
363
366
|
providerCacheKey: session.promptCacheKey || null,
|
|
@@ -396,12 +399,13 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
396
399
|
semanticCompactResult = await semanticCompactMessages(
|
|
397
400
|
provider,
|
|
398
401
|
messages,
|
|
399
|
-
opts.model || session.model,
|
|
402
|
+
opts.model || resolveSemanticSummaryModel(session, { budgetTokens: budget }) || session.model,
|
|
400
403
|
budget,
|
|
401
404
|
{
|
|
402
405
|
reserveTokens,
|
|
403
406
|
providerName: session.provider || provider?.name || null,
|
|
404
407
|
sessionId: opts.sessionId || session.id || null,
|
|
408
|
+
cwd: session.cwd,
|
|
405
409
|
signal: opts.signal || null,
|
|
406
410
|
promptCacheKey: session.promptCacheKey || null,
|
|
407
411
|
providerCacheKey: session.promptCacheKey || null,
|
|
@@ -552,9 +556,13 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
552
556
|
lastChangedAt: changed ? now : session.compaction?.lastChangedAt || null,
|
|
553
557
|
lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
|
|
554
558
|
lastSemantic: semanticCompactResult?.semantic === true,
|
|
555
|
-
|
|
559
|
+
// This is a terminal success record. A failed component may have been
|
|
560
|
+
// recovered by semantic compaction or pruning, but must not remain as a
|
|
561
|
+
// status/UI failure after the final compacted transcript is accepted.
|
|
562
|
+
lastSemanticError: null,
|
|
556
563
|
lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
|
|
557
|
-
lastRecallFastTrackError:
|
|
564
|
+
lastRecallFastTrackError: null,
|
|
565
|
+
lastError: null,
|
|
558
566
|
lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
|
|
559
567
|
lastSemanticUsage: semanticCompactResult?.usage ? {
|
|
560
568
|
inputTokens: semanticCompactResult.usage.inputTokens || 0,
|
|
@@ -584,9 +592,9 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
584
592
|
targetBudgetTokens: budget,
|
|
585
593
|
reserveTokens,
|
|
586
594
|
semanticCompact: semanticCompactResult?.semantic === true,
|
|
587
|
-
semanticError:
|
|
595
|
+
semanticError: null,
|
|
588
596
|
recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
|
|
589
|
-
recallFastTrackError:
|
|
597
|
+
recallFastTrackError: null,
|
|
590
598
|
usage: semanticCompactResult?.usage || null,
|
|
591
599
|
};
|
|
592
600
|
}
|
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
// Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
|
|
3
3
|
// Drives sweepStaleSessions on an unref'd interval; closeSession is imported
|
|
4
4
|
// from session-close.mjs (one-way dependency, no cycle).
|
|
5
|
-
import { sweepStaleSessions } from '../store.mjs';
|
|
5
|
+
import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
|
|
6
6
|
import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
|
|
7
7
|
import {
|
|
8
8
|
_getRuntimeEntry,
|
|
9
9
|
_clearSessionRuntime,
|
|
10
|
-
_runtimeEntries,
|
|
11
10
|
_sweepTerminalSessionRuntimes,
|
|
12
11
|
} from './runtime-liveness.mjs';
|
|
13
12
|
import { _closeBashSessionLazy } from './runtime-loaders.mjs';
|
|
14
|
-
import { closeSession } from './session-close.mjs';
|
|
15
13
|
import { nonNegativeIntEnv } from './env-utils.mjs';
|
|
16
14
|
|
|
17
15
|
// --- Periodic idle session cleanup ---
|
|
@@ -41,6 +39,32 @@ function _previewIds(items, limit = 5) {
|
|
|
41
39
|
return ` (${ids.join(', ')}${more})`;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
const IN_FLIGHT_STAGES = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling']);
|
|
43
|
+
|
|
44
|
+
export function _finalizeSweptSessionRuntime(detail) {
|
|
45
|
+
if (!detail?.id) return false;
|
|
46
|
+
const rtEntry = _getRuntimeEntry(detail.id);
|
|
47
|
+
// The store scan and this runtime cleanup are not atomic. If the session
|
|
48
|
+
// became active after the scan, leave its controller and runtime untouched;
|
|
49
|
+
// a later idle cycle can reconsider it after the work settles.
|
|
50
|
+
if (rtEntry && (
|
|
51
|
+
(rtEntry.controller && !rtEntry.controller.signal?.aborted)
|
|
52
|
+
|| IN_FLIGHT_STAGES.has(rtEntry.stage)
|
|
53
|
+
)) return false;
|
|
54
|
+
_clearSessionRuntime(detail.id);
|
|
55
|
+
if (detail.bashSessionId) {
|
|
56
|
+
try { _closeBashSessionLazy(detail.bashSessionId, `idle-sweep:${detail.id}`); } catch { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Informational sweep telemetry is debug-gated: unconditional stderr writes
|
|
62
|
+
// land inside the interactive TUI and corrupt the composer/status line.
|
|
63
|
+
// Genuine sweep errors (catch paths) stay unconditional.
|
|
64
|
+
const _sweepLog = (line) => {
|
|
65
|
+
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
|
|
66
|
+
};
|
|
67
|
+
|
|
44
68
|
function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
|
|
45
69
|
const startedAt = Date.now();
|
|
46
70
|
try {
|
|
@@ -59,35 +83,24 @@ function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {})
|
|
|
59
83
|
} = result;
|
|
60
84
|
if (cleaned > 0) {
|
|
61
85
|
for (const d of details) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// without signalling the controller leaves orphan provider work.
|
|
65
|
-
const rtEntry = _getRuntimeEntry(d.id);
|
|
66
|
-
if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
|
|
67
|
-
try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
|
|
68
|
-
} else {
|
|
69
|
-
_clearSessionRuntime(d.id);
|
|
70
|
-
if (d.bashSessionId) {
|
|
71
|
-
try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
86
|
+
if (!_finalizeSweptSessionRuntime(d)) continue;
|
|
87
|
+
_sweepLog(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
75
88
|
}
|
|
76
|
-
|
|
89
|
+
_sweepLog(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
|
|
77
90
|
}
|
|
78
91
|
if (tombstonesCleaned > 0) {
|
|
79
92
|
for (const d of tombstoneDetails) {
|
|
80
93
|
if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
|
|
81
94
|
}
|
|
82
|
-
|
|
95
|
+
_sweepLog(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
|
|
83
96
|
}
|
|
84
97
|
if (tombstoneErrors.length > 0) {
|
|
85
98
|
const first = tombstoneErrors[0];
|
|
86
|
-
|
|
99
|
+
_sweepLog(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
|
|
87
100
|
}
|
|
88
101
|
const elapsed = Date.now() - startedAt;
|
|
89
102
|
if (elapsed >= CLEANUP_SLOW_LOG_MS) {
|
|
90
|
-
|
|
103
|
+
_sweepLog(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
|
|
91
104
|
}
|
|
92
105
|
} catch (e) {
|
|
93
106
|
process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
|
|
@@ -117,11 +130,11 @@ export function sweepTombstones() {
|
|
|
117
130
|
if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
|
|
118
131
|
}
|
|
119
132
|
if (tombstonesCleaned > 0) {
|
|
120
|
-
|
|
133
|
+
_sweepLog(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
|
|
121
134
|
}
|
|
122
135
|
if (tombstoneErrors.length > 0) {
|
|
123
136
|
const first = tombstoneErrors[0];
|
|
124
|
-
|
|
137
|
+
_sweepLog(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
|
|
125
138
|
}
|
|
126
139
|
return tombstonesCleaned;
|
|
127
140
|
} catch (e) {
|
|
@@ -130,28 +143,15 @@ export function sweepTombstones() {
|
|
|
130
143
|
}
|
|
131
144
|
}
|
|
132
145
|
|
|
133
|
-
function
|
|
134
|
-
for (const [, entry] of _runtimeEntries()) {
|
|
135
|
-
if (!entry || entry.closed === true) continue;
|
|
136
|
-
if (entry.controller && !entry.controller.signal?.aborted) return true;
|
|
137
|
-
if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
|
|
138
|
-
}
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function _runCleanupCycle() {
|
|
146
|
+
export function _runCleanupCycle() {
|
|
143
147
|
// Drain every settled runtime entry on each pass, not just the one or two
|
|
144
148
|
// sessions whose on-disk idle TTL happened to expire in this interval.
|
|
145
149
|
_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
|
-
}
|
|
153
150
|
sweepOrphanedPendingMessages();
|
|
154
151
|
sweepIdleSessions({ includeTombstones: true });
|
|
152
|
+
// Reclaim same-process session snapshots whose state is durable on disk
|
|
153
|
+
// (memory-leak guard: _liveSessions used to grow for process lifetime).
|
|
154
|
+
try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
function _startCleanupInterval() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Steering / pending-message queue with sync buffered + atomic-file persistence.
|
|
2
2
|
// Extracted verbatim from manager.mjs (behavior-preserving).
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { readFileSync } from 'fs';
|
|
4
|
+
import { readFileSync, statSync } from 'fs';
|
|
5
5
|
import { createHash, randomBytes } from 'crypto';
|
|
6
6
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
7
7
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
@@ -47,8 +47,13 @@ function pendingMessageId(entry) {
|
|
|
47
47
|
return typeof entry?.id === 'string' && entry.id ? entry.id : null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Content-addressed (NO index): an index-keyed id changed whenever the queue
|
|
51
|
+
// shifted, so ack/ledger suppression missed the renamed entry and the foreign
|
|
52
|
+
// drain re-injected it as a duplicate user message. Identical legacy strings
|
|
53
|
+
// now share one id — acceptable, since ack-by-id then clears such duplicate
|
|
54
|
+
// stale rows together.
|
|
50
55
|
function legacyPendingMessageId(sessionId, index, value) {
|
|
51
|
-
return `legacy_${createHash('sha256').update(`${sessionId}:${
|
|
56
|
+
return `legacy_${createHash('sha256').update(`${sessionId}:${value}`).digest('hex').slice(0, 24)}`;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function isCompletionNotificationEntry(entry) {
|
|
@@ -97,6 +102,12 @@ function pendingMessagesPath() {
|
|
|
97
102
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
// Exposed for live-share owners: they fs.watch this file for instant pickup
|
|
106
|
+
// of cross-surface submits (the 3s drain tick remains the safety net).
|
|
107
|
+
export function pendingMessagesSpoolPath() {
|
|
108
|
+
return pendingMessagesPath();
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
function isValidPendingSessionId(sessionId) {
|
|
101
112
|
return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
|
|
102
113
|
}
|
|
@@ -371,11 +382,19 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
|
|
|
371
382
|
const reported = operation.then(() => true).catch((err) => {
|
|
372
383
|
try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
373
384
|
return false;
|
|
374
|
-
}).
|
|
385
|
+
}).then((ok) => {
|
|
386
|
+
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
387
|
+
// Keep the acked ids when the spool cleanup FAILED: the durable entry
|
|
388
|
+
// still exists, and forgetting that it was already delivered let the
|
|
389
|
+
// foreign-injection drain re-take it as a "new" cross-surface submit
|
|
390
|
+
// (user saw the same message duplicated in the transcript). Retained
|
|
391
|
+
// ids also keep suppressing the hydrate path; they are dropped only
|
|
392
|
+
// once a later ack round actually lands.
|
|
393
|
+
if (!ok) return false;
|
|
375
394
|
const currentAcked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
376
395
|
for (const id of ids) currentAcked.delete(id);
|
|
377
396
|
if (currentAcked.size === 0) _ackedPendingIds.delete(sessionId);
|
|
378
|
-
|
|
397
|
+
return true;
|
|
379
398
|
});
|
|
380
399
|
return reported;
|
|
381
400
|
}
|
|
@@ -638,6 +657,91 @@ export function enqueuePendingMessage(sessionId, message) {
|
|
|
638
657
|
return Math.max(q.length, bufferedDepth || 0);
|
|
639
658
|
}
|
|
640
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Remote-attach injection enqueue: persist a user message into the shared
|
|
662
|
+
* cross-process spool WITHOUT touching this process's in-memory queues. Used
|
|
663
|
+
* by a viewer surface attached to a session another live process owns — the
|
|
664
|
+
* owner's injection poller (drainForeignUserInjections) picks it up there.
|
|
665
|
+
* Skipping the local queues is deliberate: if this process later takes real
|
|
666
|
+
* ownership of the session, a lingering local copy would double-inject.
|
|
667
|
+
*/
|
|
668
|
+
export function enqueueRemotePendingMessage(sessionId, message) {
|
|
669
|
+
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
670
|
+
const normalized = pendingMessageQueueEntry(message);
|
|
671
|
+
if (!normalized) return 0;
|
|
672
|
+
return persistPendingMessages(sessionId, [{ ...normalized, id: newPendingMessageId() }]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Spool-file mtime gate so the owner's idle poller costs one stat per tick,
|
|
676
|
+
// not a locked read-modify-write.
|
|
677
|
+
let _foreignSpoolScanMtime = 0;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Owner-side drain of FOREIGN user injections for a session this process
|
|
681
|
+
* owns: atomically removes (and returns the text of) genuine user/steering
|
|
682
|
+
* entries that were persisted by ANOTHER process — entries known locally
|
|
683
|
+
* (own steering buffers, hydrated, in-delivery, acked) and completion/
|
|
684
|
+
* internal-notification entries are left untouched for the normal
|
|
685
|
+
* askSession hydrate path.
|
|
686
|
+
*/
|
|
687
|
+
export function drainForeignUserInjections(sessionId) {
|
|
688
|
+
if (!isValidPendingSessionId(sessionId)) return [];
|
|
689
|
+
let mtime = 0;
|
|
690
|
+
try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
|
|
691
|
+
if (mtime === _foreignSpoolScanMtime) return [];
|
|
692
|
+
_foreignSpoolScanMtime = mtime;
|
|
693
|
+
const localIds = new Set();
|
|
694
|
+
for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
|
|
695
|
+
for (const entry of map.get(sessionId) || []) {
|
|
696
|
+
const id = pendingMessageId(entry);
|
|
697
|
+
if (id) localIds.add(id);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
for (const set of [_inDeliveryPendingIds.get(sessionId), _ackedPendingIds.get(sessionId)]) {
|
|
701
|
+
for (const id of set || []) localIds.add(id);
|
|
702
|
+
}
|
|
703
|
+
// The durable delivered-ledger mirrors the hydrate path's suppression: an
|
|
704
|
+
// entry whose id was recorded as delivered (but whose spool cleanup has
|
|
705
|
+
// not landed yet — crash, lock contention, another process) is NOT a
|
|
706
|
+
// foreign submit and must never re-inject.
|
|
707
|
+
try {
|
|
708
|
+
for (const id of loadSession(sessionId)?.deliveredPendingMessageIds || []) {
|
|
709
|
+
if (typeof id === 'string' && id) localIds.add(id);
|
|
710
|
+
}
|
|
711
|
+
} catch { /* ledger unavailable — in-memory sets still guard */ }
|
|
712
|
+
const taken = [];
|
|
713
|
+
try {
|
|
714
|
+
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
715
|
+
const next = normalizePendingStore(raw);
|
|
716
|
+
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
717
|
+
if (q.length === 0) return undefined;
|
|
718
|
+
const kept = [];
|
|
719
|
+
for (const entry of q) {
|
|
720
|
+
const id = pendingMessageId(entry);
|
|
721
|
+
const text = pendingMessageText(entry);
|
|
722
|
+
const foreignUser = id && !localIds.has(id)
|
|
723
|
+
&& !isCompletionNotificationEntry(entry)
|
|
724
|
+
&& !isLegacyUnmarkedCompletionNotification(text)
|
|
725
|
+
&& text && !isInternalRuntimeNotificationText(text);
|
|
726
|
+
if (foreignUser) taken.push(text);
|
|
727
|
+
else kept.push(entry);
|
|
728
|
+
}
|
|
729
|
+
if (taken.length === 0) return undefined;
|
|
730
|
+
if (kept.length > 0) next.sessions[sessionId] = kept;
|
|
731
|
+
else {
|
|
732
|
+
delete next.sessions[sessionId];
|
|
733
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
734
|
+
}
|
|
735
|
+
next.updatedAt = Date.now();
|
|
736
|
+
return next;
|
|
737
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
740
|
+
return [];
|
|
741
|
+
}
|
|
742
|
+
return taken;
|
|
743
|
+
}
|
|
744
|
+
|
|
641
745
|
export function drainPendingMessages(sessionId) {
|
|
642
746
|
const q = _sessionPendingMessages.get(sessionId);
|
|
643
747
|
const memory = q && q.length > 0 ? q.slice() : [];
|
|
@@ -133,6 +133,8 @@ export function markSessionAskStart(id) {
|
|
|
133
133
|
entry.lastToolCall = null;
|
|
134
134
|
entry.toolStartedAt = null;
|
|
135
135
|
entry.toolSelfDeadlineMs = null;
|
|
136
|
+
entry.toolOutputTail = null;
|
|
137
|
+
entry.toolOutputTailAt = null;
|
|
136
138
|
entry.lastError = null;
|
|
137
139
|
// A new ask starts a fresh turn lifecycle — clear any stale empty-final
|
|
138
140
|
// classification from the prior turn so inspectBridgeEntry doesn't keep
|
|
@@ -238,6 +240,9 @@ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
|
|
|
238
240
|
const entry = _touchRuntime(id);
|
|
239
241
|
entry.stage = 'tool_running';
|
|
240
242
|
entry.lastToolCall = toolName || null;
|
|
243
|
+
// A new tool call invalidates the previous call's live-output tail.
|
|
244
|
+
entry.toolOutputTail = null;
|
|
245
|
+
entry.toolOutputTailAt = null;
|
|
241
246
|
// Self-enforced deadline (ms) for tools that kill themselves at a known
|
|
242
247
|
// budget (shell timeout / task wait). The watchdog raises the tool-running
|
|
243
248
|
// ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
|
|
@@ -256,6 +261,18 @@ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
|
|
|
256
261
|
publishHeartbeat(id, entry.toolStartedAt);
|
|
257
262
|
_startToolActivityHeartbeat(id);
|
|
258
263
|
}
|
|
264
|
+
// Live shell-output tail for the CURRENT running tool. Written by the shell
|
|
265
|
+
// tool's onOutputTail timer (~1 s cadence), read by engine transcript
|
|
266
|
+
// consumers via getSessionProgressSnapshot. Never creates a runtime entry —
|
|
267
|
+
// a settled/unknown session ignores late writes.
|
|
268
|
+
export function markSessionToolOutputTail(id, tail) {
|
|
269
|
+
if (!id) return;
|
|
270
|
+
const entry = _runtimeState.get(id);
|
|
271
|
+
if (!entry) return;
|
|
272
|
+
const text = typeof tail === 'string' ? tail : String(tail ?? '');
|
|
273
|
+
entry.toolOutputTail = text ? text.slice(-4000) : null;
|
|
274
|
+
entry.toolOutputTailAt = Date.now();
|
|
275
|
+
}
|
|
259
276
|
// Parent AbortSignal listeners are dropped on askSession unwind (finally /
|
|
260
277
|
// terminal return) and on error/cancel/close — not in markSessionDone, which
|
|
261
278
|
// also runs between queued follow-up turns within one ask.
|
|
@@ -383,6 +400,8 @@ export function getSessionProgressSnapshot(sessionId) {
|
|
|
383
400
|
toolStartedAt: entry.toolStartedAt || 0,
|
|
384
401
|
currentTool: entry.lastToolCall || null,
|
|
385
402
|
toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
|
|
403
|
+
toolOutputTail: entry.toolOutputTail || null,
|
|
404
|
+
toolOutputTailAt: entry.toolOutputTailAt || 0,
|
|
386
405
|
lastProgressAt: entry.lastProgressAt || 0,
|
|
387
406
|
updatedAt: entry.updatedAt || 0,
|
|
388
407
|
hasFirstActivity: Boolean(firstSemanticAt && (!askStartedAt || firstSemanticAt >= askStartedAt)),
|