mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Read-only session summary catalog for cold desktop startup.
3
3
  *
4
- * This module intentionally avoids store.mjs, config/provider loading, workers,
5
- * and atomic-lock writers. The full runtime remains authoritative after it is
6
- * loaded; cold startup only needs enough metadata to paint the session list.
4
+ * The summary path intentionally avoids store.mjs, config/provider loading,
5
+ * workers, and atomic-lock writers. A transcript read stays lightweight too
6
+ * unless a durable turn checkpoint exists; only then does it lazily enter the
7
+ * reconnect recovery boundary so restored panes never paint a stale prompt.
7
8
  */
8
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
9
+ import { readFileSync, readdirSync } from 'fs';
9
10
  import { homedir } from 'os';
10
11
  import { join } from 'path';
12
+ // Leaf helpers only (no store.mjs, no workers, no config): the three-way
13
+ // present/absent/unreadable classification and the strict record parser the
14
+ // authoritative store uses, so the cold catalog cannot disagree with it.
15
+ import { probePath, readTextFile, PROBE_PRESENT, PROBE_ABSENT } from './store/fs-probe.mjs';
16
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from './lifecycle-scan.mjs';
11
17
 
12
18
  const SESSION_SUMMARY_INDEX_VERSION = 2;
19
+ const TERMINAL_AGENT_STATUS = /idle|done|complete|success|closed|error|fail|cancel|killed|timeout/i;
20
+ const ACTIVE_AGENT_STATUS = /^(?:connecting|requesting|streaming|tool[-_\s]?running|running|queued|pending|starting|cancelling)$/i;
21
+ const AGENT_POOL_HEARTBEAT_FRESH_MS = 2 * 60 * 1000;
13
22
 
14
23
  // Mirror of lifecycle-api.mjs listLeadSessions visibility: the cold catalog
15
24
  // must never surface worker/agent dispatches (memory ingest chunks, judges,
@@ -46,6 +55,125 @@ function positiveNumber(value, fallback = 0) {
46
55
  return Number.isFinite(number) && number > 0 ? number : fallback;
47
56
  }
48
57
 
