@cohortapp/agent-sdk 2.11.3 → 2.11.5

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.
@@ -663,7 +663,21 @@ export function dailyAllowance(deps) {
663
663
  * @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap?,
664
664
  * seatEnvelope?, mandateCachePath?, log? }
665
665
  */
666
+ /**
667
+ * Is this seat authenticated by a flat-fee Max SUBSCRIPTION (not the metered
668
+ * API)? A subscription seat has no dollar meter: the CLI's `total_cost_usd` is
669
+ * API-EQUIVALENT, not spend, and every session reports "unmeasured" by design —
670
+ * that is normal, not a telemetry outage. Banding such a seat on dollars
671
+ * suspends a seat that costs a flat fee, stops its autonomous work, and floods
672
+ * the log every tick. `deps.subscriptionAuth` overrides for tests.
673
+ */
674
+ export function isSubscriptionAuth(deps) {
675
+ if (deps && typeof deps.subscriptionAuth === "boolean") return deps.subscriptionAuth;
676
+ return process.env.MAESTRO_PREFER_SUBSCRIPTION_AUTH === "1" && !process.env.ANTHROPIC_API_KEY;
677
+ }
678
+
666
679
  export function dailyStatus(deps) {
680
+ const subscriptionAuth = isSubscriptionAuth(deps);
667
681
  const iterationCap = cfgIterationCap(deps);
668
682
  const today = sumToday(deps);
669
683
  const { spentUSD, sessions } = today;
@@ -708,15 +722,23 @@ export function dailyStatus(deps) {
708
722
  // The binding constraint is whichever is closest to (or furthest past) the cap.
709
723
  // A null day-reading contributes nothing; the exhausted-envelope case is
710
724
  // already ≥100 % on the month, so nothing is lost by skipping it.
711
- const pct = Math.max(spendPct ?? 0, monthPct, iterPct);
725
+ // A subscription-auth seat has no dollar meter (see isSubscriptionAuth): its
726
+ // dollar "spend" is API-equivalent fiction, so banding on it wrongly suspends a
727
+ // flat-fee seat and halts its autonomous work. Band it on SESSION VOLUME only
728
+ // (a real limit); concurrency (resource-governor) + the 429 rate-breaker are
729
+ // its other guards.
730
+ const pct = subscriptionAuth
731
+ ? iterPct
732
+ : Math.max(spendPct ?? 0, monthPct, iterPct);
712
733
  let band = bandForPct(pct);
713
734
 
714
735
  // ── BLIND ⇒ DEGRADE ────────────────────────────────────────────────────────
715
736
  // `blind` (sessions ran, none measured) was computed and reported and enforced
716
737
  // by nothing. It is now load-bearing: we do not invent a price, we lower the
717
738
  // rung. Only when the day is TRULY unpriceable — if a month mean was available
718
- // the spend was imputed from it and the ordinary band applies.
719
- if (today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
739
+ // the spend was imputed from it and the ordinary band applies. Does NOT apply
740
+ // to subscription auth, where "unmeasured" is the normal, expected state.
741
+ if (!subscriptionAuth && today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
720
742
  extraDegradations.push(
721
743
  `band raised ${band}% → ${BLIND_MIN_BAND}%: ${today.unmeasuredSessions} session(s) ran today and NONE could be ` +
722
744
  "measured or priced. An unmeasurable seat is degraded, not trusted."
@@ -725,7 +747,16 @@ export function dailyStatus(deps) {
725
747
  }
726
748
  const posture = postureForBand(band);
727
749
 
728
- const degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
750
+ let degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
751
+ // On a subscription seat the dollar-meter degradations (imputed / unmeasured /
752
+ // unfunded-envelope / telemetry) are irrelevant — there is no dollar meter —
753
+ // and were logged EVERY tick, burying real signal (a DM not answered) under a
754
+ // flood. Drop them; keep any non-dollar note (e.g. a malformed ledger line).
755
+ if (subscriptionAuth) {
756
+ degradations = degradations.filter(
757
+ (d) => !/imputed|unmeasured|unpriceable|UNFUNDED SEAT|envelope|per-session|telemetry|blind/i.test(String(d)),
758
+ );
759
+ }
729
760
  // Degradations ride on the returned status AND go to the log: the daemon is
730
761
  // usually the only thing awake when telemetry breaks, and a governor that
731
762
  // quietly under-counts is the failure mode this module now exists to prevent.
@@ -338,3 +338,28 @@ test("a ledger of only zero-token rows sums to $0 (proves the bug is the input,
338
338
  assert.equal(s.sessions, 2);
339
339
  } finally { await rm(dir); }
340
340
  });
341
+
342
+ test("dailyStatus: a subscription-auth seat is banded on session VOLUME, not dollars", async () => {
343
+ const dir = await makeLedgerDir();
344
+ try {
345
+ seedLedger(dir, [{ usd: 40 }]); // $40 of $10 = 400%
346
+ // A metered (API) seat: dollars bind → band 150 (refused).
347
+ assert.equal(dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100 }).band, 150);
348
+ // A flat-fee subscription seat has no dollar meter — the same API-equivalent
349
+ // "spend" is fiction. It lands at band 0 / normal so autonomous work keeps
350
+ // running; only session VOLUME can band it.
351
+ const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100, subscriptionAuth: true });
352
+ assert.equal(sub.band, 0);
353
+ assert.equal(sub.mode, "normal");
354
+ } finally { await rm(dir); }
355
+ });
356
+
357
+ test("dailyStatus: a subscription-auth seat STILL bands on session volume (a real limit)", async () => {
358
+ const dir = await makeLedgerDir();
359
+ try {
360
+ seedLedger(dir, Array.from({ length: 12 }, () => ({ usd: 0.01 }))); // 12 sessions
361
+ const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 1000, iterationCap: 10, subscriptionAuth: true });
362
+ assert.equal(sub.band, 100, "12 sessions / cap 10 = 120% → degrade even on subscription");
363
+ assert.equal(sub.mode, "degraded");
364
+ } finally { await rm(dir); }
365
+ });
@@ -16,14 +16,26 @@
16
16
  * never dropped — and the agent throttles instead of bricking.
