@cohortapp/agent-sdk 2.18.15 → 2.18.16

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.
@@ -432,7 +432,29 @@ function hydrateMessage(c, facts) {
432
432
  const page = facts.channelPages instanceof Map ? facts.channelPages.get(s(c.ids.channelId)) : null;
433
433
  const msg = page && page.byId ? page.byId.get(s(c.ids.messageId)) : null;
434
434
  if (!msg) return { ok: false, reason: "message_not_in_page", text: "" };
435
- const body = s(msg.body ?? msg.text).trim();
435
+ let body = s(msg.body ?? msg.text).trim();
436
+
437
+ // VOICE_NOTE_OVERLAY (2026-09-26; pb-2026-09-26-inbound-voice-notes-reach-agents-as-fileid-only).
438
+ // A voice note / attachment-only message arrives with an EMPTY body + attachments[]. Treating that
439
+ // as empty_body dropped the whole event. Server carries no transcript + no byte route, so the honest
440
+ // text is what the attachment IS and that it couldn't be heard; metadata rides on the event.
441
+ const atts = Array.isArray(msg.attachments) ? msg.attachments.filter((a) => a && typeof a === "object") : [];
442
+ if (!body && atts.length) {
443
+ body = atts
444
+ .map((a) => {
445
+ const name = s(a.name || a.filename || a.fileId || "attachment");
446
+ const mime = s(a.contentType || a.mime || a.mimetype || "");
447
+ const bytes = Number(a.sizeBytes ?? a.size_bytes ?? a.size ?? 0);
448
+ const secs = Number(a.durationSec ?? a.duration_sec ?? a.duration ?? 0);
449
+ const isAudio = /^audio\//.test(mime) || secs > 0;
450
+ const tx = s(a.transcript);
451
+ return tx
452
+ ? `[${isAudio ? "voice note" : "attachment"} ${name}] ${tx}`
453
+ : `[${isAudio ? "voice note" : "attachment"}: ${name}${mime ? ", " + mime : ""}${bytes ? ", " + bytes + " bytes" : ""}${secs ? ", " + secs + " s" : ""}; no transcript available on this seat]`;
454
+ })
455
+ .join("\n");
456
+ }
457
+
436
458
  if (!body) return { ok: false, reason: "empty_body", text: "" };
437
459
 
438
460
  const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
@@ -465,6 +487,18 @@ function hydrateMessage(c, facts) {
465
487
  from: { id: s(msg.authorId || c.actor), name: s(msg.authorName || msg.authorId || c.actor) },
466
488
  channelId: s(c.ids.channelId),
467
489
  channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId),
490
+ // VOICE_NOTE_OVERLAY: attachment metadata rides on the event so
491
+ // lib/channels/inbox-item.mjs writes attachments[] (and transcript when present).
492
+ attachments: atts.length
493
+ ? atts.map((a) => ({
494
+ id: s(a.fileId || a.id),
495
+ name: s(a.name || a.filename || "attachment"),
496
+ mimetype: s(a.contentType || a.mime || a.mimetype || "application/octet-stream"),
497
+ size: Number(a.sizeBytes ?? a.size_bytes ?? a.size ?? 0),
498
+ duration_sec: Number(a.durationSec ?? a.duration_sec ?? 0) || undefined,
499
+ ...(s(a.transcript) ? { transcript: s(a.transcript) } : {}),
500
+ }))
501
+ : undefined,
468
502
  };
469
503
  }
470
504
 
