@cohortapp/agent-sdk 2.18.8 → 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.
- package/docs/runbooks/fleet-rollout.md +30 -0
- package/lib/comms/send-gate.mjs +28 -4
- package/lib/daemon/reply-debt.mjs +105 -0
- package/lib/org/inbound/directedness.mjs +60 -0
- package/lib/org/inbound/facts.mjs +29 -4
- package/lib/org/inbound/project.mjs +38 -1
- package/lib/org/inbound/surfaces.mjs +68 -0
- package/lib/runtime/adapter.mjs +39 -4
- package/lib/session/first-run.mjs +213 -16
- package/lib/telemetry/alerts.mjs +159 -2
- package/lib/telemetry/collect.mjs +27 -1
- package/package.json +1 -1
- package/policies/ai-disclosure.yaml +47 -0
- package/scripts/daemon/agent-daemon.mjs +206 -13
- package/scripts/daemon/assurance.mjs +5 -0
- package/scripts/daemon/deliver.mjs +79 -11
- package/scripts/daemon/dispatcher.mjs +68 -26
- package/scripts/daemon/lib/session-router.mjs +21 -0
- package/scripts/daemon/responder.mjs +92 -17
- package/scripts/session/supervisor.mjs +70 -13
|
@@ -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`),
|
package/lib/comms/send-gate.mjs
CHANGED
|
@@ -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
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
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.
|
|
357
|
-
* Org-scoped, read-only, the same
|
|
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
|
|
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
|
package/lib/runtime/adapter.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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]
|
|
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
|