@agentchatme/agent-core 0.0.131 → 0.0.1312

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.
@@ -47,8 +47,12 @@ declare class AgentWsClient extends EventEmitter {
47
47
  private attempt;
48
48
  private reconnectTimer;
49
49
  private livenessTimer;
50
+ private ackRetryTimer;
50
51
  private stopped;
51
52
  private ackMode;
53
+ private inboundPaused;
54
+ private readonly pendingAcks;
55
+ private readonly acksInFlight;
52
56
  constructor(url: string, apiKey: string);
53
57
  /** True only while the socket is live and ready. The heartbeat writer keys
54
58
  * off this, so a reconnecting/terminal daemon lets its heartbeat go stale
@@ -56,6 +60,14 @@ declare class AgentWsClient extends EventEmitter {
56
60
  get connected(): boolean;
57
61
  start(): void;
58
62
  stop(): void;
63
+ /**
64
+ * Apply TCP backpressure while the model-turn queue is saturated. `ws`
65
+ * delegates this to the underlying socket; no delivered frame is discarded.
66
+ * A server heartbeat may close a very long pause, which is safe because all
67
+ * unacked messages re-drain after reconnect.
68
+ */
69
+ pauseInbound(): void;
70
+ resumeInbound(): void;
59
71
  getState(): State;
60
72
  /**
61
73
  * Confirm a message as handled: `{"type":"ack","message_id":"msg_..."}`.
@@ -65,6 +77,8 @@ declare class AgentWsClient extends EventEmitter {
65
77
  * real-time push — which carries no delivery_id — be acked at all.
66
78
  */
67
79
  ack(messageId: string): void;
80
+ private flushAcks;
81
+ private scheduleAckRetry;
68
82
  private open;
69
83
  private scheduleReconnect;
70
84
  private armLiveness;
@@ -94,6 +108,8 @@ declare class ReplyCoord {
94
108
  }
95
109
 
96
110
  interface TurnContext {
111
+ /** Trusted server message id that caused this autonomous turn. */
112
+ messageId?: string | undefined;
97
113
  /** The AgentChat conversation the message belongs to. */
98
114
  conversationId: string;
99
115
  /** @handle of the sender. */
@@ -127,6 +143,13 @@ interface TurnResult {
127
143
  }
128
144
  interface RuntimeAdapter {
129
145
  readonly name: string;
146
+ /**
147
+ * Reset conversation continuity when the authenticated AgentChat identity
148
+ * changes. `identityNamespace` contains no credential material; callers use
149
+ * the authenticated API base + handle. Adapters that persist host sessions
150
+ * must include it in their session key, not merely clear an in-memory map.
151
+ */
152
+ reset?(identityNamespace: string): void;
130
153
  /** Verify the runtime is usable (binary present, logged in). */
131
154
  preflight(): Promise<{
132
155
  ok: boolean;
@@ -186,6 +209,10 @@ interface ResolveDaemonOpts {
186
209
  */
187
210
  declare function resolveDaemonConfig(opts: ResolveDaemonOpts): Promise<DaemonConfig>;
188
211
 
212
+ interface DaemonFailure {
213
+ kind: 'socket-auth' | 'runtime';
214
+ reason: string;
215
+ }
189
216
  declare class Daemon {
190
217
  private readonly cfg;
191
218
  private readonly adapter;
@@ -195,7 +222,9 @@ declare class Daemon {
195
222
  private readonly ws;
196
223
  private readonly coord;
197
224
  private readonly seen;
198
- private readonly convChains;
225
+ private readonly convQueues;
226
+ private readonly convWorkers;
227
+ private pending;
199
228
  private inFlight;
200
229
  private readonly waiters;
201
230
  private stopping;
@@ -203,13 +232,19 @@ declare class Daemon {
203
232
  constructor(cfg: DaemonConfig, adapter: RuntimeAdapter, ws?: AgentWsClient, // injectable for tests; defaults to a real socket
204
233
  /** Called when the socket gives up for good (auth refused). The supervisor
205
234
  * above decides what happens next — this class does not end the process. */
206
- onTerminal?: ((reason: string) => void) | undefined);
235
+ onTerminal?: ((failure: DaemonFailure) => void) | undefined);
207
236
  start(): Promise<void>;
208
237
  stop(): void;
209
238
  private onInbound;
210
- /** Serialize turns within a conversation; the global semaphore caps total. */
211
- private enqueue;
239
+ /** Queue one already-tracked row and ensure exactly one worker for its conversation. */
240
+ private enqueueExisting;
241
+ /** Process each message independently, in arrival order within a conversation. */
242
+ private drainConversation;
212
243
  private handle;
244
+ private markHandled;
245
+ private markNoLongerPending;
246
+ /** Bound reconnect-dedup memory without ever evicting unfinished work. */
247
+ private pruneSeen;
213
248
  private acquireSlot;
214
249
  private releaseSlot;
215
250
  }
@@ -1,5 +1,7 @@
1
1
  import {
2
+ CODING_AGENTS_CLIENT_HEADERS,
2
3
  acquireLeaderLock,
4
+ atomicWriteFile,
3
5
  beat,
4
6
  credentialsPath,
5
7
  external_exports,
@@ -7,7 +9,7 @@ import {
7
9
  idle,
8
10
  log,
9
11
  resolveIdentity
10
- } from "./chunk-XLRPNQ7G.js";
12
+ } from "./chunk-AGDJ4A6R.js";
11
13
 
12
14
  // src/daemon/ws-client.ts
13
15
  import { WebSocket } from "ws";
@@ -49,6 +51,7 @@ function contextOf(row) {
49
51
  // src/daemon/ws-client.ts
50
52
  var BASE_BACKOFF_MS = 1e3;
51
53
  var MAX_BACKOFF_MS = 6e4;
54
+ var ACK_RETRY_MS = 1e3;
52
55
  var LIVENESS_MS = 1e5;
53
56
  var AgentWsClient = class extends EventEmitter {
54
57
  constructor(url, apiKey) {
@@ -63,8 +66,12 @@ var AgentWsClient = class extends EventEmitter {
63
66
  attempt = 0;
64
67
  reconnectTimer = null;
65
68
  livenessTimer = null;
69
+ ackRetryTimer = null;
66
70
  stopped = false;
67
71
  ackMode = false;
72
+ inboundPaused = false;
73
+ pendingAcks = /* @__PURE__ */ new Set();
74
+ acksInFlight = /* @__PURE__ */ new Set();
68
75
  /** True only while the socket is live and ready. The heartbeat writer keys
69
76
  * off this, so a reconnecting/terminal daemon lets its heartbeat go stale
70
77
  * and the next session detects that always-on is actually down. */
@@ -79,6 +86,7 @@ var AgentWsClient = class extends EventEmitter {
79
86
  this.stopped = true;
80
87
  this.state = "closed";
81
88
  this.clearTimers();
89
+ this.acksInFlight.clear();
82
90
  if (this.ws) {
83
91
  try {
84
92
  this.ws.close(1e3, "daemon shutdown");
@@ -87,6 +95,29 @@ var AgentWsClient = class extends EventEmitter {
87
95
  this.ws = null;
88
96
  }
89
97
  }
98
+ /**
99
+ * Apply TCP backpressure while the model-turn queue is saturated. `ws`
100
+ * delegates this to the underlying socket; no delivered frame is discarded.
101
+ * A server heartbeat may close a very long pause, which is safe because all
102
+ * unacked messages re-drain after reconnect.
103
+ */
104
+ pauseInbound() {
105
+ if (this.inboundPaused) return;
106
+ this.inboundPaused = true;
107
+ try {
108
+ this.ws?.pause();
109
+ } catch {
110
+ }
111
+ }
112
+ resumeInbound() {
113
+ if (!this.inboundPaused) return;
114
+ this.inboundPaused = false;
115
+ try {
116
+ this.ws?.resume();
117
+ if (this.state === "ready") this.armLiveness();
118
+ } catch {
119
+ }
120
+ }
90
121
  getState() {
91
122
  return this.state;
92
123
  }
@@ -98,19 +129,48 @@ var AgentWsClient = class extends EventEmitter {
98
129
  * real-time push — which carries no delivery_id — be acked at all.
99
130
  */
100
131
  ack(messageId) {
132
+ this.pendingAcks.add(messageId);
133
+ this.flushAcks();
134
+ }
135
+ flushAcks() {
101
136
  if (this.state !== "ready" || !this.ws) return;
102
- try {
103
- this.ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
104
- } catch (err) {
105
- log.debug(`ack send failed for ${messageId} (will re-drain): ${String(err)}`);
137
+ for (const messageId of this.pendingAcks) {
138
+ if (this.acksInFlight.has(messageId)) continue;
139
+ this.acksInFlight.add(messageId);
140
+ try {
141
+ this.ws.send(
142
+ JSON.stringify({ type: "ack", message_id: messageId }),
143
+ (err) => {
144
+ this.acksInFlight.delete(messageId);
145
+ if (!err) this.pendingAcks.delete(messageId);
146
+ else {
147
+ log.debug(`ack send failed for ${messageId} (will retry): ${String(err)}`);
148
+ this.scheduleAckRetry();
149
+ }
150
+ }
151
+ );
152
+ } catch (err) {
153
+ this.acksInFlight.delete(messageId);
154
+ log.debug(`ack send failed for ${messageId} (will retry): ${String(err)}`);
155
+ this.scheduleAckRetry();
156
+ }
106
157
  }
107
158
  }
159
+ scheduleAckRetry() {
160
+ if (this.stopped || this.ackRetryTimer) return;
161
+ this.ackRetryTimer = setTimeout(() => {
162
+ this.ackRetryTimer = null;
163
+ this.flushAcks();
164
+ }, ACK_RETRY_MS);
165
+ this.ackRetryTimer.unref();
166
+ }
108
167
  open() {
109
168
  if (this.stopped) return;
110
169
  this.state = this.attempt === 0 ? "connecting" : "reconnecting";
111
170
  log.info(`ws ${this.state} (attempt ${this.attempt + 1}) \u2192 ${this.url}`);
112
171
  const ws = new WebSocket(this.url, {
113
172
  headers: {
173
+ ...CODING_AGENTS_CLIENT_HEADERS,
114
174
  authorization: `Bearer ${this.apiKey}`,
115
175
  // Opt into the delivery-ack protocol: the server then leaves each
116
176
  // delivery 'stored' until we ack it (by message id) instead of
@@ -124,8 +184,10 @@ var AgentWsClient = class extends EventEmitter {
124
184
  this.attempt = 0;
125
185
  this.state = "ready";
126
186
  this.armLiveness();
187
+ if (this.inboundPaused) ws.pause();
127
188
  log.info("ws ready \u2014 draining + listening");
128
189
  this.emit("ready");
190
+ this.flushAcks();
129
191
  });
130
192
  ws.on("message", (data) => {
131
193
  this.armLiveness();
@@ -150,6 +212,7 @@ var AgentWsClient = class extends EventEmitter {
150
212
  });
151
213
  ws.on("ping", () => this.armLiveness());
152
214
  ws.on("unexpected-response", (_req, res) => {
215
+ if (this.stopped) return;
153
216
  if (res.statusCode === 401 || res.statusCode === 403) {
154
217
  this.state = "terminal";
155
218
  this.clearTimers();
@@ -165,18 +228,23 @@ var AgentWsClient = class extends EventEmitter {
165
228
  });
166
229
  ws.on("close", (code) => {
167
230
  if (this.state === "terminal" || this.stopped) return;
231
+ this.acksInFlight.clear();
168
232
  log.warn(`ws closed (${code}) \u2014 scheduling reconnect`);
169
233
  this.scheduleReconnect();
170
234
  });
171
235
  }
172
236
  scheduleReconnect() {
173
237
  if (this.stopped || this.state === "terminal") return;
238
+ if (this.reconnectTimer) return;
174
239
  this.state = "reconnecting";
175
240
  this.clearTimers();
176
241
  const backoff = Math.min(BASE_BACKOFF_MS * 2 ** this.attempt, MAX_BACKOFF_MS);
177
242
  const jitter = backoff * (0.5 + Math.random() * 0.5);
178
243
  this.attempt++;
179
- this.reconnectTimer = setTimeout(() => this.open(), jitter);
244
+ this.reconnectTimer = setTimeout(() => {
245
+ this.reconnectTimer = null;
246
+ this.open();
247
+ }, jitter);
180
248
  }
181
249
  armLiveness() {
182
250
  if (this.livenessTimer) clearTimeout(this.livenessTimer);
@@ -198,6 +266,10 @@ var AgentWsClient = class extends EventEmitter {
198
266
  clearTimeout(this.livenessTimer);
199
267
  this.livenessTimer = null;
200
268
  }
269
+ if (this.ackRetryTimer) {
270
+ clearTimeout(this.ackRetryTimer);
271
+ this.ackRetryTimer = null;
272
+ }
201
273
  }
202
274
  };
203
275
 
@@ -212,6 +284,7 @@ var ReplyCoord = class {
212
284
  const res = await fetch(url, {
213
285
  method,
214
286
  headers: {
287
+ ...CODING_AGENTS_CLIENT_HEADERS,
215
288
  authorization: `Bearer ${this.cfg.apiKey}`,
216
289
  ...body !== void 0 ? { "content-type": "application/json" } : {}
217
290
  },
@@ -263,8 +336,8 @@ function describeSender(ctx) {
263
336
  }
264
337
 
265
338
  // src/daemon/run.ts
266
- import * as path2 from "path";
267
- import * as fs from "fs";
339
+ import * as path3 from "path";
340
+ import * as fs2 from "fs";
268
341
 
269
342
  // src/daemon/config.ts
270
343
  import * as path from "path";
@@ -298,12 +371,49 @@ async function resolveDaemonConfig(opts) {
298
371
  }
299
372
 
300
373
  // src/daemon/loop.ts
301
- import * as os from "os";
374
+ import * as crypto from "crypto";
375
+ import * as fs from "fs";
376
+ import * as path2 from "path";
377
+ var MAX_TIMER_MS = 2147483647;
378
+ function positiveBoundedEnv(name, fallback) {
379
+ const parsed = Number(process.env[name]);
380
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_TIMER_MS) : fallback;
381
+ }
302
382
  var MAX_CONCURRENT_TURNS = 3;
303
- var MAX_ATTEMPTS = 3;
304
383
  var HEARTBEAT_MS = 3e4;
384
+ var SEEN_TTL_MS = 24 * 60 * 6e4;
385
+ var MAX_COMPLETED_SEEN = 1e4;
386
+ var PAUSE_AT_PENDING = Math.max(
387
+ 1,
388
+ Math.floor(positiveBoundedEnv("AGENTCHATD_MAX_PENDING", 2e3))
389
+ );
390
+ var RESUME_AT_PENDING = Math.max(1, Math.floor(PAUSE_AT_PENDING / 2));
391
+ var RETRY_BASE_MS = positiveBoundedEnv("AGENTCHATD_RETRY_MS", 1e3);
392
+ var RETRY_MAX_MS = Math.max(
393
+ RETRY_BASE_MS,
394
+ positiveBoundedEnv("AGENTCHATD_RETRY_MAX_MS", 5 * 6e4)
395
+ );
305
396
  var YIELD_MS = Number(process.env["AGENTCHATD_YIELD_MS"] ?? 1e4);
306
397
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
398
+ function retryDelay(attempt) {
399
+ return Math.min(RETRY_BASE_MS * 2 ** Math.min(20, Math.max(0, attempt - 1)), RETRY_MAX_MS);
400
+ }
401
+ function installationId(home) {
402
+ const file = path2.join(home, "daemon.installation-id");
403
+ try {
404
+ const existing = fs.readFileSync(file, "utf-8").trim();
405
+ if (/^[0-9a-f-]{36}$/i.test(existing)) return existing;
406
+ } catch {
407
+ }
408
+ const id = crypto.randomUUID();
409
+ try {
410
+ atomicWriteFile(file, `${id}
411
+ `, 384);
412
+ } catch (err) {
413
+ log.warn(`could not persist daemon installation id: ${String(err)}`);
414
+ }
415
+ return id;
416
+ }
307
417
  var Daemon = class {
308
418
  constructor(cfg, adapter, ws, onTerminal) {
309
419
  this.cfg = cfg;
@@ -312,7 +422,7 @@ var Daemon = class {
312
422
  this.coord = new ReplyCoord({
313
423
  apiKey: cfg.apiKey,
314
424
  apiBase: cfg.apiBase,
315
- holder: `daemon:${os.hostname()}`
425
+ holder: `daemon:${installationId(cfg.home)}`
316
426
  });
317
427
  this.ws = ws ?? new AgentWsClient(cfg.wsUrl, cfg.apiKey);
318
428
  this.ws.on("inbound", (row) => this.onInbound(row));
@@ -320,7 +430,7 @@ var Daemon = class {
320
430
  this.ws.on("terminal", (reason) => {
321
431
  log.error(`daemon terminal: ${reason}`);
322
432
  this.stop();
323
- this.onTerminal?.(reason);
433
+ this.onTerminal?.({ kind: "socket-auth", reason });
324
434
  });
325
435
  }
326
436
  cfg;
@@ -329,8 +439,9 @@ var Daemon = class {
329
439
  ws;
330
440
  coord;
331
441
  seen = /* @__PURE__ */ new Map();
332
- // message id attempts
333
- convChains = /* @__PURE__ */ new Map();
442
+ convQueues = /* @__PURE__ */ new Map();
443
+ convWorkers = /* @__PURE__ */ new Set();
444
+ pending = 0;
334
445
  inFlight = 0;
335
446
  waiters = [];
336
447
  stopping = false;
@@ -354,23 +465,53 @@ var Daemon = class {
354
465
  }
355
466
  onInbound(row) {
356
467
  if (senderOf(row) === this.cfg.handle) return;
357
- if (this.seen.has(row.id)) return;
358
- this.seen.set(row.id, 0);
359
- this.enqueue(row);
360
- }
361
- /** Serialize turns within a conversation; the global semaphore caps total. */
362
- enqueue(row) {
363
- const prev = this.convChains.get(row.conversation_id) ?? Promise.resolve();
364
- const next = prev.then(() => this.handle(row)).catch((err) => {
365
- log.warn(`unhandled in conv ${row.conversation_id}: ${String(err)}`);
366
- });
367
- this.convChains.set(row.conversation_id, next);
368
- void next.then(() => {
369
- if (this.convChains.get(row.conversation_id) === next) this.convChains.delete(row.conversation_id);
370
- });
468
+ this.pruneSeen();
469
+ const prior = this.seen.get(row.id);
470
+ if (prior) {
471
+ prior.updatedAt = Date.now();
472
+ if (prior.status === "handled") this.ws.ack(row.id);
473
+ return;
474
+ }
475
+ this.seen.set(row.id, { row, status: "queued", attempts: 0, updatedAt: Date.now() });
476
+ this.pending += 1;
477
+ if (this.pending >= PAUSE_AT_PENDING) this.ws.pauseInbound();
478
+ this.enqueueExisting(row);
479
+ }
480
+ /** Queue one already-tracked row and ensure exactly one worker for its conversation. */
481
+ enqueueExisting(row) {
482
+ const queue = this.convQueues.get(row.conversation_id) ?? [];
483
+ queue.push(row);
484
+ this.convQueues.set(row.conversation_id, queue);
485
+ if (this.convWorkers.has(row.conversation_id)) return;
486
+ this.convWorkers.add(row.conversation_id);
487
+ void this.drainConversation(row.conversation_id);
488
+ }
489
+ /** Process each message independently, in arrival order within a conversation. */
490
+ async drainConversation(conversationId) {
491
+ try {
492
+ while (!this.stopping) {
493
+ const queue = this.convQueues.get(conversationId);
494
+ if (!queue || queue.length === 0) break;
495
+ const row = queue.shift();
496
+ if (!row) break;
497
+ await this.handle(row);
498
+ }
499
+ } catch (err) {
500
+ log.warn(`unhandled in conv ${conversationId}: ${String(err)}`);
501
+ } finally {
502
+ this.convWorkers.delete(conversationId);
503
+ const queue = this.convQueues.get(conversationId);
504
+ if (!queue || queue.length === 0) this.convQueues.delete(conversationId);
505
+ else if (!this.stopping) {
506
+ this.convWorkers.add(conversationId);
507
+ void this.drainConversation(conversationId);
508
+ }
509
+ }
371
510
  }
372
511
  async handle(row) {
373
512
  if (this.stopping) return;
513
+ const initial = this.seen.get(row.id);
514
+ if (!initial || initial.status !== "queued") return;
374
515
  if (await this.coord.isSessionActive()) {
375
516
  log.info(`msg ${row.id}: live session active \u2014 yielding for ${YIELD_MS}ms`);
376
517
  await delay(YIELD_MS);
@@ -378,37 +519,90 @@ var Daemon = class {
378
519
  }
379
520
  if (!await this.coord.claim(row.id)) {
380
521
  log.info(`msg ${row.id}: claimed by the live session \u2014 standing down`);
522
+ this.seen.delete(row.id);
523
+ this.markNoLongerPending();
381
524
  return;
382
525
  }
383
- await this.acquireSlot();
384
- try {
385
- const attempts = (this.seen.get(row.id) ?? 0) + 1;
386
- this.seen.set(row.id, attempts);
387
- log.info(`turn for msg ${row.id} from @${senderOf(row)} (attempt ${attempts})`);
388
- const ctx = contextOf(row);
389
- const result = await this.adapter.runTurn({
390
- conversationId: row.conversation_id,
391
- sender: senderOf(row),
392
- text: typeof row.content?.["text"] === "string" ? row.content["text"] : "",
393
- createdAt: typeof row.created_at === "string" ? row.created_at : void 0,
394
- type: typeof row.type === "string" ? row.type : void 0,
395
- senderDisplayName: ctx.senderDisplayName,
396
- senderKind: ctx.senderKind,
397
- groupName: ctx.groupName,
398
- mentioned: ctx.mentions.includes(this.cfg.handle.toLowerCase())
399
- });
526
+ while (!this.stopping) {
527
+ const state = this.seen.get(row.id);
528
+ if (!state || state.status === "handled") return;
529
+ state.status = "running";
530
+ state.attempts += 1;
531
+ state.updatedAt = Date.now();
532
+ const attempt = state.attempts;
533
+ await this.acquireSlot();
534
+ if (this.stopping) {
535
+ this.releaseSlot();
536
+ return;
537
+ }
538
+ let result;
539
+ try {
540
+ log.info(
541
+ `turn for msg ${row.id} in ${row.conversation_id} from @${senderOf(row)} (attempt ${attempt})`
542
+ );
543
+ const ctx = contextOf(row);
544
+ result = await this.adapter.runTurn({
545
+ messageId: row.id,
546
+ conversationId: row.conversation_id,
547
+ sender: senderOf(row),
548
+ text: typeof row.content?.["text"] === "string" ? row.content["text"] : "",
549
+ createdAt: typeof row.created_at === "string" ? row.created_at : void 0,
550
+ type: typeof row.type === "string" ? row.type : void 0,
551
+ senderDisplayName: ctx.senderDisplayName,
552
+ senderKind: ctx.senderKind,
553
+ groupName: ctx.groupName,
554
+ mentioned: ctx.mentions.includes(this.cfg.handle.toLowerCase())
555
+ });
556
+ } catch (err) {
557
+ result = { ok: false, detail: `adapter threw: ${String(err)}` };
558
+ } finally {
559
+ this.releaseSlot();
560
+ }
400
561
  if (result.ok) {
401
- this.ws.ack(row.id);
402
- } else if (result.fatal) {
403
- log.error(`fatal turn error: ${result.detail} \u2014 not acking (will re-drain)`);
404
- } else if (attempts >= MAX_ATTEMPTS) {
405
- log.warn(`msg ${row.id} failed ${attempts}\xD7 (${result.detail}); acking to drop (poison guard)`);
406
- this.ws.ack(row.id);
407
- } else {
408
- log.warn(`turn failed for ${row.id}: ${result.detail}; leaving unacked for re-drain`);
562
+ this.markHandled(row.id);
563
+ return;
409
564
  }
410
- } finally {
411
- this.releaseSlot();
565
+ if (result.fatal) {
566
+ log.error(`fatal turn error: ${result.detail} \u2014 stopping runtime so preflight can recover`);
567
+ this.stop();
568
+ this.onTerminal?.({ kind: "runtime", reason: result.detail ?? "runtime failed" });
569
+ return;
570
+ }
571
+ const retryMs = retryDelay(attempt);
572
+ state.status = "retry-wait";
573
+ state.updatedAt = Date.now();
574
+ log.warn(
575
+ `turn failed for msg ${row.id}: ${result.detail}; retrying in ${retryMs}ms without acknowledging it`
576
+ );
577
+ await delay(retryMs);
578
+ }
579
+ }
580
+ markHandled(messageId) {
581
+ const state = this.seen.get(messageId);
582
+ if (!state || state.status === "handled") return;
583
+ state.status = "handled";
584
+ state.updatedAt = Date.now();
585
+ this.markNoLongerPending();
586
+ this.ws.ack(messageId);
587
+ }
588
+ markNoLongerPending() {
589
+ this.pending = Math.max(0, this.pending - 1);
590
+ if (this.pending <= RESUME_AT_PENDING) this.ws.resumeInbound();
591
+ }
592
+ /** Bound reconnect-dedup memory without ever evicting unfinished work. */
593
+ pruneSeen() {
594
+ const cutoff = Date.now() - SEEN_TTL_MS;
595
+ const completed = [];
596
+ for (const entry of this.seen.entries()) {
597
+ const [id, state] = entry;
598
+ if (state.status !== "handled") continue;
599
+ if (state.updatedAt < cutoff) this.seen.delete(id);
600
+ else completed.push(entry);
601
+ }
602
+ if (completed.length <= MAX_COMPLETED_SEEN) return;
603
+ completed.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
604
+ for (const [id] of completed.slice(0, completed.length - MAX_COMPLETED_SEEN)) {
605
+ this.seen.delete(id);
412
606
  }
413
607
  }
414
608
  // ─── global concurrency semaphore ─────────────────────────────────────────
@@ -433,19 +627,44 @@ var Daemon = class {
433
627
  var POLL_MS = 5e3;
434
628
  var TICK_MS = 250;
435
629
  var MAX_BACKOFF_MS2 = 5 * 6e4;
630
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
631
+ var KEEP_LOG_BYTES = 1024 * 1024;
436
632
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
437
633
  function fingerprint(home) {
438
634
  const id = resolveIdentity(home);
439
635
  return id === null ? null : `${id.apiKey}:${id.handle ?? ""}`;
440
636
  }
637
+ function boundDaemonLog(home) {
638
+ const file = path3.join(home, "daemon.log");
639
+ try {
640
+ const size = fs2.statSync(file).size;
641
+ if (size <= MAX_LOG_BYTES) return;
642
+ const fd = fs2.openSync(file, "r");
643
+ try {
644
+ const keep = Buffer.alloc(Math.min(KEEP_LOG_BYTES, size));
645
+ fs2.readSync(fd, keep, 0, keep.length, size - keep.length);
646
+ fs2.writeFileSync(
647
+ file,
648
+ `[agentchat:info] older daemon log output truncated at ${(/* @__PURE__ */ new Date()).toISOString()}
649
+ ${keep.toString("utf-8")}`
650
+ );
651
+ } finally {
652
+ fs2.closeSync(fd);
653
+ }
654
+ } catch {
655
+ }
656
+ }
441
657
  async function runDaemon(opts) {
442
- const home = path2.resolve(opts.home);
443
- const workdir = opts.workdir ?? path2.join(home, "daemon-workdir");
658
+ const home = path3.resolve(opts.home);
659
+ const workdir = opts.workdir ?? path3.join(home, "daemon-workdir");
660
+ boundDaemonLog(home);
444
661
  if (process.env["AGENTCHAT_LOG_LEVEL"] === void 0) process.env["AGENTCHAT_LOG_LEVEL"] = "info";
445
662
  const lock = acquireLeaderLock(home);
446
663
  if (lock === null) return 1;
447
664
  let live = null;
448
665
  let liveFingerprint = null;
666
+ let observedFingerprint = null;
667
+ let adapterFingerprint = null;
449
668
  let refused = null;
450
669
  let failures = 0;
451
670
  let lastFailure = null;
@@ -471,10 +690,17 @@ async function runDaemon(opts) {
471
690
  process.on("SIGTERM", () => shutdown("SIGTERM"));
472
691
  let nudged = false;
473
692
  try {
474
- fs.mkdirSync(home, { recursive: true });
475
- const watcher = fs.watch(home, (_event, filename) => {
693
+ fs2.mkdirSync(home, { recursive: true });
694
+ const watcher = fs2.watch(home, (_event, filename) => {
476
695
  if (filename === null || String(filename).startsWith("credentials")) nudged = true;
477
696
  });
697
+ watcher.on("error", (err) => {
698
+ log.warn(`credential watcher unavailable; polling instead: ${String(err)}`);
699
+ try {
700
+ watcher.close();
701
+ } catch {
702
+ }
703
+ });
478
704
  watcher.unref();
479
705
  } catch {
480
706
  }
@@ -483,19 +709,33 @@ async function runDaemon(opts) {
483
709
  for (; ; ) {
484
710
  if (shuttingDown) break;
485
711
  const fp = fingerprint(home);
712
+ const identityChanged = fp !== observedFingerprint;
713
+ if (identityChanged) {
714
+ disconnect(fp === null ? "signed out" : "identity changed");
715
+ observedFingerprint = fp;
716
+ failures = 0;
717
+ lastFailure = null;
718
+ if (fp !== refused) refused = null;
719
+ }
486
720
  if (fp === null) {
487
- disconnect("signed out");
488
721
  if (refused !== null) refused = null;
489
722
  } else if (fp !== liveFingerprint) {
490
- disconnect("identity changed");
491
- failures = 0;
492
723
  if (fp === refused) {
493
724
  } else {
494
725
  try {
495
726
  const cfg = await resolveDaemonConfig({ home, workdir });
496
- const candidate = new Daemon(cfg, opts.adapter, void 0, (reason) => {
497
- log.warn(`credential refused (${reason}) \u2014 idling until it changes`);
498
- refused = fp;
727
+ if (adapterFingerprint !== fp) {
728
+ opts.adapter.reset?.(`${cfg.apiBase}:${cfg.handle}`);
729
+ adapterFingerprint = fp;
730
+ }
731
+ const candidate = new Daemon(cfg, opts.adapter, void 0, (failure) => {
732
+ if (failure.kind === "socket-auth") {
733
+ log.warn(`credential refused (${failure.reason}) \u2014 idling until it changes`);
734
+ refused = fp;
735
+ } else {
736
+ log.warn(`runtime became unhealthy (${failure.reason}) \u2014 re-running preflight`);
737
+ failures += 1;
738
+ }
499
739
  live = null;
500
740
  liveFingerprint = null;
501
741
  idle(home);