@cohortapp/agent-sdk 2.18.15 → 2.18.17

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,16 +166,46 @@ 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
+ * PER-ITEM OVERRIDE — `itemHandled` (DM-LATENCY DOUBLE-ANSWER, 2026-09-27). The
178
+ * `sessionConsumed` clause is a SEAT-WIDE fact: a live door that has not stamped
179
+ * `last-consumed.json` looks not-consuming for EVERY item, so the daemon takes
180
+ * over the whole service. But a door that has already answered a SPECIFIC item
181
+ * left a marker on that item on disk — a terminal `.yaml.processed`, or a live
182
+ * `claimed_by: session` `.yaml.dispatched` — through `maestro inbox
183
+ * claim/reply/done`, which is OLD code every door already runs REGARDLESS of
184
+ * whether it restarted onto the stamp path. So when the caller has read that
185
+ * marker and passes `itemHandled:true`, the daemon must NOT re-take that item,
186
+ * even mid-takeover — this is what closes the double-answer without requiring a
187
+ * fleet restart. It short-circuits FIRST and unconditionally: an already-handled
188
+ * item is the door's whether the door is live, wedged or dead. Opt-in on an
189
+ * explicit `true`: an absent `itemHandled` is exactly the pre-fix gate.
190
+ *
191
+ * @param {{frontDoor?:string, sessionLive?:boolean, sessionConsumed?:boolean, itemHandled?:boolean, service?:string, services?:string[]}} a
120
192
  * @returns {{dispatch:boolean, reason:string}}
121
193
  */
122
194
  export function shouldDaemonDispatch(a) {
123
195
  const x = a && typeof a === "object" ? a : {};
196
+ // A specific item the door already holds on disk is never re-taken — not even
197
+ // when the seat-wide gate would open because a live door has not stamped
198
+ // last-consumed. See PER-ITEM OVERRIDE above.
199
+ if (x.itemHandled === true) return { dispatch: false, reason: "item-already-handled" };
124
200
  if (x.frontDoor !== "session") return { dispatch: true, reason: "front-door-daemon" };
125
201
  if (x.sessionLive !== true) return { dispatch: true, reason: "session-not-live" };
126
202
  const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
127
203
  const svc = String(x.service || "").trim().toLowerCase();
128
204
  if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
205
+ // Live and owns the service, but has it actually drained inbound? A live
206
+ // front door that has not consumed within the window is wedged mid-turn —
207
+ // the daemon takes the item so a slow DM is not left unanswered.
208
+ if (x.sessionConsumed === false) return { dispatch: true, reason: "session-not-consuming" };
129
209
  return { dispatch: false, reason: "session-live" };
130
210
  }
131
211
 
@@ -187,9 +267,10 @@ function parseSessionYaml(text, yamlParse) {
187
267
  * @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
188
268
  * @param {object} [deps.env] (default process.env)
189
269
  * @param {number} [deps.now] epoch ms
190
- * @param {number} [deps.staleMs]
270
+ * @param {number} [deps.staleMs] heartbeat freshness window
271
+ * @param {number} [deps.consumedStaleMs] last-consume freshness window (default DEFAULT_CONSUMED_STALE_MS)
191
272
  * @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
192
- * @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
273
+ * @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, sessionConsumed:boolean, lastConsumedAt:number|null, services:string[], liveness:object, consumed:object, heartbeat:object|null}}
193
274
  */
194
275
  export function readFrontDoorState(agentRoot, deps = {}) {
195
276
  const fs = deps.fs || nodeFs;
@@ -209,11 +290,24 @@ export function readFrontDoorState(agentRoot, deps = {}) {
209
290
  }
210
291
  } catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
211
292
  const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
293
+ // The other half of "provably answering": has the front door drained inbound
294
+ // within the window? `readLastConsumed` (lib/session/handoffs.mjs) reads the
295
+ // ONE last-consumed.json both consume paths stamp; a missing/unreadable
296
+ // stamp is `null` and reads as not-consumed (fail-open to the daemon taking
297
+ // the item). Kept SEPARATE from `sessionLive`: an alive-but-unconsumed
298
+ // session is still alive, so the revive path (which reads `sessionLive`)
299
+ // must not fire on it — only the dispatch gate reacts to `sessionConsumed`.
300
+ let lastConsumedAt = null;
301
+ try { lastConsumedAt = readLastConsumed(agentRoot, { fs }); } catch { lastConsumedAt = null; }
302
+ const consumed = sessionConsumedRecently(lastConsumedAt, { now, staleMs: deps.consumedStaleMs });
212
303
  return {
213
304
  frontDoor: resolveFrontDoor({ env, config }),
214
305
  sessionLive: liveness.live,
306
+ sessionConsumed: consumed.consumed,
307
+ lastConsumedAt,
215
308
  services: frontDoorServices(config),
216
309
  liveness,
310
+ consumed,
217
311
  heartbeat,
218
312
  };
