mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -15,6 +15,7 @@ import { presentErrorText, errorLine } from '../runtime/shared/err-text.mjs';
15
15
  import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
16
16
  import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
17
17
  import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
18
+ import { resourceAdmission } from '../runtime/shared/resource-admission.mjs';
18
19
  import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
19
20
  import {
20
21
  abortAgentProgressWatchdog,
@@ -76,20 +77,6 @@ const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)),
76
77
  // cap and restore strictly-unbounded prep.
77
78
  const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
78
79
 
79
- // Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
80
- // first provider calls in the same instant, racing the server-side prompt-
81
- // cache write/propagation window. Default 0 (off): mirrors the explore fan-out
82
- // finding — the first spawn's prompt-cache write only lands after its iter1
83
- // completes (~seconds), so a sub-second stagger yields ~no cross-spawn cache
84
- // reads while charging every later spawn the full delay, i.e. pure fan-out
85
- // latency for negligible hit-rate gain. Kept as a knob for tuning: set
86
- // MIXDOG_SPAWN_STAGGER_MS>0 to re-enable. When >0 it chains (not a fixed lane
87
- // count) so it scales to any N: each new spawn's start is pushed to at least
88
- // STAGGER_MS after the previous spawn's start; sequential/non-overlapping
89
- // spawns pay zero added latency. Applied inside the deferred job body (see
90
- // startDeferredSpawnJob) so the agent tool call itself still returns task_id
91
- // immediately.
92
- const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
93
80
  const TAG_TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
94
81
  const MAX_TAG_TOMBSTONES = 500;
95
82
 
@@ -156,20 +143,6 @@ export function resolveAgentSpawnPreset(config, args = {}) {
156
143
  if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
157
144
  return { presetName, preset };
158
145
  }
159
- let lastSpawnStartAt = 0;
160
- async function waitForSpawnStagger() {
161
- if (SPAWN_STAGGER_MS <= 0) return;
162
- const now = Date.now();
163
- const myStart = Math.max(now, lastSpawnStartAt + SPAWN_STAGGER_MS);
164
- lastSpawnStartAt = myStart;
165
- const delay = myStart - now;
166
- if (delay <= 0) return;
167
- await new Promise((resolve) => {
168
- const t = setTimeout(resolve, delay);
169
- t.unref?.();
170
- });
171
- }
172
-
173
146
  export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
174
147
  // Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
175
148
  // Best-effort: a hook error must never affect worker spawn/finish.
@@ -1042,6 +1015,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1042
1015
  ...(clientHostPid ? { clientHostPid } : {}),
1043
1016
  });
1044
1017
  let task;
1018
+ const admissionController = new AbortController();
1045
1019
  task = startBackgroundTask({
1046
1020
  surface: 'agent',
1047
1021
  operation: type,
@@ -1052,18 +1026,27 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1052
1026
  resultType: 'agent_task_result',
1053
1027
  renderResult: (result) => renderResult(result),
1054
1028
  cancel: () => {
1029
+ try { admissionController.abort(new Error('agent task cancelled before resource admission')); } catch {}
1055
1030
  const currentMeta = task?.meta || jobMeta;
1056
1031
  if (currentMeta?.sessionId) {
1057
1032
  try { mgr.closeSession(currentMeta.sessionId, 'agent-task-cancel'); } catch {}
1058
1033
  }
1059
1034
  },
1060
1035
  run: async () => {
1061
- // Yield one macrotask before doing agent work. startBackgroundTask uses
1062
- // a Promise microtask, which otherwise begins CPU-heavy spawn prep
1063
- // before the TUI receives/render the "running" result.
1064
- await new Promise((resolve) => setImmediate(resolve));
1065
- if (task?.status === 'cancelled') return null;
1066
- return await run(task);
1036
+ const lease = await resourceAdmission.acquire('agent', {
1037
+ signal: admissionController.signal,
1038
+ label: jobMeta?.tag || type,
1039
+ });
1040
+ try {
1041
+ // Yield one macrotask before doing agent work. startBackgroundTask uses
1042
+ // a Promise microtask, which otherwise begins CPU-heavy spawn prep
1043
+ // before the TUI receives/render the "running" result.
1044
+ await new Promise((resolve) => setImmediate(resolve));
1045
+ if (task?.status === 'cancelled') return null;
1046
+ return await resourceAdmission.runWithLease(lease, () => run(task));
1047
+ } finally {
1048
+ await lease.release();
1049
+ }
1067
1050
  },
1068
1051
  });
