@agentchatme/agent-core 0.0.1311 → 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,6 +1,7 @@
1
1
  import {
2
2
  CODING_AGENTS_CLIENT_HEADERS,
3
3
  acquireLeaderLock,
4
+ atomicWriteFile,
4
5
  beat,
5
6
  credentialsPath,
6
7
  external_exports,
@@ -8,7 +9,7 @@ import {
8
9
  idle,
9
10
  log,
10
11
  resolveIdentity
11
- } from "./chunk-ER4AFPH7.js";
12
+ } from "./chunk-AGDJ4A6R.js";
12
13
 
13
14
  // src/daemon/ws-client.ts
14
15
  import { WebSocket } from "ws";
@@ -50,6 +51,7 @@ function contextOf(row) {
50
51
  // src/daemon/ws-client.ts
51
52
  var BASE_BACKOFF_MS = 1e3;
52
53
  var MAX_BACKOFF_MS = 6e4;
54
+ var ACK_RETRY_MS = 1e3;
53
55
  var LIVENESS_MS = 1e5;
54
56
  var AgentWsClient = class extends EventEmitter {
55
57
  constructor(url, apiKey) {
@@ -64,8 +66,12 @@ var AgentWsClient = class extends EventEmitter {
64
66
  attempt = 0;
65
67
  reconnectTimer = null;
66
68
  livenessTimer = null;
69
+ ackRetryTimer = null;
67
70
  stopped = false;
68
71
  ackMode = false;
72
+ inboundPaused = false;
73
+ pendingAcks = /* @__PURE__ */ new Set();
74
+ acksInFlight = /* @__PURE__ */ new Set();
69
75
  /** True only while the socket is live and ready. The heartbeat writer keys
70
76
  * off this, so a reconnecting/terminal daemon lets its heartbeat go stale
71
77
  * and the next session detects that always-on is actually down. */
@@ -80,6 +86,7 @@ var AgentWsClient = class extends EventEmitter {
80
86
  this.stopped = true;
81
87
  this.state = "closed";
82
88
  this.clearTimers();
89
+ this.acksInFlight.clear();
83
90
  if (this.ws) {
84
91
  try {
85
92
  this.ws.close(1e3, "daemon shutdown");
@@ -88,6 +95,29 @@ var AgentWsClient = class extends EventEmitter {
88
95
  this.ws = null;
89
96
  }
90
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
+ }
91
121
  getState() {
92
122
  return this.state;
93
123
  }
@@ -99,13 +129,41 @@ var AgentWsClient = class extends EventEmitter {
99
129
  * real-time push — which carries no delivery_id — be acked at all.
100
130
  */
101
131
  ack(messageId) {
132
+ this.pendingAcks.add(messageId);
133
+ this.flushAcks();
134
+ }
135
+ flushAcks() {
102
136
  if (this.state !== "ready" || !this.ws) return;
103
- try {
104
- this.ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
105
- } catch (err) {
106
- 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
+ }
107
157
  }
108
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
+ }
109
167
  open() {
110
168
  if (this.stopped) return;
111
169
  this.state = this.attempt === 0 ? "connecting" : "reconnecting";
@@ -126,8 +184,10 @@ var AgentWsClient = class extends EventEmitter {
126
184
  this.attempt = 0;
127
185
  this.state = "ready";
128
186
  this.armLiveness();
187
+ if (this.inboundPaused) ws.pause();
129
188
  log.info("ws ready \u2014 draining + listening");
130
189
  this.emit("ready");
190
+ this.flushAcks();
131
191
  });
132
192
  ws.on("message", (data) => {
133
193
  this.armLiveness();
@@ -152,6 +212,7 @@ var AgentWsClient = class extends EventEmitter {
152
212
  });
153
213
  ws.on("ping", () => this.armLiveness());
154
214
  ws.on("unexpected-response", (_req, res) => {
215
+ if (this.stopped) return;
155
216
  if (res.statusCode === 401 || res.statusCode === 403) {
156
217
  this.state = "terminal";
157
218
  this.clearTimers();
@@ -167,18 +228,23 @@ var AgentWsClient = class extends EventEmitter {
167
228
  });
168
229
  ws.on("close", (code) => {
169
230
  if (this.state === "terminal" || this.stopped) return;
231
+ this.acksInFlight.clear();
170
232
  log.warn(`ws closed (${code}) \u2014 scheduling reconnect`);
171
233
  this.scheduleReconnect();
172
234
  });
173
235
  }
174
236
  scheduleReconnect() {
175
237
  if (this.stopped || this.state === "terminal") return;
238
+ if (this.reconnectTimer) return;
176
239
  this.state = "reconnecting";
177
240
  this.clearTimers();
178
241
  const backoff = Math.min(BASE_BACKOFF_MS * 2 ** this.attempt, MAX_BACKOFF_MS);
179
242
  const jitter = backoff * (0.5 + Math.random() * 0.5);
180
243
  this.attempt++;
181
- this.reconnectTimer = setTimeout(() => this.open(), jitter);
244
+ this.reconnectTimer = setTimeout(() => {
245
+ this.reconnectTimer = null;
246
+ this.open();
247
+ }, jitter);
182
248
  }
183
249
  armLiveness() {
184
250
  if (this.livenessTimer) clearTimeout(this.livenessTimer);
@@ -200,6 +266,10 @@ var AgentWsClient = class extends EventEmitter {
200
266
  clearTimeout(this.livenessTimer);
201
267
  this.livenessTimer = null;
202
268
  }
269
+ if (this.ackRetryTimer) {
270
+ clearTimeout(this.ackRetryTimer);
271
+ this.ackRetryTimer = null;
272
+ }
203
273
  }
204
274
  };
205
275
 
@@ -266,8 +336,8 @@ function describeSender(ctx) {
266
336
  }
267
337
 
268
338
  // src/daemon/run.ts
269
- import * as path2 from "path";
270
- import * as fs from "fs";
339
+ import * as path3 from "path";
340
+ import * as fs2 from "fs";
271
341
 
272
342
  // src/daemon/config.ts
273
343
  import * as path from "path";
@@ -301,12 +371,49 @@ async function resolveDaemonConfig(opts) {
301
371
  }
302
372
 
303
373
  // src/daemon/loop.ts
304
- 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
+ }
305
382
  var MAX_CONCURRENT_TURNS = 3;
306
- var MAX_ATTEMPTS = 3;
307
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
+ );
308
396
  var YIELD_MS = Number(process.env["AGENTCHATD_YIELD_MS"] ?? 1e4);
309
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
+ }
310
417
  var Daemon = class {
311
418
  constructor(cfg, adapter, ws, onTerminal) {
312
419
  this.cfg = cfg;
@@ -315,7 +422,7 @@ var Daemon = class {
315
422
  this.coord = new ReplyCoord({
316
423
  apiKey: cfg.apiKey,
317
424
  apiBase: cfg.apiBase,
318
- holder: `daemon:${os.hostname()}`
425
+ holder: `daemon:${installationId(cfg.home)}`
319
426
  });
320
427
  this.ws = ws ?? new AgentWsClient(cfg.wsUrl, cfg.apiKey);
321
428
  this.ws.on("inbound", (row) => this.onInbound(row));
@@ -323,7 +430,7 @@ var Daemon = class {
323
430
  this.ws.on("terminal", (reason) => {
324
431
  log.error(`daemon terminal: ${reason}`);
325
432
  this.stop();
326
- this.onTerminal?.(reason);
433
+ this.onTerminal?.({ kind: "socket-auth", reason });
327
434
  });
328
435
  }
329
436
  cfg;
@@ -332,8 +439,9 @@ var Daemon = class {
332
439
  ws;
333
440
  coord;
334
441
  seen = /* @__PURE__ */ new Map();
335
- // message id attempts
336
- convChains = /* @__PURE__ */ new Map();
442
+ convQueues = /* @__PURE__ */ new Map();
443
+ convWorkers = /* @__PURE__ */ new Set();
444
+ pending = 0;
337
445
  inFlight = 0;
338
446
  waiters = [];
339
447
  stopping = false;
@@ -357,23 +465,53 @@ var Daemon = class {
357
465
  }
358
466
  onInbound(row) {
359
467
  if (senderOf(row) === this.cfg.handle) return;
360
- if (this.seen.has(row.id)) return;
361
- this.seen.set(row.id, 0);
362
- this.enqueue(row);
363
- }
364
- /** Serialize turns within a conversation; the global semaphore caps total. */
365
- enqueue(row) {
366
- const prev = this.convChains.get(row.conversation_id) ?? Promise.resolve();
367
- const next = prev.then(() => this.handle(row)).catch((err) => {
368
- log.warn(`unhandled in conv ${row.conversation_id}: ${String(err)}`);
369
- });
370
- this.convChains.set(row.conversation_id, next);
371
- void next.then(() => {
372
- if (this.convChains.get(row.conversation_id) === next) this.convChains.delete(row.conversation_id);
373
- });
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
+ }
374
510
  }
375
511
  async handle(row) {
376
512
  if (this.stopping) return;
513
+ const initial = this.seen.get(row.id);
514
+ if (!initial || initial.status !== "queued") return;
377
515
  if (await this.coord.isSessionActive()) {
378
516
  log.info(`msg ${row.id}: live session active \u2014 yielding for ${YIELD_MS}ms`);
379
517
  await delay(YIELD_MS);
@@ -381,37 +519,90 @@ var Daemon = class {
381
519
  }
382
520
  if (!await this.coord.claim(row.id)) {
383
521
  log.info(`msg ${row.id}: claimed by the live session \u2014 standing down`);
522
+ this.seen.delete(row.id);
523
+ this.markNoLongerPending();
384
524
  return;
385
525
  }
386
- await this.acquireSlot();
387
- try {
388
- const attempts = (this.seen.get(row.id) ?? 0) + 1;
389
- this.seen.set(row.id, attempts);
390
- log.info(`turn for msg ${row.id} from @${senderOf(row)} (attempt ${attempts})`);
391
- const ctx = contextOf(row);
392
- const result = await this.adapter.runTurn({
393
- conversationId: row.conversation_id,
394
- sender: senderOf(row),
395
- text: typeof row.content?.["text"] === "string" ? row.content["text"] : "",
396
- createdAt: typeof row.created_at === "string" ? row.created_at : void 0,
397
- type: typeof row.type === "string" ? row.type : void 0,
398
- senderDisplayName: ctx.senderDisplayName,
399
- senderKind: ctx.senderKind,
400
- groupName: ctx.groupName,
401
- mentioned: ctx.mentions.includes(this.cfg.handle.toLowerCase())
402
- });
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
+ }
403
561
  if (result.ok) {
404
- this.ws.ack(row.id);
405
- } else if (result.fatal) {
406
- log.error(`fatal turn error: ${result.detail} \u2014 not acking (will re-drain)`);
407
- } else if (attempts >= MAX_ATTEMPTS) {
408
- log.warn(`msg ${row.id} failed ${attempts}\xD7 (${result.detail}); acking to drop (poison guard)`);
409
- this.ws.ack(row.id);
410
- } else {
411
- log.warn(`turn failed for ${row.id}: ${result.detail}; leaving unacked for re-drain`);
562
+ this.markHandled(row.id);
563
+ return;
412
564
  }
413
- } finally {
414
- 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);
415
606
  }
416
607
  }
417
608
  // ─── global concurrency semaphore ─────────────────────────────────────────
@@ -436,19 +627,44 @@ var Daemon = class {
436
627
  var POLL_MS = 5e3;
437
628
  var TICK_MS = 250;
438
629
  var MAX_BACKOFF_MS2 = 5 * 6e4;
630
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
631
+ var KEEP_LOG_BYTES = 1024 * 1024;
439
632
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
440
633
  function fingerprint(home) {
441
634
  const id = resolveIdentity(home);
442
635
  return id === null ? null : `${id.apiKey}:${id.handle ?? ""}`;
443
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
+ }
444
657
  async function runDaemon(opts) {
445
- const home = path2.resolve(opts.home);
446
- 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);
447
661
  if (process.env["AGENTCHAT_LOG_LEVEL"] === void 0) process.env["AGENTCHAT_LOG_LEVEL"] = "info";
448
662
  const lock = acquireLeaderLock(home);
449
663
  if (lock === null) return 1;
450
664
  let live = null;
451
665
  let liveFingerprint = null;
666
+ let observedFingerprint = null;
667
+ let adapterFingerprint = null;
452
668
  let refused = null;
453
669
  let failures = 0;
454
670
  let lastFailure = null;
@@ -474,8 +690,8 @@ async function runDaemon(opts) {
474
690
  process.on("SIGTERM", () => shutdown("SIGTERM"));
475
691
  let nudged = false;
476
692
  try {
477
- fs.mkdirSync(home, { recursive: true });
478
- const watcher = fs.watch(home, (_event, filename) => {
693
+ fs2.mkdirSync(home, { recursive: true });
694
+ const watcher = fs2.watch(home, (_event, filename) => {
479
695
  if (filename === null || String(filename).startsWith("credentials")) nudged = true;
480
696
  });
481
697
  watcher.on("error", (err) => {
@@ -493,19 +709,33 @@ async function runDaemon(opts) {
493
709
  for (; ; ) {
494
710
  if (shuttingDown) break;
495
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
+ }
496
720
  if (fp === null) {
497
- disconnect("signed out");
498
721
  if (refused !== null) refused = null;
499
722
  } else if (fp !== liveFingerprint) {
500
- disconnect("identity changed");
501
- failures = 0;
502
723
  if (fp === refused) {
503
724
  } else {
504
725
  try {
505
726
  const cfg = await resolveDaemonConfig({ home, workdir });
506
- const candidate = new Daemon(cfg, opts.adapter, void 0, (reason) => {
507
- log.warn(`credential refused (${reason}) \u2014 idling until it changes`);
508
- 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
+ }
509
739
  live = null;
510
740
  liveFingerprint = null;
511
741
  idle(home);