mixdog 0.9.29 → 0.9.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.29",
3
+ "version": "0.9.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,96 @@
1
+ // Regression for the execution-pending-resume kick (engine/agent-job-feed.mjs).
2
+ // Guards two coupled defects:
3
+ // A (body loss): parallel completions deferred while busy must each surface
4
+ // their model-visible body on resume — the old single string slot dropped
5
+ // all but the last.
6
+ // B (missed resume): a deferred kick left after a busy->false transition that
7
+ // did NOT go through drain-finally / normal-turn-end (watchdog force-release
8
+ // or stale-unwind) must still fire when flushDeferred is called.
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
13
+
14
+ // Minimal harness: a mutable busy flag, a pending queue, and a synchronous
15
+ // drain stub that "resumes" by surfacing every pending-resume entry's body.
16
+ function makeHarness() {
17
+ const pending = [];
18
+ const surfaced = [];
19
+ const state = { busy: false };
20
+ let draining = false;
21
+
22
+ function drain() {
23
+ if (draining) return Promise.resolve();
24
+ draining = true;
25
+ try {
26
+ for (let i = 0; i < pending.length;) {
27
+ const entry = pending[i];
28
+ if (entry.mode === 'pending-resume') {
29
+ surfaced.push(entry.text);
30
+ pending.splice(i, 1);
31
+ } else {
32
+ i += 1;
33
+ }
34
+ }
35
+ } finally {
36
+ draining = false;
37
+ }
38
+ return Promise.resolve();
39
+ }
40
+
41
+ const feed = createAgentJobFeed({
42
+ runtime: {},
43
+ getState: () => state,
44
+ set: () => {},
45
+ nextId: () => 'id',
46
+ getDisposed: () => false,
47
+ patchItem: () => {},
48
+ enqueue: () => {},
49
+ drain,
50
+ pushUserOrSyntheticItem: () => {},
51
+ makeQueueEntry: (text, opts = {}) => ({ text, mode: opts.mode, priority: opts.priority }),
52
+ getPending: () => pending,
53
+ agentStatusState: () => ({}),
54
+ displayedExecutionNotificationKeys: new Set(),
55
+ pushNotice: () => {},
56
+ });
57
+
58
+ return { feed, pending, surfaced, state };
59
+ }
60
+
61
+ const microtasks = () => new Promise((r) => setImmediate(r));
62
+
63
+ test('A: two completions deferred while busy both surface their bodies on resume', async () => {
64
+ const { feed, surfaced, state } = makeHarness();
65
+ state.busy = true;
66
+ feed.scheduleExecutionPendingResumeKick('body A');
67
+ feed.scheduleExecutionPendingResumeKick('body B');
68
+ await microtasks();
69
+ // Both kicks deferred while busy; nothing surfaced yet.
70
+ assert.deepEqual(surfaced, []);
71
+
72
+ // Turn settles normally -> busy false -> flush re-arms the kick.
73
+ state.busy = false;
74
+ feed.flushDeferredExecutionPendingResumeKick();
75
+ assert.equal(surfaced.length, 1, 'a single merged resume entry surfaced');
76
+ assert.match(surfaced[0], /body A/, 'first body preserved');
77
+ assert.match(surfaced[0], /body B/, 'second body preserved (not dropped)');
78
+ });
79
+
80
+ test('B: deferred kick after a non-drain busy->false transition still fires', async () => {
81
+ const { feed, surfaced, state } = makeHarness();
82
+ state.busy = true;
83
+ feed.scheduleExecutionPendingResumeKick('only body');
84
+ await microtasks();
85
+ assert.deepEqual(surfaced, [], 'deferred while busy');
86
+
87
+ // Simulate watchdog force-release / stale-unwind: busy flips to false WITHOUT
88
+ // drain-finally, then flushDeferred runs on that transition.
89
+ state.busy = false;
90
+ feed.flushDeferredExecutionPendingResumeKick();
91
+ assert.deepEqual(surfaced, ['only body'], 'resume fired on the non-drain transition');
92
+
93
+ // Idempotent: a second flush with nothing deferred is a no-op.
94
+ feed.flushDeferredExecutionPendingResumeKick();
95
+ assert.deepEqual(surfaced, ['only body']);
96
+ });
@@ -0,0 +1,64 @@
1
+ // Proves deferred agent/tool *completion* notifications are DROPPED on drain
2
+ // (never replayed out-of-order on a later session resume) while genuine
3
+ // user/steering messages in the same queue survive with order preserved.
4
+ // Owner decision: out-of-order replay is worse than loss.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ // Redirect the pending-message spool to a throwaway dir before import so the
12
+ // real ~/.mixdog/data spool is never touched.
13
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixpend-'));
14
+ process.env.MIXDOG_DATA_DIR = dataDir;
15
+
16
+ const {
17
+ enqueuePendingMessage,
18
+ drainPendingMessages,
19
+ COMPLETION_NOTIFICATION_KIND,
20
+ markCompletionEntry,
21
+ } = await import('../src/runtime/agent/orchestrator/session/manager/pending-messages.mjs');
22
+
23
+ function completionEntry(text) {
24
+ return { content: text, text, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
25
+ }
26
+
27
+ test('drain drops completion notifications, keeps user messages in order', async () => {
28
+ const sid = 'sess_drop_test_1';
29
+ enqueuePendingMessage(sid, 'user first');
30
+ enqueuePendingMessage(sid, completionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
31
+ enqueuePendingMessage(sid, 'user second');
32
+
33
+ // Let the buffered persist flush to disk so we also verify the on-disk marker
34
+ // (the shape a later resume would drain).
35
+ await new Promise((r) => setImmediate(r));
36
+ await new Promise((r) => setTimeout(r, 30));
37
+ const store = JSON.parse(readFileSync(join(dataDir, 'session-pending-messages.json'), 'utf8'));
38
+ const persistedQueue = store.sessions[sid] || [];
39
+ const marked = persistedQueue.filter((e) => e && typeof e === 'object' && e.notificationKind === COMPLETION_NOTIFICATION_KIND);
40
+ assert.equal(marked.length, 1, 'completion notification persisted as a marked object');
41
+ assert.ok(persistedQueue.includes('user first') && persistedQueue.includes('user second'), 'user messages persisted as plain strings');
42
+
43
+ const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
44
+ assert.deepEqual(drained, ['user first', 'user second'], 'completion dropped; user messages kept in order');
45
+ assert.ok(!drained.some((t) => /finished/.test(t)), 'no completion prose replayed');
46
+
47
+ // A second drain (post-resume) yields nothing — the completion was discarded,
48
+ // not deferred.
49
+ assert.deepEqual(drainPendingMessages(sid), []);
50
+ });
51
+
52
+ test.after(() => {
53
+ try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
54
+ });
55
+
56
+ // Proves the shared tagger (used by notify.mjs, tool-exec.mjs, runtime-core.mjs)
57
+ // yields entries drain drops, while a real user message survives in order.
58
+ test('markCompletionEntry path is dropped on drain; user message survives', () => {
59
+ const sid = 'sess_drop_test_2';
60
+ enqueuePendingMessage(sid, 'real user question');
61
+ enqueuePendingMessage(sid, markCompletionEntry('Async agent task abc finished.\n\nResult:\n> ok'));
62
+ const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
63
+ assert.deepEqual(drained, ['real user question']);
64
+ });
@@ -9,7 +9,7 @@ import { executeBashSessionTool } from '../../tools/bash-session.mjs';
9
9
  import { executePatchTool } from '../../tools/patch.mjs';
10
10
  import { executeInternalTool, isInternalTool } from '../../internal-tools.mjs';
11
11
  import { normalizeToolEnvelope, makeToolEnvelope } from '../tool-envelope.mjs';
12
- import { getSessionAbortSignal, enqueuePendingMessage } from '../manager.mjs';
12
+ import { getSessionAbortSignal, enqueuePendingMessage, markCompletionEntry } from '../manager.mjs';
13
13
  import { createScopedCacheOutcome } from '../cache/scoped-cache-outcome.mjs';
14
14
  import { modelVisibleToolCompletionMessage } from '../../../../shared/tool-execution-contract.mjs';
15
15
  import { _isScopedCacheableTool } from './tool-classify.mjs';
@@ -68,7 +68,9 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
68
68
  if (!notificationSessionId) return;
69
69
  try {
70
70
  const visible = modelVisibleToolCompletionMessage(text, meta);
71
- if (visible) enqueuePendingMessage(notificationSessionId, visible);
71
+ // Inherently a tool-completion notification → tag so a later
72
+ // resume drops it instead of replaying it as user text.
73
+ if (visible) enqueuePendingMessage(notificationSessionId, markCompletionEntry(visible));
72
74
  } catch { /* best effort */ }
73
75
  };
74
76
  const completionToolOpts = {
@@ -11,9 +11,31 @@ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
11
11
  const PENDING_MESSAGES_MODE = 0o600;
12
12
  const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
13
13
  const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
14
+ // Marker for deferred agent/tool *completion* notifications. Such entries must
15
+ // never be replayed into a later turn on session resume (out-of-order delivery
16
+ // is worse than loss — owner decision), so drain discards them. Genuine
17
+ // user/steering messages carry no marker and keep full queue + replay behavior.
18
+ export const COMPLETION_NOTIFICATION_KIND = 'completion_notification';
14
19
  const _pendingPersistBuffers = new Map();
15
20
  let _pendingPersistImmediate = null;
16
21
 
22
+ function isCompletionNotificationEntry(entry) {
23
+ return Boolean(entry) && typeof entry === 'object'
24
+ && entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
25
+ }
26
+
27
+ // Canonical completion-enqueue tagger. Every deferred tool/agent completion
28
+ // notification MUST be enqueued through this so drain can discard it on resume
29
+ // (never replay out-of-order). Pass the model-visible completion text (or an
30
+ // existing entry); genuine user/steering messages must NOT be tagged.
31
+ export function markCompletionEntry(text) {
32
+ const value = typeof text === 'string'
33
+ ? text
34
+ : (text && typeof text === 'object' ? (text.text || text.content || '') : '');
35
+ const content = String(value ?? '');
36
+ return { content, text: content, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
37
+ }
38
+
17
39
  function pendingMessagesPath() {
18
40
  return join(resolvePluginData(), PENDING_MESSAGES_FILE);
19
41
  }
@@ -53,13 +75,7 @@ function normalizePendingStore(raw) {
53
75
  if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
54
76
  const q = isTuiSteeringPendingKey(sid)
55
77
  ? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
56
- : value
57
- .map((entry) => {
58
- if (typeof entry === 'string') return entry;
59
- if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
60
- return '';
61
- })
62
- .filter(Boolean);
78
+ : value.map(normalizePersistedEntry).filter(Boolean);
63
79
  if (q.length > 0) {
64
80
  out.sessions[sid] = q;
65
81
  const touched = Number(touchedRaw[sid]);
@@ -85,16 +101,22 @@ function normalizePendingMessageEntry(entry) {
85
101
  return { content: entry, text };
86
102
  }
87
103
  if (!entry || typeof entry !== 'object') return null;
104
+ const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
105
+ ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
106
+ : null;
88
107
  const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
89
108
  if (content == null) return null;
90
109
  const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
91
- if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
92
- if (typeof content === 'string') {
110
+ let out = null;
111
+ if (Array.isArray(content)) out = content.length > 0 ? { content, text } : null;
112
+ else if (typeof content === 'string') {
93
113
  const value = content.trim();
94
- return value ? { content: value, text: text || value } : null;
114
+ out = value ? { content: value, text: text || value } : null;
115
+ } else {
116
+ const fallback = promptContentText(content).trim();
117
+ out = fallback ? { content: fallback, text: text || fallback } : null;
95
118
  }
96
- const fallback = promptContentText(content).trim();
97
- return fallback ? { content: fallback, text: text || fallback } : null;
119
+ return out && marker ? { ...out, ...marker } : out;
98
120
  }
99
121
 
100
122
  function pendingMessageText(entry) {
@@ -105,14 +127,39 @@ function pendingMessageText(entry) {
105
127
  function pendingMessageQueueEntry(entry) {
106
128
  const normalized = normalizePendingMessageEntry(entry);
107
129
  if (!normalized) return null;
108
- if (typeof normalized.content === 'string' && normalized.content === normalized.text) return normalized.content;
109
- return { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
130
+ const marker = isCompletionNotificationEntry(normalized)
131
+ ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
132
+ : null;
133
+ if (typeof normalized.content === 'string' && normalized.content === normalized.text && !marker) return normalized.content;
134
+ const base = { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
135
+ return marker ? { ...base, ...marker } : base;
136
+ }
137
+
138
+ // Canonical persisted-queue entry: a plain string for user/steering messages,
139
+ // or a { message, notificationKind, enqueuedAt } object for completion/task
140
+ // notifications so the marker survives an on-disk round trip. Accepts either an
141
+ // in-memory queue entry (content/text) or an already-persisted entry
142
+ // (string | { message } legacy | marked object); back-compatible with both.
143
+ function normalizePersistedEntry(entry) {
144
+ if (typeof entry === 'string') { const t = entry.trim(); return t || null; }
145
+ if (!entry || typeof entry !== 'object') return null;
146
+ if (isCompletionNotificationEntry(entry)) {
147
+ const message = (typeof entry.message === 'string' && entry.message.trim())
148
+ ? entry.message.trim()
149
+ : pendingMessageText(entry);
150
+ return message
151
+ ? { message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
152
+ : null;
153
+ }
154
+ if (typeof entry.message === 'string') { const t = entry.message.trim(); return t || null; }
155
+ const t = pendingMessageText(entry);
156
+ return t || null;
110
157
  }
111
158
 
112
159
  function persistPendingMessages(sessionId, messages) {
113
160
  if (!isValidPendingSessionId(sessionId)) return 0;
114
161
  const persistedMessages = (Array.isArray(messages) ? messages : [messages])
115
- .map(pendingMessageText)
162
+ .map(normalizePersistedEntry)
116
163
  .filter(Boolean);
117
164
  if (persistedMessages.length === 0) return 0;
118
165
  // Async lock wait: this runs on the lead/TUI main process (tool-exec +
@@ -159,7 +206,7 @@ function flushPendingMessagePersistsSync() {
159
206
 
160
207
  function schedulePendingMessagePersist(sessionId, message) {
161
208
  if (!isValidPendingSessionId(sessionId)) return 0;
162
- const persistedMessage = pendingMessageText(message);
209
+ const persistedMessage = normalizePersistedEntry(message);
163
210
  if (!persistedMessage) return 0;
164
211
  const q = _pendingPersistBuffers.get(sessionId) || [];
165
212
  q.push(persistedMessage);
@@ -192,7 +239,7 @@ function drainPersistedPendingMessages(sessionId) {
192
239
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
193
240
  const next = normalizePendingStore(raw);
194
241
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
195
- drained = q.filter((m) => typeof m === 'string' && m.length > 0);
242
+ drained = q.filter((m) => (typeof m === 'string' && m.length > 0) || isCompletionNotificationEntry(m));
196
243
  if (drained.length === 0) return undefined;
197
244
  delete next.sessions[sessionId];
198
245
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
@@ -335,8 +382,13 @@ export function drainPendingMessages(sessionId) {
335
382
  // first. Reversing this order (buffer before disk) delivered newer
336
383
  // buffered sends ahead of older persisted ones after a restart.
337
384
  const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
338
- const memoryVisible = modelVisiblePendingMessages(memory);
339
- const persistedVisible = modelVisiblePendingMessages(persisted);
385
+ // Discard deferred completion/task notifications instead of injecting them
386
+ // out-of-order into a future turn on resume. Genuine user/steering messages
387
+ // (plain strings / content entries) carry no marker and are kept in order.
388
+ const memoryKept = memory.filter((m) => !isCompletionNotificationEntry(m));
389
+ const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m));
390
+ const memoryVisible = modelVisiblePendingMessages(memoryKept);
391
+ const persistedVisible = modelVisiblePendingMessages(persistedKept);
340
392
  if (memoryVisible.length === 0) return persistedVisible;
341
393
  if (persistedVisible.length === 0) return memoryVisible;
342
394
  const persistedTexts = persistedVisible.map(pendingMessageText);
@@ -63,6 +63,8 @@ export {
63
63
  _mergePendingMessageEntries,
64
64
  enqueuePendingMessage,
65
65
  drainPendingMessages,
66
+ markCompletionEntry,
67
+ COMPLETION_NOTIFICATION_KIND,
66
68
  } from './manager/pending-messages.mjs';
67
69
  export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText } from './manager/prompt-utils.mjs';
68
70
 
@@ -212,10 +212,10 @@ export async function addCore(dataDir, { element, summary, category }, projectId
212
212
  const sm = trimOrNull(summary) ?? el
213
213
  if (!el || !sm) throw new Error('add requires element and summary')
214
214
  if (el.length > CORE_ELEMENT_MAX) {
215
- throw new Error(`core element too long (${el.length} chars, max ${CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
215
+ throw new Error(`core element too long (${el.length}/${CORE_ELEMENT_MAX} chars, remove ${el.length - CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
216
216
  }
217
217
  if (sm.length > CORE_SUMMARY_MAX) {
218
- throw new Error(`core summary too long (${sm.length} chars, max ${CORE_SUMMARY_MAX}) — core memory must be 1 fact in 1-2 sentences; procedures, multi-step, or code belong in recap or docs. Compress and retry.`)
218
+ throw new Error(`core summary too long (${sm.length}/${CORE_SUMMARY_MAX} chars, remove ${sm.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
219
219
  }
220
220
  const cat = (trimOrNull(category) ?? 'fact').toLowerCase()
221
221
  if (!VALID_CAT.has(cat)) {
@@ -310,7 +310,7 @@ export async function editCore(dataDir, id, patch) {
310
310
  throw new Error('no change')
311
311
  }
312
312
  if (newSummary && newSummary.length > CORE_SUMMARY_MAX) {
313
- throw new Error(`core summary too long (${newSummary.length} chars, max ${CORE_SUMMARY_MAX}) — core memory must be 1 fact in 1-2 sentences; procedures, multi-step, or code belong in recap or docs. Compress and retry.`)
313
+ throw new Error(`core summary too long (${newSummary.length}/${CORE_SUMMARY_MAX} chars, remove ${newSummary.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
314
314
  }
315
315
  const now = Date.now()
316
316
  const textChanged = newElement !== cur.element || newSummary !== cur.summary
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { spawn, spawnSync } from 'node:child_process';
3
+ import { spawn } from 'node:child_process';
4
4
  import { detachedSpawnOpts } from './spawn-flags.mjs';
5
5
 
6
6
  function positiveInt(value) {
@@ -8,64 +8,12 @@ function positiveInt(value) {
8
8
  return Number.isInteger(n) && n > 0 ? n : null;
9
9
  }
10
10
 
11
- // On Windows a windowless, orphaned pwsh->cli chain keeps the immediate parent
12
- // PID alive after its terminal window is closed, so parent-liveness alone never
13
- // releases the worker. When the session is genuinely interactive (a TTY), the
14
- // controlling console window is owned by a host process (conhost.exe) that dies
15
- // with the terminal — monitoring that PID turns "terminal closed" into a fatal
16
- // orphan signal. Returns null for non-interactive/service/hidden-console starts
17
- // (no TTY, or no console window) so those are never treated as terminal loss.
18
- function detectControllingTerminalPid() {
19
- if (process.platform !== 'win32') return null;
20
- // Gate on a real TTY: a service/pipe/hidden launch has no controlling
21
- // terminal, and probing one there would spawn a transient console whose host
22
- // dies immediately — a false "terminal lost" that would kill a healthy worker.
23
- const interactive = Boolean(
24
- (process.stdin && process.stdin.isTTY) || (process.stdout && process.stdout.isTTY),
25
- );
26
- if (!interactive) return null;
27
- try {
28
- const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
29
- const powershell = systemRoot + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
30
- const script = [
31
- 'Add-Type @"',
32
- 'using System;',
33
- 'using System.Runtime.InteropServices;',
34
- 'public class MixdogTerm {',
35
- ' [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();',
36
- ' [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);',
37
- '}',
38
- '"@',
39
- '$h=[MixdogTerm]::GetConsoleWindow()',
40
- 'if($h -eq [IntPtr]::Zero){ Write-Output 0 } else { $p=0; [void][MixdogTerm]::GetWindowThreadProcessId($h,[ref]$p); Write-Output $p }',
41
- ].join('\n');
42
- // NO windowsHide: CREATE_NO_WINDOW gives the probe its own/absent console so
43
- // GetConsoleWindow() returns 0 or a foreign hwnd. A plain console app spawned
44
- // from a console-attached parent (this runs at the guardian start site, in the
45
- // guarded parent) inherits that console with no window flash. Under Windows
46
- // Terminal/ConPTY the owner is the tab's conhost (dies on tab close), which is
47
- // exactly the pid we want to monitor.
48
- const res = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
49
- timeout: 4000,
50
- encoding: 'utf8',
51
- stdio: ['ignore', 'pipe', 'ignore'],
52
- });
53
- const pid = positiveInt(String((res && res.stdout) || '').trim());
54
- // Ignore our own PID: that would make the guardian its own kill trigger.
55
- if (pid && pid !== process.pid) return pid;
56
- return null;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
- function guardianScript({ parentPid, childPid, childGroupPid, terminalPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
11
+ function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
63
12
  return `
64
13
  const { spawnSync } = require('node:child_process');
65
14
  const parentPid = ${JSON.stringify(parentPid)};
66
15
  const childPid = ${JSON.stringify(childPid)};
67
16
  const childGroupPid = ${JSON.stringify(childGroupPid || childPid)};
68
- const terminalPid = ${JSON.stringify(terminalPid || 0)};
69
17
  const platform = ${JSON.stringify(platform)};
70
18
  const pollMs = ${JSON.stringify(pollMs)};
71
19
  const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
@@ -95,12 +43,10 @@ let killing = false;
95
43
  let orphanedAt = 0;
96
44
  const timer = setInterval(() => {
97
45
  if (!alive(childPid)) process.exit(0);
98
- // Orphaned when the parent PID dies OR (interactive only) the controlling
99
- // terminal's console host dies the latter catches a windowless pwsh->cli
100
- // chain that outlives its closed terminal window. terminalPid is 0 for
101
- // non-interactive starts, so those fall back to pure parent-liveness.
102
- const terminalLost = terminalPid > 0 && !alive(terminalPid);
103
- if (alive(parentPid) && !terminalLost) {
46
+ // Orphaned when the parent PID dies. Matches Claude Code / pi: no console
47
+ // probe a hidden shell spawn (windowsHide) plus tree-kill on cleanup, so
48
+ // orphan detection is pure parent-liveness.
49
+ if (alive(parentPid)) {
104
50
  orphanedAt = 0;
105
51
  return;
106
52
  }
@@ -135,7 +81,6 @@ export function startChildGuardian({
135
81
  parentPid: parent,
136
82
  childPid: child,
137
83
  childGroupPid: positiveInt(childGroupPid) || child,
138
- terminalPid: detectControllingTerminalPid() || 0,
139
84
  platform: process.platform,
140
85
  pollMs: Math.max(100, Math.floor(Number(pollMs) || 750)),
141
86
  orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
@@ -31,6 +31,7 @@ import {
31
31
  } from '../runtime/shared/markdown-frontmatter.mjs';
32
32
  import { setConfiguredShell } from '../runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
33
33
  import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
34
+ import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
34
35
  import {
35
36
  beginOAuthProviderLogin,
36
37
  forgetProviderAuth,
@@ -773,7 +774,9 @@ export async function createMixdogSessionRuntime({
773
774
  && shouldPersistModelVisibleToolCompletion(text, meta)) {
774
775
  try {
775
776
  const visible = modelVisibleToolCompletionMessage(text, meta);
776
- if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, visible) > 0;
777
+ // Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
778
+ // → tag so drain discards it on resume rather than replaying out-of-order.
779
+ if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
777
780
  } catch {}
778
781
  }
779
782
  // Headless/API listeners may exist but not consume the event; preserve
@@ -782,7 +785,10 @@ export async function createMixdogSessionRuntime({
782
785
  && typeof mgr.enqueuePendingMessage === 'function') {
783
786
  try {
784
787
  const visible = modelVisibleToolCompletionMessage(text, meta);
785
- if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, visible) > 0;
788
+ // modelVisibleToolCompletionMessage only returns non-empty for a
789
+ // persistable terminal completion, so this fallback is a completion
790
+ // too → tag it (genuine non-completion notifications yield '' above).
791
+ if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
786
792
  } catch {}
787
793
  }
788
794
  return enqueued || handledByRuntimeListener;
@@ -3,6 +3,7 @@
3
3
  // Behavior-preserving: bodies identical to the originals; deps injected.
4
4
  import { modelVisibleToolCompletionMessage } from '../../runtime/shared/tool-execution-contract.mjs';
5
5
  import { renderBackgroundTask, sanitizeTaskMeta, setBackgroundTaskEnqueueFallback } from '../../runtime/shared/background-tasks.mjs';
6
+ import { markCompletionEntry } from '../../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
6
7
  import { clean } from './helpers.mjs';
7
8
 
8
9
  export function createNotify(mgr) {
@@ -11,7 +12,10 @@ export function createNotify(mgr) {
11
12
  if (!target || typeof mgr.enqueuePendingMessage !== 'function') return false;
12
13
  try {
13
14
  const visible = modelVisibleToolCompletionMessage(text, meta);
14
- return Boolean(visible && mgr.enqueuePendingMessage(target, visible) > 0);
15
+ if (!visible) return false;
16
+ // Mark this as a deferred completion/task notification so a later session
17
+ // resume drops it rather than replaying it out-of-order (owner decision).
18
+ return Boolean(mgr.enqueuePendingMessage(target, markCompletionEntry(visible)) > 0);
15
19
  } catch {
16
20
  return false;
17
21
  }
@@ -23060,7 +23060,9 @@ function createAgentJobFeed({
23060
23060
  pushNotice
23061
23061
  }) {
23062
23062
  let executionResumeKickDeferred = false;
23063
- function kickExecutionPendingResume() {
23063
+ const executionResumeKickBodies = [];
23064
+ function kickExecutionPendingResume(body = "") {
23065
+ if (body) executionResumeKickBodies.push(body);
23064
23066
  if (getDisposed()) return;
23065
23067
  if (getState().busy) {
23066
23068
  executionResumeKickDeferred = true;
@@ -23072,15 +23074,16 @@ function createAgentJobFeed({
23072
23074
  return;
23073
23075
  }
23074
23076
  executionResumeKickDeferred = false;
23075
- pending.push(makeQueueEntry("", { mode: "pending-resume", priority: "next" }));
23077
+ const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join("\n\n");
23078
+ pending.push(makeQueueEntry(resumeBody, { mode: "pending-resume", priority: "next" }));
23076
23079
  void drain();
23077
23080
  }
23078
23081
  function flushDeferredExecutionPendingResumeKick() {
23079
23082
  if (!executionResumeKickDeferred || getDisposed() || getState().busy) return;
23080
23083
  kickExecutionPendingResume();
23081
23084
  }
23082
- function scheduleExecutionPendingResumeKick() {
23083
- queueMicrotask(() => kickExecutionPendingResume());
23085
+ function scheduleExecutionPendingResumeKick(body = "") {
23086
+ queueMicrotask(() => kickExecutionPendingResume(body));
23084
23087
  }
23085
23088
  function updateAgentJobCard(itemId, text, isError = false) {
23086
23089
  const parsed = parseAgentJob(text);
@@ -23120,8 +23123,9 @@ function createAgentJobFeed({
23120
23123
  pushUserOrSyntheticItem(delivery.displayText, nextId2());
23121
23124
  }
23122
23125
  if (parsed?.taskId) set(agentStatusState({ force: true }));
23123
- if (String(delivery.modelContent || "").trim()) {
23124
- scheduleExecutionPendingResumeKick();
23126
+ const resumeBody = String(delivery.modelContent || "").trim();
23127
+ if (resumeBody) {
23128
+ scheduleExecutionPendingResumeKick(resumeBody);
23125
23129
  }
23126
23130
  return true;
23127
23131
  }
@@ -23994,6 +23998,7 @@ function createRunTurn(bag) {
23994
23998
  flags.activePromptRestore = null;
23995
23999
  if (flags.draining) flags.draining = false;
23996
24000
  if (pending.length > 0) void drain();
24001
+ flushDeferredExecutionPendingResumeKick();
23997
24002
  }, 5e3);
23998
24003
  watchdogGraceTimer.unref?.();
23999
24004
  }, LEAD_TURN_TIMEOUT_MS2);
@@ -24697,6 +24702,7 @@ function createRunTurn(bag) {
24697
24702
  if (isStaleUnwind) {
24698
24703
  if (closingItems.length) appendItemsBatch(closingItems);
24699
24704
  tuiDebug2(`runTurn STALE UNWIND turn=${turnIndex} \u2014 force-released; skipping shared-getState() writes`);
24705
+ flushDeferredExecutionPendingResumeKick();
24700
24706
  } else {
24701
24707
  if (!isNoOpTurn) {
24702
24708
  getState().stats.turns = (getState().stats.turns || 0) + 1;
@@ -56,7 +56,14 @@ export function createAgentJobFeed({
56
56
  }) {
57
57
  let executionResumeKickDeferred = false;
58
58
 
59
- function kickExecutionPendingResume() {
59
+ // FIFO accumulation of model-visible bodies from completions that arrived
60
+ // while busy (or while a pending-resume entry was already queued). A single
61
+ // string slot dropped all-but-the-last body when parallel completions landed;
62
+ // the queue preserves every body and merges them into the resume turn.
63
+ const executionResumeKickBodies = [];
64
+
65
+ function kickExecutionPendingResume(body = '') {
66
+ if (body) executionResumeKickBodies.push(body);
60
67
  if (getDisposed()) return;
61
68
  if (getState().busy) {
62
69
  executionResumeKickDeferred = true;
@@ -68,7 +75,10 @@ export function createAgentJobFeed({
68
75
  return;
69
76
  }
70
77
  executionResumeKickDeferred = false;
71
- pending.push(makeQueueEntry('', { mode: 'pending-resume', priority: 'next' }));
78
+ // Drain every accumulated body into ONE resume turn so no completion body
79
+ // is lost when several deferred while busy.
80
+ const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join('\n\n');
81
+ pending.push(makeQueueEntry(resumeBody, { mode: 'pending-resume', priority: 'next' }));
72
82
  void drain();
73
83
  }
74
84
 
@@ -77,10 +87,11 @@ export function createAgentJobFeed({
77
87
  kickExecutionPendingResume();
78
88
  }
79
89
 
80
- function scheduleExecutionPendingResumeKick() {
81
- // notifyFnForSession enqueues the model-visible body after onNotification
82
- // returns; defer the kick so askSession pre-drain sees session pending.
83
- queueMicrotask(() => kickExecutionPendingResume());
90
+ function scheduleExecutionPendingResumeKick(body = '') {
91
+ // Carry the model-visible body directly into the pending-resume entry so
92
+ // the resumed turn sends it, instead of relying on the session-pending
93
+ // completion marker (dropped by askSession pre-drain).
94
+ queueMicrotask(() => kickExecutionPendingResume(body));
84
95
  }
85
96
 
86
97
  function updateAgentJobCard(itemId, text, isError = false) {
@@ -122,8 +133,9 @@ export function createAgentJobFeed({
122
133
  pushUserOrSyntheticItem(delivery.displayText, nextId());
123
134
  }
124
135
  if (parsed?.taskId) set(agentStatusState({ force: true }));
125
- if (String(delivery.modelContent || '').trim()) {
126
- scheduleExecutionPendingResumeKick();
136
+ const resumeBody = String(delivery.modelContent || '').trim();
137
+ if (resumeBody) {
138
+ scheduleExecutionPendingResumeKick(resumeBody);
127
139
  }
128
140
  return true;
129
141
  }
@@ -84,6 +84,10 @@ export function createRunTurn(bag) {
84
84
  flags.activePromptRestore = null;
85
85
  if (flags.draining) flags.draining = false;
86
86
  if (pending.length > 0) void drain();
87
+ // busy→false here bypasses both the normal turn-end flush and the
88
+ // drain-finally flush, so a deferred completion kick would never re-arm.
89
+ // Fire it explicitly (idempotent: guarded by deferred flag + !busy).
90
+ flushDeferredExecutionPendingResumeKick();
87
91
  }, 5_000);
88
92
  watchdogGraceTimer.unref?.();
89
93
  }, LEAD_TURN_TIMEOUT_MS);
@@ -1048,6 +1052,10 @@ export function createRunTurn(bag) {
1048
1052
  // deferred cards (turn-local teardown) but writes no other shared getState().
1049
1053
  if (closingItems.length) appendItemsBatch(closingItems);
1050
1054
  tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
1055
+ // A stale unwind settles busy→false (via the watchdog release) without
1056
+ // the normal turn-end flush, so re-arm any deferred completion kick here
1057
+ // too (idempotent: no-ops unless a kick is deferred and nothing is busy).
1058
+ flushDeferredExecutionPendingResumeKick();
1051
1059
  } else {
1052
1060
  if (!isNoOpTurn) {
1053
1061
  getState().stats.turns = (getState().stats.turns || 0) + 1;