1069
1052
  return task;
@@ -1071,10 +1054,6 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1071
1054
 
1072
1055
  function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
1073
1056
  return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
1074
- // Stagger concurrent spawn starts before prep/model-call begins (see
1075
- // SPAWN_STAGGER_MS above). Runs inside the job body, so the tool call
1076
- // that queued this job already returned task_id before this awaits.
1077
- await waitForSpawnStagger();
1078
1057
  if (job?.status === 'cancelled') return null;
1079
1058
  // prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
1080
1059
  // installs its progress watchdog, so guard prep with an internal env-
@@ -6,6 +6,7 @@
6
6
  // worker->parent notify path arrives on a persistent SSE stream (GET /events),
7
7
  // replacing the old node-IPC `{type:'notify'}` messages.
8
8
  import http from 'node:http';
9
+ import { randomUUID } from 'node:crypto';
9
10
  import { readFileSync } from 'node:fs';
10
11
 
11
12
  function parsePid(value) {
@@ -41,7 +42,29 @@ export function readDaemonDiscovery(discoveryPath) {
41
42
  function request({ port, method = 'GET', path = '/', token, body = null, timeoutMs = 10_000 }) {
42
43
  return new Promise((resolve, reject) => {
43
44
  const payload = body == null ? null : JSON.stringify(body);
44
- const req = http.request({
45
+ let req = null;
46
+ let response = null;
47
+ let ended = false;
48
+ let settled = false;
49
+ const finish = (fn, value) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ clearTimeout(deadline);
53
+ fn(value);
54
+ };
55
+ const fail = (error) => finish(reject, error instanceof Error ? error : new Error(String(error)));
56
+ const lifecycleTimeout = () => {
57
+ const error = new Error(`daemon request timed out: ${method} ${path}`);
58
+ fail(error);
59
+ try { response?.destroy?.(error); } catch {}
60
+ try { req?.destroy?.(error); } catch {}
61
+ };
62
+ // http.request's timeout is socket-idle only. This deadline covers headers
63
+ // and the entire response body so a truncated post-header response cannot
64
+ // leave reconnect registration pending forever.
65
+ const deadline = setTimeout(lifecycleTimeout, timeoutMs);
66
+ deadline.unref?.();
67
+ req = http.request({
45
68
  hostname: '127.0.0.1',
46
69
  port,
47
70
  path,
@@ -54,23 +77,31 @@ function request({ port, method = 'GET', path = '/', token, body = null, timeout
54
77
  },
55
78
  timeout: timeoutMs,
56
79
  }, (res) => {
80
+ response = res;
81
+ if (settled) { try { res.resume?.(); } catch {} return; }
57
82
  let data = '';
58
83
  res.setEncoding('utf8');
59
- res.on('data', (chunk) => { data += chunk; });
60
- res.on('end', () => {
84
+ res.on('data', (chunk) => { if (!settled) data += chunk; });
85
+ res.once('aborted', () => fail(new Error(`daemon response aborted: ${method} ${path}`)));
86
+ res.once('error', (error) => fail(error));
87
+ res.once('end', () => {
88
+ ended = true;
61
89
  let parsed = null;
62
90
  try { parsed = data ? JSON.parse(data) : null; } catch {}
63
91
  if (res.statusCode && res.statusCode >= 400) {
64
92
  const err = new Error(parsed?.error || data || `HTTP ${res.statusCode}`);
65
93
  err.statusCode = res.statusCode;
66
- reject(err);
94
+ fail(err);
67
95
  return;
68
96
  }
69
- resolve(parsed ?? {});
97
+ finish(resolve, parsed ?? {});
98
+ });
99
+ res.once('close', () => {
100
+ if (!ended && !settled) fail(new Error(`daemon response closed before end: ${method} ${path}`));
70
101
  });
71
102
  });
72
- req.on('error', reject);
73
- req.on('timeout', () => { req.destroy(); reject(new Error(`daemon request timed out: ${method} ${path}`)); });
103
+ req.on('error', fail);
104
+ req.on('timeout', lifecycleTimeout);
74
105
  if (payload) req.write(payload);
75
106
  req.end();
76
107
  });
@@ -96,29 +127,104 @@ export async function attachToDaemon({
96
127
  log = () => {},
97
128
  onFatal = () => {},
98
129
  } = {}) {
99
- if (!discovery?.port || !discovery?.token) throw new Error('daemon discovery {port, token} required');
130
+ const expectedPid = parsePid(discovery?.pid);
131
+ if (!discovery?.port || !discovery?.token || !expectedPid) throw new Error('daemon discovery {port, pid, token} required');
100
132
  const { port, token: serverToken } = discovery;
133
+ const staleDiscoveryError = (reason) => {
134
+ const err = new Error(reason);
135
+ err.daemonDiscoveryStale = true;
136
+ return err;
137
+ };
138
+ const isExpectedDaemon = (health) => Number(health?.pid) === expectedPid;
101
139
 
102
- const reg = await request({
103
- port,
104
- token: serverToken,
105
- method: 'POST',
106
- path: '/client/register',
107
- body: { leadPid, cwd },
108
- timeoutMs: 3000,
109
- });
140
+ const initialHealth = await probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 });
141
+ if (!isExpectedDaemon(initialHealth)) throw staleDiscoveryError('daemon discovery pid does not match health');
142
+
143
+ let reg;
144
+ try {
145
+ reg = await request({
146
+ port,
147
+ token: serverToken,
148
+ method: 'POST',
149
+ path: '/client/register',
150
+ body: { leadPid, cwd },
151
+ timeoutMs: 3000,
152
+ });
153
+ } catch (err) {
154
+ if (err?.statusCode === 401 || err?.statusCode === 403) {
155
+ const stale = staleDiscoveryError(`daemon register rejected (${err.statusCode})`);
156
+ stale.daemonAuthRejected = true;
157
+ throw stale;
158
+ }
159
+ throw err;
160
+ }
110
161
  let clientToken = reg?.token;
111
162
  if (!clientToken) throw new Error('daemon register returned no client token');
112
163
 
113
164
  let sseReq = null;
114
165
  let closed = false;
166
+ let fatal = false;
115
167
  let reconnectTimer = null;
116
- // Bounded SSE reconnect: after repeated failures (daemon dead/restarted) give
117
- // up and signal the owner via onFatal so it can invalidate the cached attach
118
- // and re-attach fresh (re-read discovery, respawn-if-dead) rather than spin
119
- // forever against a dead port.
168
+ let reconnectProbe = false;
169
+ let reconnectRegistration = null;
170
+ let reconnectRegistrationId = null;
171
+ let reconnectReplaceToken = null;
172
+ let lifecycle = 0;
173
+ let stableTimer = null;
174
+ let closePromise = null;
175
+ // Bounded reconnect is only for a verified-live daemon's transient SSE loss.
176
+ // A stale/dead endpoint signals onFatal immediately so the owner re-reads
177
+ // discovery instead of spinning against the captured port.
120
178
  let reconnectAttempts = 0;
121
179
  const MAX_RECONNECTS = 5;
180
+ const STABLE_STREAM_MS = 5_000;
181
+
182
+ async function deregister(token, { registrationId = null, replaceToken = null } = {}) {
183
+ if (!token) return;
184
+ try {
185
+ await request({
186
+ port, token: serverToken, method: 'POST', path: '/client/deregister',
187
+ body: {
188
+ token,
189
+ ...(registrationId ? { registrationId, replaceToken, leadPid, cwd } : {}),
190
+ },
191
+ timeoutMs: 1500,
192
+ });
193
+ } catch { /* best-effort; daemon sweep reaps us */ }
194
+ }
195
+
196
+ function clearStableTimer() {
197
+ if (stableTimer) { try { clearTimeout(stableTimer); } catch {} stableTimer = null; }
198
+ }
199
+
200
+ function signalFatal(reason) {
201
+ if (closed || fatal) return;
202
+ fatal = true;
203
+ closed = true;
204
+ lifecycle++;
205
+ if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
206
+ clearStableTimer();
207
+ try { sseReq?.destroy?.(); } catch {}
208
+ log(`sse stale endpoint (${reason}); signalling re-attach`);
209
+ try { onFatal(reason); } catch {}
210
+ }
211
+
212
+ // A stream ending can be a transient connection loss, but must not leave this
213
+ // client retrying a dead discovery endpoint. Verify that the original daemon
214
+ // is still alive before spending the bounded reconnect budget on it.
215
+ function handleStreamLoss(reason) {
216
+ if (closed || fatal || reconnectTimer || reconnectProbe) return;
217
+ reconnectProbe = true;
218
+ void probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 }).then((health) => {
219
+ reconnectProbe = false;
220
+ if (closed || fatal) return;
221
+ if (!isExpectedDaemon(health)) {
222
+ signalFatal(reason);
223
+ return;
224
+ }
225
+ scheduleReconnect(reason);
226
+ });
227
+ }
122
228
 
123
229
  function openStream() {
124
230
  if (closed) return;
@@ -129,15 +235,29 @@ export async function attachToDaemon({
129
235
  method: 'GET',
130
236
  headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
131
237
  }, (res) => {
238
+ if (req !== sseReq || closed) { res.resume(); return; }
132
239
  if (res.statusCode !== 200) {
133
240
  res.resume();
134
- scheduleReconnect('bad sse status ' + res.statusCode);
241
+ // A token rejection means this port now belongs to a different daemon;
242
+ // re-read discovery now rather than re-registering against it.
243
+ if (res.statusCode === 401 || res.statusCode === 403) {
244
+ signalFatal('bad sse status ' + res.statusCode);
245
+ } else {
246
+ handleStreamLoss('bad sse status ' + res.statusCode);
247
+ }
135
248
  return;
136
249
  }
137
250
  res.setEncoding('utf8');
138
- reconnectAttempts = 0; // a live stream resets the failure budget
251
+ // A bare 200 followed by an immediate end is not a stable stream. Only
252
+ // reset the bounded reconnect budget after this exact stream stays live.
253
+ clearStableTimer();
254
+ stableTimer = setTimeout(() => {
255
+ if (!closed && !fatal && req === sseReq) reconnectAttempts = 0;
256
+ }, STABLE_STREAM_MS);
257
+ stableTimer.unref?.();
139
258
  let buf = '';
140
259
  res.on('data', (chunk) => {
260
+ if (req !== sseReq || closed) return;
141
261
  buf += chunk;
142
262
  let idx;
143
263
  while ((idx = buf.indexOf('\n\n')) >= 0) {
@@ -153,35 +273,67 @@ export async function attachToDaemon({
153
273
  }
154
274
  }
155
275
  });
156
- res.on('end', () => scheduleReconnect('sse ended'));
157
- res.on('error', () => scheduleReconnect('sse error'));
276
+ res.on('end', () => {
277
+ if (req === sseReq) {
278
+ clearStableTimer();
279
+ handleStreamLoss('sse ended');
280
+ }
281
+ });
282
+ res.on('error', () => {
283
+ if (req === sseReq) {
284
+ clearStableTimer();
285
+ handleStreamLoss('sse error');
286
+ }
287
+ });
288
+ });
289
+ req.on('error', () => {
290
+ if (req === sseReq) signalFatal('sse req error');
158
291
  });
159
- req.on('error', () => scheduleReconnect('sse req error'));
160
- req.end();
161
292
  sseReq = req;
293
+ req.end();
162
294
  }
163
295
 
164
296
  function scheduleReconnect(reason) {
165
297
  if (closed || reconnectTimer) return;
166
298
  if (++reconnectAttempts > MAX_RECONNECTS) {
167
- log(`sse giving up after ${reconnectAttempts} attempts (${reason}); signalling re-attach`);
168
- closed = true;
169
- try { sseReq?.destroy?.(); } catch {}
170
- try { onFatal(reason); } catch {}
299
+ signalFatal(`giving up after ${reconnectAttempts} attempts (${reason})`);
171
300
  return;
172
301
  }
173
302
  log(`sse reconnect scheduled (${reason}, attempt ${reconnectAttempts})`);
174
303
  reconnectTimer = setTimeout(() => {
175
304
  reconnectTimer = null;
305
+ if (closed || fatal) return;
176
306
  // Re-register (the daemon may have pruned us) then reopen the stream.
177
307
  // Re-register mints a fresh client token (the old one was pruned); adopt
178
308
  // it so the reopened stream and subsequent calls target the live entry.
179
309
  // reattach:true — a reconnect is NOT a new terminal, so it must NOT steal
180
310
  // the ownership seat (last-wins is reserved for genuine fresh registers).
181
- request({ port, token: serverToken, method: 'POST', path: '/client/register', body: { leadPid, cwd, reattach: true }, timeoutMs: 3000 })
182
- .then((r) => { if (r?.token) clientToken = r.token; })
183
- .catch(() => {})
184
- .finally(() => openStream());
311
+ const generation = lifecycle;
312
+ const registrationId = reconnectRegistrationId ||= randomUUID();
313
+ const replaceToken = reconnectReplaceToken ||= clientToken;
314
+ const registration = request({
315
+ port, token: serverToken, method: 'POST', path: '/client/register',
316
+ body: {
317
+ leadPid, cwd, reattach: true, replaceToken,
318
+ registrationId,
319
+ }, timeoutMs: 3000,
320
+ }).then(async (r) => {
321
+ const freshToken = r?.token;
322
+ if (!freshToken) return false;
323
+ if (closed || fatal || generation !== lifecycle) {
324
+ await deregister(freshToken);
325
+ return false;
326
+ }
327
+ clientToken = freshToken;
328
+ reconnectRegistrationId = null;
329
+ reconnectReplaceToken = null;
330
+ return true;
331
+ }).catch(() => false);
332
+ reconnectRegistration = registration;
333
+ void registration.finally(() => {
334
+ if (reconnectRegistration === registration) reconnectRegistration = null;
335
+ if (!closed && !fatal && generation === lifecycle) openStream();
336
+ });
185
337
  }, 1000);
186
338
  reconnectTimer.unref?.();
187
339
  }
@@ -213,13 +365,23 @@ export async function attachToDaemon({
213
365
  }
214
366
 
215
367
  async function close(reason = 'client close') {
368
+ if (closePromise) return closePromise;
216
369
  closed = true;
370
+ lifecycle++;
217
371
  if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
372
+ clearStableTimer();
218
373
  try { sseReq?.destroy?.(); } catch {}
219
- try {
220
- await request({ port, token: serverToken, method: 'POST', path: '/client/deregister', body: { token: clientToken }, timeoutMs: 1500 });
221
- } catch { /* best-effort; daemon sweep reaps us */ }
222
- log(`detached (${reason})`);
374
+ // A re-register already in flight can mint a fresh token after close().
375
+ // Await it so its stale branch deregisters that token before close resolves.
376
+ const pendingRegistration = reconnectRegistration;
377
+ const registrationId = reconnectRegistrationId;
378
+ const replaceToken = reconnectReplaceToken;
379
+ closePromise = (async () => {
380
+ if (pendingRegistration) await pendingRegistration;
381
+ await deregister(clientToken, { registrationId, replaceToken });
382
+ log(`detached (${reason})`);
383
+ })();
384
+ return closePromise;
223
385
  }
224
386
 
225
387
  return { call, close, clientToken, port };