58
+ function cleanValue(value) {
59
+ return String(value || '').trim();
60
+ }
61
+
62
+ function activeAgentWorker(row) {
63
+ const statuses = [row?.stage, row?.status]
64
+ .map(cleanValue)
65
+ .filter((status, index, values) => Boolean(status) && values.indexOf(status) === index);
66
+ return statuses.length > 0
67
+ && !statuses.some((status) => TERMINAL_AGENT_STATUS.test(status))
68
+ && statuses.some((status) => ACTIVE_AGENT_STATUS.test(status));
69
+ }
70
+
71
+ export function storedAgentWorkerIndexPath() {
72
+ return join(dataDir(), 'agent-workers.json');
73
+ }
74
+
75
+ /** Process-global active agent pool. Fresh child heartbeat sidecars are the
76
+ * cross-process running source even when their durable session is detached
77
+ * (`closed`) and a terminal reaper has already removed the worker-index row.
78
+ * The index remains additive for active rows published before the heartbeat or
79
+ * by runtimes without a sidecar. No runtime starts. */
80
+ export function listStoredAgentWorkers() {
81
+ let parsed = null;
82
+ try {
83
+ parsed = JSON.parse(readFileSync(storedAgentWorkerIndexPath(), 'utf8'));
84
+ } catch { /* heartbeat-backed rows remain authoritative without the index */ }
85
+ const source = Array.isArray(parsed?.workers)
86
+ ? parsed.workers
87
+ : parsed?.workers && typeof parsed.workers === 'object'
88
+ ? Object.values(parsed.workers)
89
+ : [];
90
+ const bySessionId = new Map();
91
+ for (const row of source) {
92
+ if (!row || typeof row !== 'object' || !activeAgentWorker(row)) continue;
93
+ const sessionId = cleanValue(row.sessionId);
94
+ const tag = cleanValue(row.tag);
95
+ if (!sessionId || !tag || !/^[A-Za-z0-9_-]+$/.test(sessionId)) continue;
96
+ let session = null;
97
+ if (!cleanValue(row.ownerSessionId || row.parentSessionId)) {
98
+ try {
99
+ session = JSON.parse(readFileSync(
100
+ join(dataDir(), 'sessions', `${sessionId}.json`),
101
+ 'utf8',
102
+ ));
103
+ } catch { /* a new row may precede its first session save */ }
104
+ }
105
+ bySessionId.set(sessionId, {
106
+ tag,
107
+ sessionId,
108
+ ownerSessionId: cleanValue(
109
+ row.ownerSessionId || row.parentSessionId
110
+ || session?.ownerSessionId || session?.parentSessionId,
111
+ ) || null,
112
+ agent: cleanValue(row.agent || session?.agent) || null,
113
+ provider: cleanValue(row.provider || session?.provider) || null,
114
+ model: cleanValue(row.model || session?.model) || null,
115
+ status: cleanValue(row.status) || 'running',
116
+ stage: cleanValue(row.stage || row.status) || 'running',
117
+ startedAt: row.startedAt || row.createdAt || session?.createdAt || null,
118
+ turnStartedAt: row.turnStartedAt || null,
119
+ createdAt: row.createdAt || session?.createdAt || null,
120
+ updatedAt: row.updatedAt || session?.updatedAt || null,
121
+ cwd: cleanValue(row.cwd || session?.cwd) || null,
122
+ clientHostPid: positiveNumber(row.clientHostPid || session?.clientHostPid, 0) || null,
123
+ taskId: cleanValue(row.task_id || row.taskId) || null,
124
+ });
125
+ }
126
+ const now = Date.now();
127
+ for (const [sessionId, heartbeatAt] of sessionHeartbeatMtimes()) {
128
+ if (now - heartbeatAt > AGENT_POOL_HEARTBEAT_FRESH_MS) continue;
129
+ let session;
130
+ try {
131
+ session = JSON.parse(readFileSync(
132
+ join(dataDir(), 'sessions', `${sessionId}.json`),
133
+ 'utf8',
134
+ ));
135
+ } catch {
136
+ continue;
137
+ }
138
+ const ownerSessionId = cleanValue(
139
+ session?.ownerSessionId || session?.parentSessionId,
140
+ );
141
+ const owner = cleanValue(session?.owner).toLowerCase();
142
+ const agent = cleanValue(session?.agent);
143
+ if (!ownerSessionId || (owner !== 'agent' && (!agent || agent === 'lead'))) continue;
144
+ const current = bySessionId.get(sessionId) || {};
145
+ bySessionId.set(sessionId, {
146
+ ...current,
147
+ tag: cleanValue(session?.agentTag) || cleanValue(current.tag)
148
+ || `${agent || 'agent'}:${sessionId}`,
149
+ sessionId,
150
+ ownerSessionId,
151
+ agent: agent || current.agent || null,
152
+ provider: cleanValue(session?.provider) || current.provider || null,
153
+ model: cleanValue(session?.model) || current.model || null,
154
+ // The sidecar is the live lease. Durable child sessions are
155
+ // intentionally detached/closed while their external owner runs.
156
+ status: 'running',
157
+ stage: 'running',
158
+ startedAt: session?.createdAt || current.startedAt || heartbeatAt,
159
+ turnStartedAt: current.turnStartedAt || null,
160
+ createdAt: session?.createdAt || current.createdAt || null,
161
+ updatedAt: heartbeatAt,
162
+ cwd: cleanValue(session?.cwd) || current.cwd || null,
163
+ clientHostPid: positiveNumber(session?.clientHostPid, 0)
164
+ || current.clientHostPid || null,
165
+ taskId: cleanValue(session?.task_id || session?.taskId)
166
+ || current.taskId || null,
167
+ });
168
+ }
169
+ const rows = [...bySessionId.values()];
170
+ return rows.sort((left, right) => {
171
+ const leftTime = Date.parse(String(left.startedAt || '')) || 0;
172
+ const rightTime = Date.parse(String(right.startedAt || '')) || 0;
173
+ return leftTime - rightTime || left.tag.localeCompare(right.tag);
174
+ });
175
+ }
176
+
49
177
  function cleanText(value, maximum = 240) {
50
178
  return String(value || '')
51
179
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
@@ -113,6 +241,7 @@ function normalizedRow(row, heartbeatAt = 0) {
113
241
  permission: row.permission || null,
114
242
  toolPermission: row.toolPermission || null,
115
243
  messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
244
+ title: cleanText(row.title, 100),
116
245
  preview: cleanText(row.preview),
117
246
  generation: typeof row.generation === 'number' ? row.generation : 0,
118
247
  implicitBashSessionId: row.implicitBashSessionId || null,
@@ -120,6 +249,28 @@ function normalizedRow(row, heartbeatAt = 0) {
120
249
  };
121
250
  }
122
251
 
252
+ function leadRowsWithAgentHeartbeat(rows) {
253
+ const agentHeartbeatByOwner = new Map();
254
+ for (const row of rows) {
255
+ const heartbeatAt = positiveNumber(row?.heartbeatAt, 0);
256
+ const ownerSessionId = String(row?.ownerSessionId || '').trim();
257
+ const owner = String(row?.owner || '').trim().toLowerCase();
258
+ const agent = String(row?.agent || '').trim().toLowerCase();
259
+ if (!heartbeatAt || !ownerSessionId
260
+ || (owner !== 'agent' && (!agent || agent === 'lead'))) continue;
261
+ agentHeartbeatByOwner.set(
262
+ ownerSessionId,
263
+ Math.max(agentHeartbeatByOwner.get(ownerSessionId) || 0, heartbeatAt),
264
+ );
265
+ }
266
+ return rows
267
+ .filter((row) => row && isLeadVisibleRow(row))
268
+ .map((row) => {
269
+ const agentHeartbeatAt = agentHeartbeatByOwner.get(row.id) || 0;
270
+ return agentHeartbeatAt > 0 ? { ...row, agentHeartbeatAt } : row;
271
+ });
272
+ }
273
+
123
274
  function rowFromSession(session, heartbeatAt = 0) {
124
275
  const messages = Array.isArray(session?.messages) ? session.messages : [];
125
276
  const preview = messages
@@ -141,80 +292,353 @@ function rowFromSession(session, heartbeatAt = 0) {
141
292
  function sessionHeartbeatMtimes() {
142
293
  const directory = join(dataDir(), 'sessions');
143
294
  const result = new Map();
144
- if (!existsSync(directory)) return result;
145
- for (const filename of readdirSync(directory)) {
295
+ if (probePath(directory).state !== PROBE_PRESENT) return result;
296
+ let entries = [];
297
+ try { entries = readdirSync(directory); } catch { return result; }
298
+ for (const filename of entries) {
146
299
  if (!filename.endsWith('.hb')) continue;
147
300
  const id = filename.slice(0, -3);
148
301
  if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
149
- try {
150
- const mtime = statSync(join(directory, filename)).mtimeMs || 0;
151
- if (mtime > 0) result.set(id, mtime);
152
- } catch { /* sidecar disappeared after readdir */ }
302
+ // Liveness is additive: a sidecar that is absent OR unreadable simply
303
+ // contributes nothing, and can never remove a row.
304
+ const probe = probePath(join(directory, filename));
305
+ if (probe.state === PROBE_PRESENT && probe.mtimeMs > 0) result.set(id, probe.mtimeMs);
153
306
  }
154
307
  return result;
155
308
  }
156
309
 
157
- function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes()) {
310
+ /**
311
+ * Read-only scan of the authoritative session files.
312
+ * Returns `null` when the directory itself is NOT enumerable (unreadable
313
+ * stat / readdir): that is not "no sessions", and the caller must keep the
314
+ * authority it already has (the index rows) instead of publishing an empty
315
+ * catalog. `indexRowsById` carries those rows so a single UNREADABLE session
316
+ * file also retains its last known row instead of vanishing.
317
+ */
318
+ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsById = new Map()) {
158
319
  const directory = join(dataDir(), 'sessions');
159
- if (!existsSync(directory)) return [];
320
+ const dirProbe = probePath(directory);
321
+ if (dirProbe.state === PROBE_ABSENT) return [];
322
+ if (dirProbe.state !== PROBE_PRESENT) return null;
323
+ let entries;
324
+ try { entries = readdirSync(directory); } catch { return null; }
160
325
  const rows = [];
161
- for (const filename of readdirSync(directory)) {
326
+ for (const filename of entries) {
162
327
  if (!filename.endsWith('.json')) continue;
163
- try {
164
- const session = JSON.parse(readFileSync(join(directory, filename), 'utf8'));
165
- const row = rowFromSession(session, heartbeatMtimes.get(session?.id) || 0);
166
- if (row && isLeadVisibleRow(row)) rows.push(row);
167
- } catch {
168
- // A corrupt or concurrently replaced session is omitted from the
169
- // cold catalog; the authoritative runtime can reconcile it later.
328
+ const storageId = filename.slice(0, -5);
329
+ const read = readTextFile(join(directory, filename));
330
+ // Provably gone: the row legitimately disappears with the file.
331
+ if (read.state === PROBE_ABSENT) continue;
332
+ if (read.state !== PROBE_PRESENT) {
333
+ // Exists and could not be read (EACCES/EIO): retain the last known
334
+ // row rather than let a transient IO error delete it from view.
335
+ const retained = indexRowsById.get(storageId);
336
+ if (retained) rows.push(retained);
337
+ continue;
170
338
  }
339
+ // Same strict authority as the store: a duplicate/ambiguous top-level
340
+ // record or a foreign identity is not this file's session.
341
+ const record = readTopLevelLifecycleRecord(read.text);
342
+ if (isLifecycleUnreadable(record) || record.id !== storageId) continue;
343
+ const row = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
344
+ if (row) rows.push(row);
171
345
  }
172
- return rows.sort((left, right) =>
346
+ return leadRowsWithAgentHeartbeat(rows).sort((left, right) =>
173
347
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
174
348
  }
175
349
 
176
350
  function sessionFilesAreNewerThan(indexPath) {
177
351
  const directory = join(dataDir(), 'sessions');
178
- if (!existsSync(directory)) return false;
179
- let indexMtime = 0;
180
- try { indexMtime = statSync(indexPath).mtimeMs || 0; }
181
- catch { return true; }
182
- try {
183
- for (const filename of readdirSync(directory)) {
184
- if (!filename.endsWith('.json')) continue;
185
- try {
186
- if ((statSync(join(directory, filename)).mtimeMs || 0) > indexMtime) return true;
187
- } catch { /* session disappeared after readdir */ }
188
- }
189
- } catch {
190
- return true;
352
+ if (probePath(directory).state !== PROBE_PRESENT) return false;
353
+ const indexProbe = probePath(indexPath);
354
+ // Only a PROVABLY absent index makes the files newer by definition. An
355
+ // index that exists but cannot be stat'ed (EACCES/EIO) is still the
356
+ // authority we failed to consult — claiming "files are newer" there would
357
+ // replace the whole catalog on the strength of an IO error.
358
+ if (indexProbe.state === PROBE_ABSENT) return true;
359
+ if (indexProbe.state !== PROBE_PRESENT) return false;
360
+ const indexMtime = indexProbe.mtimeMs || 0;
361
+ let entries;
362
+ try { entries = readdirSync(directory); } catch { return false; }
363
+ for (const filename of entries) {
364
+ if (!filename.endsWith('.json')) continue;
365
+ // Absent (raced unlink) and unreadable both prove nothing about
366
+ // freshness: only an observed newer mtime does.
367
+ const probe = probePath(join(directory, filename));
368
+ if (probe.state === PROBE_PRESENT && (probe.mtimeMs || 0) > indexMtime) return true;
191
369
  }
192
370
  return false;
193
371
  }
194
372
 
195
373
  export function listStoredSessionSummaries(options = {}) {
196
374
  const heartbeatMtimes = sessionHeartbeatMtimes();
197
- if (options.refreshFromStorage === true) return scanSessionFiles(heartbeatMtimes);
198
375
  const indexPath = join(dataDir(), 'session-summaries.json');
199
- try {
200
- const index = JSON.parse(readFileSync(indexPath, 'utf8'));
201
- if (Number(index?.version) === SESSION_SUMMARY_INDEX_VERSION) {
202
- const rows = (Array.isArray(index.rows) ? index.rows : [])
203
- .map((row) => normalizedRow(row, heartbeatMtimes.get(row?.id) || 0))
204
- .filter((row) => row && isLeadVisibleRow(row))
205
- .sort((left, right) =>
206
- (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
207
- // A crashed/non-blocking sidecar writer can leave a valid but old,
208
- // non-empty index indefinitely. Detect only newer session JSONs
209
- // (heartbeat churn does not count), then recover read-only from the
210
- // authoritative files without trying to rewrite the stuck index.
211
- if (options.rebuildIfMissing !== false && sessionFilesAreNewerThan(indexPath)) {
212
- return scanSessionFiles(heartbeatMtimes);
376
+ // The index is read on EVERY path (it is one small file): its rows are the
377
+ // authority that must be retained whenever storage cannot be enumerated or
378
+ // an individual session file cannot be read.
379
+ const indexRowsById = new Map();
380
+ let indexRows = null;
381
+ const indexRead = readTextFile(indexPath);
382
+ if (indexRead.state === PROBE_PRESENT) {
383
+ try {
384
+ const index = JSON.parse(indexRead.text);
385
+ if (Number(index?.version) === SESSION_SUMMARY_INDEX_VERSION) {
386
+ const normalizedRows = (Array.isArray(index.rows) ? index.rows : [])
387
+ .map((row) => normalizedRow(row, heartbeatMtimes.get(row?.id) || 0))
388
+ .filter(Boolean);
389
+ for (const row of normalizedRows) indexRowsById.set(row.id, row);
390
+ indexRows = leadRowsWithAgentHeartbeat(normalizedRows)
391
+ .sort((left, right) =>
392
+ (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
393
+ }
394
+ } catch { /* malformed sidecar: the files below are the authority */ }
395
+ }
396
+ // An index that EXISTS but is unreadable (EACCES/EIO) is neither missing
397
+ // nor empty: the scan below still runs, and when IT cannot enumerate
398
+ // either, the catalog reports nothing rather than inventing an empty truth.
399
+ const scan = () => scanSessionFiles(heartbeatMtimes, indexRowsById);
400
+ if (options.refreshFromStorage === true) return scan() ?? indexRows ?? [];
401
+ if (indexRows) {
402
+ // A crashed/non-blocking sidecar writer can leave a valid but old,
403
+ // non-empty index indefinitely. Detect only newer session JSONs
404
+ // (heartbeat churn does not count), then recover read-only from the
405
+ // authoritative files without trying to rewrite the stuck index.
406
+ if (options.rebuildIfMissing !== false && sessionFilesAreNewerThan(indexPath)) {
407
+ return scan() ?? indexRows;
408
+ }
409
+ if (indexRows.length > 0 || options.rebuildIfMissing === false) return indexRows;
410
+ }
411
+ return options.rebuildIfMissing === false ? [] : (scan() ?? indexRows ?? []);
412
+ }
413
+
414
+ /** Read exactly one persisted session for a visible desktop pane. This never
415
+ * enumerates siblings. Normal reads stay independent of runtime ownership;
416
+ * interrupted turns conditionally use the same durable reconnect recovery as
417
+ * resumeSession before projecting the transcript. */
418
+ export async function readStoredSessionTranscript(id, options = {}) {
419
+ const sessionId = String(id || '').trim();
420
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
421
+ // Same strict authority as the store, and the same fail-closed rule: an
422
+ // absent, unreadable, ambiguous or foreign record yields no transcript.
423
+ const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
424
+ if (read.state !== PROBE_PRESENT) return null;
425
+ const record = readTopLevelLifecycleRecord(read.text);
426
+ if (isLifecycleUnreadable(record) || record.id !== sessionId) return null;
427
+ let session = record.doc;
428
+ const owner = cleanValue(session.owner).toLowerCase();
429
+ const agent = cleanValue(session.agent).toLowerCase();
430
+ const liveDetachedAgent = session.closed === true
431
+ && (owner === 'agent' || (agent && agent !== 'lead'))
432
+ && Boolean(cleanValue(session.ownerSessionId || session.parentSessionId));
433
+ // Only a PROVABLY absent checkpoint skips recovery: an unreadable probe
434
+ // must not silently downgrade an interrupted turn to a plain cold read.
435
+ if (probePath(join(dataDir(), 'turn-checkpoints', `${sessionId}.json`)).state !== PROBE_ABSENT) {
436
+ if (liveDetachedAgent) {
437
+ const {
438
+ projectTurnCheckpointMessages,
439
+ readTurnCheckpoint,
440
+ } = await import('./manager/turn-checkpoint.mjs');
441
+ const checkpoint = readTurnCheckpoint(sessionId);
442
+ if (checkpoint) {
443
+ session = {
444
+ ...session,
445
+ messages: projectTurnCheckpointMessages(session, checkpoint),
446
+ };
213
447
  }
214
- if (rows.length > 0 || options.rebuildIfMissing === false) return rows;
448
+ } else {
449
+ const { recoverSessionAfterProcessRestart } = await import('./manager.mjs');
450
+ session = recoverSessionAfterProcessRestart(sessionId) || session;
215
451
  }
452
+ }
453
+ const {
454
+ restoreTranscriptItems,
455
+ sessionContextSnapshotProjection,
456
+ } = await import(
457
+ '../../../../tui/engine/session-api-ext.mjs'
458
+ );
459
+ let preparedContextProjection = null;
460
+ try {
461
+ const [
462
+ { prepareSessionProjection },
463
+ { createContextStatus },
464
+ ] = await Promise.all([
465
+ import('./manager.mjs'),
466
+ import('../../../../session-runtime/context-status.mjs'),
467
+ ]);
468
+ const prepared = prepareSessionProjection(session, 'full');
469
+ if (prepared) session = prepared;
470
+ const { contextStatus } = createContextStatus({
471
+ getSession: () => session,
472
+ getRoute: () => ({
473
+ provider: session.provider || '',
474
+ model: session.model || '',
475
+ contextWindow: session.contextWindow || null,
476
+ }),
477
+ getCurrentCwd: () => session.cwd || '',
478
+ getMode: () => 'full',
479
+ });
480
+ preparedContextProjection = sessionContextSnapshotProjection(session, contextStatus());
216
481
  } catch {
217
- // Missing/malformed sidecars fall through to the read-only scan.
482
+ // Cold context projection is presentation-only. The transcript and
483
+ // legacy estimator below remain available if provider/tool prep fails.
218
484
  }
219
- return options.rebuildIfMissing === false ? [] : scanSessionFiles(heartbeatMtimes);
485
+ const messages = Array.isArray(session.messages) ? session.messages : [];
486
+ const hasConversationActivity = messages.some((message) => message?.role === 'user');
487
+ let currentEstimatedContextTokens = 0;
488
+ if (hasConversationActivity) {
489
+ try {
490
+ const { estimateTranscriptContextUsage } = await import('./context-utils.mjs');
491
+ currentEstimatedContextTokens = estimateTranscriptContextUsage(
492
+ messages,
493
+ Array.isArray(session.tools) ? session.tools : [],
494
+ { provider: session.provider },
495
+ );
496
+ } catch {
497
+ // Context metering is presentation-only. A cold transcript remains
498
+ // readable even if its optional estimator cannot be loaded.
499
+ }
500
+ }
501
+ const contextWindow = positiveNumber(session.contextWindow);
502
+ const rawContextWindow = positiveNumber(session.rawContextWindow, contextWindow);
503
+ const displayContextWindow = positiveNumber(session.compactBoundaryTokens, contextWindow);
504
+ const rawAutoCompactTokenLimit = positiveNumber(session.autoCompactTokenLimit);
505
+ const autoCompactTokenLimit = rawAutoCompactTokenLimit
506
+ && (!displayContextWindow || rawAutoCompactTokenLimit < displayContextWindow)
507
+ ? rawAutoCompactTokenLimit
508
+ : 0;
509
+ const requestedLimit = Number(options.transcriptItemLimit);
510
+ return {
511
+ sessionId,
512
+ items: restoreTranscriptItems(messages, {
513
+ sessionId,
514
+ itemLimit: Number.isFinite(requestedLimit) && requestedLimit > 0
515
+ ? requestedLimit
516
+ : Number.POSITIVE_INFINITY,
517
+ }),
518
+ provider: session.provider || '',
519
+ model: session.model || '',
520
+ effort: session.effort || '',
521
+ fast: session.fast === true,
522
+ cwd: session.cwd || '',
523
+ desktopSession: desktopSession(session.desktopSession, session.cwd),
524
+ workflow: session.workflow || null,
525
+ ...(preparedContextProjection ? {
526
+ ...preparedContextProjection,
527
+ preparedContextProjection: true,
528
+ } : {
529
+ stats: {
530
+ currentContextTokens: 0,
531
+ currentEstimatedContextTokens,
532
+ currentContextSource: currentEstimatedContextTokens > 0 ? 'estimated' : null,
533
+ },
534
+ contextWindow: contextWindow || null,
535
+ rawContextWindow: rawContextWindow || null,
536
+ displayContextWindow: displayContextWindow || null,
537
+ autoCompactTokenLimit: autoCompactTokenLimit || null,
538
+ }),
539
+ // Desktop must not ask its unrelated active engine to recover/peek an
540
+ // externally owned child: that runtime can only return the detached
541
+ // Task row and masks this checkpoint projection.
542
+ readOnlyDetachedAgent: liveDetachedAgent,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * Lightweight desktop background viewer. It reuses the owner's existing
548
+ * live-share pipe without creating a second engine/runtime or claiming session
549
+ * ownership. The caller has already published the cold disk snapshot; the
550
+ * owner's first full frame atomically replaces it, then deltas keep the pane
551
+ * current while it remains visible.
552
+ */
553
+ export async function createStoredSessionLiveViewer(id, options = {}) {
554
+ const sessionId = String(id || '').trim();
555
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)
556
+ || typeof options.onSnapshot !== 'function') return null;
557
+ const initial = options.initialSnapshot && typeof options.initialSnapshot === 'object'
558
+ ? options.initialSnapshot
559
+ : {};
560
+ let state = {
561
+ ...initial,
562
+ sessionId,
563
+ items: Array.isArray(initial.items) ? initial.items : [],
564
+ queued: Array.isArray(initial.queued) ? initial.queued : [],
565
+ streamingTail: initial.streamingTail || null,
566
+ spinner: initial.spinner || null,
567
+ };
568
+ let disposed = false;
569
+ let publishQueued = false;
570
+ const publish = () => {
571
+ if (disposed || publishQueued) return;
572
+ publishQueued = true;
573
+ queueMicrotask(() => {
574
+ publishQueued = false;
575
+ if (disposed) return;
576
+ try { options.onSnapshot(state); } catch { /* host lane isolation */ }
577
+ });
578
+ };
579
+ const commit = (patch) => {
580
+ state = { ...state, ...patch, sessionId };
581
+ publish();
582
+ };
583
+ const viewerApply = {
584
+ getState: () => state,
585
+ set: (patch) => commit(patch && typeof patch === 'object' ? patch : {}),
586
+ replaceItems: (items, replaceOptions = {}) => commit({
587
+ items: Array.isArray(items) ? [...items] : [],
588
+ ...(replaceOptions.preserveStreamingTail === true
589
+ ? {}
590
+ : { streamingTail: null }),
591
+ }),
592
+ patchItem: (itemId, patch) => {
593
+ const index = state.items.findIndex((item) => item?.id === itemId);
594
+ if (index < 0) return false;
595
+ const items = [...state.items];
596
+ items[index] = { ...items[index], ...patch };
597
+ commit({ items });
598
+ return true;
599
+ },
600
+ appendItems: (items) => {
601
+ if (!Array.isArray(items) || items.length === 0) return;
602
+ commit({ items: [...state.items, ...items] });
603
+ },
604
+ updateStreamingTail: (itemId, patch, _unused = {}, updateOptions = {}) => {
605
+ const current = updateOptions.resetText !== true
606
+ && state.streamingTail?.id === itemId
607
+ ? state.streamingTail
608
+ : { id: itemId, text: '' };
609
+ commit({ streamingTail: { ...current, ...patch, id: itemId } });
610
+ },
611
+ clearStreamingTail: () => commit({ streamingTail: null }),
612
+ };
613
+ const { createLiveShare, liveSharePipePath } = await import(
614
+ '../../../../tui/engine/live-share.mjs'
615
+ );
616
+ const share = createLiveShare({
617
+ ownerSessionId: () => '',
618
+ viewerSessionId: () => disposed ? '' : sessionId,
619
+ socketPathFor: (targetId) => liveSharePipePath(
620
+ targetId,
621
+ join(dataDir(), 'sessions', `${targetId}.json`),
622
+ ),
623
+ getPublishedState: () => state,
624
+ listeners: new Set(),
625
+ onRemoteSubmit: () => {},
626
+ onRemoteAbort: () => {},
627
+ onOwnerClosed: () => {
628
+ if (!disposed) options.onOwnerClosed?.();
629
+ },
630
+ viewerApply,
631
+ // A visible desktop pane may remain open before its external owner
632
+ // starts. Keep one bounded retry per pane instead of the TUI's
633
+ // latency-first 160ms retry ceiling.
634
+ viewerRetryMaxMs: 1_000,
635
+ });
636
+ share.ensure();
637
+ return {
638
+ dispose() {
639
+ if (disposed) return;
640
+ disposed = true;
641
+ share.dispose();
642
+ },
643
+ };
220
644
  }