mixdog 0.9.19 → 0.9.21

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/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,564 @@
1
+ /**
2
+ * src/tui/engine/session-api.mjs - part of the public engine session object.
3
+ */
4
+ import { compactEventDetail, projectNameFromPath } from './labels.mjs';
5
+ import { toolErrorDisplay } from './tool-result-text.mjs';
6
+ import { isQueuedEntryEditable, promptDisplayText } from './queue-helpers.mjs';
7
+ import { createEngineApiB } from './session-api-ext.mjs';
8
+ import { buildDoctorReport } from '../app/doctor.mjs';
9
+
10
+ export function createEngineApi(bag) {
11
+ return { ...createEngineApiA(bag), ...createEngineApiB(bag) };
12
+ }
13
+
14
+ export function createEngineApiA(bag) {
15
+ const {
16
+ runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext,
17
+ } = bag;
18
+ return {
19
+ getState: () => getState(),
20
+ patchItem,
21
+ subscribe: (listener) => {
22
+ listeners.add(listener);
23
+ return () => listeners.delete(listener);
24
+ },
25
+ submit: (text, options = {}) => {
26
+ const t = promptDisplayText(text, options).trim();
27
+ if (!t) return false;
28
+ const mode = options.mode || 'prompt';
29
+ // Prompt input queued while a turn is active keeps the
30
+ // default `next` priority, so it is injected at the next tool/model
31
+ // boundary. Explicit options.priority still wins.
32
+ const priority = options.priority;
33
+ const queueOptions = {
34
+ ...options,
35
+ mode,
36
+ displayText: promptDisplayText(text, options),
37
+ priority,
38
+ };
39
+ // A running clear (idle auto-clear or session_manage) sets commandBusy;
40
+ // queue the prompt instead of dropping it — it drains after the clear.
41
+ if (flags.autoClearRunning) {
42
+ enqueue(text, queueOptions);
43
+ return true;
44
+ }
45
+ if (getState().commandBusy) return false;
46
+ if (getState().busy) {
47
+ enqueue(text, queueOptions);
48
+ return true;
49
+ }
50
+ void autoClearBeforeSubmit().then(() => enqueue(text, queueOptions));
51
+ return true;
52
+ },
53
+ restoreQueued,
54
+ setModel: async (m) => {
55
+ if (getState().commandBusy) return false;
56
+ set({ commandBusy: true });
57
+ try {
58
+ // Model changes apply to the NEXT session only (default setRoute
59
+ // behavior) — never rewrite the live session's provider/model, which
60
+ // would force a full prompt-cache rewrite mid-conversation.
61
+ await runtime.setRoute({ model: m });
62
+ set({ ...routeState(), stats: { ...getState().stats } });
63
+ return true;
64
+ } finally {
65
+ set({ commandBusy: false });
66
+ }
67
+ },
68
+ setEffort: async (value) => {
69
+ if (getState().commandBusy) return false;
70
+ set({ commandBusy: true });
71
+ try {
72
+ await runtime.setEffort(value);
73
+ set({ ...routeState() });
74
+ return runtime.effort || 'auto';
75
+ } finally {
76
+ set({ commandBusy: false });
77
+ }
78
+ },
79
+ setFast: async (value) => {
80
+ if (getState().commandBusy) return null;
81
+ set({ commandBusy: true });
82
+ try {
83
+ const enabled = await runtime.setFast(value);
84
+ set({ ...routeState() });
85
+ return enabled;
86
+ } finally {
87
+ set({ commandBusy: false });
88
+ }
89
+ },
90
+ toggleFast: async () => {
91
+ if (getState().commandBusy) return null;
92
+ set({ commandBusy: true });
93
+ try {
94
+ const enabled = await runtime.toggleFast();
95
+ set({ ...routeState() });
96
+ return enabled;
97
+ } finally {
98
+ set({ commandBusy: false });
99
+ }
100
+ },
101
+ setToolMode: (m) => {
102
+ void runtime.setToolMode(m)
103
+ .then(() => {
104
+ resetStatsAndSyncContext();
105
+ set({ ...routeState(), toolMode: runtime.toolMode, stats: { ...getState().stats } });
106
+ })
107
+ .catch((error) => pushNotice(toolErrorDisplay(error, 'tool'), 'error'));
108
+ },
109
+ getAutoClear: () => autoClearState(),
110
+ setAutoClear: (input = {}) => {
111
+ const next = runtime.setAutoClear?.(input) || autoClearState();
112
+ set({ autoClear: next });
113
+ return next;
114
+ },
115
+ getUpdateSettings: () => runtime.getUpdateSettings?.() || null,
116
+ setAutoUpdate: (enabled) => runtime.setAutoUpdate?.(enabled),
117
+ checkForUpdate: (input = {}) => runtime.checkForUpdate?.(input),
118
+ runUpdateNow: () => runtime.runUpdateNow?.(),
119
+ getUpdateStatus: () => runtime.getUpdateStatus?.() || { phase: 'idle' },
120
+ getProfile: () => runtime.getProfile?.() || { title: '', language: 'system', languages: [] },
121
+ setProfile: (input = {}) => {
122
+ const next = runtime.setProfile?.(input) || runtime.getProfile?.() || null;
123
+ return next;
124
+ },
125
+ getCompactionSettings: () => {
126
+ return runtime.getCompactionSettings?.() || {};
127
+ },
128
+ setCompactionSettings: async (input = {}) => {
129
+ if (getState().commandBusy) return null;
130
+ set({ commandBusy: true });
131
+ try {
132
+ const next = runtime.setCompactionSettings?.(input) || {};
133
+ set({ ...routeState(), stats: { ...getState().stats } });
134
+ // Context-stats recompute (transcript scan + per-message JSON
135
+ // stringify) is the secondary hitch source on this toggle; defer it
136
+ // off the key-handler tick so Ink repaints the setting change first.
137
+ // Stats become eventually consistent on the next tick/repaint.
138
+ setTimeout(() => {
139
+ syncContextStats({ allowEstimated: true });
140
+ set({ stats: { ...getState().stats } });
141
+ }, 0);
142
+ return next;
143
+ } finally {
144
+ set({ commandBusy: false });
145
+ }
146
+ },
147
+ getMemorySettings: () => {
148
+ return runtime.getMemorySettings?.() || { enabled: true };
149
+ },
150
+ setMemoryEnabled: async (enabled) => {
151
+ if (getState().commandBusy) return null;
152
+ set({ commandBusy: true });
153
+ try {
154
+ const next = await runtime.setMemoryEnabled?.(enabled);
155
+ set({ ...routeState(), stats: { ...getState().stats } });
156
+ // Deferred for the same reason as setCompactionSettings above: keep
157
+ // the recompute off the key-handler tick so the toggle repaints
158
+ // immediately; stats catch up right after.
159
+ setTimeout(() => {
160
+ syncContextStats({ allowEstimated: true });
161
+ set({ stats: { ...getState().stats } });
162
+ }, 0);
163
+ return next;
164
+ } finally {
165
+ set({ commandBusy: false });
166
+ }
167
+ },
168
+ getChannelSettings: (options = {}) => {
169
+ return runtime.getChannelSettings?.(options) || {
170
+ enabled: true,
171
+ ...(options?.includeStatus === false ? {} : { status: runtime.getChannelWorkerStatus?.() }),
172
+ };
173
+ },
174
+ setChannelsEnabled: async (enabled) => {
175
+ if (getState().commandBusy) return null;
176
+ set({ commandBusy: true });
177
+ try {
178
+ const next = await runtime.setChannelsEnabled?.(enabled);
179
+ set({ ...routeState(), stats: { ...getState().stats } });
180
+ return next;
181
+ } finally {
182
+ set({ commandBusy: false });
183
+ }
184
+ },
185
+ agentControl: async (args = {}) => {
186
+ if (getState().commandBusy) return null;
187
+ set({ commandBusy: true });
188
+ try {
189
+ const result = await runtime.agentControl(args);
190
+ const text = String(result ?? '').trim();
191
+ const itemId = nextId();
192
+ pushItem({
193
+ kind: 'tool',
194
+ id: itemId,
195
+ name: 'agent',
196
+ args,
197
+ result: null,
198
+ isError: false,
199
+ expanded: false,
200
+ count: 1,
201
+ completedCount: 0,
202
+ startedAt: Date.now(),
203
+ });
204
+ updateAgentJobCard(itemId, text, /^error:/i.test(text));
205
+ set(agentStatusState({ force: true }));
206
+ return result;
207
+ } finally {
208
+ set({ commandBusy: false });
209
+ }
210
+ },
211
+ toolsStatus: (query = '') => {
212
+ return runtime.toolsStatus?.(query) || { mode: getState().toolMode, count: 0, activeCount: 0, tools: [] };
213
+ },
214
+ selectTools: (names) => {
215
+ const result = runtime.selectTools?.(names) || { added: [], already: [], blocked: [], missing: [] };
216
+ const added = result.added?.length ? `added ${result.added.join(', ')}` : '';
217
+ const already = result.already?.length ? `already ${result.already.join(', ')}` : '';
218
+ const blocked = result.blocked?.length ? `blocked ${result.blocked.map((row) => row.name).join(', ')}` : '';
219
+ const missing = result.missing?.length ? `missing ${result.missing.join(', ')}` : '';
220
+ pushNotice(
221
+ [added, already, blocked, missing].filter(Boolean).join(' - ') || 'no tool changes',
222
+ result.blocked?.length || result.missing?.length ? 'warn' : 'info',
223
+ );
224
+ return result;
225
+ },
226
+ setCwd: (path, options = {}) => {
227
+ const next = runtime.setCwd(path);
228
+ set({ cwd: next });
229
+ if (options?.notice !== false) {
230
+ pushNotice(options?.message || `Project set: ${projectNameFromPath(next)}`, 'info');
231
+ }
232
+ return next;
233
+ },
234
+ getSystemShell: () => {
235
+ return runtime.getSystemShell?.() || runtime.systemShell || { source: 'auto', command: '', effective: '' };
236
+ },
237
+ setSystemShell: (command) => {
238
+ const next = runtime.setSystemShell?.(command) || { source: 'auto', command: '', effective: '' };
239
+ set({ ...routeState(), systemShell: next });
240
+ pushNotice(`system shell -> ${next.effective || 'auto'}`, 'info');
241
+ return next;
242
+ },
243
+ mcpStatus: () => {
244
+ return runtime.mcpStatus?.() || { servers: [], configuredCount: 0, connectedCount: 0, failedCount: 0 };
245
+ },
246
+ reconnectMcp: async () => {
247
+ if (getState().commandBusy) return null;
248
+ set({ commandBusy: true });
249
+ try {
250
+ const status = await runtime.reconnectMcp?.();
251
+ resetStatsAndSyncContext();
252
+ set({ ...routeState(), stats: { ...getState().stats } });
253
+ pushNotice(
254
+ `mcp reconnect: ${status?.connectedCount || 0}/${status?.configuredCount || 0} connected${status?.failedCount ? ` - ${status.failedCount} failed` : ''}`,
255
+ status?.failedCount ? 'warn' : 'info',
256
+ );
257
+ return status;
258
+ } finally {
259
+ set({ commandBusy: false });
260
+ }
261
+ },
262
+ addMcpServer: async (input) => {
263
+ if (getState().commandBusy) return null;
264
+ set({ commandBusy: true });
265
+ try {
266
+ const result = await runtime.addMcpServer?.(input);
267
+ resetStatsAndSyncContext();
268
+ set({ ...routeState(), stats: { ...getState().stats } });
269
+ pushNotice(`mcp added: ${result?.name || input?.name || 'server'}`, 'info');
270
+ return result;
271
+ } finally {
272
+ set({ commandBusy: false });
273
+ }
274
+ },
275
+ removeMcpServer: async (name) => {
276
+ if (getState().commandBusy) return null;
277
+ set({ commandBusy: true });
278
+ try {
279
+ const status = await runtime.removeMcpServer?.(name);
280
+ resetStatsAndSyncContext();
281
+ set({ ...routeState(), stats: { ...getState().stats } });
282
+ pushNotice(`mcp removed: ${name}`, 'info');
283
+ return status;
284
+ } finally {
285
+ set({ commandBusy: false });
286
+ }
287
+ },
288
+ setMcpServerEnabled: async (name, enabled) => {
289
+ if (getState().commandBusy) return null;
290
+ set({ commandBusy: true });
291
+ try {
292
+ const status = await runtime.setMcpServerEnabled?.(name, enabled);
293
+ resetStatsAndSyncContext();
294
+ set({ ...routeState(), stats: { ...getState().stats } });
295
+ pushNotice(`mcp ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
296
+ return status;
297
+ } finally {
298
+ set({ commandBusy: false });
299
+ }
300
+ },
301
+ getDisabledSkills: () => runtime.getDisabledSkills?.() || { disabled: [] },
302
+ setDisabledSkills: (disabled) => runtime.setDisabledSkills?.(disabled) || { disabled: [] },
303
+ skillsStatus: () => {
304
+ return runtime.skillsStatus?.() || { cwd: getState().cwd, count: 0, skills: [] };
305
+ },
306
+ skillContent: (name) => {
307
+ return runtime.skillContent?.(name);
308
+ },
309
+ addSkill: async (input) => {
310
+ if (getState().commandBusy) return null;
311
+ set({ commandBusy: true });
312
+ try {
313
+ const result = await runtime.addSkill?.(input);
314
+ resetStatsAndSyncContext();
315
+ set({ ...routeState(), stats: { ...getState().stats } });
316
+ pushNotice(`skill added: ${result?.skill?.name || input?.name || 'skill'}`, 'info');
317
+ return result;
318
+ } finally {
319
+ set({ commandBusy: false });
320
+ }
321
+ },
322
+ reloadSkills: async () => {
323
+ if (getState().commandBusy) return null;
324
+ set({ commandBusy: true });
325
+ try {
326
+ const status = await runtime.reloadSkills?.();
327
+ resetStatsAndSyncContext();
328
+ set({ ...routeState(), stats: { ...getState().stats } });
329
+ pushNotice(`skills reload: ${status?.count || 0} available`, 'info');
330
+ return status;
331
+ } finally {
332
+ set({ commandBusy: false });
333
+ }
334
+ },
335
+ pluginsStatus: () => {
336
+ return runtime.pluginsStatus?.() || { count: 0, plugins: [] };
337
+ },
338
+ reloadPlugins: async () => {
339
+ if (getState().commandBusy) return null;
340
+ set({ commandBusy: true });
341
+ try {
342
+ const status = await runtime.reloadPlugins?.();
343
+ resetStatsAndSyncContext();
344
+ set({ ...routeState(), stats: { ...getState().stats } });
345
+ pushNotice(`plugins reload: ${status?.count || 0} detected`, 'info');
346
+ return status;
347
+ } finally {
348
+ set({ commandBusy: false });
349
+ }
350
+ },
351
+ addPlugin: async (source) => {
352
+ if (getState().commandBusy) return null;
353
+ set({ commandBusy: true });
354
+ try {
355
+ const result = await runtime.addPlugin?.(source);
356
+ resetStatsAndSyncContext();
357
+ set({ ...routeState(), stats: { ...getState().stats } });
358
+ pushNotice(`plugin added: ${result?.plugin?.title || result?.plugin?.name || source}`, 'info');
359
+ return result;
360
+ } finally {
361
+ set({ commandBusy: false });
362
+ }
363
+ },
364
+ updatePlugin: async (plugin) => {
365
+ if (getState().commandBusy) return null;
366
+ set({ commandBusy: true });
367
+ try {
368
+ const result = await runtime.updatePlugin?.(plugin);
369
+ resetStatsAndSyncContext();
370
+ set({ ...routeState(), stats: { ...getState().stats } });
371
+ pushNotice(`plugin updated: ${result?.plugin?.title || result?.plugin?.name || plugin?.name || plugin}`, 'info');
372
+ return result;
373
+ } finally {
374
+ set({ commandBusy: false });
375
+ }
376
+ },
377
+ removePlugin: async (plugin) => {
378
+ if (getState().commandBusy) return null;
379
+ set({ commandBusy: true });
380
+ try {
381
+ const result = await runtime.removePlugin?.(plugin);
382
+ resetStatsAndSyncContext();
383
+ set({ ...routeState(), stats: { ...getState().stats } });
384
+ pushNotice(`plugin uninstalled: ${result?.plugin?.title || result?.plugin?.name || plugin?.name || plugin}`, 'info');
385
+ return result;
386
+ } finally {
387
+ set({ commandBusy: false });
388
+ }
389
+ },
390
+ enablePluginMcp: async (plugin) => {
391
+ if (getState().commandBusy) return null;
392
+ set({ commandBusy: true });
393
+ try {
394
+ const result = await runtime.enablePluginMcp?.(plugin);
395
+ resetStatsAndSyncContext();
396
+ set({ ...routeState(), stats: { ...getState().stats } });
397
+ pushNotice(`plugin MCP enabled: ${result?.serverName || plugin?.name || 'plugin'}`, 'info');
398
+ return result;
399
+ } finally {
400
+ set({ commandBusy: false });
401
+ }
402
+ },
403
+ hooksStatus: () => {
404
+ return runtime.hooksStatus?.() || { enabled: false, events: [], recent: [] };
405
+ },
406
+ contextStatus: () => {
407
+ return runtime.contextStatus?.() || null;
408
+ },
409
+ addHookRule: (rule) => {
410
+ const rules = runtime.addHookRule?.(rule) || [];
411
+ pushNotice(`hook rule added (${rules.length} total)`, 'info');
412
+ return rules;
413
+ },
414
+ setHookRuleEnabled: (index, enabled) => {
415
+ const rules = runtime.setHookRuleEnabled?.(index, enabled) || [];
416
+ pushNotice(`hook rule ${index + 1} ${enabled ? 'enabled' : 'disabled'}`, 'info');
417
+ return rules;
418
+ },
419
+ deleteHookRule: (index) => {
420
+ const rules = runtime.deleteHookRule?.(index) || [];
421
+ pushNotice(`hook rule ${index + 1} deleted`, 'info');
422
+ return rules;
423
+ },
424
+ memoryControl: async (args = {}, options = {}) => {
425
+ if (getState().commandBusy) return null;
426
+ set({ commandBusy: true });
427
+ try {
428
+ const result = await runtime.memoryControl(args);
429
+ const text = String(result || '').trim() || '(empty memory result)';
430
+ if (!options.silent) pushNotice(text, 'info');
431
+ return result;
432
+ } finally {
433
+ set({ commandBusy: false });
434
+ }
435
+ },
436
+ recall: async (query, args = {}) => {
437
+ if (getState().commandBusy) return null;
438
+ const startedAt = Date.now();
439
+ set({ commandBusy: true, commandStatus: { active: true, verb: 'Recalling memory', startedAt, mode: 'recalling' } });
440
+ try {
441
+ const result = await runtime.recall(query, args);
442
+ pushNotice(String(result || '').trim() || '(empty recall result)', 'info');
443
+ return result;
444
+ } finally {
445
+ set({ commandBusy: false, commandStatus: null });
446
+ }
447
+ },
448
+ runDoctor: async () => {
449
+ if (getState().commandBusy) return null;
450
+ const startedAt = Date.now();
451
+ set({ commandBusy: true, commandStatus: { active: true, verb: 'Running diagnostics', startedAt, mode: 'doctor' } });
452
+ try {
453
+ // Yield one event-loop turn so Ink paints the running indicator before
454
+ // the (mostly synchronous) health checks run — same pattern as compact.
455
+ await new Promise((resolve) => setTimeout(resolve, 0));
456
+ const report = await buildDoctorReport(runtime, getState);
457
+ pushNotice(report, 'info');
458
+ return report;
459
+ } catch (e) {
460
+ pushNotice(`doctor failed: ${e?.message || e}`, 'error');
461
+ return null;
462
+ } finally {
463
+ set({ commandBusy: false, commandStatus: null });
464
+ }
465
+ },
466
+ compact: async () => {
467
+ if (getState().commandBusy) return null;
468
+ if (getState().busy) {
469
+ pushNotice('Compact skipped: turn in progress', 'info');
470
+ return { changed: false, reason: 'compact skipped: turn in progress' };
471
+ }
472
+ const startedAt = Date.now();
473
+ set({ commandBusy: true, commandStatus: { active: true, verb: 'Compacting conversation', startedAt, mode: 'compacting' } });
474
+ try {
475
+ // Give Ink one event-loop turn to paint the compacting spinner before
476
+ // runtime.compact() starts synchronous session/transcript work (same
477
+ // yield as the auto-clear path; without it /compact looks frozen with
478
+ // no spinner until the compact already finished).
479
+ await new Promise((resolve) => setTimeout(resolve, 0));
480
+ const result = await runtime.compact({ recoverAgent: true });
481
+ syncContextStats({ allowEstimated: true });
482
+ set({ ...routeState(), stats: { ...getState().stats } });
483
+ if (result) {
484
+ pushItem({
485
+ kind: 'statusdone',
486
+ id: nextId(),
487
+ label: result.error ? 'Compact failed' : (result.changed === false ? 'Compact checked' : 'Compact complete'),
488
+ detail: compactEventDetail({
489
+ stage: 'manual',
490
+ trigger: 'manual',
491
+ status: result.error ? 'failed' : (result.changed === false ? 'no_change' : 'compacted'),
492
+ compactType: result.compactType,
493
+ beforeTokens: result.beforeTokens,
494
+ afterTokens: result.afterTokens,
495
+ beforeMessages: result.beforeMessages,
496
+ afterMessages: result.afterMessages,
497
+ semantic: result.semanticCompact,
498
+ recallFastTrack: result.recallFastTrack,
499
+ durationMs: Date.now() - startedAt,
500
+ error: result.error,
501
+ }),
502
+ });
503
+ } else {
504
+ // null = session missing/closed: still surface a done row so
505
+ // /compact never ends silently without a completion marker.
506
+ pushItem({
507
+ kind: 'statusdone',
508
+ id: nextId(),
509
+ label: 'Compact failed',
510
+ detail: 'no active session',
511
+ });
512
+ }
513
+ return result;
514
+ } finally {
515
+ set({ commandBusy: false, commandStatus: null });
516
+ }
517
+ },
518
+ abort: () => {
519
+ if (!getState().busy) return false;
520
+ denyAllToolApprovals('interrupted by user');
521
+ const restoreState = flags.activePromptRestore;
522
+ // A queued steering prompt means the user already redirected the turn:
523
+ // interrupting should just cancel the running turn and let the steering
524
+ // prompt run next, NOT resurrect the in-flight prompt back into the draft.
525
+ const hasPendingSteering = pending.some((entry) => isQueuedEntryEditable(entry));
526
+ const canRestore = restoreState?.restorable && !hasPendingSteering;
527
+ const restoreText = canRestore ? restoreState.text : '';
528
+ const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
529
+ const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
530
+ // When steering suppresses the restore, the interrupted prompt's pasted
531
+ // images never get committed (onCommitted won't fire) nor re-installed into
532
+ // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
533
+ // lingering in the paste snapshot.
534
+ const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
535
+ ? restoreState.pastedImages
536
+ : null;
537
+ const discardPastedTexts = restoreState?.restorable && hasPendingSteering && restoreState?.pastedTexts
538
+ ? restoreState.pastedTexts
539
+ : null;
540
+ const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
541
+ ? restoreState.requeueEntries.slice()
542
+ : [];
543
+ const aborted = runtime.abort('cli-react-abort');
544
+ if (restoreState) {
545
+ if ((restoreText || requeueEntries.length > 0) && aborted !== false) {
546
+ restoreState.reclaimed = true;
547
+ const idSet = new Set((restoreState.submittedIds || []).filter((id) => id != null));
548
+ const patch = { spinner: null, thinking: null, lastTurn: null };
549
+ if (idSet.size > 0) {
550
+ const items = getState().items.filter((item) => !idSet.has(item?.id));
551
+ if (items.length !== getState().items.length) {
552
+ patch.items = replaceItems(items);
553
+ }
554
+ }
555
+ set(patch);
556
+ if (requeueEntries.length > 0) requeueEntriesFront(requeueEntries);
557
+ }
558
+ restoreState.restorable = false;
559
+ restoreState.requeueEntries = [];
560
+ }
561
+ return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
562
+ },
563
+ };
564
+ }