17
17
  *
18
18
  * Dynamic ceiling (replaces the hardcoded MAX_CONCURRENT=10):
19
- * dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(10, cpus))
19
+ * dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(HARD_MAX, cpus))
20
20
  * effectiveMax = min(dynamicMax, throttleCeiling)
21
21
  * where throttleCeiling comes from state/throttle.json (written by the memory
22
22
  * watchdog's SOFT tier; auto-expiring). The governor only READS that file —
23
- * it never writes it.
23
+ * it never writes it. HARD_MAX defaults to 4, not 10: the binding constraint on
24
+ * a subscription-auth seat is the ONE Max subscription, which cannot serve many
25
+ * parallel `claude --print` calls — ten concurrent sessions starved the reactive
26
+ * quick-reply's claude call into a 60s timeout. API/multi-key deployments raise
27
+ * it with GOV_HARD_MAX.
28
+ *
29
+ * The concurrency ceiling is PER SOURCE, because a dispatch slot is not the
30
+ * scarce resource — the subscription + host CPU are. Reactive work (inbox / a
31
+ * direct human or agent reply) may use the WHOLE envelope; self-directed backlog
32
+ * is held REACTIVE_RESERVE (default 2) slots short so a reply can always be
33
+ * GENERATED, not merely dispatched. This is the governor-level twin of the
34
+ * dispatcher's RESERVED_INBOX_SLOTS (which reserves a dispatch slot).
24
35
  *
25
36
  * Thresholds (env GOV_* overridable; tests inject `deps` instead):
26
- * liveCount >= effectiveMax → QUEUE
37
+ * inbox liveCount >= effectiveMax → QUEUE
38
+ * backlog liveCount >= effectiveMax - REACTIVE_RESERVE → QUEUE
27
39
  * freemem < totalmem*0.15 OR < 2*750MB headroom → DEFER
28
40
  * loadavg1 > cpus*1.5 → DEFER
29
41
  * liveRSS + 750MB > 0.60*totalmem → QUEUE
@@ -31,7 +43,9 @@
31
43
  *
32
44
  * Plus the budget ladder (`mode`, from `lib/budget-guard.dailyStatus().mode`),
33
45
  * banded against the seat's hq-funded envelope rather than a local config cap:
34
- * degraded (≥100%) source==="backlog" → DEFER (D3 stops)
46
+ * degraded (≥100%) source==="backlog" → THROTTLE to DEGRADED_BACKLOG_MAX
47
+ * (default 1) — a trickle in idle gaps,
48
+ * not a stop (owner instruction).
35
49
  * suspended (≥125%) source==="backlog"|"cadence" → DEFER
36
50
  * refused (>150%) anything that is not a direct human reply → DEFER
37
51
  * Inbox always goes through the normal ADMIT/QUEUE path — never go dark on the
@@ -64,8 +78,29 @@ export const RAM_FRACTION = frac(process.env.GOV_RAM_FRACTION, 0.6);
64
78
  export const FREEMEM_FLOOR_FRACTION = frac(process.env.GOV_FREEMEM_FLOOR, 0.15);
65
79
  /** Load-average multiplier over cpu count that triggers a DEFER. */
66
80
  export const LOAD_FACTOR = num(process.env.GOV_LOAD_FACTOR, 1.5);
67
- /** Hard ceiling on dynamicMax regardless of RAM. */
68
- export const HARD_MAX = num(process.env.GOV_HARD_MAX, 10);
81
+ /**
82
+ * Hard ceiling on dynamicMax regardless of RAM. The binding constraint on a
83
+ * subscription-auth seat is NOT host RAM — it is the ONE Max subscription, which
84
+ * cannot serve many parallel `claude --print` calls without throttling. Ten
85
+ * concurrent backlog sessions starved the reactive quick-reply's claude call
86
+ * into a 60s timeout (observed on A016/isla). Default is subscription-safe;
87
+ * API/multi-key deployments raise it with GOV_HARD_MAX.
88
+ */
89
+ export const HARD_MAX = num(process.env.GOV_HARD_MAX, 4);
90
+ /**
91
+ * Slots the governor holds back from self-directed (backlog/cadence) work so a
92
+ * human/agent reply always has subscription + host headroom to GENERATE, not
93
+ * merely a dispatch slot. The dispatcher's RESERVED_INBOX_SLOTS reserves a
94
+ * dispatch slot; this reserves the scarce resource that actually timed out.
95
+ */
96
+ export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
97
+ /**
98
+ * The thin trickle of self-directed work a DEGRADED/unfunded seat still runs.
99
+ * Owner instruction (2026-08-25): throttle backlog on an unfunded seat, do not
100
+ * stop it — autonomous progress keeps ticking in idle gaps while the inbox lane
101
+ * gets near-all capacity.
102
+ */
103
+ export const DEGRADED_BACKLOG_MAX = num(process.env.GOV_DEGRADED_BACKLOG_MAX, 1);
69
104
  /** Minimum dynamicMax (always allow at least one session). */
70
105
  const MIN_MAX = 1;
71
106
 
@@ -348,15 +383,36 @@ export function admit(req = {}, deps) {
348
383
  if (mode === "suspended" && (source === "backlog" || source === "cadence")) {
349
384
  return decide(DEFER, "budget suspended (>=125% of the seat envelope): deferring non-inbox work");
350
385
  }
351
- if (mode === "degraded" && source === "backlog") {
352
- return decide(DEFER, "budget degraded (>=100% of the seat envelope): self-directed backlog work stopped; obligations + inbox continue");
386
+ // NOTE: `degraded` (>=100% / unfunded) no longer hard-stops backlog. Owner
387
+ // instruction (2026-08-25): THROTTLE, don't stop — the seat keeps a thin
388
+ // trickle of self-directed work so autonomous progress never fully halts,
389
+ // while the inbox lane gets near-all capacity. Enforced as a lowered
390
+ // per-source concurrency ceiling immediately below, not a DEFER.
391
+
392
+ // (1) Concurrency ceiling → QUEUE (work re-derivable from the queue). The
393
+ // ceiling is PER SOURCE, because a dispatch slot is not the scarce
394
+ // resource — the single Max subscription + host CPU are. Reactive work
395
+ // (inbox / a direct human or agent reply) may use the WHOLE envelope;
396
+ // self-directed backlog is held REACTIVE_RESERVE slots short so a reply
397
+ // can always be GENERATED (10 parallel sessions once starved the
398
+ // quick-reply's claude call into a 60s timeout). A DEGRADED/unfunded seat
399
+ // throttles backlog to DEGRADED_BACKLOG_MAX — a trickle in idle gaps.
400
+ let sourceCeiling;
401
+ if (humanReply) {
402
+ sourceCeiling = effectiveMax;
403
+ } else if (mode === "degraded" && source === "backlog") {
404
+ sourceCeiling = Math.min(effectiveMax, DEGRADED_BACKLOG_MAX);
405
+ } else {
406
+ sourceCeiling = Math.max(1, effectiveMax - REACTIVE_RESERVE);
353
407
  }
354
-
355
- // (1) Concurrency ceiling → QUEUE (work re-derivable from the queue).
356
- if (liveCount >= effectiveMax) {
357
- const why = throttleCeiling < dMax
358
- ? `at throttled ceiling (${liveCount}/${effectiveMax}; soft-throttle active)`
359
- : `at concurrency ceiling (${liveCount}/${effectiveMax})`;
408
+ if (liveCount >= sourceCeiling) {
409
+ const why = mode === "degraded" && source === "backlog"
410
+ ? `budget degraded: self-directed backlog throttled to ${sourceCeiling} (${liveCount} live; inbox unaffected)`
411
+ : throttleCeiling < dMax
412
+ ? `at throttled ceiling (${liveCount}/${sourceCeiling}; soft-throttle active)`
413
+ : source === "inbox"
414
+ ? `at concurrency ceiling (${liveCount}/${sourceCeiling})`
415
+ : `self-directed work yields ${REACTIVE_RESERVE} slots to the inbox (${liveCount}/${sourceCeiling})`;
360
416
  return decide(QUEUE, why);
361
417
  }
362
418
 
@@ -68,14 +68,16 @@ function deps(over = {}) {
68
68
  // ---------------------------------------------------------------------------
69
69
 
70
70
  test("dynamicMax sizes by RAM, clamped to min(HARD_MAX, cpus)", () => {
71
- // 8GB: floor(8*0.6/0.75) = floor(6.4) = 6, capped by cpus too.
72
- assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 6);
73
- // 16GB: floor(16*0.6/0.75)=12 → clamp to min(10, cpus). cpus=8 → 8.
74
- assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 8 }), 8);
75
- // 16GB with 16 cpus → min(10,16)=10, byRam=12 → clamp to 10.
76
- assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 10);
77
- // 32GB with 16 cpus: byRam=floor(32*0.6/0.75)=25 → clamp to min(10,16)=10.
78
- assert.equal(dynamicMax({ totalmem: 32 * GB, cpus: 16 }), 10);
71
+ // HARD_MAX is 4 (subscription-safe): the single Max subscription, not host RAM,
72
+ // is the binding constraint, so anything with a few GB clamps to 4.
73
+ // 4GB: byRam=floor(4*0.6/0.75)=3 → RAM-bound, below HARD_MAX → 3.
74
+ assert.equal(dynamicMax({ totalmem: 4 * GB, cpus: 8 }), 3);
75
+ // 8GB: byRam=floor(8*0.6/0.75)=6 → clamp to min(4,8)=4 → HARD_MAX-bound.
76
+ assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 4);
77
+ // 16GB with 16 cpus: byRam=12 → clamp to min(4,16)=4.
78
+ assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 4);
79
+ // cpu-bound below HARD_MAX: 16GB with 2 cpus → min(4,2)=2.
80
+ assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 2 }), 2);
79
81
  // Tiny box always yields at least 1.
