@cohortapp/agent-sdk 2.18.7 → 2.18.10

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.
@@ -123,6 +123,36 @@ Until it does, the run says so in words on every all-unverifiable finish — tha
123
123
  exit 3 does **not** mean a seat is behind — rather than leaving the reader to
124
124
  infer it from a count.
125
125
 
126
+ ## What a rollout cannot do: a seat whose front door is blocked
127
+
128
+ Publishing is not deploying, and a green stage 3 is not a seat that answers.
129
+ Between "the registry has it" and "this fix is running on that machine" there
130
+ are three steps, and only the first two happen by themselves:
131
+
132
+ 1. **the install** — the seat's hourly `autoupdate.sh` runs
133
+ `npm install @cohortapp/agent-sdk@latest` and lays the framework down;
134
+ 2. **the restart** — the daemon is kickstarted and the front door relaunched.
135
+ Since 2.18.8 the upgrade restarts a front door that is loaded but not
136
+ beating, so this no longer needs a person in the ordinary case;
137
+ 3. **a front door that can actually start.** This one has no automatic path.
138
+
139
+ Step 3 is the gap to state out loud whenever a change ships that only runs on a
140
+ restarted daemon. A front-door session sitting on a **first-run, permission or
141
+ subscription modal** is alive to launchd, alive to `ps`, and answering nobody;
142
+ the supervisor (2.18.6+) clears the modals it can name and restarts the session
143
+ when it cannot, but a dialog that needs a human decision — trust this folder,
144
+ grant this access, choose how to spend money — is not one of those, and waiting
145
+ longer never clears it. Somebody has to be **at that keyboard**: attach to the
146
+ session (`tmux attach -t maestro-<name>`, or `screen -r maestro-<name>`), read
147
+ what is on the screen, and answer it. `plugins/maestro-skills/skills/seat-upgrade.md`
148
+ is the procedure, including which options are never yours to press.
149
+
150
+ So the honest form of "this fix is rolled out" is per seat, not per publish:
151
+ the version moved, the beat is fresh, `maestro session status` is not
152
+ `NOT LIVE`, and the inbox is being answered. A seat stuck at step 3 will show a
153
+ fresh beat from a restarted DAEMON and still answer nothing — which is why the
154
+ beat alone is never the proof.
155
+
126
156
  ## One wrinkle: the gates dirty the tree
127
157
 
128
158
  `npm test` appends to a tracked runtime ledger (`.claude-flow/policy/state.json`),