219
313
  }
@@ -254,9 +348,12 @@ export function makeFrontDoorGate(o = {}) {
254
348
 
255
349
  export default {
256
350
  DEFAULT_STALE_MS,
351
+ DEFAULT_CONSUMED_STALE_MS,
352
+ LAST_CONSUMED_RELATIVE,
257
353
  DEFAULT_FRONT_DOOR_SERVICES,
258
354
  HANDOFF_MODES,
259
355
  sessionLiveFromHeartbeat,
356
+ sessionConsumedRecently,
260
357
  resolveFrontDoor,
261
358
  frontDoorServices,
262
359
  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
@@ -194,6 +210,64 @@ export function findInboxFile(agentRoot, id, o = {}) {
194
210
  return looseIds.size === 1 ? loose : null;
195
211
  }
196
212
 
213
+ /**
214
+ * Does the front door ALREADY hold this specific inbound item on disk? A
215
+ * terminal `.yaml.processed` / `.yaml.processed-bundled` marker (the door read
216
+ * it and ran `inbox done`), or a live `claimed_by: session` `.yaml.dispatched`
217
+ * claim, both mean the door has this item — so a daemon takeover must NOT answer
218
+ * it a second time in the seat's name (DM-LATENCY DOUBLE-ANSWER, 2026-09-27),
219
+ * even when `last-consumed.json` was never stamped because the door has not
220
+ * restarted onto the stamp path.
221
+ *
222
+ * Keyed on the inbound id / raw_ref across the WHOLE service dir, NOT on an
223
+ * exact filename — and that is the crux of the fix. `writeInboxItem`
224
+ * (scripts/poller/utils.mjs) dedups on the exact `<ts>-<id>` base, so a re-fetch
225
+ * that lands the SAME message under a different timestamp base slips its dedup
226
+ * and writes a fresh live `.yaml` beside the door's terminal file. The seat then
227
+ * carries two files for one message — one answered, one live — and the live one
228
+ * is what the daemon scans. An id-keyed sweep sees the answered sibling; the
229
+ * exact-path dedup structurally cannot.
230
+ *
231
+ * A daemon-owned `.dispatched` (no session marker) is NOT the door holding it —
232
+ * it is the daemon's own in-flight admission, and must never block the daemon's
233
+ * own reclaim — so only a `claimed_by` claim counts. Fail-open in the safe
234
+ * direction: an unreadable dir or claim body reads as NOT handled (the item is
235
+ * answered, never dropped), the same bias as the rest of the DM-latency
236
+ * contract; the assurance sweep releases a genuinely stale claim (>20 min) back
237
+ * to `.yaml`, so a dead door can never permanently hold an item this way.
238
+ *
239
+ * @param {string} agentRoot
240
+ * @param {string} service
241
+ * @param {{id?:string, raw_ref?:string}} item
242
+ * @returns {{handled:boolean, reason:string}}
243
+ */
244
+ export function itemHandledByDoor(agentRoot, service, item) {
245
+ const needles = [item && item.id, item && item.raw_ref]
246
+ .filter(Boolean)
247
+ .map((n) => String(n).trim())
248
+ .filter((n) => n.length > 0);
249
+ if (!needles.length) return { handled: false, reason: "no-id" };
250
+ const dir = join(agentRoot, "state", "inbox", service);
251
+ let names;
252
+ try { names = readdirSync(dir); } catch { return { handled: false, reason: "no-dir" }; }
253
+ for (const file of names) {
254
+ if (!needles.some((n) => file.includes(n))) continue;
255
+ if (file.endsWith(".yaml.processed") || file.endsWith(".yaml.processed-bundled")) {
256
+ return { handled: true, reason: "processed" };
257
+ }
258
+ if (file.endsWith(".yaml.dispatched")) {
259
+ // Only a SESSION claim (`claimed_by`) is the door holding it; a
260
+ // daemon-owned `.dispatched` carries no marker and is the daemon's own.
261
+ try {
262
+ if (parseSessionMarkers(readFileSync(join(dir, file), "utf-8")).claimedBy) {
263
+ return { handled: true, reason: "claimed-by-session" };
264
+ }
265
+ } catch { /* unreadable claim body → not proven handled (fail-open to answering) */ }
266
+ }
267
+ }
268
+ return { handled: false, reason: "not-handled" };
269
+ }
270
+
197
271
  /**
198
272
  * Claim an item for the session: `.yaml` → `.yaml.dispatched` (the shared
199
273
  * durable-admission marker) + `claimed_by: "session"`. Only a NEW item is
@@ -216,6 +290,7 @@ export function markSessionClaim(agentRoot, service, item, o = {}) {
216
290
  const body = stripSessionMarkers(readFileSync(dst, "utf-8"));
217
291
  const sep = body.endsWith("\n") || body === "" ? "" : "\n";
218
292
  writeFileAtomic(dst, body + sep + sessionMarkerLines({ claimedBy: o.by || "session", now: nowOf(o) }));
293
+ noteConsumed(agentRoot, o); // claiming an item is the front door draining inbound (DM-LATENCY)
219
294
  return { ok: true, path: dst };
220
295
  } catch (err) {
221
296
  return { ok: false, error: err && err.message ? err.message : String(err) };
@@ -248,6 +323,7 @@ export function markSessionReplied(agentRoot, service, item, o = {}) {
248
323
  repliedAt: nowOf(o),
249
324
  });
250
325
  writeInPlace(found.path, body + sep + markers);
326
+ noteConsumed(agentRoot, o); // replying is the front door draining inbound (DM-LATENCY)
251
327
  return { ok: true, path: found.path };
252
328
  } catch (err) {
253
329
  return { ok: false, error: err && err.message ? err.message : String(err) };
@@ -318,19 +394,46 @@ function retire(dir, file) {
318
394
 
319
395
  /**
320
396
  * 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.
397
+ * the CLI's terminal transitions (`inbox done`). Never throws; returns whether
398
+ * it wrote.
399
+ *
400
+ * Finishing an item is also the front door draining inbound, so it advances
401
+ * the last-consumed stamp (DM-LATENCY) — the same consume signal a claim and a
402
+ * reply write. The item path is `<agentRoot>/state/inbox/<service>/<file>`, so
403
+ * the agent root is the path with those four segments removed; when it can be
404
+ * recovered, the consume is stamped best-effort. An explicit `o.agentRoot`
405
+ * wins, so a caller that already holds the root need not rely on the derivation.
406
+ *
322
407
  * @param {string} path
408
+ * @param {{now?:number, agentRoot?:string, writeConsumed?:Function}} [o]
323
409
  * @returns {boolean}
324
410
  */
325
- export function stripMarkersInPlace(path) {
411
+ export function stripMarkersInPlace(path, o = {}) {
326
412
  try {
327
413
  const raw = readFileSync(path, "utf-8");
328
414
  const clean = stripSessionMarkers(raw);
329
415
  if (clean !== raw) writeInPlace(path, clean);
416
+ const agentRoot = o.agentRoot || agentRootFromInboxPath(path);
417
+ if (agentRoot) noteConsumed(agentRoot, o);
330
418
  return true;
331
419
  } catch { return false; }
332
420
  }
333
421
 
422
+ /**
423
+ * Recover the agent root from an inbox item path
424
+ * (`<agentRoot>/state/inbox/<service>/<file>`) by dropping the last four
425
+ * segments — but only when the tail actually IS `state/inbox/<service>/<file>`,
426
+ * so a path of an unexpected shape yields null rather than a wrong root.
427
+ */
428
+ function agentRootFromInboxPath(path) {
429
+ const parts = String(path || "").split(/[\\/]/);
430
+ if (parts.length < 5) return null;
431
+ const [file, service, inbox, state] = [parts[parts.length - 1], parts[parts.length - 2], parts[parts.length - 3], parts[parts.length - 4]];
432
+ void file; void service;
433
+ if (inbox !== "inbox" || state !== "state") return null;
434
+ return parts.slice(0, parts.length - 4).join("/") || "/";
435
+ }
436
+
334
437
  /**
335
438
  * THE ASSURANCE SWEEP. Reopens (a) a session claim neither replied nor done
336
439
  * within `staleMs` (default 20 min), (b) a session deferral whose
@@ -425,6 +528,7 @@ export default {
425
528
  parseSessionMarkers,
426
529
  stripSessionMarkers,
427
530
  stripMarkersInPlace,
531
+ itemHandledByDoor,
428
532
  findInboxFile,
429
533
  markSessionClaim,
430
534
  markSessionDeferral,