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.
Files changed (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Run a schedule as a VISIBLE Mixdog session instead of a hidden agent
3
+ * dispatch. Shared by the engine run-now capability and the channels-worker
4
+ * scheduler so both paths leave the same trail: a resumable session with
5
+ * owner 'user' + sourceType 'schedule' (admitted by the lead-session list
6
+ * filter as its own type, so it shows in desktop Recent / TUI resume), the
7
+ * schedule's provider/model route, and the schedule's project cwd.
8
+ */
9
+ import { loadConfig } from '../agent/orchestrator/config.mjs';
10
+ import { createSession } from '../agent/orchestrator/session/manager/session-lifecycle.mjs';
11
+ import { askSession } from '../agent/orchestrator/session/manager/ask-session.mjs';
12
+ import { parseScheduleModelRef } from './schedule-model-ref.mjs';
13
+
14
+ /** Resolve schedule.model into a {provider,model,effort?,fast?} route. */
15
+ export function scheduleRouteFromModelRef(modelRef, config = null) {
16
+ const ref = parseScheduleModelRef(modelRef);
17
+ if (ref && typeof ref === 'object') return ref;
18
+ const cfg = config || loadConfig({ secrets: false });
19
+ const preset = cfg.presets?.find((p) => p.id === ref || p.name === ref);
20
+ if (!preset) {
21
+ throw new Error(`schedule model "${ref}" is neither a provider/model route nor a known preset name`);
22
+ }
23
+ return {
24
+ provider: preset.provider,
25
+ model: preset.model,
26
+ ...(preset.effort ? { effort: preset.effort } : {}),
27
+ ...(preset.fast === true ? { fast: true } : {}),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Create the visible schedule session and run the prompt in it.
33
+ * `prompt` overrides schedule.prompt when the caller (channels worker) has
34
+ * already resolved/wrapped the prompt body; the override is used verbatim
35
+ * while the fallback gets a small origin header.
36
+ */
37
+ export async function runScheduleSession(schedule, { config = null, prompt: promptOverride = null } = {}) {
38
+ if (!schedule?.name) throw new Error('runScheduleSession: schedule row required');
39
+ const prompt = String(promptOverride ?? schedule.prompt ?? '').trim();
40
+ if (!prompt) throw new Error(`schedule "${schedule.name}" has no instructions`);
41
+ if (!schedule.model) {
42
+ throw new Error(`schedule "${schedule.name}" has no model configured — edit it and choose a model first`);
43
+ }
44
+ const route = scheduleRouteFromModelRef(schedule.model, config);
45
+ const cwd = schedule.cwd ? String(schedule.cwd) : null;
46
+ const session = createSession({
47
+ provider: route.provider,
48
+ model: route.model,
49
+ ...(route.effort ? { effort: route.effort } : {}),
50
+ ...(route.fast === true ? { fast: true } : {}),
51
+ owner: 'user',
52
+ sourceType: 'schedule',
53
+ sourceName: schedule.name,
54
+ ...(cwd ? { cwd } : {}),
55
+ desktopSession: cwd
56
+ ? { classification: 'project', projectPath: cwd }
57
+ : { classification: 'task', projectPath: null },
58
+ });
59
+ // The user message is the schedule's instructions verbatim: the desktop
60
+ // title/preview derive from it, so no "[Scheduled task: …]" header noise.
61
+ const result = await askSession(session.id, prompt, null, null, cwd || undefined);
62
+ return { sessionId: session.id, result: String(result?.content || '') };
63
+ }
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
32
32
  target text NOT NULL CHECK (target IN ('channel','session')),
33
33
  channel_id text,
34
34
  model text,
35
+ cwd text,
35
36
  prompt text NOT NULL,
36
37
  enabled boolean NOT NULL DEFAULT true,
37
38
  status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
@@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
43
44
  updated_at timestamptz NOT NULL DEFAULT now(),
44
45
  CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
45
46
  );
47
+ ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS cwd text;
46
48
  `;
47
49
 
48
50
  async function getDb(dataDir = resolvePluginData()) {
@@ -79,6 +81,7 @@ function rowToDef(row) {
79
81
  target: row.target,
80
82
  channelId: row.channel_id,
81
83
  model: row.model,
84
+ cwd: row.cwd,
82
85
  prompt: row.prompt,
83
86
  enabled: row.enabled,
84
87
  status: row.status,
@@ -91,7 +94,7 @@ function rowToDef(row) {
91
94
  };
92
95
  }
93
96
 
94
- const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
97
+ const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
95
98
 
96
99
  // ---------------------------------------------------------------------------
97
100
  // Public API
@@ -126,6 +129,7 @@ export async function upsertSchedule(def, { dataDir } = {}) {
126
129
  def.target,
127
130
  def.channelId ?? null,
128
131
  def.model ?? null,
132
+ def.cwd ?? null,
129
133
  def.prompt,
130
134
  def.enabled ?? true,
131
135
  def.status ?? 'active',
@@ -133,8 +137,8 @@ export async function upsertSchedule(def, { dataDir } = {}) {
133
137
  ];
134
138
  const { rows } = await db.query(
135
139
  `INSERT INTO scheduler.schedules
136
- (name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, next_fire_at)
137
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
140
+ (name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, next_fire_at)
141
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
138
142
  ON CONFLICT (name) DO UPDATE SET
139
143
  description = EXCLUDED.description,
140
144
  when_at = EXCLUDED.when_at,
@@ -143,6 +147,7 @@ export async function upsertSchedule(def, { dataDir } = {}) {
143
147
  target = EXCLUDED.target,
144
148
  channel_id = EXCLUDED.channel_id,
145
149
  model = EXCLUDED.model,
150
+ cwd = EXCLUDED.cwd,
146
151
  prompt = EXCLUDED.prompt,
147
152
  enabled = EXCLUDED.enabled,
148
153
  next_fire_at = EXCLUDED.next_fire_at,
@@ -449,7 +449,10 @@ export function summarizeToolResult(name, args, resultText, isError = false) {
449
449
  // Agent/task result cards show only a one-liner; full report via ctrl+o.
450
450
  if (answerLine) return truncateSingleLine(stripInlineMarkdown(answerLine), AGENT_SURFACE_BRIEF_MAX);
451
451
  const task = /^agent task:\s*(\S+)/mi.exec(text);
452
- const status = /^status:\s*([^\s(]+)/mi.exec(text);
452
+ const statusMatch = /^status:\s*([^\s(]+)/mi.exec(text);
453
+ // Defensive twin of the render-side guard: an envelope that still says
454
+ // "status: undefined"/"null" must not surface a titleized "Undefined".
455
+ const status = statusMatch && !/^(?:undefined|null)$/i.test(statusMatch[1]) ? statusMatch : null;
453
456
  const agent = /^agent:\s*(.+)$/mi.exec(text);
454
457
  const preset = /^preset:\s*(.+)$/mi.exec(text);
455
458
  const model = /^model:\s*(.+)$/mi.exec(text);
@@ -602,17 +602,17 @@ export function aggregateToolCategoryEntry(name, args = {}, category = '') {
602
602
  }
603
603
 
604
604
  /**
605
- * Rebuild the per-category count map for the DONE state, excluding calls that
606
- * terminated in an error (rec.isError). Mirrors the call-time accumulation in
607
- * turn.mjs (sum aggregateToolCategoryEntry(...).count per key) but drops failed
608
- * calls so the completed label counts only successful work across ALL
609
- * categories, not just Memory. The active/in-flight header keeps using the raw
610
- * `categories` map, so in-flight counting is unchanged.
605
+ * Rebuild the per-category count map for the DONE state. Counts ATTEMPTS
606
+ * failed calls included so the collapsed header total always agrees with
607
+ * the 'N Ok · N Failed' breakdown rendered beside it ("Ran 5 commands ·
608
+ * 3 Ok · 2 Failed", never "Ran 3 commands · 3 Ok · 2 Failed"). Mirrors the
609
+ * call-time accumulation in turn.mjs (sum aggregateToolCategoryEntry(...).count
610
+ * per key); outcome splitting stays in the failure detail, not the header.
611
611
  */
612
612
  export function aggregateDoneCategories(calls = []) {
613
613
  const map = new Map();
614
614
  for (const rec of calls || []) {
615
- if (!rec || rec.isError) continue;
615
+ if (!rec) continue;
616
616
  const entry = aggregateToolCategoryEntry(rec.name, rec.args, rec.category);
617
617
  const prev = map.get(entry.key);
618
618
  map.set(entry.key, { ...entry, count: Number(prev?.count || 0) + Number(entry.count || 1) });
@@ -167,6 +167,22 @@ export function createTranscriptWriter({ mixdogHome, sessionId, cwd, pid } = {})
167
167
  });
168
168
  }
169
169
 
170
+ // User prompt row. The channel forwarder ignores plain user text rows
171
+ // (output-forwarder extractNewText only surfaces assistant rows and
172
+ // user rows carrying tool_result), so this never echoes back to the
173
+ // channel — it exists so the memory transcript watcher ingests BOTH
174
+ // sides of the conversation (user rows were previously never written,
175
+ // leaving recall unable to reconstruct recent sessions).
176
+ function appendUser(text) {
177
+ const value = typeof text === 'string' ? text : (text == null ? '' : String(text));
178
+ if (!value.trim()) return;
179
+ appendLine({
180
+ type: 'user',
181
+ sessionId,
182
+ message: { content: [{ type: 'text', text: value }] },
183
+ });
184
+ }
185
+
170
186
  function appendToolUse(name, input) {
171
187
  if (!name) return;
172
188
  appendLine({
@@ -217,6 +233,7 @@ export function createTranscriptWriter({ mixdogHome, sessionId, cwd, pid } = {})
217
233
  ensureTranscriptFile,
218
234
  refresh,
219
235
  appendAssistant,
236
+ appendUser,
220
237
  appendToolUse,
221
238
  appendToolResult,
222
239
  };
@@ -9,6 +9,8 @@ import {
9
9
  setWebhookEnabled,
10
10
  setWebhookConfigAsync,
11
11
  } from '../standalone/channel-admin.mjs';
12
+ import { getSchedule } from '../runtime/shared/schedules-db.mjs';
13
+ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
12
14
 
13
15
  // Channel/webhook/schedule config surface. Extracted verbatim from the runtime
14
16
  // API object; the mutating admin helpers are imported directly here and the
@@ -66,5 +68,14 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
66
68
  reloadChannelsSoon();
67
69
  return result;
68
70
  },
71
+ async runScheduleNow(name) {
72
+ const id = String(name || '').trim();
73
+ const schedule = await getSchedule(id);
74
+ if (!schedule) throw new Error(`schedule "${id}" does not exist`);
75
+ // Runs in the engine process as a VISIBLE schedule session, so it works
76
+ // even while the channels worker is off and the run lands in Recent.
77
+ const { sessionId, result } = await runScheduleSession(schedule);
78
+ return { name: schedule.name, ok: true, sessionId, result };
79
+ },
69
80
  };
70
81
  }
@@ -114,18 +114,21 @@ const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
114
114
 
115
115
  // Provider-aware auto-clear idle-sweep defaults. Rationale mirrors the
116
116
  // provider cache TTLs documented in agent-runtime/cache-strategy.mjs
117
- // (explicit breakpoint / managed-cache windows): Anthropic follows the BP4
118
- // messages-tail TTL (5m default cache_control window; BP1~3 stay on the 1h
119
- // extended TTL and are prefix-shared, so reaping at 5m only forfeits an
120
- // already-cold tail see resolveLeadMessagesTtl). Gemini, xAI and Mistral
117
+ // (explicit breakpoint / managed-cache windows): Anthropic follows the 1h BP4
118
+ // messages-tail TTL. The 2026-07-16 6.8h trace found no intra-session gaps
119
+ // over 1h; gaps over 5m were common for agent tool executions/reviewer
120
+ // fix-loop reuse, and 24/25 such Lead gaps were agent waits where autoClear
121
+ // cannot fire. Tail-cost simulation favors 1h despite its 2x write premium:
122
+ // Lead 11.75M vs 20.11M and agents 14.46M vs 45.04M input-token equivalents,
123
+ // because cold misses rewrite the accumulated tail. Gemini, xAI and Mistral
121
124
  // caches run ~1h, Groq's implicit cache persists ~2h, OpenAI/DeepSeek
122
125
  // key-prefix/implicit caches persist far longer (~24h), and OpenAI OAuth's
123
126
  // in-memory server cache is short-lived (~5-10min), so we round up slightly
124
127
  // to 10m to avoid sweeping a still-warm cache. Unknown/unrecognized
125
128
  // providers fall back to the 'default' 1h entry.
126
129
  export const AUTO_CLEAR_PROVIDER_IDLE_MS = Object.freeze({
127
- 'anthropic': 5 * 60 * 1000,
128
- 'anthropic-oauth': 5 * 60 * 1000,
130
+ 'anthropic': 60 * 60 * 1000,
131
+ 'anthropic-oauth': 60 * 60 * 1000,
129
132
  'gemini': 60 * 60 * 1000,
130
133
  'groq': 2 * 60 * 60 * 1000,
131
134
  'openai': 24 * 60 * 60 * 1000,
@@ -2,10 +2,13 @@ import {
2
2
  estimateRequestReserveTokens,
3
3
  estimateToolSchemaTokens,
4
4
  contextMessagesRevision,
5
+ providerTokenCalibration,
5
6
  resolveSessionCompactPolicy,
6
7
  summarizeContextMessages,
7
8
  toolSchemaSignature,
8
9
  } from '../runtime/agent/orchestrator/session/context-utils.mjs';
10
+ import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
11
+ import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
9
12
  import {
10
13
  resolveCompactionPressureTokens,
11
14
  resolveWorkerCompactPolicy,
@@ -130,14 +133,85 @@ export function createContextStatus({
130
133
  function contextStatus() {
131
134
  const session = getSession();
132
135
  const route = getRoute();
136
+ const committedMessages = Array.isArray(session?.messages) ? session.messages : [];
137
+ const liveMessages = Array.isArray(session?.liveTurnMessages) ? session.liveTurnMessages : null;
138
+ const activityMessages = liveMessages || committedMessages;
139
+ const hasConversationActivity = hasUserConversationMessage(activityMessages)
140
+ || activityMessages.some((message) => (
141
+ message?.role === 'user'
142
+ && typeof message.content === 'string'
143
+ && message.content.startsWith(SUMMARY_PREFIX)
144
+ ));
145
+ // A route is not a conversation. Keep a pristine desktop/TUI task truly
146
+ // empty until the first real turn. Remote auto-start may prepare a local
147
+ // session shell containing system/tool templates, but those templates have
148
+ // not entered a provider request and must not appear as consumed context.
149
+ if (!session?.id || !hasConversationActivity) {
150
+ const emptyCompactPolicy = session
151
+ ? resolveWorkerCompactPolicy(session, Array.isArray(session.tools) ? session.tools : [])
152
+ : null;
153
+ const routeWindow = Math.max(0, Number(
154
+ session?.compactBoundaryTokens
155
+ || session?.contextWindow
156
+ || route?.contextWindow
157
+ || 0,
158
+ ));
159
+ return {
160
+ sessionId: session?.id || null,
161
+ provider: route.provider,
162
+ model: route.model,
163
+ cwd: getCurrentCwd(),
164
+ toolMode: getMode(),
165
+ contextWindow: routeWindow || null,
166
+ effectiveContextWindow: routeWindow || null,
167
+ rawContextWindow: routeWindow || null,
168
+ effectiveContextWindowPercent: null,
169
+ usedTokens: 0,
170
+ usedSource: 'empty',
171
+ currentEstimatedTokens: 0,
172
+ lastApiRequestTokens: 0,
173
+ lastApiRequestStale: false,
174
+ freeTokens: routeWindow,
175
+ compaction: {
176
+ boundaryTokens: Number(session?.compactBoundaryTokens || emptyCompactPolicy?.boundaryTokens || 0) || null,
177
+ triggerTokens: Number(emptyCompactPolicy?.triggerTokens || 0) || null,
178
+ bufferTokens: Number(emptyCompactPolicy?.bufferTokens || 0) || null,
179
+ bufferRatio: Number.isFinite(emptyCompactPolicy?.bufferRatio)
180
+ ? emptyCompactPolicy.bufferRatio
181
+ : null,
182
+ currentEstimatedTokens: 0,
183
+ lastApiRequestTokens: 0,
184
+ lastApiRequestStale: false,
185
+ },
186
+ messages: summarizeContextMessages([]),
187
+ request: {
188
+ toolSchemaTokens: 0,
189
+ toolSchemaBreakdown: {},
190
+ requestOverheadTokens: 0,
191
+ reserveTokens: 0,
192
+ },
193
+ usage: {
194
+ lastInputTokens: 0,
195
+ lastUncachedInputTokens: 0,
196
+ lastOutputTokens: 0,
197
+ lastCachedReadTokens: 0,
198
+ lastCacheWriteTokens: 0,
199
+ lastContextTokens: 0,
200
+ totalInputTokens: 0,
201
+ totalUncachedInputTokens: 0,
202
+ totalOutputTokens: 0,
203
+ totalCachedReadTokens: 0,
204
+ totalCacheWriteTokens: 0,
205
+ },
206
+ };
207
+ }
133
208
  // Prefer the in-flight working transcript while a turn is running so the
134
209
  // context gauge reflects LIVE growth (user turn + tool calls/results) as
135
210
  // it accumulates, instead of freezing at the pre-turn committed snapshot.
136
211
  // askSession() sets session.liveTurnMessages for the turn duration and
137
212
  // clears it on commit/cancel/error, after which we fall back to the
138
213
  // authoritative committed transcript.
139
- const liveTurnMessages = Array.isArray(session?.liveTurnMessages) ? session.liveTurnMessages : null;
140
- const messages = liveTurnMessages || (Array.isArray(session?.messages) ? session.messages : []);
214
+ const messages = activityMessages;
141
215
  const requestProvider = session?.provider || route.provider;
142
216
  // Do not even evaluate live native definitions when an in-flight request
143
217
  // scope owns the complete immutable provider surface.
@@ -185,7 +259,10 @@ export function createContextStatus({
185
259
  const workerCompactPolicy = resolveWorkerCompactPolicy(session, requestTools);
186
260
  const compactPolicy = workerCompactPolicy?.boundaryTokens
187
261
  ? workerCompactPolicy
188
- : resolveSessionCompactPolicy(session || {}, compactBoundaryTokens);
262
+ : {
263
+ ...resolveSessionCompactPolicy(session || {}, compactBoundaryTokens),
264
+ tokenCalibration: providerTokenCalibration(session?.provider || route.provider),
265
+ };
189
266
  // Match the pre-provider-send auto-compact check exactly: the gauge uses
190
267
  // the same provider-baseline-or-estimate pressure, including request and
191
268
  // configured reserves, rather than a separate transcript-only estimate.
@@ -11,6 +11,10 @@ import {
11
11
  } from './session-text.mjs';
12
12
  import { toolSpecForMode, deferredSurfaceModeForLead } from './effort.mjs';
13
13
  import { unregisterLiveSession } from '../runtime/shared/staged-update.mjs';
14
+ import {
15
+ getStoreDir,
16
+ listSessionHeartbeatMtimes,
17
+ } from '../runtime/agent/orchestrator/session/store/paths-heartbeat.mjs';
14
18
 
15
19
  export function resolveResumeCwd(session, currentCwd) {
16
20
  const desktop = session?.desktopSession;
@@ -44,10 +48,90 @@ export function createLifecycleApi(deps) {
44
48
  withTeardownDeadline, closePatchRuntimeIfLoaded,
45
49
  createCurrentSession, refreshRouteEffort,
46
50
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
47
- applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
51
+ applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
48
52
  pushTranscriptRebind,
49
53
  notificationListeners, remoteStateListeners, desktopSession,
50
54
  } = deps;
55
+ const listLeadSessions = (options = {}) => {
56
+ const heartbeatMtimes = listSessionHeartbeatMtimes();
57
+ return mgr.listSessions({
58
+ refreshFromStorage: options?.refreshFromStorage === true,
59
+ }).map(s => {
60
+ const owner = clean(s.owner || 'user').toLowerCase();
61
+ if (owner && !['cli', 'user', 'mixdog', 'legacy'].includes(owner)) return null;
62
+ const sourceType = clean(s.sourceType || '').toLowerCase();
63
+ const sourceName = clean(s.sourceName || '').toLowerCase();
64
+ const agent = clean(s.agent || '').toLowerCase();
65
+ const leadish = agent === 'lead'
66
+ || sourceType === 'lead'
67
+ || (sourceType === 'cli')
68
+ // Schedule runs are their own visible type: they surface in desktop
69
+ // Recent / TUI resume next to lead sessions instead of hiding like
70
+ // agent dispatches.
71
+ || sourceType === 'schedule'
72
+ || (!sourceType && !sourceName && !isAgentOwner(owner));
73
+ if (!leadish) return null;
74
+ const rawPreview = s.preview || '';
75
+ let preview = isSessionPreviewNoise(rawPreview) ? '' : cleanSessionPreview(rawPreview);
76
+ let messageCount = Math.max(0, Number(s.messageCount) || 0);
77
+ if (!preview && Array.isArray(s.messages)) {
78
+ const msgs = s.messages || [];
79
+ const userPreviews = msgs
80
+ .filter(m => m && m.role === 'user')
81
+ .map(m => sessionMessageText(m.content))
82
+ .filter(text => !isSessionPreviewNoise(text))
83
+ .map(text => cleanSessionPreview(text))
84
+ .filter(Boolean);
85
+ preview = userPreviews[0] || '';
86
+ messageCount = msgs.filter(m => m && (m.role === 'user' || m.role === 'assistant')).length;
87
+ }
88
+ if (!preview && messageCount === 0) return null;
89
+ return {
90
+ id: s.id,
91
+ updatedAt: s.updatedAt,
92
+ cwd: s.cwd || '',
93
+ model: s.model,
94
+ provider: s.provider,
95
+ messageCount,
96
+ preview,
97
+ heartbeatAt: Math.max(
98
+ Number(s.lastHeartbeatAt) || 0,
99
+ Number(heartbeatMtimes.get(s.id)) || 0,
100
+ ),
101
+ desktopSession: s.desktopSession || null,
102
+ };
103
+ }).filter(Boolean);
104
+ };
105
+ // Persist a closing session's conversation into the memory DB. Sessions
106
+ // that never compacted had NO session-sourced rows (ingest_session
107
+ // previously ran only inside compaction), so recall could not reconstruct
108
+ // the most recent conversations. Best-effort and deadline-capped: the row
109
+ // inserts are fast and committed even if the bounded wait elapses during
110
+ // the trailing embedding flush (the raw-embedding backlog sweep embeds
111
+ // them later). Never loads the memory runtime just for this — a null
112
+ // module promise means memory was never used this run, so skip.
113
+ async function ingestSessionIntoMemory(session) {
114
+ try {
115
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
116
+ if (!session?.id || messages.length === 0) return;
117
+ const modPromise = getMemoryModPromise();
118
+ if (!modPromise) return;
119
+ await withTeardownDeadline(
120
+ Promise.resolve(modPromise)
121
+ .then((mod) => (typeof mod?.handleToolCall === 'function'
122
+ ? mod.handleToolCall('memory', {
123
+ action: 'ingest_session',
124
+ sessionId: session.id,
125
+ cwd: session.cwd || getCurrentCwd(),
126
+ messages,
127
+ })
128
+ : null))
129
+ .catch(() => {}),
130
+ 2500,
131
+ undefined,
132
+ );
133
+ } catch { /* best-effort: memory ingest must never break lifecycle paths */ }
134
+ }
51
135
  return {
52
136
  async close(reason = 'cli-exit', options = {}) {
53
137
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
@@ -78,6 +162,9 @@ export function createLifecycleApi(deps) {
78
162
  );
79
163
  }
80
164
  } catch { /* best-effort: SessionEnd hook must never wedge teardown */ }
165
+ // Ingest the final conversation BEFORE the memory runtime stop below is
166
+ // kicked off, so the write happens against a live module/daemon.
167
+ try { await ingestSessionIntoMemory(getSession()); } catch { /* best-effort */ }
81
168
  // Teardown stays async end-to-end across every writer sharing the config
82
169
  // lock. Never start a synchronous lock wait while an in-process async
83
170
  // holder still needs the event loop to finish and release it.
@@ -189,47 +276,50 @@ export function createLifecycleApi(deps) {
189
276
  return mgr.abortSessionTurn(session.id, reason);
190
277
  },
191
278
  listSessions(options = {}) {
192
- return mgr.listSessions({
193
- refreshFromStorage: options?.refreshFromStorage === true,
194
- }).map(s => {
195
- const owner = clean(s.owner || 'user').toLowerCase();
196
- if (owner && !['cli', 'user', 'mixdog', 'legacy'].includes(owner)) return null;
197
- const sourceType = clean(s.sourceType || '').toLowerCase();
198
- const sourceName = clean(s.sourceName || '').toLowerCase();
199
- const agent = clean(s.agent || '').toLowerCase();
200
- const leadish = agent === 'lead'
201
- || sourceType === 'lead'
202
- || (sourceType === 'cli')
203
- || (!sourceType && !sourceName && !isAgentOwner(owner));
204
- if (!leadish) return null;
205
- let preview = cleanSessionPreview(s.preview || '');
206
- let messageCount = Math.max(0, Number(s.messageCount) || 0);
207
- if (!preview && Array.isArray(s.messages)) {
208
- const msgs = s.messages || [];
209
- const userPreviews = msgs
210
- .filter(m => m && m.role === 'user')
211
- .map(m => cleanSessionPreview(sessionMessageText(m.content)))
212
- .filter(text => !isSessionPreviewNoise(text));
213
- preview = userPreviews[userPreviews.length - 1] || userPreviews[0] || '';
214
- messageCount = msgs.filter(m => m && (m.role === 'user' || m.role === 'assistant')).length;
215
- }
216
- if (!preview && messageCount === 0) return null;
217
- return {
218
- id: s.id,
219
- updatedAt: s.updatedAt,
220
- cwd: s.cwd || '',
221
- model: s.model,
222
- provider: s.provider,
223
- messageCount,
224
- preview,
225
- desktopSession: s.desktopSession || null,
226
- };
227
- }).filter(Boolean);
279
+ return listLeadSessions(options);
280
+ },
281
+ // Desktop watcher hook: absolute path of the on-disk session store so the
282
+ // host can fs.watch it and push sidebar updates instead of polling.
283
+ sessionStoreDir() {
284
+ try { return getStoreDir(); } catch { return null; }
285
+ },
286
+ async deleteSession(id) {
287
+ const sessionId = clean(id);
288
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return false;
289
+ const available = listLeadSessions({ refreshFromStorage: true })
290
+ .some(row => row.id === sessionId);
291
+ if (!available) return false;
292
+ const current = getSession();
293
+ if (current?.id !== sessionId) return mgr.deleteSession(sessionId) === true;
294
+
295
+ const cleanupReason = 'desktop-session-delete';
296
+ try {
297
+ cancelBackgroundTasksForLifecycle({
298
+ reason: cleanupReason,
299
+ notify: false,
300
+ callerSessionId: sessionId,
301
+ });
302
+ } catch {}
303
+ try { agentTool?.closeAll?.(cleanupReason); } catch {}
304
+ statusRoutes?.clearGatewaySessionRoute?.(sessionId);
305
+ // Active sessions retain a tombstone until the normal sweep. Unlinking
306
+ // immediately would let a late provider/save continuation resurrect the
307
+ // deleted conversation after the user has moved to its replacement.
308
+ if (mgr.closeSession(sessionId, cleanupReason, { tombstone: true }) !== true) return false;
309
+ setSession(null);
310
+ invalidateContextStatusCache();
311
+ invalidatePreSessionToolSurface();
312
+ await createCurrentSession();
313
+ pushTranscriptRebind?.();
314
+ return true;
228
315
  },
229
316
  async switchContext({ cwd, desktopSession: nextDesktopSession } = {}) {
230
317
  const session = getSession();
231
318
  if (session?.id) {
232
319
  const cleanupReason = 'desktop-context-switch';
320
+ // Fire-and-forget: context switch is user-facing latency; the memory
321
+ // runtime outlives the closed session so the write completes safely.
322
+ void ingestSessionIntoMemory(session);
233
323
  try {
234
324
  cancelBackgroundTasksForLifecycle({
235
325
  reason: cleanupReason,
@@ -247,7 +337,21 @@ export function createLifecycleApi(deps) {
247
337
  setDesktopSession(nextDesktopSession && typeof nextDesktopSession === 'object'
248
338
  ? nextDesktopSession
249
339
  : null);
250
- await applyResolvedCwd(cwd, { markRefresh: false, waitForMcpReset: true });
340
+ // Do NOT block the switch on the project MCP reconnect (observed 5s+ per
341
+ // project entry on desktop). The reset still STARTS here synchronously
342
+ // (generation bump + in-flight registration inside applyResolvedCwd), so
343
+ // stale servers cannot be re-adopted, and the ask path gates boundedly on
344
+ // the in-flight connect before the next turn's tool surface is built.
345
+ await applyResolvedCwd(cwd, { markRefresh: false });
346
+ // Resuming a historical session temporarily routes the runtime through
347
+ // that session's provider/model. A fresh desktop task or project must
348
+ // return to the configured Lead route instead of inheriting the route
349
+ // of whichever session happened to be open immediately beforehand.
350
+ if (typeof setRoute === 'function' && typeof getConfig === 'function'
351
+ && typeof resolveRoute === 'function') {
352
+ setRoute(resolveRoute(getConfig(), {}));
353
+ await refreshRouteEffort?.();
354
+ }
251
355
  invalidateContextStatusCache();
252
356
  invalidatePreSessionToolSurface();
253
357
  return true;
@@ -255,6 +359,7 @@ export function createLifecycleApi(deps) {
255
359
  async newSession() {
256
360
  const session = getSession();
257
361
  if (session?.id) {
362
+ void ingestSessionIntoMemory(session);
258
363
  const tombstone = !hasUserConversationMessage(session.messages)
259
364
  && !hasUserConversationMessage(session.liveTurnMessages);
260
365
  mgr.closeSession(session.id, 'cli-new', { tombstone });
@@ -268,12 +373,20 @@ export function createLifecycleApi(deps) {
268
373
  pushTranscriptRebind?.();
269
374
  return getSession().id;
270
375
  },
376
+ prefetchSession(id) {
377
+ return mgr.prefetchSession?.(id, toolSpecForMode(getMode())) === true;
378
+ },
271
379
  async resume(id) {
272
380
  const prev = getSession();
273
381
  const previousId = prev?.id || null;
274
382
  const previousMessages = prev?.messages || null;
275
383
  const previousLive = prev?.liveTurnMessages || null;
276
- const activeDesktopSession = getDesktopSession?.() ?? desktopSession;
384
+ // A context switch can deliberately clear the desktop marker for legacy
385
+ // sessions. Fall back to the creation-time value only for callers that
386
+ // do not supply mutable context bindings.
387
+ const activeDesktopSession = typeof getDesktopSession === 'function'
388
+ ? getDesktopSession()
389
+ : desktopSession;
277
390
  const resumeOptions = activeDesktopSession && typeof activeDesktopSession === 'object'
278
391
  ? { desktopSession: activeDesktopSession }
279
392
  : undefined;
@@ -281,6 +394,7 @@ export function createLifecycleApi(deps) {
281
394
  if (!resumed) return null;
282
395
  if (previousId && previousId !== resumed.id) {
283
396
  statusRoutes?.clearGatewaySessionRoute?.(previousId);
397
+ void ingestSessionIntoMemory(prev);
284
398
  const tombstone = !hasUserConversationMessage(previousMessages)
285
399
  && !hasUserConversationMessage(previousLive);
286
400
  mgr.closeSession(previousId, 'cli-resume', { tombstone });
@@ -294,7 +408,7 @@ export function createLifecycleApi(deps) {
294
408
  const session = getSession();
295
409
  session.effort = getRoute().effectiveEffort || null;
296
410
  session.cwd = getCurrentCwd();
297
- applyDeferredToolSurface(session, deferredSurfaceModeForLead(getMode()), standaloneTools, { provider: getRoute().provider });
411
+ applyDeferredToolSurface(session, deferredSurfaceModeForLead(getMode()), getStandaloneTools(), { provider: getRoute().provider });
298
412
  invalidatePreSessionToolSurface();
299
413
  invalidateContextStatusCache();
300
414
  setSessionNeedsCwdRefresh(false);