@@ -487,7 +487,7 @@ export function screenScaffold(text) {
487
487
  *
488
488
  * @returns {string|null} block reason, or null if disclosure rules are satisfied
489
489
  */
490
- export function screenDisclosure(text, { policy, jurisdiction, firstContact }) {
490
+ export function screenDisclosure(text, { policy, jurisdiction, firstContact, recipientClass, channel }) {
491
491
  const lc = String(text || "").toLowerCase();
492
492
 
493
493
  // 1. Truthfulness invariant — locked, applies under every posture. A message
@@ -537,9 +537,31 @@ export function screenDisclosure(text, { policy, jurisdiction, firstContact }) {
537
537
 
538
538
  const posture = postures[postureName] || {};
539
539
 
540
- // Only first-contact messages carry the proactive-disclosure duty; a reply in
541
- // an ongoing thread does not have to re-disclose every turn.
542
- if (posture.proactive_disclosure && firstContact) {
540
+ // WHO THE DUTY IS OWED TO, and when.
541
+ //
542
+ // Two scopes, both read from the policy rather than assumed here:
543
+ // - first contact only. A reply in an ongoing thread does not re-disclose
544
+ // every turn.
545
+ // - external recipients only, when the posture says
546
+ // `internal_recipients_exempt`. A colleague inside the deploying
547
+ // organisation, who sees an AI badge beside the agent's name on every
548
+ // surface, is not the person Art. 50(1) is about.
549
+ //
550
+ // Unscoped on the second axis, this screen blocked every internal post that
551
+ // did not carry the identity line — which taught agents to open every
552
+ // message, including mid-thread corrections to their own numbers, with
553
+ // "Quick note before we get into it: I'm <name>, an AI assistant working
554
+ // with <principal>". The block was doing that, not the model: the only way
555
+ // past a gate that demands disclosure is to disclose.
556
+ //
557
+ // A channel may narrow it further via `channels.<channel>.identity_scope`,
558
+ // the same shape the email footer already carries.
559
+ const chScope = (policy.channels && policy.channels[channel] && policy.channels[channel].identity_scope) || {};
560
+ const internalExempt = chScope.external_recipients_only !== undefined
561
+ ? chScope.external_recipients_only === true
562
+ : posture.internal_recipients_exempt === true;
563
+ const dutyOwed = !(internalExempt && recipientClass === "internal");
564
+ if (posture.proactive_disclosure && firstContact && dutyOwed) {
543
565
  const discloses =
544
566
  /\bai\b/i.test(String(text || "")) ||
545
567
  lc.includes("automated") ||
@@ -738,6 +760,8 @@ export async function screenOutbound({
738
760
  policy: disclosurePolicy,
739
761
  jurisdiction,
740
762
  firstContact,
763
+ recipientClass,
764
+ channel,
741
765
  });
742
766
  if (disclosureReason) return blocked(disclosureReason);
743
767
 
@@ -0,0 +1,105 @@
1
+ /**
2
+ * lib/daemon/reply-debt.mjs — the seat's count of replies it OWED and did not send.
3
+ *
4
+ * Three measured failures on this machine ended the reply path in nothing:
5
+ *
6
+ * 1. `claude CLI exited 1: Error: Session ID <uuid> is already in use.` — the
7
+ * session router's RESUME decision handed a used id to `--session-id`,
8
+ * which is the flag for STARTING a session. Fixed at the source
9
+ * (`sessionArgs` in lib/runtime/adapter.mjs) and retried once here.
10
+ * 2. `Quick reply PERMANENTLY failed for <name> (FORBIDDEN_SCOPE)` — the seat
11
+ * is missing a scope. That is a CONFIGURATION fault: no retry, no session,
12
+ * and no amount of waiting fixes it. Someone has to grant the scope.
13
+ * 3. A CLI stdout that does not parse as the JSON envelope. Failing closed is
14
+ * right — the raw model turn must not reach the wire — but the outcome is
15
+ * still silence.
16
+ *
17
+ * In all three the seat knows it withheld a reply and nobody downstream does.
18
+ * The obligation file and the needs-attention JSON are on the seat's own disk;
19
+ * an operator would have to already suspect this seat to go and look. So every
20
+ * withheld reply is counted through `lib/diagnostics/counters.mjs` (durable
21
+ * JSONL, per UTC day, survives a restart) under the names below, and
22
+ * {@link replyDebtFromCounters} folds today's totals into the one small object
23
+ * the status snapshot carries on every presence beat — which is what turns
24
+ * "this seat withheld N replies today" into a number somebody can see without
25
+ * an ssh.
26
+ *
27
+ * PURE. No clock, no disk, no env: the caller passes today's totals and the
28
+ * date. (`docs`/CLAUDE.md: decisions are pure functions; I/O at the edge.)
29
+ *
30
+ * @module lib/daemon/reply-debt
31
+ */
32
+
33
+ "use strict";
34
+
35
+ /**
36
+ * The counter names the reply path bumps. One table so the emitter
37
+ * (scripts/daemon/*) and the reader (lib/telemetry/collect) cannot drift —
38
+ * a typo on either side would silently zero the number a human reads.
39
+ */
40
+ export const REPLY_DEBT_COUNTERS = Object.freeze({
41
+ /** A reply was withheld because the CLI's stdout did not parse as the envelope. */
42
+ unparseable: "reply.withheld_unparseable",
43
+ /** A reply was refused by a scope the seat does not hold (configuration fault). */
44
+ scopeRefused: "reply.scope_refused",
45
+ /** A spawn hit `Session ID … is already in use`. */
46
+ sessionCollision: "reply.session_collision",
47
+ /** …and the one retry with a fresh id recovered it. */
48
+ sessionCollisionRecovered: "reply.session_collision_recovered",
49
+ });
50
+
51
+ function count(totals, name) {
52
+ const n = Number(totals && totals[name]);
53
+ return Number.isFinite(n) && n > 0 ? n : 0;
54
+ }
55
+
56
+ /**
57
+ * Fold today's counter totals into the seat-level reply-debt summary.
58
+ *
59
+ * Returns `null` when the seat owes nothing today — an all-zero object on every
60
+ * beat would be noise in the snapshot and would train a reader to ignore the
61
+ * field, which is the failure this exists to fix.
62
+ *
63
+ * `withheld` is the headline: replies a person was owed and did not get. A
64
+ * collision that the retry RECOVERED is not withheld (the person got the
65
+ * answer), so it is reported separately rather than summed in — overcounting
66
+ * would make the number untrustworthy in the direction that gets it ignored.
67
+ *
68
+ * @param {Record<string, number>} totals `counters.snapshot()` for today
69
+ * @param {{date?: string}} [o] UTC date stamp the totals are for (YYYY-MM-DD)
70
+ * @returns {{withheld:number, unparseable:number, scopeRefused:number,
71
+ * sessionCollisions:number, recovered:number, date?:string}|null}
72
+ */
73
+ export function replyDebtFromCounters(totals, o = {}) {
74
+ const unparseable = count(totals, REPLY_DEBT_COUNTERS.unparseable);
75
+ const scopeRefused = count(totals, REPLY_DEBT_COUNTERS.scopeRefused);
76
+ const sessionCollisions = count(totals, REPLY_DEBT_COUNTERS.sessionCollision);
77
+ const recovered = count(totals, REPLY_DEBT_COUNTERS.sessionCollisionRecovered);
78
+ // A collision that the retry did not recover cost a reply. Clamped at 0: the
79
+ // two counters are bumped by different code paths and a restart between them
80
+ // could in principle leave recovered > collisions for the day.
81
+ const lostToCollision = Math.max(0, sessionCollisions - recovered);
82
+ const withheld = unparseable + scopeRefused + lostToCollision;
83
+ if (withheld === 0 && sessionCollisions === 0) return null;
84
+ const out = { withheld, unparseable, scopeRefused, sessionCollisions, recovered };
85
+ if (typeof o.date === "string" && o.date) out.date = o.date;
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Is this send failure a CONFIGURATION fault — a scope or permission the seat
91
+ * does not hold — rather than a transport problem?
92
+ *
93
+ * The distinction decides who is owed the trace. A transport failure is the
94
+ * seat's own problem and the assurance sweep retries it; a scope fault is
95
+ * somebody else's problem and retrying is dead time. `FORBIDDEN_SCOPE` is hq's
96
+ * code for a 403 (lib/org/client.mjs:230).
97
+ *
98
+ * @param {string|null|undefined} code
99
+ * @returns {boolean}
100
+ */
101
+ export function isScopeFault(code) {
102
+ return String(code || "").toUpperCase() === "FORBIDDEN_SCOPE";
103
+ }
104
+
105
+ export default { REPLY_DEBT_COUNTERS, replyDebtFromCounters, isScopeFault };
@@ -82,6 +82,66 @@ export const MEMBERSHIP_REQUIRED_KINDS = Object.freeze([
82
82
  * "@here", "roll call"); surface `broadcast`. See broadcast.mjs.
83
83
  */
84
84
 
85
+ /**
86
+ * The reasons that are MEMBERSHIP or VISIBILITY, not identity: this seat was
87
+ * addressed because it can see the thing, not because anything named it.
88
+ *
89
+ * The distinction matters downstream: an ambient address is what a responder
90
+ * ELECTION exists to arbitrate (one seat answers for the room), while a personal
91
+ * address has nothing to arbitrate and is simply mine to answer. Get a reason on
92
+ * the wrong side of this line and every seat that shares the room answers the
93
+ * same comment.
94
+ *
95
+ * ── THE TEST A REASON HAS TO PASS TO STAY OUT OF THIS LIST ──
96
+ * Not "does it sound personal" — CAN TWO SEATS HOLD IT AT ONCE FOR THE SAME
97
+ * EVENT, on a fact that is about access rather than about them? Walked through
98
+ * the whole vocabulary, with the fact each one is read from:
99
+ *
100
+ * IN (plural, access-shaped):
101
+ * - `channel` — `facts.memberChannelIds`. I am in the room.
102
+ * - `participant` — a call in a room I belong to; the room is the join.
103
+ * - `shared` — `facts.fileAcl`, and this one was the bug. `resolveDoc`
104
+ * answers `shared` whenever the ACL entry says so, and `facts.mjs` builds
105
+ * that entry as `Boolean(permission && permission !== "none")` — i.e. "I can
106
+ * OPEN this file". That is a visibility fact of exactly the same class as
107
+ * room membership: a doc shared to a 16-seat channel grants it to all
108
+ * sixteen. Treating it as a personal address put sixteen replies on one
109
+ * comment — the storm the election exists to prevent, relocated to a new
110
+ * surface.
111
+ *
112
+ * OUT (singular, identity-shaped), and why each is safe:
113
+ * - `owner` / `calendar_owner` — one id compared to mine (`acl.ownerId`).
114
+ * - `named` — my own name matched in the comment body.
115
+ * - `assignee` / `reviewer` — `facts.taskRole`, built from the task's single
116
+ * `assigneeId` / `reviewerId` columns. hq has no reviewer LIST; if it ever
117
+ * grows one, `reviewer` moves into this list on the same argument as
118
+ * `shared`.
119
+ * - `approver` / `requester` / `waiting_on` / `direct` / `to` — a single id on
120
+ * the row, compared to mine.
121
+ * - `proposer` — `decision.proposedBy`, one id.
122
+ * - `attendee` — my own attendee row on the event, from my ACL'd read.
123
+ * - `commenter` — plural, but it is `decisionVoices.has(me)`: I SPOKE there.
124
+ * That is the same shape as `thread` below — participation I performed, not
125
+ * access I was granted — and it stays out for the same reason.
126
+ *
127
+ * `thread` is deliberately NOT here: a reply in a thread I have spoken in is a
128
+ * continuation of my own participation, and it is a room surface anyway, so it
129
+ * keeps the messaging election by the ordinary route.
130
+ *
131
+ * @type {readonly string[]}
132
+ */
133
+ export const MEMBERSHIP_REASONS = Object.freeze(["channel", "participant", "shared"]);
134
+
135
+ /**
136
+ * Was this item's address proved by membership rather than by naming this seat?
137
+ *
138
+ * @param {string} reason a {@link Reason}
139
+ * @returns {boolean}
140
+ */
141
+ export function isMembershipReason(reason) {
142
+ return MEMBERSHIP_REASONS.includes(String(reason || ""));
143
+ }
144
+
85
145
  /** Narrow an unknown payload to a readable record (never throws). */
86
146
  function obj(v) {
87
147
  return v && typeof v === "object" && !Array.isArray(v) ? v : {};
@@ -80,6 +80,7 @@ export const DEFAULT_LIMITS = Object.freeze({
80
80
  * @property {Set<string>|null} myEventIds calendar events I own or attend (null == unreadable)
81
81
  * @property {Map<string,object>|null} myEvents eventId → the calendar event DTO
82
82
  * @property {Map<string,string>|null} memberKinds member id AND slug → "HUMAN"|"AI_AGENT" (null == not read / unreadable)
83
+ * @property {Map<string,string>|null} memberNames member id AND slug → displayName (null == not read / unreadable)
83
84
  * @property {number|null} aiMemberCount how many AI_AGENT members the org has (the roll-call respondent bound)
84
85
  * @property {string[]} degraded names of facts that could not be read
85
86
  */
@@ -134,6 +135,7 @@ export async function resolveFacts(o = {}) {
134
135
  myEventIds: null,
135
136
  myEvents: null,
136
137
  memberKinds: null,
138
+ memberNames: null,
137
139
  aiMemberCount: null,
138
140
  degraded,
139
141
  };
@@ -353,8 +355,18 @@ export function wantsMemberKinds(candidates, facts, me) {
353
355
  /**
354
356
  * `member.list` → `facts.memberKinds` keyed by BOTH member id and slug (the
355
357
  * chain's `actor` is documented as "member slug or api-key id" while history's
356
- * `authorId` is the cuid; one map answers either) + `facts.aiMemberCount`.
357
- * Org-scoped, read-only, the same directory every seat may already list.
358
+ * `authorId` is the cuid; one map answers either) + `facts.memberNames`, keyed
359
+ * the same way, + `facts.aiMemberCount`. Org-scoped, read-only, the same
360
+ * directory every seat may already list.
361
+ *
362
+ * WHY THE NAMES, GIVEN THE KINDS WERE ENOUGH. Every hydrator falls back to
363
+ * `c.actor` when the surface's own payload carries no author NAME — and for a
364
+ * doc comment, a decision comment or an approval, the payload routinely carries
365
+ * only an id. That fallback put a raw member cuid into `from.name`, so the item
366
+ * arrived with `sender: "cmub6e9lb0ojn…"` and every log line, every prompt and
367
+ * every board row said it. The directory that answers "who is that id" was
368
+ * already being fetched here, once per pull, and its names were being thrown
369
+ * away. Keeping them costs no extra call.
358
370
  */
359
371
  async function resolveMemberKinds({ facts, io, log, degraded }) {
360
372
  const frame = await io.call("member.list", {});
@@ -364,13 +376,25 @@ async function resolveMemberKinds({ facts, io, log, degraded }) {
364
376
  return;
365
377
  }
366
378
  const kinds = new Map();
379
+ const names = new Map();
367
380
  let ai = 0;
368
381
  for (const m of arrayOf(resultOf(frame), "members")) {
369
382
  if (!m) continue;
370
- const kind = String(m.kind || "").toUpperCase();
371
- if (!kind) continue;
372
383
  const id = String(m.id || "");
373
384
  const slug = String(m.slug || "");
385
+ // The name is recorded even for a member whose `kind` is blank — an unknown
386
+ // kind is a reason not to prove anybody human, never a reason to forget who
387
+ // they are.
388
+ const name = String(m.displayName || "").trim();
389
+ if (name) {
390
+ if (id) names.set(id, name);
391
+ if (slug) {
392
+ names.set(slug, name);
393
+ names.set(slug.toLowerCase(), name);
394
+ }
395
+ }
396
+ const kind = String(m.kind || "").toUpperCase();
397
+ if (!kind) continue;
374
398
  if (id) kinds.set(id, kind);
375
399
  if (slug) {
376
400
  kinds.set(slug, kind);
@@ -379,6 +403,7 @@ async function resolveMemberKinds({ facts, io, log, degraded }) {
379
403
  if (kind === "AI_AGENT") ai += 1;
380
404
  }
381
405
  facts.memberKinds = kinds;
406
+ facts.memberNames = names;
382
407
  facts.aiMemberCount = ai;
383
408
  }
384
409
 
@@ -87,7 +87,7 @@ export function toMessageEvent(o = {}) {
87
87
  const subjectDetail = s(hydrated.subjectDetail);
88
88
  const subject = subjectDetail ? `${def.subject}: ${subjectDetail}` : def.subject;
89
89
 
90
- const from = hydrated.from || { id: s(c.actor), name: s(c.actor) };
90
+ const from = resolveFrom(hydrated, c, facts);
91
91
  const scope = scopeFor(verdict.surface, channelKind, def.scope);
92
92
 
93
93
  const ev = {
@@ -199,6 +199,43 @@ export function toMessageEvent(o = {}) {
199
199
  return ev;
200
200
  }
201
201
 
202
+ /**
203
+ * Who sent this — with a NAME, not a database id.
204
+ *
205
+ * Every hydrator ends its `from` with the same fallback chain, and the last link
206
+ * is always `c.actor`, a member cuid. That is correct as a LAST resort and wrong
207
+ * as an answer: a surface whose payload carries only an author id (a doc
208
+ * comment, a decision comment, an approval) arrived with `name === id`, and the
209
+ * id is what then appeared in the daemon's log lines ("staying silent for
210
+ * cmub6e9lb0ojn…"), in the classifier prompt, and on any board row the turn
211
+ * opened. The org directory that answers "who is that id" is already in hand —
212
+ * `facts.memberNames`, built from the one `member.list` the facts pass makes per
213
+ * pull — so this consults it before settling for the id.
214
+ *
215
+ * FAIL-OPEN, like everything else in this file: an unreadable directory, an
216
+ * outsider, an api-key actor with no member row, all keep today's behaviour and
217
+ * carry the id. A name is an improvement on an id, never a precondition for
218
+ * delivering the item.
219
+ *
220
+ * @param {import("./hydrate.mjs").Hydrated} hydrated
221
+ * @param {import("./directedness.mjs").Candidate} c
222
+ * @param {import("./facts.mjs").Facts} facts
223
+ * @returns {{id:string,name:string}}
224
+ */
225
+ export function resolveFrom(hydrated, c, facts) {
226
+ const raw = (hydrated && hydrated.from) || {};
227
+ const id = s(raw.id || (c && c.actor));
228
+ const given = s(raw.name).trim();
229
+ // A hydrator that could not find a name hands back the id itself. Treat that
230
+ // as "no name", not as a name that happens to look like a cuid.
231
+ if (given && given !== id) return { id, name: given };
232
+ const names = facts && facts.memberNames instanceof Map ? facts.memberNames : null;
233
+ const looked = names
234
+ ? s(names.get(id) || names.get(id.toLowerCase()) || "").trim()
235
+ : "";
236
+ return { id, name: looked || given || id };
237
+ }
238
+
202
239
  /**
203
240
  * The per-surface `message_id`. For messaging this stays the hq message id so
204
241
  * the projection is byte-identical to today's DM path; for everything else the
@@ -16,6 +16,10 @@
16
16
  * daemon classifier can route WITHOUT parsing prose,
17
17
  * - `subject` the human-readable inbox subject prefix,
18
18
  * - `scope` the `source.userScope` the projection stamps,
19
+ * - `room` is this surface ROOM TRAFFIC — does it arrive on a Cohort
20
+ * channel, carry a real `channel_id`, and is a reply to it a
21
+ * message posted back into that room? See `ROOM_SURFACES` below
22
+ * for why this is a structural fact and not a convenience.
19
23
  * - `default` whether the surface is tailed unless the operator says otherwise.
20
24
  *
21
25
  * Adding a surface is a data edit here plus a `classifyEvent` branch in
@@ -45,6 +49,7 @@ export const SURFACES = Object.freeze({
45
49
  kind: "message",
46
50
  subject: "Cohort message",
47
51
  scope: "dm",
52
+ room: true,
48
53
  default: true,
49
54
  }),
50
55
  /** An @mention of me in a public/private space. THE headline gap. */
@@ -53,6 +58,7 @@ export const SURFACES = Object.freeze({
53
58
  kind: "mention",
54
59
  subject: "Cohort @mention",
55
60
  scope: "channel",
61
+ room: true,
56
62
  default: true,
57
63
  }),
58
64
  /**
@@ -67,6 +73,7 @@ export const SURFACES = Object.freeze({
67
73
  kind: "broadcast",
68
74
  subject: "Cohort roll-call",
69
75
  scope: "channel",
76
+ room: true,
70
77
  default: true,
71
78
  }),
72
79
  /** A reply in a thread I have spoken in / been named in. */
@@ -75,6 +82,7 @@ export const SURFACES = Object.freeze({
75
82
  kind: "thread_reply",
76
83
  subject: "Cohort thread reply",
77
84
  scope: "channel",
85
+ room: true,
78
86
  default: true,
79
87
  }),
80
88
  /** A call started/invited in one of my rooms. Byte-compatible with today. */
@@ -83,6 +91,7 @@ export const SURFACES = Object.freeze({
83
91
  kind: "call",
84
92
  subject: "Cohort call invite",
85
93
  scope: "channel",
94
+ room: true,
86
95
  default: true,
87
96
  }),
88
97
  /** A board item assigned to me (or claimed/completed on my behalf). */
@@ -91,6 +100,7 @@ export const SURFACES = Object.freeze({
91
100
  kind: "task_assigned",
92
101
  subject: "Cohort task assigned",
93
102
  scope: "channel",
103
+ room: false,
94
104
  default: true,
95
105
  }),
96
106
  /** A comment / block / move on a board item I own or review. */
@@ -99,6 +109,7 @@ export const SURFACES = Object.freeze({
99
109
  kind: "task_comment",
100
110
  subject: "Cohort task update",
101
111
  scope: "channel",
112
+ room: false,
102
113
  default: true,
103
114
  }),
104
115
  /** A comment on a chat-attached file in one of my rooms (family `file`). */
@@ -107,6 +118,7 @@ export const SURFACES = Object.freeze({
107
118
  kind: "file_comment",
108
119
  subject: "Cohort file comment",
109
120
  scope: "channel",
121
+ room: false,
110
122
  default: true,
111
123
  }),
112
124
  /** A comment / suggestion on a workspace doc I own or am shared on (family `files`). */
@@ -115,6 +127,7 @@ export const SURFACES = Object.freeze({
115
127
  kind: "file_comment",
116
128
  subject: "Cohort document comment",
117
129
  scope: "channel",
130
+ room: false,
118
131
  default: true,
119
132
  }),
120
133
  /** An approval on my desk, or the outcome of one I requested. */
@@ -123,6 +136,7 @@ export const SURFACES = Object.freeze({
123
136
  kind: "approval",
124
137
  subject: "Cohort approval",
125
138
  scope: "channel",
139
+ room: false,
126
140
  default: true,
127
141
  }),
128
142
  /** A decision I proposed / commented on / must sign. */
@@ -131,6 +145,7 @@ export const SURFACES = Object.freeze({
131
145
  kind: "decision",
132
146
  subject: "Cohort decision",
133
147
  scope: "channel",
148
+ room: false,
134
149
  default: true,
135
150
  }),
136
151
  /** An escalation raised on my task / in my room / waiting on me. */
@@ -139,6 +154,7 @@ export const SURFACES = Object.freeze({
139
154
  kind: "escalation",
140
155
  subject: "Cohort escalation",
141
156
  scope: "channel",
157
+ room: false,
142
158
  default: true,
143
159
  }),
144
160
  /**
@@ -159,6 +175,7 @@ export const SURFACES = Object.freeze({
159
175
  kind: "calendar",
160
176
  subject: "Cohort calendar",
161
177
  scope: "channel",
178
+ room: false,
162
179
  default: true,
163
180
  }),
164
181
  /** A delegation offered to me, or one I offered being accepted/declined. */
@@ -167,6 +184,7 @@ export const SURFACES = Object.freeze({
167
184
  kind: "handoff",
168
185
  subject: "Cohort handoff",
169
186
  scope: "channel",
187
+ room: false,
170
188
  default: true,
171
189
  }),
172
190
  /** An inbound email routed to my mailbox. OFF by default — see note above. */
@@ -175,6 +193,7 @@ export const SURFACES = Object.freeze({
175
193
  kind: "email",
176
194
  subject: "Cohort email",
177
195
  scope: "dm",
196
+ room: false,
178
197
  default: false,
179
198
  }),
180
199
  });
@@ -182,6 +201,55 @@ export const SURFACES = Object.freeze({
182
201
  /** Every surface name, sorted for determinism. */
183
202
  export const SURFACE_NAMES = Object.freeze(Object.keys(SURFACES).sort());
184
203
 
204
+ /**
205
+ * The surfaces that are ROOM TRAFFIC: the event arrived on a Cohort channel, the
206
+ * projection stamps a real `channel_id`, and a reply is a message posted back
207
+ * into that room.
208
+ *
209
+ * WHY THIS IS A STRUCTURAL FACT AND NOT A CONVENIENCE LIST. Two mechanisms are
210
+ * only defined for room traffic, and both used to be decided by a hand-kept list
211
+ * or by nothing at all:
212
+ *
213
+ * 1. REPLY ROUTING. `scripts/daemon/deliver.mjs` kept its own copy of these
214
+ * five names (`CHANNEL_SURFACES`) and now derives them from here, so a new
215
+ * surface cannot be routed by a list that was never told about it.
216
+ * 2. THE RESPONDER ELECTION. `messaging.electResponder` is a MESSAGING
217
+ * mechanism: it takes `{messageId, channelId}`, loads that Message row and
218
+ * that channel's AI roster, and elects one of the seats in the room. A
219
+ * surface that is not room traffic has neither id, so the election cannot
220
+ * run on it — and, because the election's failure mode is inverted to
221
+ * fail-SILENT, running it anyway turns "this is not a message" into "stay
222
+ * quiet". That is exactly how 23 document comments were dropped on this
223
+ * seat in a day, each one logged as a decision not to answer a message.
224
+ *
225
+ * A non-room surface reaches this seat only because `directedness.mjs` PROVED
226
+ * the address (I own the doc, I am the assignee, the approval is on my desk).
227
+ * There is no roster to arbitrate and nothing to elect.
228
+ */
229
+ export const ROOM_SURFACES = Object.freeze(
230
+ Object.entries(SURFACES)
231
+ .filter(([, def]) => def.room === true)
232
+ .map(([name]) => name)
233
+ .sort(),
234
+ );
235
+
236
+ /**
237
+ * Is this surface room traffic?
238
+ *
239
+ * UNKNOWN NAMES ANSWER `true`. The caller that asks is deciding whether to SKIP
240
+ * the responder election, and skipping it for an ambient channel message is the
241
+ * multi-seat storm this whole mechanism exists to prevent. So an unrecognised
242
+ * surface keeps the election — the conservative answer — and only the surfaces
243
+ * this table positively declares non-room are exempted.
244
+ *
245
+ * @param {string} name
246
+ * @returns {boolean}
247
+ */
248
+ export function isRoomSurface(name) {
249
+ const def = SURFACES[String(name || "")];
250
+ return def ? def.room === true : true;
251
+ }
252
+
185
253
  /**
186
254
  * The MessageEvent `kind` values these surfaces introduce, beyond the five the
187
255
  * channel contract already ships. `lib/channels/contract.mjs` must list them in
@@ -207,14 +207,46 @@ export function subscriptionAuth(env) {
207
207
  * capture the child's transcript (lib/collective/loop-guard.mjs) — without it,
208
208
  * every capture spawns a session whose end spawns another capture.
209
209
  */
210
+ /**
211
+ * The session flag for a `--print` lane, and THE reason this is a function.
212
+ *
213
+ * `--session-id <uuid>` means "START a session under this id". The Claude CLI
214
+ * REFUSES it — `Error: Session ID <uuid> is already in use.`, exit 1, in 0.1s,
215
+ * before any model call — when a transcript for that id already exists. So the
216
+ * note these lanes used to carry ("pre-minting + reusing the same UUID across
217
+ * spawns IS the resume mechanism; never combine --resume with --session-id")
218
+ * was false, and it was false in the only direction that costs a reply: the
219
+ * session-router's RESUME decision handed a previously-used id straight back to
220
+ * `--session-id`, so EVERY resume exited 1 and the reply was dropped. Measured
221
+ * on this seat: four dropped quick replies 09-22..09-24, one per room per TTL
222
+ * cycle (the non-zero exit makes the next route EPHEMERAL_REPLACE, which is why
223
+ * it alternated rather than wedged — and why continuity had in fact never once
224
+ * worked).
225
+ *
226
+ * Continuing an existing transcript is `--resume <uuid>` (`claude --help`:
227
+ * "continues that session in the background under the same ID, or starts a copy
228
+ * and says so when the session is already running" — non-fatal either way).
229
+ * Starting a fresh one under a chosen id is `--session-id <uuid>`. The caller
230
+ * knows which it has; `resumeSession` is it saying so.
231
+ *
232
+ * @param {{sessionId?:string|null, resumeSession?:boolean}} i
233
+ * @returns {string[]}
234
+ */
235
+ export function sessionArgs(i = {}) {
236
+ if (!i.sessionId) return [];
237
+ return i.resumeSession === true
238
+ ? ["--resume", String(i.sessionId)]
239
+ : ["--session-id", String(i.sessionId)];
240
+ }
241
+
210
242
  const LANES = Object.freeze({
211
243
  dispatcher: {
212
244
  posture: "stock-oauth", stdin: false, retarget: true,
213
- argv: (i, x) => ["--print", "--output-format", "json", ...x.settings, ...x.perm, ...x.mcp, ...x.knobs, "--session-id", i.sessionId, "--model", i.model, i.prompt],
245
+ argv: (i, x) => ["--print", "--output-format", "json", ...x.settings, ...x.perm, ...x.mcp, ...x.knobs, ...sessionArgs(i), "--model", i.model, i.prompt],
214
246
  },
215
247
  resume: {
216
248
  posture: "stock-oauth", stdin: false, retarget: true,
217
- argv: (i, x) => ["--print", "--output-format", "json", ...x.settings, ...x.perm, ...x.mcp, ...x.knobs, "--session-id", i.sessionId, "--model", i.model, i.prompt],
249
+ argv: (i, x) => ["--print", "--output-format", "json", ...x.settings, ...x.perm, ...x.mcp, ...x.knobs, ...sessionArgs(i), "--model", i.model, i.prompt],
218
250
  },
219
251
  cadence: {
220
252
  posture: "cadence", stdin: false, retarget: true,
@@ -222,7 +254,7 @@ const LANES = Object.freeze({
222
254
  },
223
255
  responder: {
224
256
  posture: "stock-oauth", stdin: true, retarget: false,
225
- argv: (i, x) => ["--print", ...x.perm, ...x.mcp, "--model", i.model, "--append-system-prompt", i.systemPrompt, "--output-format", "json", ...(i.sessionId ? ["--session-id", i.sessionId] : [])],
257
+ argv: (i, x) => ["--print", ...x.perm, ...x.mcp, "--model", i.model, "--append-system-prompt", i.systemPrompt, "--output-format", "json", ...sessionArgs(i)],
226
258
  },
227
259
  classifier: {
228
260
  posture: "stock-oauth", stdin: true, retarget: false,
@@ -361,7 +393,10 @@ function laneEnv(spec, i, deps) {
361
393
  * @param {string} [i.model] the --model value
362
394
  * @param {string} [i.prompt] the prompt; argv or stdin per lane
363
395
  * @param {string} [i.systemPrompt] --append-system-prompt (responder/classifier/ack/enrich)
364
- * @param {string} [i.sessionId] --session-id (dispatcher/resume/responder/main-session)
396
+ * @param {string} [i.sessionId] the session uuid (dispatcher/resume/responder/main-session)
397
+ * @param {boolean} [i.resumeSession] true → `--resume <id>` (continue an existing
398
+ * transcript); false/absent → `--session-id <id>`
399
+ * (start a new one under that id). See {@link sessionArgs}.
365
400
  * @param {null|"strict"|{source?:string, agentRoot?:string}} [i.mcp] MCP flags
366
401
  * @param {string[]} [i.permissions] resolved permission args (lib/session-permissions.mjs)
367
402
  * @param {object} [i.knobs] {maxTurns, effort, agentsJson} router spawn knobs