80
82
  assert.equal(dynamicMax({ totalmem: 1 * GB, cpus: 1 }), 1);
81
83
  });
@@ -91,8 +93,8 @@ test("dynamicMax never returns 0 even on a starved box", () => {
91
93
  test("admit: healthy box with spare capacity → ADMIT", () => {
92
94
  const r = admit({ source: "backlog" }, deps());
93
95
  assert.equal(r.decision, DECISIONS.ADMIT);
94
- assert.equal(r.snapshot.dynamicMax, 8);
95
- assert.equal(r.snapshot.effectiveMax, 8);
96
+ assert.equal(r.snapshot.dynamicMax, 4);
97
+ assert.equal(r.snapshot.effectiveMax, 4);
96
98
  });
97
99
 
98
100
  test("admit: inbox on a healthy box → ADMIT", () => {
@@ -104,9 +106,23 @@ test("admit: inbox on a healthy box → ADMIT", () => {
104
106
  // QUEUE — concurrency ceiling
105
107
  // ---------------------------------------------------------------------------
106
108
 
107
- test("admit: at the concurrency ceiling → QUEUE", () => {
108
- // effectiveMax = 8; 8 already live → QUEUE.
109
- const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 8, rssMB: 2000 } }));
109
+ test("admit: backlog stops REACTIVE_RESERVE slots short of the ceiling → QUEUE", () => {
110
+ // effectiveMax = 4; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
111
+ // = 2. With 2 already live, backlog QUEUEs — holding 2 slots for the inbox so a
112
+ // reply can always be GENERATED (subscription + host headroom, not just a slot).
113
+ const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 2, rssMB: 500 } }));
114
+ assert.equal(r.decision, DECISIONS.QUEUE);
115
+ assert.match(r.reason, /yields 2 slots to the inbox/);
116
+ });
117
+
118
+ test("admit: inbox may use the whole envelope; the reactive reserve is for it", () => {
119
+ // 2 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
120
+ assert.equal(
121
+ admit({ source: "inbox" }, deps({ liveClaude: { count: 2, rssMB: 500 } })).decision,
122
+ DECISIONS.ADMIT,
123
+ );
124
+ // Only at the full envelope (effectiveMax=4 live) does even the inbox QUEUE.
125
+ const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 4, rssMB: 1000 } }));
110
126
  assert.equal(r.decision, DECISIONS.QUEUE);