@@ -116,6 +116,9 @@ export function toMessageEvent(o = {}) {
116
116
  sender: s(from.name || from.id),
117
117
  is_reply: Boolean(hydrated.isReply),
118
118
  thread_context: hydrated.threadContext != null ? hydrated.threadContext : null,
119
+ // VOICE_NOTE_OVERLAY (2026-09-26): attachment metadata from the hydrator rides on the event so
120
+ // lib/channels/inbox-item.mjs writes attachments[] (and a transcript when one exists).
121
+ ...(Array.isArray(hydrated.attachments) && hydrated.attachments.length ? { attachments: hydrated.attachments } : {}),
119
122
  priority_signals: {
120
123
  from_ceo: false,
121
124
  tagged_urgent: URGENT.test(text) || c.kind === "board.blocked" || c.family === "escalation",
@@ -3,14 +3,29 @@
3
3
  * or the daemon's legacy `claude --print` lane (design §3.1, §3.3, §3.4).
4
4
  *
5
5
  * THE ONE RULE. When the seat's main session is the configured front door AND
6
- * it is provably live (a fresh `state/session/heartbeat.json`), the daemon
7
- * stops spawning for the surfaces the session owns: Cohort inbox items are
8
- * left in place for the session to claim, and escalate/guarded cadence ticks
9
- * are written as handoffs for the session to ack. When the session is NOT
10
- * live — no heartbeat, a stale one, or the front door is `daemon` — the daemon
6
+ * it is provably live (a fresh `state/session/heartbeat.json`) AND it has
7
+ * actually CONSUMED inbound within the assurance window, the daemon stops
8
+ * spawning for the surfaces the session owns: Cohort inbox items are left in
9
+ * place for the session to claim, and escalate/guarded cadence ticks are
10
+ * written as handoffs for the session to ack. When the session is NOT live —
11
+ * no heartbeat, a stale one, or the front door is `daemon` — the daemon
11
12
  * dispatches exactly as it always has. Liveness is MEASURED, never assumed,
12
13
  * and the fallback is the legacy lane, so nothing is ever dropped.
13
14
  *
15
+ * A WRITTEN HEARTBEAT IS NOT A CONSUMED INBOX (DM-LATENCY). A session answers
16
+ * inbound only between its own tool calls; it can sit in a long catch-up turn,
17
+ * beating the whole time, without ever draining the inbox. "Live" and "not
18
+ * draining" are then both true and nobody answers a slow DM. So liveness alone
19
+ * no longer holds the item: the session must ALSO have proved a consume — an
20
+ * inbox claim/reply/done or a handoff ack, both of which stamp the ONE
21
+ * `state/session/last-consumed.json` file (see lib/session/handoffs.mjs). A
22
+ * fresh beat with a stale last-consume is a beating-but-wedged front door, and
23
+ * the daemon takes the item rather than let it go unanswered. The window is
24
+ * the 20-min assurance sweep interval, NOT the 90-s heartbeat: a session mid
25
+ * long turn is normal and must not lose the item on a heartbeat-length silence.
26
+ * The consume signal only ever *loses* the session the item — a missing or
27
+ * unreadable stamp fails open to the daemon taking it, never to dropping it.
28
+ *
14
29
  * PURE CORE. `sessionLiveFromHeartbeat`, `resolveFrontDoor`,
15
30
  * `frontDoorServices`, `shouldDaemonDispatch` and `shouldHandOffTick` take
16
31
  * every input as a parameter — the parsed heartbeat object, the clock, the
@@ -28,6 +43,7 @@
28
43
  import { join } from "node:path";
29
44
  import * as nodeFs from "node:fs";
30
45
  import { createRequire } from "node:module";
46
+ import { readLastConsumed } from "./handoffs.mjs";
31
47
 
32
48
  // js-yaml is a declared dependency; loaded synchronously so the reader stays
33
49
  // sync. Fail-open: without it the flat line parser below still reads the
@@ -40,6 +56,16 @@ try {
40
56
 
41
57
  /** A heartbeat older than this is stale — the session is treated as not live. */
42
58
  export const DEFAULT_STALE_MS = 90_000;
59
+ /**
60
+ * A last-consume older than this is stale — the session is beating but has not
61
+ * proved it drained inbound, so the daemon takes the item. This is the 20-min
62
+ * assurance-sweep interval, deliberately much longer than DEFAULT_STALE_MS: a
63
+ * session mid a long catch-up turn is normal and must not lose the item on a
64
+ * heartbeat-length silence.
65
+ */
66
+ export const DEFAULT_CONSUMED_STALE_MS = 20 * 60 * 1000;
67
+ /** Where the session runtime stamps its last inbound consume (relative to the agent root). */
68
+ export const LAST_CONSUMED_RELATIVE = "state/session/last-consumed.json";
43
69
  /** Inbox services the session fronts by default. */
44
70
  export const DEFAULT_FRONT_DOOR_SERVICES = Object.freeze(["cohort"]);
45
71
  /** Cadence modes whose tick spawns a sub-session — the ones a live session takes over. */
@@ -75,6 +101,30 @@ export function sessionLiveFromHeartbeat(heartbeat, o = {}) {
75
101
  return { live: true, reason: "fresh", ageMs };
76
102
  }
77
103
 
104
+ /**
105
+ * Has the front door CONSUMED inbound recently, given its last-consume epoch
106
+ * ms (from `state/session/last-consumed.json`, stamped by an inbox
107
+ * claim/reply/done or a handoff ack)? Pure. `null`/`undefined`/non-finite
108
+ * means "never proved a consume" — NOT "just now" — so it reads as not
109
+ * consumed and the daemon takes the item (fail-open in the safe direction:
110
+ * the item is answered, never dropped). A stamp a moment in the future is
111
+ * writer/reader clock skew, not a broken read, and counts as consumed.
112
+ *
113
+ * @param {number|null|undefined} lastConsumedAt epoch ms, or nullish = never
114
+ * @param {object} [o]
115
+ * @param {number} [o.now] epoch ms
116
+ * @param {number} [o.staleMs] window (default DEFAULT_CONSUMED_STALE_MS)
117
+ * @returns {{consumed:boolean, reason:string, ageMs:number|null}}
118
+ */
119
+ export function sessionConsumedRecently(lastConsumedAt, o = {}) {
120
+ const now = typeof o.now === "number" ? o.now : Date.now();
121
+ const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_CONSUMED_STALE_MS;
122
+ if (!Number.isFinite(lastConsumedAt)) return { consumed: false, reason: "never-consumed", ageMs: null };
123
+ const ageMs = now - lastConsumedAt;
124
+ if (ageMs > staleMs) return { consumed: false, reason: "consumed-stale", ageMs };
125
+ return { consumed: true, reason: "consumed", ageMs };
126
+ }
127
+
78
128
  /**
79
129
  * Which process is the front door. `MAESTRO_FRONT_DOOR` in the env wins, then
80
130
  * `frontDoor` / `front_door` in config/session.yaml (a string, or an object
@@ -116,7 +166,15 @@ export function frontDoorServices(config) {
116
166
  * `dispatch:false` means "leave the item in place — the live session owns it".
117
167
  * Any malformed input resolves to dispatch (fail-open: never drop an item).
118
168
  *
119
- * @param {{frontDoor?:string, sessionLive?:boolean, service?:string, services?:string[]}} a
169
+ * The gate is two clauses now: the session must be the live front door for the
170
+ * service AND it must have consumed inbound within the window. A live session
171
+ * that has stopped draining the inbox (`sessionConsumed:false`) loses the item
172
+ * to the daemon — a beating heartbeat is not a consumed inbox (DM-LATENCY).
173
+ * The consumed clause is opt-in on an explicit `false`: an absent
174
+ * `sessionConsumed` (an older reader, a direct caller) never steals the item,
175
+ * so behaviour without the field is exactly the pre-DM-latency gate.
176
+ *
177
+ * @param {{frontDoor?:string, sessionLive?:boolean, sessionConsumed?:boolean, service?:string, services?:string[]}} a
120
178
  * @returns {{dispatch:boolean, reason:string}}
121
179
  */
122
180
  export function shouldDaemonDispatch(a) {
@@ -126,6 +184,10 @@ export function shouldDaemonDispatch(a) {
126
184
  const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
127
185
  const svc = String(x.service || "").trim().toLowerCase();
128
186
  if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
187
+ // Live and owns the service, but has it actually drained inbound? A live
188
+ // front door that has not consumed within the window is wedged mid-turn —
189
+ // the daemon takes the item so a slow DM is not left unanswered.
190
+ if (x.sessionConsumed === false) return { dispatch: true, reason: "session-not-consuming" };
129
191
  return { dispatch: false, reason: "session-live" };
130
192
  }
131
193
 
@@ -187,9 +249,10 @@ function parseSessionYaml(text, yamlParse) {
187
249
  * @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
188
250
  * @param {object} [deps.env] (default process.env)
189
251
  * @param {number} [deps.now] epoch ms
190
- * @param {number} [deps.staleMs]
252
+ * @param {number} [deps.staleMs] heartbeat freshness window
253
+ * @param {number} [deps.consumedStaleMs] last-consume freshness window (default DEFAULT_CONSUMED_STALE_MS)
191
254
  * @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
192
- * @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
255
+ * @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, sessionConsumed:boolean, lastConsumedAt:number|null, services:string[], liveness:object, consumed:object, heartbeat:object|null}}
193
256
  */
194
257
  export function readFrontDoorState(agentRoot, deps = {}) {
195
258
  const fs = deps.fs || nodeFs;
@@ -209,11 +272,24 @@ export function readFrontDoorState(agentRoot, deps = {}) {
209
272
  }
210
273
  } catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
211
274
  const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
275
+ // The other half of "provably answering": has the front door drained inbound
276
+ // within the window? `readLastConsumed` (lib/session/handoffs.mjs) reads the
277
+ // ONE last-consumed.json both consume paths stamp; a missing/unreadable
278
+ // stamp is `null` and reads as not-consumed (fail-open to the daemon taking
279
+ // the item). Kept SEPARATE from `sessionLive`: an alive-but-unconsumed
280
+ // session is still alive, so the revive path (which reads `sessionLive`)
281
+ // must not fire on it — only the dispatch gate reacts to `sessionConsumed`.
282
+ let lastConsumedAt = null;
283
+ try { lastConsumedAt = readLastConsumed(agentRoot, { fs }); } catch { lastConsumedAt = null; }
284
+ const consumed = sessionConsumedRecently(lastConsumedAt, { now, staleMs: deps.consumedStaleMs });
212
285
  return {
213
286
  frontDoor: resolveFrontDoor({ env, config }),
214
287
  sessionLive: liveness.live,
288
+ sessionConsumed: consumed.consumed,
289
+ lastConsumedAt,
215
290
  services: frontDoorServices(config),
216
291
  liveness,
292
+ consumed,
217
293
  heartbeat,
218
294
  };
219
295
  }
@@ -254,9 +330,12 @@ export function makeFrontDoorGate(o = {}) {
254
330
 
255
331
  export default {
256
332
  DEFAULT_STALE_MS,
333
+ DEFAULT_CONSUMED_STALE_MS,
334
+ LAST_CONSUMED_RELATIVE,
257
335
  DEFAULT_FRONT_DOOR_SERVICES,
258
336
  HANDOFF_MODES,
259
337
  sessionLiveFromHeartbeat,
338
+ sessionConsumedRecently,
260
339
  resolveFrontDoor,
261
340
  frontDoorServices,
262
341
  shouldDaemonDispatch,
@@ -28,6 +28,14 @@ export const DEFAULT_HANDOFF_DEADLINE_MS = 30 * 60_000;
28
28
  export const DEFAULT_HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60_000;
29
29
  /** Handoff directory (relative to the agent root). */
30
30
  export const HANDOFFS_RELATIVE = "state/session/handoffs";
31
+ /**
32
+ * WHEN the front door last consumed inbound (DARK-SEAT wedged-verdict).
33
+ * There are two consume paths — acking a handoff (this module, below) and an
34
+ * inbound claim/reply/done (session-runtime) — and both stamp this ONE file,
35
+ * so `collect.mjs#sessionWedge` has a single fact to read regardless of which
36
+ * path fired. Relative to the agent root, like every other state/ path here.
37
+ */
38
+ export const LAST_CONSUMED_RELATIVE = "state/session/last-consumed.json";
31
39
 
32
40
  // A tick id is a bus event id (`evt-<iso>-<hex>`) or a test literal. It is a
33
41
  // path segment, so anything that could climb out of the directory is refused.
@@ -139,6 +147,47 @@ export function readHandoff(agentRoot, tickId, deps = {}) {
139
147
  return rec ? { ...rec, path } : null;
140
148
  }
141
149
 
150
+ /**
151
+ * Stamp `LAST_CONSUMED_RELATIVE` with the current instant — the front door
152
+ * just consumed inbound. Best-effort by design: every caller of this (an ack,
153
+ * an inbound claim) has its own success/failure to report, and a stamp that
154
+ * cannot be written must never turn an otherwise-successful consume into a
155
+ * failure. Callers that need to know still get `{ok:false, error}` back; they
156
+ * are just not obliged to act on it.
157
+ *
158
+ * @param {string} agentRoot
159
+ * @param {{now?:number, fs?:object, writeJson?:Function}} [o]
160
+ * @returns {{ok:true, path:string}|{ok:false, error:string}}
161
+ */
162
+ export function stampConsumed(agentRoot, o = {}) {
163
+ const fs = o.fs || nodeFs;
164
+ const writeJson = o.writeJson || writeJsonAtomicDefault;
165
+ const path = join(agentRoot, LAST_CONSUMED_RELATIVE);
166
+ try {
167
+ fs.mkdirSync(join(agentRoot, "state", "session"), { recursive: true });
168
+ writeJson(path, { ts: iso(nowOf(o)) });
169
+ return { ok: true, path };
170
+ } catch (err) {
171
+ return { ok: false, error: err && err.message ? err.message : String(err) };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read `LAST_CONSUMED_RELATIVE` back as epoch ms, or `null` when absent or
177
+ * unreadable — fail-open, like every read in this ledger. `sessionWedge`
178
+ * treats `null` the same as "never consumed", not as "just now".
179
+ *
180
+ * @param {string} agentRoot
181
+ * @param {{fs?:object}} [deps]
182
+ * @returns {number|null}
183
+ */
184
+ export function readLastConsumed(agentRoot, deps = {}) {
185
+ const fs = deps.fs || nodeFs;
186
+ const rec = readJson(fs, join(agentRoot, LAST_CONSUMED_RELATIVE));
187
+ const ms = rec ? Date.parse(rec.ts || "") : NaN;
188
+ return Number.isFinite(ms) ? ms : null;
189
+ }
190
+
142
191
  /** Move an open handoff into done/ with a terminal status. */
143
192
  function retire(agentRoot, rec, patch, deps) {
144
193
  const fs = deps.fs || nodeFs;
@@ -181,6 +230,11 @@ export function ackHandoff(agentRoot, tickId, o = {}) {
181
230
  resultPath: o.resultPath || null,
182
231
  ...(o.note ? { note: String(o.note) } : {}),
183
232
  }, o);
233
+ // Consume path #1 (DARK-SEAT wedged-verdict): acking a handoff is proof
234
+ // the front door read and answered inbound. Best-effort — a stamp that
235
+ // fails to write must never turn a completed ack into a reported failure;
236
+ // the wedge verdict simply falls back to whatever it saw before.
237
+ try { stampConsumed(agentRoot, o); } catch { /* the ack already succeeded */ }
184
238
  return { ok: true, path };
185
239
  } catch (err) {
186
240
  return { ok: false, error: err && err.message ? err.message : String(err) };
@@ -285,6 +339,7 @@ export default {
285
339
  DEFAULT_HANDOFF_DEADLINE_MS,
286
340
  DEFAULT_HANDOFF_RETENTION_MS,
287
341
  HANDOFFS_RELATIVE,
342
+ LAST_CONSUMED_RELATIVE,
288
343
  handoffPaths,
289
344
  writeHandoff,
290
345
  listHandoffs,
@@ -292,4 +347,6 @@ export default {
292
347
  ackHandoff,
293
348
  expireHandoffs,
294
349
  pruneHandoffs,
350
+ stampConsumed,
351
+ readLastConsumed,
295
352
  };
@@ -43,6 +43,7 @@ import { join } from "node:path";
43
43
  import { readdirSync, readFileSync, existsSync, renameSync, statSync, openSync, ftruncateSync, writeSync, closeSync } from "node:fs";
44
44
  import { writeFileAtomic } from "../fs-atomic.mjs";
45
45
  import { readFrontDoorState } from "./frontdoor.mjs";
46
+ import { stampConsumed } from "./handoffs.mjs";
46
47
 
47
48
  /** A session claim neither replied nor done within this window is reopened. */
48
49
  export const SESSION_CLAIM_STALE_MS = 20 * 60_000;
@@ -68,6 +69,21 @@ function iso(ms) { return new Date(ms).toISOString(); }
68
69
  function nowOf(o) { return typeof o.now === "number" ? o.now : Date.now(); }
69
70
  function q(v) { return `"${String(v).replace(/[\r\n]+/g, " ").replace(/"/g, "'")}"`; }
70
71
 
72
+ /**
73
+ * Stamp the ONE `state/session/last-consumed.json` the front door reads to
74
+ * decide whether a live session is actually draining inbound (DM-LATENCY).
75
+ * This is the inbound consume path — a claim/reply/done — the mirror of the
76
+ * handoff-ack path in lib/session/handoffs.mjs; both stamp the same file, so
77
+ * `frontdoor.readFrontDoorState` has a single fact to read. Best-effort by
78
+ * design: a stamp that cannot be written must never turn an otherwise
79
+ * successful claim/reply/done into a failure. The injected `writeConsumed`
80
+ * seam (default `stampConsumed`) lets a test drive the failure path.
81
+ */
82
+ function noteConsumed(agentRoot, o = {}) {
83
+ const stamp = typeof o.writeConsumed === "function" ? o.writeConsumed : stampConsumed;
84
+ try { stamp(agentRoot, { now: nowOf(o) }); } catch { /* the caller's own transition already succeeded */ }
85
+ }
86
+
71
87
  /**
72
88
  * The marker lines to append below an item.
73
89
  * @param {{claimedBy?:string, claimedAt?:string, now?:number, deferredUntil?:number|string, deferredReason?:string, repliedAt?:number|string}} o
@@ -216,6 +232,7 @@ export function markSessionClaim(agentRoot, service, item, o = {}) {
216
232
  const body = stripSessionMarkers(readFileSync(dst, "utf-8"));
217
233
  const sep = body.endsWith("\n") || body === "" ? "" : "\n";
218
234
  writeFileAtomic(dst, body + sep + sessionMarkerLines({ claimedBy: o.by || "session", now: nowOf(o) }));
235
+ noteConsumed(agentRoot, o); // claiming an item is the front door draining inbound (DM-LATENCY)
219
236
  return { ok: true, path: dst };
220
237
  } catch (err) {
221
238
  return { ok: false, error: err && err.message ? err.message : String(err) };
@@ -248,6 +265,7 @@ export function markSessionReplied(agentRoot, service, item, o = {}) {
248
265
  repliedAt: nowOf(o),
249
266
  });
250
267
  writeInPlace(found.path, body + sep + markers);
268
+ noteConsumed(agentRoot, o); // replying is the front door draining inbound (DM-LATENCY)
251
269
  return { ok: true, path: found.path };
252
270
  } catch (err) {
253
271
  return { ok: false, error: err && err.message ? err.message : String(err) };
@@ -318,19 +336,46 @@ function retire(dir, file) {
318
336
 
319
337
  /**
320
338
  * Strip the session markers off a file in place (same inode). Exported for
321
- * the CLI's terminal transitions. Never throws; returns whether it wrote.
339
+ * the CLI's terminal transitions (`inbox done`). Never throws; returns whether
340
+ * it wrote.
341
+ *
342
+ * Finishing an item is also the front door draining inbound, so it advances
343
+ * the last-consumed stamp (DM-LATENCY) — the same consume signal a claim and a
344
+ * reply write. The item path is `<agentRoot>/state/inbox/<service>/<file>`, so
345
+ * the agent root is the path with those four segments removed; when it can be
346
+ * recovered, the consume is stamped best-effort. An explicit `o.agentRoot`
347
+ * wins, so a caller that already holds the root need not rely on the derivation.
348
+ *
322
349
  * @param {string} path
350
+ * @param {{now?:number, agentRoot?:string, writeConsumed?:Function}} [o]
323
351
  * @returns {boolean}
324
352
  */
325
- export function stripMarkersInPlace(path) {
353
+ export function stripMarkersInPlace(path, o = {}) {
326
354
  try {
327
355
  const raw = readFileSync(path, "utf-8");
328
356
  const clean = stripSessionMarkers(raw);
329
357
  if (clean !== raw) writeInPlace(path, clean);
358
+ const agentRoot = o.agentRoot || agentRootFromInboxPath(path);
359
+ if (agentRoot) noteConsumed(agentRoot, o);
330
360
  return true;
331
361
  } catch { return false; }
332
362
  }
333
363
 
364
+ /**
365
+ * Recover the agent root from an inbox item path
366
+ * (`<agentRoot>/state/inbox/<service>/<file>`) by dropping the last four
367
+ * segments — but only when the tail actually IS `state/inbox/<service>/<file>`,
368
+ * so a path of an unexpected shape yields null rather than a wrong root.
369
+ */
370
+ function agentRootFromInboxPath(path) {
371
+ const parts = String(path || "").split(/[\\/]/);
372
+ if (parts.length < 5) return null;
373
+ const [file, service, inbox, state] = [parts[parts.length - 1], parts[parts.length - 2], parts[parts.length - 3], parts[parts.length - 4]];
374
+ void file; void service;
375
+ if (inbox !== "inbox" || state !== "state") return null;
376
+ return parts.slice(0, parts.length - 4).join("/") || "/";
377
+ }
378
+
334
379
  /**
335
380
  * THE ASSURANCE SWEEP. Reopens (a) a session claim neither replied nor done
336
381
  * within `staleMs` (default 20 min), (b) a session deferral whose