mixdog 0.9.51 → 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 (110) 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/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -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 };
@@ -46,6 +46,7 @@ export function createChannelDaemonTransport({
46
46
  onClientsEmpty = null,
47
47
  getStatus = () => ({}),
48
48
  dispatchBind = null,
49
+ registrationReplayTtlMs = 60_000,
49
50
  } = {}) {
50
51
  if (typeof handleCall !== 'function') throw new Error('handleCall is required');
51
52
 
@@ -72,6 +73,11 @@ export function createChannelDaemonTransport({
72
73
  // retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
73
74
  const callCache = new Map();
74
75
  const CALL_CACHE_TTL_MS = 60_000;
76
+ // Reconnect register replay: a server may commit replacement just before its
77
+ // HTTP response is lost. The retry supplies this stable id and receives the
78
+ // already-created fresh token instead of creating an orphan replacement.
79
+ const registrationReplays = new Map(); // registrationId -> { token, leadPid, cwd, replaceToken, responseFinished }
80
+ const registrationReplayTtl = Math.max(1, Number(registrationReplayTtlMs) || 60_000);
75
81
  let server = null;
76
82
  let graceTimer = null;
77
83
  let sweepTimer = null;
@@ -103,8 +109,7 @@ export function createChannelDaemonTransport({
103
109
  const c = clients.get(token);
104
110
  if (!c) return;
105
111
  const wasPointer = pointerToken === token;
106
- clients.delete(token);
107
- try { c.sse?.end?.(); } catch {}
112
+ removeClientRecord(token);
108
113
  if (pointerToken === token) pointerToken = null;
109
114
  log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
110
115
  // Pointer client death → hand the seat to a survivor (last-wins among the
@@ -115,6 +120,62 @@ export function createChannelDaemonTransport({
115
120
  maybeArmGrace('client removed');
116
121
  }
117
122
 
123
+ function removeRegistrationReplay(registrationId, replay = registrationReplays.get(registrationId)) {
124
+ if (!replay || registrationReplays.get(registrationId) !== replay) return;
125
+ registrationReplays.delete(registrationId);
126
+ try { clearTimeout(replay.timer); } catch {}
127
+ }
128
+
129
+ // Every removal path uses this primitive, including replacement's deliberate
130
+ // no-failover retirement, so replay records/timers never target absent tokens.
131
+ function removeClientRecord(token) {
132
+ const c = clients.get(token);
133
+ if (!c) return null;
134
+ clients.delete(token);
135
+ for (const [registrationId, replay] of registrationReplays) {
136
+ if (replay.token === token) removeRegistrationReplay(registrationId, replay);
137
+ }
138
+ try { c.sse?.end?.(); } catch {}
139
+ return c;
140
+ }
141
+
142
+ function clearRegistrationReplays() {
143
+ for (const [registrationId, replay] of registrationReplays) removeRegistrationReplay(registrationId, replay);
144
+ }
145
+
146
+ function armRegistrationReplay(replayId, replay) {
147
+ try { clearTimeout(replay.timer); } catch {}
148
+ replay.timer = setTimeout(() => {
149
+ if (registrationReplays.get(replayId) !== replay) return;
150
+ // A successfully flushed register response creates a valid client even
151
+ // if its SSE/call is delayed. TTL only bounds cancellation metadata.
152
+ removeRegistrationReplay(replayId, replay);
153
+ if (!replay.responseFinished && clients.has(replay.token)) {
154
+ dropClient(replay.token, 'unflushed registration replay expired');
155
+ }
156
+ }, registrationReplayTtl);
157
+ replay.timer.unref?.();
158
+ }
159
+
160
+ function markRegistrationResponseFinished(registrationId, token) {
161
+ const replay = registrationId ? registrationReplays.get(registrationId) : null;
162
+ if (replay && replay.token === token) replay.responseFinished = true;
163
+ }
164
+
165
+ // A response-loss close knows only its retired token + stable registration id.
166
+ // Bind cancellation to every logical-client field before retiring the fresh
167
+ // token; malformed/mismatched cancellation can never affect another client.
168
+ function cancelReplacementRegistration({ token, registrationId, replaceToken, leadPid, cwd }) {
169
+ const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
170
+ const replay = replayId ? registrationReplays.get(replayId) : null;
171
+ if (!replay) return 'missing';
172
+ const retiredToken = token ? String(token) : null;
173
+ if (retiredToken !== replay.replaceToken || String(replaceToken || '') !== replay.replaceToken ||
174
+ parsePid(leadPid) !== replay.leadPid || (cwd || null) !== replay.cwd) return 'forbidden';
175
+ dropClient(replay.token, 'replacement deregister');
176
+ return 'cancelled';
177
+ }
178
+
118
179
  // Pointer failover on owner death. Move the pointer to the most-recently-seen
119
180
  // LIVE client (reason 'failover'), deliver the sticky 'acquired' badge to it
120
181
  // (pending-buffered if it has no SSE yet), and re-dispatch ITS stored bind
@@ -304,8 +365,25 @@ export function createChannelDaemonTransport({
304
365
  }
305
366
  }
306
367
 
307
- function registerClient({ leadPid, cwd, reattach = false }) {
368
+ function registerClient({ leadPid, cwd, reattach = false, replaceToken = null, registrationId = null }) {
308
369
  const pid = parsePid(leadPid) ?? 0;
370
+ const replacementToken = replaceToken ? String(replaceToken) : null;
371
+ const replayId = reattach && registrationId ? String(registrationId).slice(0, 200) : null;
372
+ const existingReplay = replayId ? registrationReplays.get(replayId) : null;
373
+ if (existingReplay) {
374
+ if (existingReplay.leadPid === pid && existingReplay.cwd === (cwd || null) &&
375
+ existingReplay.replaceToken === replacementToken && clients.has(existingReplay.token)) {
376
+ armRegistrationReplay(replayId, existingReplay);
377
+ log(`client reconnect replay token=${existingReplay.token} lead=${pid}`);
378
+ return existingReplay.token;
379
+ }
380
+ if (clients.has(existingReplay.token)) {
381
+ const err = new Error('registration replay identity mismatch');
382
+ err.statusCode = 409;
383
+ throw err;
384
+ }
385
+ removeRegistrationReplay(replayId, existingReplay);
386
+ }
309
387
  const token = randomUUID();
310
388
  clients.set(token, {
311
389
  token,
@@ -320,6 +398,34 @@ export function createChannelDaemonTransport({
320
398
  cancelGrace();
321
399
  startSweep();
322
400
  log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
401
+ const rememberReplacement = (freshToken) => {
402
+ if (!replayId) return freshToken;
403
+ const replay = {
404
+ token: freshToken, leadPid: pid, cwd: cwd || null, replaceToken: replacementToken,
405
+ responseFinished: false, timer: null,
406
+ };
407
+ registrationReplays.set(replayId, replay);
408
+ armRegistrationReplay(replayId, replay);
409
+ return freshToken;
410
+ };
411
+ // A reconnect names the exact token it replaces. Retire it even when it is
412
+ // not the pointer: retaining a non-owner old token would let it later call,
413
+ // steal ownership, or accumulate a buffered frame after the fresh client
414
+ // has gone away. Token replacement never crosses leadPid boundaries.
415
+ const replaced = reattach && replacementToken ? clients.get(replacementToken) : null;
416
+ if (replaced && replaced.leadPid === pid) {
417
+ const fresh = clients.get(token);
418
+ const replacedWasPointer = pointerToken === replaced.token;
419
+ // Token-scoped state belongs to the logical client, not only the owner.
420
+ // A non-owner can hold a buffered superseded frame or stored bind intent.
421
+ if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
422
+ if (replaced.lastBind) fresh.lastBind = replaced.lastBind;
423
+ if (replacedWasPointer) pointerToken = token;
424
+ removeClientRecord(replaced.token);
425
+ log(`client reconnect replaced token=${replaced.token} -> ${token} lead=${pid}`);
426
+ if (replacedWasPointer) redispatchPointerBind(fresh, 'reconnect');
427
+ return rememberReplacement(token);
428
+ }
323
429
  // Pointer-follows-pid: if the current pointer client shares this leadPid AND
324
430
  // has no live SSE, it is the SAME terminal re-registering after its stream
325
431
  // dropped. That old entry is a dead-stream blackhole — pid-prune never reaps
@@ -335,15 +441,14 @@ export function createChannelDaemonTransport({
335
441
  const fresh = clients.get(token);
336
442
  if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
337
443
  if (old.lastBind) fresh.lastBind = old.lastBind;
338
- clients.delete(pointerToken);
339
- try { old.sse?.end?.(); } catch {}
444
+ removeClientRecord(pointerToken);
340
445
  pointerToken = token;
341
446
  log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
342
447
  // The fresh token inherited the old entry's bind intent but never went
343
448
  // through movePointer — re-dispatch it so the forwarder rebinds to this
344
449
  // reconnected terminal's transcript (guarded/no-op when no lastBind).
345
450
  redispatchPointerBind(fresh, 'reconnect');
346
- return token;
451
+ return rememberReplacement(token);
347
452
  }
348
453
  }
349
454
  if (reattach) {
@@ -356,12 +461,17 @@ export function createChannelDaemonTransport({
356
461
  // ownership seat, and the displaced owner is told it lost (superseded).
357
462
  movePointer(token, 'register');
358
463
  }
359
- return token;
464
+ return rememberReplacement(token);
360
465
  }
361
466
 
362
467
  function attachSse(token, res) {
363
468
  const c = clients.get(token);
364
469
  if (!c) return false;
470
+ // A live stream proves the client learned its fresh token, so response-loss
471
+ // cancellation is no longer needed for this logical registration.
472
+ for (const [registrationId, replay] of registrationReplays) {
473
+ if (replay.token === token) removeRegistrationReplay(registrationId, replay);
474
+ }
365
475
  res.writeHead(200, {
366
476
  'Content-Type': 'text/event-stream; charset=utf-8',
367
477
  'Cache-Control': 'no-cache, no-transform',
@@ -417,12 +527,23 @@ export function createChannelDaemonTransport({
417
527
 
418
528
  if (req.method === 'POST' && pathName === '/client/register') {
419
529
  const body = await readBody(req);
420
- const clientToken = registerClient({ leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true });
530
+ const clientToken = registerClient({
531
+ leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true,
532
+ replaceToken: body.replaceToken, registrationId: body.registrationId,
533
+ });
534
+ const replayId = body.reattach === true && body.registrationId ? String(body.registrationId).slice(0, 200) : null;
535
+ res.once('finish', () => markRegistrationResponseFinished(replayId, clientToken));
421
536
  sendJson(res, { token: clientToken, pid: process.pid });
422
537
  return;
423
538
  }
424
539
  if (req.method === 'POST' && pathName === '/client/deregister') {
425
540
  const body = await readBody(req);
541
+ if (body.registrationId) {
542
+ const cancelled = cancelReplacementRegistration(body);
543
+ if (cancelled === 'forbidden') { sendError(res, 'forbidden replacement deregister', 403); return; }
544
+ sendJson(res, { ok: true, cancelled: cancelled === 'cancelled' });
545
+ return;
546
+ }
426
547
  if (body.token) dropClient(body.token, 'deregister');
427
548
  sendJson(res, { ok: true });
428
549
  return;
@@ -436,7 +557,11 @@ export function createChannelDaemonTransport({
436
557
  const body = await readBody(req);
437
558
  const clientToken = body.token || null;
438
559
  const c = clientToken ? clients.get(clientToken) : null;
439
- if (c) c.lastSeen = nowMs();
560
+ if (!c) { sendError(res, 'unknown client token', 404); return; }
561
+ for (const [registrationId, replay] of registrationReplays) {
562
+ if (replay.token === clientToken) removeRegistrationReplay(registrationId, replay);
563
+ }
564
+ c.lastSeen = nowMs();
440
565
  const name = String(body.name || '');
441
566
  // Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
442
567
  // notify emitted synchronously during the call already targets it.
@@ -526,6 +651,7 @@ export function createChannelDaemonTransport({
526
651
  cancelGrace();
527
652
  if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
528
653
  for (const [token] of clients) dropClient(token, 'transport stop');
654
+ clearRegistrationReplays();
529
655
  if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
530
656
  if (server) {
531
657
  await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
@@ -540,6 +666,7 @@ export function createChannelDaemonTransport({
540
666
  get port() { return boundPort; },
541
667
  get token() { return serverToken; },
542
668
  _clientsForTest: clients,
669
+ _registrationReplaysForTest: registrationReplays,
543
670
  _resolveTargetForTest: resolveTarget,
544
671
  };
545
672
  }