111
127
  assert.match(r.reason, /concurrency ceiling/);
112
128
  });
@@ -125,9 +141,10 @@ test("admit: throttle ceiling clamps effectiveMax below dynamicMax → QUEUE ear
125
141
 
126
142
  test("admit: projected RSS over the RAM fraction → QUEUE", () => {
127
143
  // 16GB → RAM fraction cap = 16*0.6*1024 ≈ 9830MB. Live RSS 9500MB + 750 > cap.
128
- // Keep count below ceiling and memory/load healthy so RSS is the trigger.
144
+ // Keep count below the reserved backlog ceiling (2) so concurrency passes and
145
+ // RSS is the trigger.
129
146
  const r = admit({ source: "backlog" }, deps({
130
- liveClaude: { count: 2, rssMB: 9500 },
147
+ liveClaude: { count: 1, rssMB: 9500 },
131
148
  freemem: 16 * GB * 0.5,
132
149
  }));
133
150
  assert.equal(r.decision, DECISIONS.QUEUE);
@@ -287,9 +304,19 @@ test("admit reads a real throttle.json from agentRoot and clamps", async () => {
287
304
  // the budget ladder (banded against the seat's hq-funded envelope)
288
305
  // ---------------------------------------------------------------------------
289
306
 
290
- test("degraded (>=100%): self-directed backlog stops, obligations + inbox continue", () => {
291
- assert.equal(admit({ source: "backlog", mode: "degraded" }, deps()).decision, "DEFER");
292
- assert.match(admit({ source: "backlog", mode: "degraded" }, deps()).reason, /self-directed backlog work stopped/);
307
+ test("degraded (>=100%): self-directed backlog is THROTTLED (not stopped), obligations + inbox continue", () => {
308
+ // Owner instruction (2026-08-25): throttle, don't stop. A degraded/unfunded seat
309
+ // runs DEGRADED_BACKLOG_MAX(1) backlog in idle gaps — with the box idle it ADMITs
310
+ // one so autonomous progress keeps ticking...
311
+ assert.equal(
312
+ admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 0, rssMB: 0 } })).decision,
313
+ "ADMIT",
314
+ );
315
+ // ...but with anything already live it QUEUEs — a trickle, never a flood.
316
+ const r = admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 1, rssMB: 300 } }));
317
+ assert.equal(r.decision, "QUEUE");
318
+ assert.match(r.reason, /throttled/);
319
+ // An obligation is not discretionary — it degrades, it does not stop.
293
320
  assert.equal(admit({ source: "cadence", mode: "degraded" }, deps()).decision, "ADMIT",
294
321
  "an obligation is not discretionary — it degrades, it does not stop");
295
322
  assert.equal(admit({ source: "inbox", mode: "degraded" }, deps()).decision, "ADMIT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.3",
3
+ "version": "2.11.5",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -384,7 +384,7 @@ function formatItemPrompt(item) {
384
384
 
385
385
  // ── Parse LLM response into classification object ───────────────────────────
386
386
 
387
- function parseClassification(text) {
387
+ export function parseClassification(text) {
388
388
  // Strip markdown code fences if present
389
389
  const cleaned = text.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim();
390
390
  let parsed;
@@ -408,13 +408,24 @@ function parseClassification(text) {
408
408
  const validModels = ["opus", "sonnet"];
409
409
  const validCategories = ["action_required", "fyi", "ignore"];
410
410
 
411
+ const directedAtAgent = typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false;
412
+ let priority = validPriorities.includes(parsed.priority) ? parsed.priority : "normal";
413
+ // A message the classifier is confident is directed AT this agent (a DM or an
414
+ // @mention) is inherently latency-sensitive — a human or a peer agent is
415
+ // waiting on the reply. Floor it at "high" so the dispatcher's priority lane
416
+ // PREEMPTS self-directed backlog for it (dispatcher.isPriorityInbox gates on
417
+ // critical|high). Without this, a peer agent's routine question classified
418
+ // "normal" would queue behind a wall of the seat's own backlog and read as "no
419
+ // reply". Never downgrade critical; never touch an "ignore".
420
+ if (directedAtAgent && priority === "normal") priority = "high";
421
+
411
422
  return {
412
- priority: validPriorities.includes(parsed.priority) ? parsed.priority : "normal",
423
+ priority,
413
424
  action: validActions.includes(parsed.action) ? parsed.action : "queue",
414
425
  model: validModels.includes(parsed.model) ? parsed.model : "sonnet",
415
426
  summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
416
427
  category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
417
- directed_at_agent: typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false, // default false — don't respond unless we're confident the message is for this agent
428
+ directed_at_agent: directedAtAgent, // default false — don't respond unless we're confident the message is for this agent
418
429
  // A directly-answerable question gets a quick reply instead of a session +
419
430
  // holding ack (see responder.isQuickReply). Default false — when the field
420
431
  // is absent or non-boolean, take the session path, never guess "answerable".
@@ -245,3 +245,22 @@ test("reaction kind short-circuits before any LLM tier (no fetch)", async () =>
245
245
  assert.equal(out.action, "archive");
246
246
  assert.equal(out.directed_at_agent, false);
247
247
  });
248
+
249
+ test("parseClassification floors a directed-at-agent message at 'high' so it preempts backlog", () => {
250
+ // A directed DM/mention is latency-sensitive — someone (human OR peer agent)
251
+ // is waiting. Bumping normal→high lets dispatcher.isPriorityInbox PREEMPT
252
+ // self-directed backlog for it, so a peer's routine question isn't starved.
253
+ const directedNormal = classifier.parseClassification(
254
+ '{"priority":"normal","action":"respond","model":"sonnet","summary":"peer asks for status","category":"action_required","directed_at_agent":true,"answerable":false}'
255
+ );
256
+ assert.equal(directedNormal.priority, "high");
257
+ assert.equal(directedNormal.directed_at_agent, true);
258
+
259
+ // Ambient (not directed) work stays normal — no preemption for the firehose.
260
+ const ambient = classifier.parseClassification('{"priority":"normal","action":"archive","directed_at_agent":false}');
261
+ assert.equal(ambient.priority, "normal");
262
+
263
+ // Critical is never downgraded, and an "ignore" is never escalated.
264
+ assert.equal(classifier.parseClassification('{"priority":"critical","directed_at_agent":true}').priority, "critical");
265
+ assert.equal(classifier.parseClassification('{"priority":"ignore","directed_at_agent":true}').priority, "ignore");
266
+ });
@@ -55,6 +55,7 @@ import { rungById } from "../../lib/execution/route.mjs";
55
55
  // put an exact string in front of a human with no model in the loop, which is
56
56
  // the property the acknowledgement path needed and did not have.
57
57
  import { deliver, deliverWithRetry, resolveSlackChannel } from "./deliver.mjs";
58
+ import { startTyping, stopTyping } from "./typing-registry.mjs";
58
59
  // The acknowledgement is now COMPOSED, not generated. See assurance.mjs for why
59
60
  // (in short: a 60s `claude --print` spawn to write "let me look into it" lost
60
61
  // its own race 2 times in 3, and lost it silently).
@@ -855,6 +856,11 @@ export async function sendQuickResponse(item, classResult, routed = null) {
855
856
  : null;
856
857
 
857
858
  try {
859
+ // Show "is composing…" on the DM the instant we start generating. The quick
860
+ // path used to be silent — a human saw nothing until the reply landed, or
861
+ // (under load, when generation timed out) nothing at all. Best-effort;
862
+ // cleared in the finally so "composing" never outlives the turn.
863
+ startTyping(item);
858
864
  const text = await _generateResponse(item, classResult);
859
865
 
860
866
  // Validate before sending — block replies that violate critical rules
@@ -951,6 +957,8 @@ export async function sendQuickResponse(item, classResult, routed = null) {
951
957
  console.error(`[responder] Quick response failed for ${item.sender}:`, err.message);
952
958
  logResponse({ type: "quick_response_error", sender: item.sender, error: err.message });
953
959
  return { sent: false, text: null, error: err.message };
960
+ } finally {
961
+ try { stopTyping(item); } catch { /* never throw from teardown */ }
954
962
  }
955
963
  }
956
964
 
@@ -114,7 +114,15 @@ export function startTyping(item) {
114
114
  if (!adapter) return null;
115
115
  const source = sourceFromItem(item);
116
116
  if (!source || !source.chatId) return null;
117
- return adapter.startTypingHeartbeat(source);
117
+ const key = adapter.startTypingHeartbeat(source);
118
+ // Observability: a silent indicator was exactly the bug — "is it even
119
+ // firing?" was unanswerable from the logs. Emit one line when a heartbeat
120
+ // actually starts (startTypingHeartbeat is idempotent, so this is ~once per
121
+ // turn, not per beat).
122
+ if (key) {
123
+ try { console.log(`[typing] composing… ${service}:${source.chatId}${source.userScope === "dm" ? " (dm)" : ""}`); } catch { /* never throw from logging */ }
124
+ }
125
+ return key;
118
126
  } catch {
119
127
  return null;
120
128
  }