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
@@ -15,6 +15,14 @@
15
15
  process.env.MIXDOG_CHANNEL_DAEMON = '1';
16
16
  process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
17
17
 
18
+ // V8 compile cache: the daemon is a standalone child entry (not via cli.mjs);
19
+ // caching compiled bytecode across restarts removes the channels+memory
20
+ // runtime parse cost from every daemon boot. Best-effort.
21
+ try {
22
+ const { enableCompileCache } = await import('node:module');
23
+ enableCompileCache?.();
24
+ } catch { /* launch-speed optimization only */ }
25
+
18
26
  import os from 'node:os';
19
27
  import path from 'node:path';
20
28
  import { mkdirSync, appendFileSync } from 'node:fs';
@@ -18,7 +18,7 @@ export const EXPLORE_TOOL = {
18
18
  name: 'explore',
19
19
  title: 'Explore',
20
20
  annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21
- description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings. Every returned path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it — search only unresolved facets.',
21
+ description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings.',
22
22
  inputSchema: {
23
23
  type: 'object',
24
24
  properties: {
@@ -446,13 +446,10 @@ export function createStandaloneMemoryRuntime({
446
446
  MIXDOG_OWNER_LEAD_PID: String(process.pid),
447
447
  MIXDOG_MEMORY_SECONDARY: '0',
448
448
  MIXDOG_PG_ATTACH_ONLY: '0',
449
- // seeds.mjs defaults MIXDOG_EMBED_WARMUP to '0' for the HOST process
450
- // (TUI boot must not pay the ONNX session-create cost). The daemon is
451
- // the long-lived embed owner, so warmup is force-enabled here like the
452
- // secondary/attach flags above — otherwise the daemon boots cold and
453
- // every recall runs lexical-only until the first fallback flush
454
- // lazily loads the model (observed 1.5h '(no results)' windows).
455
- MIXDOG_EMBED_WARMUP: '1',
449
+ // The daemon owns the model, but ownership does not imply eager
450
+ // residency. A recall starts warmup on demand and immediately uses
451
+ // lexical search while it loads, keeping desktop/TUI boot lightweight.
452
+ MIXDOG_EMBED_WARMUP: process.env.MIXDOG_EMBED_WARMUP ?? '0',
456
453
  MIXDOG_MEMORY_DISABLE_CYCLES: process.env.MIXDOG_MEMORY_DISABLE_CYCLES ?? '0',
457
454
  MIXDOG_MEMORY_DISABLE_LLM_WORKER: process.env.MIXDOG_MEMORY_DISABLE_LLM_WORKER ?? '0',
458
455
  MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
@@ -12,7 +12,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
12
12
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
13
13
 
14
14
  const MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
15
- const PROJECTS_FILE = join(MIXDOG_HOME, 'projects.json');
15
+ const PROJECTS_FILE = process.env.MIXDOG_PROJECTS_FILE
16
+ ? resolve(process.env.MIXDOG_PROJECTS_FILE)
17
+ : join(MIXDOG_HOME, 'projects.json');
16
18
 
17
19
  /** Resolve a raw path to an absolute path against the user's cwd. */
18
20
  function toAbsolute(rawPath) {
@@ -76,6 +78,7 @@ function writeStore(store) {
76
78
  */
77
79
  function ensureProjectIdMarker(absPath, name) {
78
80
  try {
81
+ if (process.env.MIXDOG_DISABLE_PROJECT_MARKERS === '1') return;
79
82
  if (!absPath) return;
80
83
  // Only when the project folder actually exists as a directory on disk.
81
84
  try {
@@ -88,9 +88,25 @@ function normaliseResetCredits(value) {
88
88
  const offerRevision = clean(value.offerRevision ?? value.offer_revision);
89
89
  if (availableCount === null || !offerRevision) return null;
90
90
  const nextExpiresAt = num(value.nextExpiresAt ?? value.next_expires_at, null);
91
+ const rawAvailableCredits = value.availableCredits ?? value.available_credits;
92
+ const availableCredits = (Array.isArray(rawAvailableCredits) ? rawAvailableCredits : [])
93
+ .map((credit) => {
94
+ if (!credit || typeof credit !== 'object' || Array.isArray(credit)) return null;
95
+ const expiresAt = num(credit.expiresAt ?? credit.expires_at, null);
96
+ const grantedAt = num(credit.grantedAt ?? credit.granted_at, null);
97
+ return {
98
+ ...(expiresAt && expiresAt > 0 ? { expiresAt } : {}),
99
+ ...(grantedAt && grantedAt > 0 ? { grantedAt } : {}),
100
+ };
101
+ })
102
+ .filter(Boolean)
103
+ .sort((left, right) =>
104
+ (left.expiresAt ?? Number.POSITIVE_INFINITY)
105
+ - (right.expiresAt ?? Number.POSITIVE_INFINITY));
91
106
  return {
92
107
  availableCount: Math.max(0, Math.floor(availableCount)),
93
108
  offerRevision,
109
+ availableCredits,
94
110
  ...(nextExpiresAt && nextExpiresAt > 0 ? { nextExpiresAt } : {}),
95
111
  };
96
112
  }
@@ -234,22 +250,28 @@ function rowTone(row) {
234
250
  async function oauthSnapshot(providerId, { refresh, getProvider, log }) {
235
251
  const cached = readCachedOAuthUsageSnapshot({ provider: providerId, model: '' });
236
252
  if (!refresh && cached) return cached;
253
+ // A rate-limited or failed forced refresh (Anthropic /oauth/usage 429s
254
+ // aggressively) must not blank a known quota row: fall back to the last
255
+ // contentful snapshot — a stale meter with its updatedAt beats an empty row.
256
+ const staleFallback = () => cached
257
+ || readCachedOAuthUsageSnapshot({ provider: providerId, model: '' }, { allowStale: true });
237
258
  if (typeof getProvider === 'function') {
238
259
  try {
239
260
  const providerObj = getProvider(providerId);
240
261
  if (providerObj) {
241
- return await fetchOAuthUsageSnapshot(
262
+ const fresh = await fetchOAuthUsageSnapshot(
242
263
  { provider: providerId, model: '' },
243
264
  providerObj,
244
265
  log,
245
266
  { force: refresh === true },
246
267
  );
268
+ return fresh || staleFallback();
247
269
  }
248
270
  } catch {
249
- return null;
271
+ return staleFallback();
250
272
  }
251
273
  }
252
- return cached;
274
+ return staleFallback();
253
275
  }
254
276
 
255
277
  async function apiSnapshot(providerId, { refresh } = {}) {
package/src/tui/App.jsx CHANGED
@@ -764,8 +764,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
764
764
  });
765
765
 
766
766
 
767
- // Item-count changes are the only time we can arm follow before row totals are
768
- // recomputed. Pure streaming height growth is handled in the row-delta effect.
767
+ // Item-count changes never infer follow permission from scrollTarget=0. A
768
+ // first read-back input can be waiting one frame for committed row geometry;
769
+ // only prompt submit, session reset, or an explicit return to bottom arms
770
+ // follow. Pure streaming height growth is handled in the row-delta effect.
769
771
  useLayoutEffect(() => {
770
772
  const count = state.items.length;
771
773
  const previousCount = lastItemsCountRef.current;
@@ -775,7 +777,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
775
777
  return;
776
778
  }
777
779
  if (count === previousCount || dragRef.current.active) return;
778
- if (scrollTargetRef.current === 0 || followingRef.current) followingRef.current = true;
779
780
  }, [state.items.length, resetTranscriptScroll]);
780
781
 
781
782
  // Exit + queued-restore + prompt history: app/use-prompt-queue-history.mjs.
@@ -1139,6 +1140,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1139
1140
  structureRevision: state.transcriptViewItems
1140
1141
  ? state.transcriptViewRevision
1141
1142
  : state.structureRevision,
1143
+ sessionKey: state.sessionId || '',
1142
1144
  // Historical pages are contiguous settled windows. Keep the independently
1143
1145
  // growing live tail hidden until paging forward reaches the live window.
1144
1146
  streamingTail: state.transcriptViewItems ? null : state.streamingTail,
@@ -31,11 +31,12 @@ export function createResumePicker({
31
31
  }
32
32
  const items = sessions.map((s) => {
33
33
  const preview = String(s.preview || '').replace(/\n/g, ' ').trim();
34
+ const title = String(s.title || '').replace(/\s+/g, ' ').trim();
34
35
  const count = formatSessionMessageCount(s.messageCount);
35
36
  return {
36
37
  value: s.id,
37
38
  label: `${formatSessionUpdatedAt(s.updatedAt)} ${count}`,
38
- description: preview || '(no message)',
39
+ description: title || preview || '(no message)',
39
40
  };
40
41
  });
41
42
  setPicker({
@@ -45,8 +46,12 @@ export function createResumePicker({
45
46
  labelWidth: 21,
46
47
  onSelect: (value) => {
47
48
  setPicker(null);
49
+ const selected = sessions.find((session) => session.id === value);
50
+ const resumedName = String(selected?.title || selected?.preview || value)
51
+ .replace(/\s+/g, ' ')
52
+ .trim();
48
53
  void store.resume(value)
49
- .then(ok => store.pushNotice(ok ? `Resumed ${value}` : 'Couldn’t resume chat.', ok ? 'info' : 'warn'))
54
+ .then(ok => store.pushNotice(ok ? `Resumed ${resumedName || value}` : 'Couldn’t resume chat.', ok ? 'info' : 'warn'))
50
55
  .catch((e) => store.pushNotice(`Couldn’t resume chat: ${e?.message || e}`, 'error'));
51
56
  },
52
57
  onCancel: () => {
@@ -157,134 +157,28 @@ export function createRoutePickers({
157
157
  closeUsagePanel();
158
158
  setPicker({
159
159
  title: 'Workflow',
160
- description: 'Active workflow and its agents.',
161
- help: returnTo ? '↑/↓ Select · Enter Open · Esc Settings' : '↑/↓ Select · Enter Open · Esc Back',
160
+ description: 'Select active workflow.',
161
+ help: returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back',
162
162
  labelWidth: 18,
163
- initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialWorkflowId || ''))),
164
163
  items,
165
164
  onSelect: (_value, item) => {
166
165
  const workflow = item?._workflow;
167
166
  if (!workflow) return;
168
- // Depth mirrors the system model: a workflow pack owns the agent set
169
- // it delegates to, so Enter opens the pack (activate + its agents)
170
- // instead of switching immediately.
171
- openWorkflowDetailPicker(workflow.id, { returnTo });
172
- },
173
- onCancel: () => {
174
167
  setPicker(null);
175
- if (returnTo) returnTo();
176
- },
177
- });
178
- };
179
-
180
- // Workflow detail: activation row plus the agents this pack delegates to
181
- // (route editing inline). Packs without an `agents:` key delegate to all
182
- // fixed agents; a configured-but-empty list means Lead works alone.
183
- const openWorkflowDetailPicker = (workflowId, options = {}) => {
184
- const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
185
- let workflows = [];
186
- try {
187
- workflows = store.listWorkflows?.() || [];
188
- } catch (e) {
189
- store.pushNotice(`could not list workflows: ${e?.message || e}`, 'error');
190
- return;
191
- }
192
- const workflow = workflows.find((item) => item.id === workflowId);
193
- if (!workflow) {
194
- store.pushNotice(`workflow "${workflowId}" not found`, 'warn');
195
- openWorkflowPicker({ returnTo });
196
- return;
197
- }
198
- let agents = [];
199
- try {
200
- agents = store.listAgents?.() || [];
201
- } catch (e) {
202
- store.pushNotice(`could not list agents: ${e?.message || e}`, 'error');
203
- agents = [];
204
- }
205
- const packAgents = workflow.agentsConfigured === true
206
- ? (workflow.agents || [])
207
- .map((id) => agents.find((agent) => agent.id === id) || { id, label: id, route: null, description: '' })
208
- : agents.filter((agent) => agent.custom !== true);
209
- const routeOverrides = options.routeOverrides && typeof options.routeOverrides === 'object' ? options.routeOverrides : {};
210
- const items = [
211
- {
212
- value: '__activate',
213
- label: workflow.active ? 'Active' : 'Activate',
214
- marker: workflow.active ? '✓' : '',
215
- markerColor: theme.success,
216
- description: workflow.active
217
- ? 'This workflow is active.'
218
- : 'Make this the active workflow.',
219
- },
220
- ...(packAgents.length
221
- ? packAgents.map((agent) => ({
222
- value: agent.id,
223
- label: agent.label,
224
- metaParts: agentModelParts(routeOverrides[agent.id] || agent.route),
225
- description: agent.description || agent.definition?.description || '',
226
- _agent: agent,
227
- }))
228
- : [{
229
- value: '__lead-only',
230
- label: 'Lead only',
231
- description: 'This workflow delegates to no agents.',
232
- }]),
233
- ];
234
- setPicker({
235
- title: workflow.name,
236
- description: workflow.description || 'Workflow activation and agents.',
237
- help: '↑/↓ Select · Enter Choose · Esc Back',
238
- indexMode: 'always',
239
- labelWidth: 18,
240
- metaWidth: 33,
241
- initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialValue || '__activate'))),
242
- items,
243
- onSelect: (_value, item) => {
244
- if (item?.value === '__activate') {
245
- if (workflow.active) {
246
- store.pushNotice(`${workflow.name} is already the active workflow`, 'info');
247
- return;
248
- }
249
- setPicker(null);
250
- void store.setWorkflow?.(workflow.id)
251
- .then((result) => {
252
- if (!result) {
253
- store.pushNotice('Workflow switch is already running.', 'warn');
254
- return;
255
- }
256
- store.pushNotice(workflowSwitchNotice(result), 'info');
257
- openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
258
- })
259
- .catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
260
- return;
261
- }
262
- const agent = item?._agent;
263
- if (!agent) return;
264
- void openModelPicker({
265
- title: `${agent.label} Model`,
266
- providerDescription: 'Choose a provider for this agent.',
267
- currentRoute: agent.route || null,
268
- returnTo: () => openWorkflowDetailPicker(workflow.id, { returnTo, initialValue: agent.id }),
269
- onImmediateSelect: (routeInput) => {
270
- openWorkflowDetailPicker(workflow.id, {
271
- returnTo,
272
- routeOverrides: { [agent.id]: routeInput },
273
- initialValue: agent.id,
274
- });
275
- },
276
- onSelectRoute: async (routeInput) => {
277
- const result = await store.setAgentRoute?.(agent.id, routeInput);
168
+ void store.setWorkflow?.(workflow.id)
169
+ .then((result) => {
278
170
  if (!result) {
279
- store.pushNotice('Agent model save is already running.', 'warn');
171
+ store.pushNotice('Workflow switch is already running.', 'warn');
280
172
  return;
281
173
  }
282
- store.pushNotice(`${agent.label} model set to ${agentModelProfile(result)}`, 'info');
283
- },
284
- });
174
+ store.pushNotice(workflowSwitchNotice(result), 'info');
175
+ if (returnTo) returnTo();
176
+ })
177
+ .catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
285
178
  },
286
179
  onCancel: () => {
287
- openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
180
+ setPicker(null);
181
+ if (returnTo) returnTo();
288
182
  },
289
183
  });
290
184
  };
@@ -63,12 +63,22 @@ export function createSlashDispatch({
63
63
  if (cmd !== 'usage') closeUsagePanel();
64
64
  switch (cmd) {
65
65
  case 'clear':
66
- if (state.busy) {
67
- store.pushNotice('wait for the current turn to finish before /clear', 'warn');
66
+ if (state.busy || state.commandBusy) {
67
+ store.pushNotice(`wait for the current session command to finish before /${rawName === 'new' ? 'new' : 'clear'}`, 'warn');
68
68
  return false;
69
69
  }
70
70
  if (rawName === 'new') {
71
- void store.newSession().then(() => {}).catch((e) => store.pushNotice(`new session failed: ${e?.message || e}`, 'error'));
71
+ void store.newSession()
72
+ .then((created) => {
73
+ if (created === false) {
74
+ store.pushNotice('new session is already running', 'warn');
75
+ return;
76
+ }
77
+ // Incremental Ink rendering can otherwise retain physical rows
78
+ // from the taller outgoing transcript on Windows Terminal.
79
+ store.forceRenderRepaint?.();
80
+ })
81
+ .catch((e) => store.pushNotice(`new session failed: ${e?.message || e}`, 'error'));
72
82
  } else {
73
83
  void store.clear().then(() => {}).catch((e) => store.pushNotice(`clear failed: ${e?.message || e}`, 'error'));
74
84
  }
@@ -272,7 +272,7 @@ export function estimateTranscriptItemRows(item, columns, toolOutputExpanded, at
272
272
  // marginTop={1} (AssistantMessage <Box>) + rendered body height.
273
273
  return 1 + (item.streaming
274
274
  ? measureStreamingMarkdownRenderedRows(item.text, columns, item.id)
275
- : measureMarkdownRenderedRows(item.text, columns));
275
+ : measureMarkdownRenderedRows(item.text, columns, { trimPartialFences: true }));
276
276
  case 'tool': {
277
277
  // Consecutive tool cards render attached (marginTop 0) — see
278
278
  // TranscriptItem's attached={prevKind === 'tool'}.
@@ -354,12 +354,12 @@ export function carryTranscriptMeasuredRowsCache(prevItem, nextItem) {
354
354
  export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
355
355
  if (!TRANSCRIPT_MEASURED_ROWS || !item) return null;
356
356
  if (shouldSuppressFullyFailedToolItemCached(item)) return 0;
357
- // Streaming height flows ONLY through estimateTranscriptItemRowsCached's
358
- // max(idStore, estimate) path — never straight from the WeakMap here, or
359
- // buildTranscriptRowIndex (which calls this first) and the sigPart (which
360
- // calls estimateTranscriptItemRowsCached) could resolve different values
361
- // for the same streaming item and diverge.
362
- if (item.kind === 'assistant' && item.streaming) return null;
357
+ // Assistant Markdown has an exact deterministic renderer measurement for
358
+ // both streaming and settled states. Never mix Yoga's mounted-window height
359
+ // back into that model: a fenced script whose measured height alternated as
360
+ // the window corrected itself could otherwise trigger up to ten idle
361
+ // measure→reindex commits with no text change.
362
+ if (item.kind === 'assistant') return null;
363
363
  const entry = transcriptMeasuredRowsCache.get(item);
364
364
  if (!entry) return null;
365
365
  if (entry.rows <= 0) return null;
@@ -371,17 +371,6 @@ export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
371
371
 
372
372
  const STREAMING_ROW_QUANTUM = 1;
373
373
 
374
- // Bottom-pinned flag for the streaming-tail row estimate. Set once per hook
375
- // render (use-transcript-window.mjs) BEFORE any tail resolution so every path
376
- // that resolves the streaming tail height (tailSig, buildTranscriptRowIndex,
377
- // incremental tail, transcriptStructureSignature) reads the same value and
378
- // cannot diverge. Bottom-pinned → fold the live estimate in (geometry right on
379
- // the first commit); away from bottom → d19cad1e defer-growth.
380
- let streamingBottomPinned = false;
381
- export function setStreamingBottomPinned(pinned) {
382
- streamingBottomPinned = !!pinned;
383
- }
384
-
385
374
  function assistantTextForStreamingRowEstimate(text) {
386
375
  return streamingLayoutText(text);
387
376
  }
@@ -438,49 +427,6 @@ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
438
427
  return rows;
439
428
  }
440
429
 
441
- // ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
442
- // Away from bottom, estimateTranscriptItemRowsCached freezes the trailing
443
- // streaming item at its last Yoga-confirmed height (defer-growth), so the row
444
- // index / totalRows this render still counts the OLD height even though the
445
- // mounted Box lays out at the live-grown height NOW. That one-frame mismatch is
446
- // the newline judder: the physical content grew but the scroll offset did not,
447
- // so the visible window shifts up until measuredRowsVersion bumps next frame.
448
- // Report how many rows the mounted tail has grown beyond the height the row
449
- // index used so the caller can fold it into the scroll offset for the SAME
450
- // render. Returns { tailRows, delta }:
451
- // tailRows — the height the row index used for the tail (for the in-slice gate)
452
- // delta — live-estimate rows grown beyond that (0 when nothing to correct)
453
- // Returns null when the tail is not a deferred streaming item (no confirmed
454
- // measurement yet → the index already used the live estimate, so no mismatch).
455
- export function streamingTailMountedGrowth(items, columns, toolOutputExpanded) {
456
- const list = Array.isArray(items) ? items : [];
457
- const tail = list.length > 0 ? list[list.length - 1] : null;
458
- if (!tail || tail.kind !== 'assistant' || !tail.streaming) return null;
459
- const toolExpanded = toolOutputExpanded ? 1 : 0;
460
- const idEntry = streamingMeasuredRowsById.get(tail.id);
461
- if (!idEntry || !(idEntry.rows > 0) || idEntry.columns !== columns || idEntry.toolExpanded !== toolExpanded) {
462
- return null;
463
- }
464
- const live = streamingEstimateRows(tail, columns, toolOutputExpanded);
465
- // Bottom-pinned already folds the live estimate into the row index, so there
466
- // is nothing to compensate (delta 0); the caller also only invokes this while
467
- // scrolled up, so this is a belt-and-suspenders guard.
468
- if (streamingBottomPinned) return { tailRows: Math.max(idEntry.rows, live), delta: 0 };
469
- // Away from bottom the row index used the real Yoga height (idEntry.rows). The
470
- // physical mismatch to compensate is real_now − real_measured, but `live` (an
471
- // estimate) vs idEntry.rows (measured) also carries the estimator's steady
472
- // bias, which would keep delta positive AFTER measuredRowsVersion has already
473
- // absorbed the real height — permanent over-compensation. Measure growth in
474
- // the SAME (estimate) metric instead: delta = live_now − estimateAtMeasure,
475
- // where estimateAtMeasure is the estimate for the exact text that was
476
- // measured. The estimator bias cancels, so an unchanged tail gives delta 0
477
- // (nothing left for the index to consume) and only genuine text growth since
478
- // the last measurement compensates.
479
- const baseline = idEntry.estimateRows > 0 ? idEntry.estimateRows : idEntry.rows;
480
- const delta = Math.max(0, live - baseline);
481
- return { tailRows: idEntry.rows, delta };
482
- }
483
-
484
430
  // Identity-keyed memo, same contract as transcriptVariantKeyCache: transcript
485
431
  // items are replaced (never mutated) when their content changes, so the
486
432
  // suppress decision — which re-parses tool args via isFullyFailedToolBatch —
@@ -499,41 +445,14 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
499
445
  if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
500
446
  if (shouldSuppressFullyFailedToolItemCached(item)) return 0;
501
447
  if (item.kind === 'assistant' && item.streaming) {
502
- const toolExpanded = toolOutputExpanded ? 1 : 0;
503
- const idEntry = streamingMeasuredRowsById.get(item.id);
504
- if (idEntry && idEntry.rows > 0 && idEntry.columns === columns && idEntry.toolExpanded === toolExpanded) {
505
- if (streamingBottomPinned) {
506
- // Bottom-pinned: the view follows the tail, so its geometry must be
507
- // correct on the FIRST commit. Fold the freshly-grown live estimate in
508
- // — max(measuredFloor, liveEstimate) — so totalRows/scrollOffset are
509
- // right this frame instead of committing the stale measured floor and
510
- // growing a frame later when the harvest bumps measuredRowsVersion
511
- // (the judder). The measured floor still guards against an estimate
512
- // that undercounts the real wrap. Away from bottom the defer-growth
513
- // path below is kept (its anchor-stability / perf intent).
514
- return Math.max(idEntry.rows, streamingEstimateRows(item, columns, toolOutputExpanded));
515
- }
516
- // Defer-growth: while a confirmed (post-commit Yoga) measurement exists
517
- // for this streaming id, THIS render keeps that value instead of
518
- // folding in the freshly-grown estimate. Combining
519
- // max(measuredFloor, liveEstimate) let per-flush row growth reach
520
- // totalRows/scrollOffset a full frame BEFORE Yoga confirmed the real
521
- // wrap — the estimate could over/undercount vs the real layout, and
522
- // the next commit's harvest then corrected it, bouncing an anchored or
523
- // bottom-pinned offset by the mismatch. Freezing at the last confirmed
524
- // height means growth only reaches the row index on the SAME frame the
525
- // harvest (use-transcript-window.mjs) writes the new measured value and
526
- // bumps measuredRowsVersion — the "measured frame" consumes it, not the
527
- // estimate frame.
528
- return idEntry.rows;
529
- }
530
- if (idEntry) {
448
+ // One geometry authority for the active stream: the deterministic renderer
449
+ // model. Never feed a later Yoga measurement back into the row index; that
450
+ // estimate→measure→correct cycle produced the newline dip/snap. The
451
+ // high-water estimate is monotonic for this stream id, so code fences and
452
+ // stable/unstable chunk promotion cannot move the viewport backward.
453
+ if (streamingMeasuredRowsById.has(item.id)) {
531
454
  streamingMeasuredRowsById.delete(item.id);
532
- streamingEstimateHighWaterById.delete(item.id);
533
- streamingTailEstimateById.delete(item.id);
534
455
  }
535
- // No confirmed measurement yet for this id (item just started streaming):
536
- // nothing to defer against, so the first frame falls back to the estimate.
537
456
  return streamingEstimateRows(item, columns, toolOutputExpanded);
538
457
  }
539
458
  if (item.kind === 'assistant') {
@@ -715,6 +634,11 @@ function appendTranscriptRow(prefix, tailValue) {
715
634
  atIndex: (index) => index === prefix.length
716
635
  ? tailValue
717
636
  : (Number(prefix[index]) || 0),
637
+ at: (index) => {
638
+ const resolved = index < 0 ? prefix.length + 1 + index : index;
639
+ if (resolved < 0 || resolved > prefix.length) return undefined;
640
+ return resolved === prefix.length ? tailValue : prefix[resolved];
641
+ },
718
642
  slice: (start = 0, end = prefix.length + 1) => {
719
643
  const values = [];
720
644
  const lo = Math.max(0, start < 0 ? prefix.length + 1 + start : start);
@@ -8,7 +8,7 @@
8
8
  * its internal timers (animation interval, selection paint throttle, text
9
9
  * capture defer, scroll coalescer).
10
10
  */
11
- import { useCallback, useEffect, useRef } from 'react';
11
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
12
12
  import { theme } from '../theme.mjs';
13
13
  import {
14
14
  SELECTION_PAINT_INTERVAL_MS,
@@ -41,6 +41,7 @@ export function useTranscriptScroll({
41
41
  selectionTextRef,
42
42
  }) {
43
43
  const scrollAnimationRef = useRef(null);
44
+ const pendingReadbackRowsRef = useRef(0);
44
45
  const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
45
46
  // Coalescer for edge-drag auto-scroll + wheel scroll deltas (see
46
47
  // SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
@@ -203,6 +204,7 @@ export function useTranscriptScroll({
203
204
  const resetTranscriptScroll = useCallback(() => {
204
205
  cancelTranscriptFollow();
205
206
  stopSmoothScroll();
207
+ pendingReadbackRowsRef.current = 0;
206
208
  scrollPositionRef.current = 0;
207
209
  scrollTargetRef.current = 0;
208
210
  transcriptAnchorRef.current = null;
@@ -218,6 +220,7 @@ export function useTranscriptScroll({
218
220
  // perform the single bottom-follow when the transcript actually grows.
219
221
  transcriptAnchorRef.current = null;
220
222
  transcriptAnchorDirtyRef.current = false;
223
+ pendingReadbackRowsRef.current = 0;
221
224
  followingRef.current = true;
222
225
  stopSmoothScroll();
223
226
  }, [stopSmoothScroll]);
@@ -441,6 +444,12 @@ export function useTranscriptScroll({
441
444
 
442
445
  const scrollTranscriptRows = useCallback((deltaRows, options = {}) => {
443
446
  const maxTarget = Math.max(0, Number(maxScrollRowsRef.current) || 0);
447
+ const publishedGeometry = transcriptGeomRef.current || {};
448
+ const geometryMax = Math.max(
449
+ 0,
450
+ (Number(publishedGeometry.totalRows) || 0)
451
+ - Math.max(1, Number(publishedGeometry.viewRows) || 1),
452
+ );
444
453
  const historyState = store.getState?.() || {};
445
454
  const captureRestoreAnchor = () => {
446
455
  const geom = transcriptGeomRef.current || {};
@@ -496,6 +505,20 @@ export function useTranscriptScroll({
496
505
  const BOTTOM_SNAP_ROWS = 3;
497
506
  if (deltaRows < 0 && target > 0 && target <= BOTTOM_SNAP_ROWS) target = 0;
498
507
  const appliedDelta = target - scrollTargetRef.current;
508
+ const blockedReadbackIntent = deltaRows > 0
509
+ && appliedDelta === 0
510
+ && maxTarget === 0
511
+ && geometryMax > 0;
512
+ if (blockedReadbackIntent) {
513
+ // A newly mounted row can hold the committed max at zero for one frame.
514
+ // Preserve the first upward wheel/keyboard intent instead of letting the
515
+ // next item/measurement commit infer bottom-follow from target=0 and yank
516
+ // the viewport back. The layout effect below applies it before paint as
517
+ // soon as the committed range becomes available.
518
+ pendingReadbackRowsRef.current += deltaRows;
519
+ } else if (deltaRows < 0 || appliedDelta !== 0) {
520
+ pendingReadbackRowsRef.current = 0;
521
+ }
499
522
  // Before the scroll moves selected rows out of view, snapshot the rows
500
523
  // currently under the selection into the stitch buffer keyed by the
501
524
  // PRE-scroll offset (ref selection.ts captureScrolledRows). Runs for BOTH
@@ -509,7 +532,7 @@ export function useTranscriptScroll({
509
532
  }
510
533
  // Any manual wheel/keyboard scroll takes precedence over an in-flight
511
534
  // transcript follow: drop the glide so the user's intent wins.
512
- if (appliedDelta !== 0) cancelTranscriptFollow();
535
+ if (appliedDelta !== 0 || blockedReadbackIntent) cancelTranscriptFollow();
513
536
  scrollTargetRef.current = target;
514
537
  // A manual scroll moves the reading position. Capture the new reading anchor
515
538
  // SYNCHRONOUSLY from the latest published geometry so the very next render
@@ -593,6 +616,13 @@ export function useTranscriptScroll({
593
616
  setScrollOffset(Math.round(target));
594
617
  }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect, harvestStitchRowsNow, cancelPendingSelectionPaint, flushPendingSelectionPaint]);
595
618
 
619
+ useLayoutEffect(() => {
620
+ const pendingRows = Math.max(0, Number(pendingReadbackRowsRef.current) || 0);
621
+ if (pendingRows <= 0 || Math.max(0, Number(maxScrollRowsRef.current) || 0) <= 0) return;
622
+ pendingReadbackRowsRef.current = 0;
623
+ scrollTranscriptRows(pendingRows);
624
+ });
625
+
596
626
  // Leading-edge coalescer for edge-drag auto-scroll + wheel deltas: the first
597
627
  // delta after an idle period flushes immediately (single wheel ticks/short
598
628
  // drags stay responsive), while a flood of deltas within SCROLL_COALESCE_MS