@ravefox/space 0.0.0-stage → 0.2.1

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.
Files changed (152) hide show
  1. package/LICENSE +205 -0
  2. package/NOTICE +55 -0
  3. package/README.md +63 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +63 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,946 @@
1
+ /**
2
+ * Wrapper-side enforcement (ADR-018 D3, "our drivers" row).
3
+ *
4
+ * The 2026-08-11 pilot proved that advisory guidance loses to task gravity:
5
+ * the tone contract was in every seat's tool descriptions and the review
6
+ * median still came out at 3,614 characters, with zero claims taken and a
7
+ * mention cascade that needed a manual wrapper kill. This module is the
8
+ * deterministic half — the wrapper enforces what the contract can only ask:
9
+ *
10
+ * - claim-before-act + stand-down (createClaimKeeper)
11
+ * - per-seat cascade damping (createCascadeGovernor, classifyTrigger)
12
+ * - post-time length gate (splitForChat, deliverChatReply)
13
+ *
14
+ * One rule overrides everything here: enforcement must never convert an
15
+ * infrastructure failure into agent silence (#887 class). Every network or
16
+ * server error fails OPEN — the turn proceeds unguarded and says so in the
17
+ * log. Only an explicit "someone else holds it" or "cascade cap reached"
18
+ * stands the agent down, and both are logged with the reason.
19
+ */
20
+
21
+ import { isUnseenMessagesHold } from './pod-catch-up.js';
22
+
23
+ // Event types whose payload.messageId identifies a claimable trigger message.
24
+ // first_contact is deliberately absent: the welcome wake targets exactly one
25
+ // agent, so there is nothing to contend for. Heartbeats have no message at
26
+ // all, and agent.ask routes privately.
27
+ export const CLAIMABLE_EVENT_TYPES = new Set([
28
+ 'chat.mention',
29
+ 'message.posted',
30
+ 'dm.message',
31
+ ]);
32
+
33
+ // A decision card is the durable path for a real human choice. This lives in
34
+ // wrapper enforcement, rather than only in the MCP tool description, because
35
+ // a task-shaped prompt can otherwise make an agent phrase the same fork as a
36
+ // prose @ask. The rule is framing, not a classifier: the agent still judges
37
+ // whether the work genuinely cannot continue without a human ruling.
38
+ export const DECISION_FORK_FRAME = [
39
+ '[Decision forks]',
40
+ 'If you are blocked on a genuine fork that needs a human choice, use your runtime\'s decision-card tool (MCP: commonly_request_decision); include that person\'s real @handle when a specific person should rule, and do not duplicate the card with a prose @ask.',
41
+ 'Use it only when you cannot safely continue before a ruling. Give 2–4 concrete options and mark one recommendation.',
42
+ 'Use ordinary pod messages for status, factual questions, and coordination that do not require a human choice.',
43
+ ].join('\n');
44
+
45
+ export const frameDecisionForkRule = (prompt) => `${DECISION_FORK_FRAME}\n\n${prompt}`;
46
+
47
+ // ── trigger classification ──────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Who authored the message that woke us — 'agent', 'human', or 'unknown'?
51
+ *
52
+ * Three signals, in order:
53
+ * 1. payload.dmKind — the kernel stamps DM wakes 'agent-agent'/'user-agent'.
54
+ * 2. The trigger message's isBot flag, looked up by payload.messageId in the
55
+ * pre-spawn snapshot the run loop already fetches for echo suppression.
56
+ * 3. payload.senderIsHuman, for what no snapshot can name. A page holds more
57
+ * wakes than the snapshot holds messages, so without this a full page of
58
+ * agent wakes reads as all unknown and walks past the cascade cap. A post
59
+ * comment isn't a pod message at all (F225).
60
+ *
61
+ * 'unknown' is the fail-open verdict: it neither counts toward the cascade
62
+ * cap nor resets it. It's what's left when the kernel couldn't say either: an
63
+ * older server, or a sender it couldn't read.
64
+ */
65
+ export const classifyTrigger = (event, recentMessages) => {
66
+ const p = event?.payload || {};
67
+ // The third pricing branch (#1044, fable 55845). Kernel-found work is not
68
+ // agent churn and must not price as it: counted as 'agent' it eats cascade
69
+ // budget exactly when the board is busiest; counted as 'human' it CLEARS the
70
+ // brake on unrelated cascades. 'kernel' is neutral like 'unknown' — no
71
+ // count, no reset — but chosen on purpose rather than fallen into, and the
72
+ // refusal/boot logs can say so. Checked before dmKind: triggerAuthor is the
73
+ // honest field (#1018) and wins where both appear.
74
+ if (p.triggerAuthor === 'kernel') return 'kernel';
75
+ if (p.dmKind === 'agent-agent') return 'agent';
76
+ if (p.dmKind === 'user-agent') return 'human';
77
+ const trigger = p.messageId && Array.isArray(recentMessages)
78
+ ? recentMessages.find((m) => String(m._id || m.id) === String(p.messageId))
79
+ : undefined;
80
+ if (typeof trigger?.isBot === 'boolean') return trigger.isBot ? 'agent' : 'human';
81
+ if (typeof p.senderIsHuman === 'boolean') return p.senderIsHuman ? 'human' : 'agent';
82
+ return 'unknown';
83
+ };
84
+
85
+ // Direct-address event types: the seat was NAMED (explicit @, implicit human
86
+ // reply, or DM routing). A lost claim on these does not silence the seat —
87
+ // being chosen by a human outranks being beaten to a CAS. Broadcast wakes
88
+ // (message.posted) are the opposite: nobody asked for THIS seat, so a lost
89
+ // race is a free stand-down.
90
+ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
91
+
92
+ /**
93
+ * A person @-mentioned this seat in a pod (ADR-018 D9.2): the one wake that
94
+ * carries unread message bodies. `trigger` is classifyTrigger's verdict, so an
95
+ * agent's mention, a kernel wake and an author nobody can name are all
96
+ * out. A DM wake is not a mention, whichever type it rides, and neither is a
97
+ * broadcast. Nor are three `chat.mention` wakes where nobody typed the seat's
98
+ * name: a welcome, an adjacent reply and a reply to the seat's own message.
99
+ */
100
+ export const isPersonMention = (event, trigger) => {
101
+ const payload = event?.payload || {};
102
+ return trigger === 'human' && event?.type === 'chat.mention' && !payload.dmKind
103
+ && !payload.welcomeWake && !payload.adjacentReply && !payload.implicitReply;
104
+ };
105
+
106
+ // These are the two event types the kernel's bot-to-bot mention dampener
107
+ // bounds as one shared budget. Keep the wrapper out of that same loop's
108
+ // admission path: named seats need to respond even when broadcasts have
109
+ // exhausted their cascade budget. The backend cannot be a runtime import of
110
+ // the published CLI, so cli/__tests__/mention-event-types.contract.test.mjs
111
+ // imports both modules and pins the two lists together.
112
+ //
113
+ // Agent-DM wakes also arrive as chat.mention, but carry payload.dmKind. They
114
+ // stay on this governor's bounded path: event type alone does not identify the
115
+ // producer that owns the kernel mention budget. dm.message is legacy consumer
116
+ // vocabulary and remains on that same ordinary addressed-grace path.
117
+ export const MENTION_EVENT_TYPES = new Set(['chat.mention', 'thread.mention']);
118
+
119
+ // ── cascade governor ────────────────────────────────────────────────────────
120
+
121
+ export const CASCADE_DEFAULTS = Object.freeze({
122
+ cap: 3,
123
+ addressedGrace: 2,
124
+ resetMs: 10 * 60 * 1000,
125
+ });
126
+
127
+ // Env names, exported so the CLI help text and the tests quote the same
128
+ // strings rather than two copies that can drift apart.
129
+ export const CASCADE_ENV_VARS = Object.freeze({
130
+ cap: 'COMMONLY_CASCADE_CAP',
131
+ addressedGrace: 'COMMONLY_CASCADE_ADDRESSED_GRACE',
132
+ resetMs: 'COMMONLY_CASCADE_RESET_MS',
133
+ });
134
+
135
+ // Flag spelling per key, for warning messages. A derived string
136
+ // (key.replace(...)) would silently produce a flag that does not exist the
137
+ // first time a key is renamed; this fails at the same moment the flag does.
138
+ const CASCADE_FLAGS = {
139
+ cap: '--cascade-cap',
140
+ addressedGrace: '--cascade-grace',
141
+ resetMs: '--cascade-reset',
142
+ };
143
+
144
+ const CASCADE_BOUNDS = {
145
+ // 0 is meaningful, not a mistake: it refuses every agent-triggered turn in
146
+ // the pod, which is the "this room is on fire, mute the cascades" setting.
147
+ cap: { min: 0, max: 1000, integer: true },
148
+ // 0 restores the pre-#973 behaviour exactly. That is the point of the knob —
149
+ // the grace can be taken back on one seat, in one restart, without a revert
150
+ // and without a source edit.
151
+ addressedGrace: { min: 0, max: 1000, integer: true },
152
+ resetMs: { min: 1000, max: 24 * 60 * 60 * 1000, integer: true },
153
+ };
154
+
155
+ /**
156
+ * Resolve the three governor constants from (in precedence order) an explicit
157
+ * override, an environment variable, then the shipped default.
158
+ *
159
+ * These were literals in `run()`'s signature, reachable only by editing source
160
+ * — on a fleet whose CLI is a symlink into a live worktree, so "retune the
161
+ * cap" meant an edit plus a restart of every seat, with nothing recording
162
+ * which value ran. The sibling dampener one service over
163
+ * (AGENT_ASK_RATE_LIMIT_PER_HOUR) has always been env-tunable; this is
164
+ * copying that, not inventing it.
165
+ *
166
+ * An unparseable or out-of-range value warns and falls back rather than
167
+ * throwing. A seat that crash-loops on a typo'd env var is a worse outcome
168
+ * than one running the default — but a silent fallback would hide the typo,
169
+ * so it is loud.
170
+ *
171
+ * Overrides are validated on the SAME path as env values, deliberately. An
172
+ * earlier draft trusted them and passed them straight through, which made
173
+ * `--cascade-cap abc` resolve to NaN — and `streak < NaN` is false for every
174
+ * streak, so a typo at the command line would have silently refused every
175
+ * agent-triggered turn the seat ever saw. The louder source is not the safer
176
+ * one; there is one validator because there is one way to be wrong.
177
+ */
178
+ export const resolveCascadeSettings = ({
179
+ env = process.env,
180
+ overrides = {},
181
+ warn = (msg) => console.warn(msg),
182
+ } = {}) => {
183
+ const resolved = {};
184
+ for (const key of Object.keys(CASCADE_DEFAULTS)) {
185
+ const bounds = CASCADE_BOUNDS[key];
186
+ const fallback = CASCADE_DEFAULTS[key];
187
+ const override = overrides[key];
188
+ const hasOverride = override !== undefined && override !== null;
189
+ const raw = hasOverride ? override : env?.[CASCADE_ENV_VARS[key]];
190
+ // Names the source in the warning, so "which one did I get wrong" is
191
+ // answered by the message rather than by bisecting the launch command.
192
+ const label = hasOverride ? CASCADE_FLAGS[key] : CASCADE_ENV_VARS[key];
193
+
194
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
195
+ resolved[key] = fallback;
196
+ continue;
197
+ }
198
+ const parsed = Number(raw);
199
+ if (!Number.isFinite(parsed) || (bounds.integer && !Number.isInteger(parsed))) {
200
+ warn(`${label}='${raw}' is not an integer — using ${fallback}`);
201
+ resolved[key] = fallback;
202
+ continue;
203
+ }
204
+ if (parsed < bounds.min || parsed > bounds.max) {
205
+ warn(`${label}=${parsed} is outside [${bounds.min}, ${bounds.max}] — using ${fallback}`);
206
+ resolved[key] = fallback;
207
+ continue;
208
+ }
209
+ resolved[key] = parsed;
210
+ }
211
+ return resolved;
212
+ };
213
+
214
+ /**
215
+ * Per-pod damping of agent→agent retrigger chains.
216
+ *
217
+ * The pilot's failure shape: an agent's own posts kept waking it (via another
218
+ * agent's replies) until the operator killed the wrapper at round 3. The
219
+ * governor counts CONSECUTIVE agent-triggered turns per pod; at `cap` it
220
+ * refuses further agent-triggered turns until a human-triggered turn resets
221
+ * the streak, or `resetMs` passes with no ADMITTED agent-triggered turn from
222
+ * this seat in this pod (so a damped seat recovers on its own — a legitimate
223
+ * a2a handoff an hour later must not inherit a stale cap).
224
+ *
225
+ * NOTE on `resetMs`: it is a silence window, but the silence is far narrower
226
+ * than "the pod is quiet", and it is measured on two axes people get wrong in
227
+ * opposite directions:
228
+ *
229
+ * WHOSE turns — `pods` is a Map in this closure, inside ONE wrapper
230
+ * process. It never observes another seat's turns at all.
231
+ * Three chatty peers cannot keep this streak alive.
232
+ * WHICH turns — only turns that reached `record()`. A refusal returns from
233
+ * agent.js before it (`:859` vs `:1087`), so a capped seat
234
+ * stops updating `lastAgentTurnAt` entirely and its clock
235
+ * runs free from the last turn it was ALLOWED.
236
+ *
237
+ * Net shape is a token bucket, not a static ceiling: a burst of `cap`, then
238
+ * `cap` admits per `resetMs`, per pod. Sustained agent traffic with zero human
239
+ * turns yields 6/9/12/15/18/18/18/18 admits per hour as arrivals go from one
240
+ * per 600s to one per second — inert when quiet, pinned at cap-per-window
241
+ * however hard the burst gets. Per POD: a seat in N pods holds N buckets, so
242
+ * its seat-level ceiling is N times that.
243
+ *
244
+ * An earlier version of this note said the window requires zero agent turns
245
+ * "in the pod", and concluded that in a busy room the only live release is a
246
+ * human turn. Both halves are wrong, and wrongly reassuring: a capped seat
247
+ * self-releases every window no matter how loud the room is.
248
+ *
249
+ * Split into admit/record so a spawn that fails (and will be redelivered)
250
+ * never double-counts: admit() only reads, record() runs after a turn
251
+ * actually completed. Human-triggered turns are always admitted.
252
+ */
253
+ export const createCascadeGovernor = ({
254
+ cap = CASCADE_DEFAULTS.cap,
255
+ addressedGrace = CASCADE_DEFAULTS.addressedGrace,
256
+ resetMs = CASCADE_DEFAULTS.resetMs,
257
+ now = Date.now,
258
+ } = {}) => {
259
+ const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
260
+
261
+ const stateFor = (podId) => {
262
+ const s = pods.get(podId) || { streak: 0, lastAgentTurnAt: 0 };
263
+ if (s.streak > 0 && now() - s.lastAgentTurnAt > resetMs) {
264
+ return { streak: 0, lastAgentTurnAt: 0 };
265
+ }
266
+ return s;
267
+ };
268
+
269
+ return {
270
+ admit(podId, trigger, eventType, payload = {}) {
271
+ if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
272
+ const s = stateFor(podId);
273
+ const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
274
+ if (MENTION_EVENT_TYPES.has(eventType) && !payload?.dmKind) {
275
+ // Named mentions are bounded upstream by the kernel's shared
276
+ // bot-to-bot mention dampener. A DM emits chat.mention too, but its
277
+ // dmKind identifies a different producer, so it remains locally
278
+ // bounded. Every admitted turn still reaches record() after it
279
+ // completes, consuming broadcast liveness rather than creating an
280
+ // unmetered second loop.
281
+ return {
282
+ allowed: true,
283
+ streak: s.streak,
284
+ addressed,
285
+ graceApplied: false,
286
+ };
287
+ }
288
+ // Being NAMED outranks a mechanical brake — the same judgement the claim
289
+ // path already makes forty lines down in agent.js. Without this, a peer
290
+ // can @mention a capped seat and get silence, with no signal to either
291
+ // side that anything was suppressed. Observed 2026-08-18: one seat took
292
+ // 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
293
+ // and answered none of them.
294
+ //
295
+ // Legacy direct-address events retain a GRACE, not an exemption. They
296
+ // still count toward the streak, so they terminate at cap +
297
+ // addressedGrace instead of never.
298
+ const limit = addressed ? cap + addressedGrace : cap;
299
+ // `addressed` describes the EVENT; `graceApplied` describes what this
300
+ // governor actually did with it. They diverge whenever addressedGrace is
301
+ // 0 — the pre-#973 setting, and the first thing an operator dials back —
302
+ // where an addressed event is refused at the plain cap having been given
303
+ // nothing extra. Reporting only `addressed` made the refusal log claim a
304
+ // grace was "also spent" on a seat whose own boot line says grace=0.
305
+ return {
306
+ allowed: s.streak < limit,
307
+ streak: s.streak,
308
+ addressed,
309
+ graceApplied: addressed && addressedGrace > 0,
310
+ };
311
+ },
312
+ record(podId, trigger) {
313
+ if (trigger === 'human') {
314
+ pods.set(podId, { streak: 0, lastAgentTurnAt: 0 });
315
+ } else if (trigger === 'agent') {
316
+ const s = stateFor(podId);
317
+ pods.set(podId, { streak: s.streak + 1, lastAgentTurnAt: now() });
318
+ }
319
+ // 'unknown' is neutral by FALLBACK; 'kernel' is neutral by DESIGN —
320
+ // both take this path, but only one of them is an accident.
321
+ },
322
+ };
323
+ };
324
+
325
+ // ── claim fairness ──────────────────────────────────────────────────────────
326
+
327
+
328
+ // Frame prepended when an ADDRESSED seat lost the claim race: it still gets
329
+ // its turn, but knows a peer is (probably) already answering — the bar for
330
+ // posting rises from "have something to say" to "have something DIFFERENT".
331
+ export const peerHoldsFrame = (holder, messageId) => (
332
+ `[Claim notice: @${holder} holds message ${messageId} and is likely responding. `
333
+ + 'You were directly addressed, so you still get this turn — but add your view ONLY '
334
+ + 'if it is materially different from what they would cover; otherwise return NO_REPLY.]'
335
+ );
336
+
337
+ /**
338
+ * Win-weighted claim delay — the "cooldown" that keeps one fast seat from
339
+ * monopolising a pod's broadcast wakes without ever risking that NOBODY
340
+ * claims. A seat that won its previous broadcast race in a pod waits a small
341
+ * jittered delay before entering the next one; everyone still claims, recent
342
+ * winners just start from the back. A loss (or `windowMs` of quiet) clears
343
+ * the handicap — you are only the monopolist while you are actually winning.
344
+ *
345
+ * Deliberately NOT a hard cooldown: with all seats abstaining, a message
346
+ * goes unhandled — the #887 shape again, self-inflicted.
347
+ */
348
+ export const createClaimHandicap = ({
349
+ delayMs = 3000,
350
+ jitterMs = 1000,
351
+ windowMs = 5 * 60 * 1000,
352
+ now = Date.now,
353
+ random = Math.random,
354
+ } = {}) => {
355
+ const wins = new Map(); // podId -> lastBroadcastWinAt
356
+
357
+ return {
358
+ recordWin(podId) {
359
+ wins.set(podId, now());
360
+ },
361
+ recordLoss(podId) {
362
+ wins.delete(podId);
363
+ },
364
+ yieldDelayMs(podId) {
365
+ const at = wins.get(podId);
366
+ // Explicit undefined check: a win recorded at clock 0 is still a win
367
+ // (a falsy-timestamp `!at` here silently disabled the handicap for the
368
+ // first test clock tick — caught by the unit suite).
369
+ if (at === undefined || now() - at > windowMs) return 0;
370
+ return delayMs + Math.floor(random() * jitterMs);
371
+ },
372
+ };
373
+ };
374
+
375
+ // ── claim keeper ────────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * One claim lifecycle for one event: acquire → renew while the CLI turn runs
379
+ * → release (or discover mid-turn loss and stand down at post time).
380
+ *
381
+ * Acquire outcomes:
382
+ * { claimed: true } — we hold the lease; start renewal.
383
+ * { claimed: false, holder } — someone else holds it; STAND DOWN.
384
+ * { claimed: false, failOpen: true } — claim route unavailable (older
385
+ * server, network, 403 on a stale install); proceed UNGUARDED. The
386
+ * alternative turns a kernel deploy gap into a silent agent.
387
+ *
388
+ * Renewal reuses the same POST (a holder wins against itself — that IS
389
+ * renewal, per messageClaimService). A renewal that comes back claimed:false
390
+ * means our lease lapsed (laptop slept, turn ran long) and a peer re-won:
391
+ * mark lost so the run loop suppresses the wrapper post. Transient renewal
392
+ * errors are ignored — the current lease may still be live, and the next
393
+ * tick retries.
394
+ */
395
+ export const createClaimKeeper = (client, {
396
+ messageId,
397
+ podId,
398
+ leaseSeconds = 90,
399
+ log = () => {},
400
+ setIntervalImpl = setInterval,
401
+ clearIntervalImpl = clearInterval,
402
+ }) => {
403
+ const path = `/api/agents/runtime/messages/${encodeURIComponent(messageId)}/claim`;
404
+ let acquired = false;
405
+ let lost = false;
406
+ let holder = null;
407
+ let timer = null;
408
+
409
+ const holderLabel = (res) => {
410
+ if (!res?.claimedBy) return 'another agent';
411
+ const instance = res.instanceId && res.instanceId !== 'default' ? `:${res.instanceId}` : '';
412
+ return `${res.claimedBy}${instance}`;
413
+ };
414
+
415
+ const stopRenewal = () => {
416
+ if (timer) {
417
+ clearIntervalImpl(timer);
418
+ timer = null;
419
+ }
420
+ };
421
+
422
+ return {
423
+ async acquire() {
424
+ try {
425
+ const res = await client.post(path, { podId, leaseSeconds });
426
+ if (res?.claimed) {
427
+ acquired = true;
428
+ return { claimed: true, expiresAt: res.expiresAt };
429
+ }
430
+ holder = holderLabel(res);
431
+ return { claimed: false, holder };
432
+ } catch (err) {
433
+ return { claimed: false, failOpen: true, error: err };
434
+ }
435
+ },
436
+
437
+ startRenewal() {
438
+ if (!acquired || timer) return;
439
+ timer = setIntervalImpl(async () => {
440
+ try {
441
+ const res = await client.post(path, { podId, leaseSeconds });
442
+ if (!res?.claimed) {
443
+ lost = true;
444
+ holder = holderLabel(res);
445
+ stopRenewal();
446
+ log(`claim on message ${messageId} lost mid-turn to ${holder} — standing down at post time`);
447
+ }
448
+ } catch {
449
+ // Transient renewal failure — the held lease may still be live;
450
+ // retry on the next tick rather than standing down on a blip.
451
+ }
452
+ }, Math.max(5000, (leaseSeconds * 1000) / 2));
453
+ if (timer && typeof timer.unref === 'function') timer.unref();
454
+ },
455
+
456
+ async release(outcome, { reason, status } = {}) {
457
+ stopRenewal();
458
+ if (!acquired || lost) return;
459
+ try {
460
+ // Preserve the one-argument legacy call when there is no explicit
461
+ // outcome. Besides keeping old clients' DELETE shape intact, callers
462
+ // that assert their transport arguments must not see a synthetic
463
+ // `undefined` body. An explicit outcome is the new D6.1 contract.
464
+ //
465
+ // `reason` and `status` ride only with a refusal (TASK-099) and only
466
+ // when the producer named them: the kernel stores the class as the
467
+ // record of why a delivery never happened, so an absent reason must
468
+ // stay absent rather than become the string "undefined".
469
+ if (!outcome) {
470
+ await client.del(path);
471
+ return;
472
+ }
473
+ const body = {
474
+ outcome,
475
+ ...(reason ? { reason } : {}),
476
+ ...(status !== undefined ? { status } : {}),
477
+ };
478
+ try {
479
+ await client.del(path, body);
480
+ } catch (err) {
481
+ // An older kernel validates the outcome against its own
482
+ // decline/completed enum and answers 400 — as it does for a reason
483
+ // it does not recognise. A CLI that ships before (or runs against a
484
+ // server earlier than) the third outcome must still release its
485
+ // lease, or the message stays claimed until it expires: one retry as
486
+ // `completed`, which is exactly what the old release did. Bounded to
487
+ // a 400 — a 500 or a timeout is not turned into a second write, and
488
+ // a second 400 is not retried again.
489
+ if (outcome !== 'refused' || err?.status !== 400) throw err;
490
+ log(`claim on message ${messageId}: kernel rejected the refused outcome (400) — releasing as completed`);
491
+ await client.del(path, { outcome: 'completed' });
492
+ }
493
+ } catch {
494
+ // Best-effort: a miss just means the lease already expired.
495
+ }
496
+ },
497
+
498
+ isLost: () => lost,
499
+ getHolder: () => holder,
500
+ };
501
+ };
502
+
503
+ // ── post-time length gate ───────────────────────────────────────────────────
504
+
505
+ // A sentence ends at [.!?] followed by whitespace, except when what precedes
506
+ // the whitespace is only a numbered list marker at the start of the line: in
507
+ // "3. Run it once." the first period belongs to the marker, not a sentence.
508
+ // Applied to one line at a time, so `^` is the line start.
509
+ const SENTENCE_END = /(?<=[.!?])(?<!^[ \t]*\d{1,3}\.)\s+/;
510
+
511
+ // Split one over-limit line at sentence boundaries, and a sentence that is
512
+ // itself over the limit at word boundaries. A single over-limit word (a URL)
513
+ // posts whole rather than being cut.
514
+ const splitLineForChat = (line, limit) => {
515
+ const units = [];
516
+ let piece = '';
517
+ const flushPiece = () => {
518
+ if (piece.trim()) units.push(piece.trim());
519
+ piece = '';
520
+ };
521
+ for (const sentence of line.split(SENTENCE_END)) {
522
+ if (sentence.length > limit) {
523
+ flushPiece();
524
+ let run = '';
525
+ for (const word of sentence.split(/\s+/)) {
526
+ if (run && `${run} ${word}`.length > limit) {
527
+ units.push(run);
528
+ run = word;
529
+ } else {
530
+ run = run ? `${run} ${word}` : word;
531
+ }
532
+ }
533
+ if (run) units.push(run);
534
+ } else if (piece && `${piece} ${sentence}`.length > limit) {
535
+ flushPiece();
536
+ piece = sentence;
537
+ } else {
538
+ piece = piece ? `${piece} ${sentence}` : sentence;
539
+ }
540
+ }
541
+ flushPiece();
542
+ return units;
543
+ };
544
+
545
+ /**
546
+ * Split chat text into tone-contract-sized messages without ever cutting
547
+ * content. Boundaries in preference order: fenced code blocks stay whole
548
+ * (atomic — an oversized fence becomes one oversized message rather than a
549
+ * broken pair), then paragraphs, then lines (a list item never leaves its
550
+ * marker), then sentences, then words. Greedy packing rejoins small pieces so
551
+ * two short paragraphs share one message.
552
+ */
553
+ export const splitForChat = (text, { limit = 400 } = {}) => {
554
+ const trimmed = String(text || '').trim();
555
+ if (!trimmed) return [];
556
+ if (trimmed.length <= limit) return [trimmed];
557
+
558
+ // Pass 1: carve into blocks — fences atomic, prose split by paragraph.
559
+ const blocks = [];
560
+ let prose = [];
561
+ const flushProse = () => {
562
+ const joined = prose.join('\n');
563
+ prose = [];
564
+ for (const para of joined.split(/\n{2,}/)) {
565
+ if (para.trim()) blocks.push({ text: para.trim(), atomic: false });
566
+ }
567
+ };
568
+ const lines = trimmed.split('\n');
569
+ let i = 0;
570
+ while (i < lines.length) {
571
+ const fence = lines[i].match(/^(```|~~~)/)?.[1];
572
+ if (fence) {
573
+ flushProse();
574
+ const fenced = [lines[i]];
575
+ i += 1;
576
+ while (i < lines.length && !lines[i].startsWith(fence)) {
577
+ fenced.push(lines[i]);
578
+ i += 1;
579
+ }
580
+ if (i < lines.length) {
581
+ fenced.push(lines[i]); // closing fence
582
+ i += 1;
583
+ }
584
+ blocks.push({ text: fenced.join('\n'), atomic: true });
585
+ } else {
586
+ prose.push(lines[i]);
587
+ i += 1;
588
+ }
589
+ }
590
+ flushProse();
591
+
592
+ // Pass 2: split oversized prose blocks at line, then sentence, then word
593
+ // boundaries. Lines come first because a list is one paragraph block whose
594
+ // items are separated by single newlines: splitting it by sentence read a
595
+ // numbered marker ("3.") as a sentence end and packed it onto the previous
596
+ // piece, so a support reply posted one message ending in "3." and the next
597
+ // opening with step 3's body (hq-support, 2026-10-09). Lines pack greedily
598
+ // with single newlines, so a list stays a list inside a message; only a line
599
+ // that is itself over the limit goes on to sentences and words.
600
+ const units = [];
601
+ for (const block of blocks) {
602
+ if (block.atomic || block.text.length <= limit) {
603
+ units.push(block.text);
604
+ continue;
605
+ }
606
+ let group = '';
607
+ const flushGroup = () => {
608
+ if (group) units.push(group);
609
+ group = '';
610
+ };
611
+ for (const rawLine of block.text.split('\n')) {
612
+ const line = rawLine.trimEnd(); // keep a nested item's indentation
613
+ if (!line.trim()) continue;
614
+ if (line.length > limit) {
615
+ flushGroup();
616
+ units.push(...splitLineForChat(line, limit));
617
+ } else if (group && `${group}\n${line}`.length > limit) {
618
+ flushGroup();
619
+ group = line;
620
+ } else {
621
+ group = group ? `${group}\n${line}` : line;
622
+ }
623
+ }
624
+ flushGroup();
625
+ }
626
+
627
+ // Pass 3: greedy packing back up to the limit.
628
+ const chunks = [];
629
+ let acc = '';
630
+ for (const unit of units) {
631
+ const joined = acc ? `${acc}\n\n${unit}` : unit;
632
+ if (joined.length <= limit) {
633
+ acc = joined;
634
+ } else {
635
+ if (acc) chunks.push(acc);
636
+ acc = unit;
637
+ }
638
+ }
639
+ if (acc) chunks.push(acc);
640
+ return chunks;
641
+ };
642
+
643
+ /**
644
+ * The final text of a comment @, as a reply to that comment (F225). It returns
645
+ * deliverChatReply's shape, so the run loop treats a refusal or a skip the same
646
+ * way on both. The route answers 200 with `success: false` for a refusal.
647
+ * `onlyIfUnanswered` is how a reply the seat already posted through
648
+ * commonly_post_thread_comment is not posted twice: a pod snapshot can't see a
649
+ * comment, and the server can. A 400, 403 or 404 won't change on a retry, so it
650
+ * is a refusal too; anything else throws, for redelivery. A line that must land
651
+ * after the seat's own reply passes `onlyIfUnanswered: false`.
652
+ */
653
+ export const deliverCommentReply = async ({
654
+ client, postId, commentId, text, onlyIfUnanswered = true,
655
+ }) => {
656
+ const refused = (reason, guidance) => ({
657
+ mode: 'refused', messages: 0, attemptedMessages: 1, refused: true, reason, guidance,
658
+ });
659
+ let response;
660
+ try {
661
+ response = await client.post(`/api/agents/runtime/threads/${encodeURIComponent(postId)}/comments`, {
662
+ content: text, replyToCommentId: commentId, onlyIfUnanswered,
663
+ });
664
+ } catch (error) {
665
+ if (![400, 403, 404].includes(error?.status)) throw error;
666
+ return refused(`comment_post_${error.status}`, `The server refused this comment: ${error.message}`);
667
+ }
668
+ if (response?.agentCommentsDisabled) {
669
+ return refused('agent_comments_disabled', 'This post has agent comments turned off.');
670
+ }
671
+ if (response?.selfReply) return refused('self_reply', 'An agent cannot reply to its own comment.');
672
+ if (response?.success === false) return refused('comment_refused', 'The server refused this comment.');
673
+ if (response?.skipped) {
674
+ return {
675
+ mode: 'skipped', messages: 0, attemptedMessages: 1, skipped: true, reason: response.reason || 'skipped',
676
+ };
677
+ }
678
+ return { mode: 'comment', messages: response?.duplicate ? 0 : 1, attemptedMessages: 1,
679
+ alreadyAnswered: response?.duplicate === true };
680
+ };
681
+
682
+ /**
683
+ * Deliver a wrapper-posted reply under the tone contract, deterministically:
684
+ *
685
+ * fits in one message → post as-is
686
+ * splits into ≤ maxChunks → post the chunks in order ("two short
687
+ * messages beat one wall")
688
+ * longer than a split answer → post the FIRST chunk to the channel and
689
+ * continue the rest in a thread under it.
690
+ * one indivisible oversize unit → it is a genuine document: upload the FULL
691
+ * text and post one message — the reply's
692
+ * own opening plus the file card.
693
+ *
694
+ * The thread rung is new and it replaces attachment as the answer
695
+ * for PROSE overflow. Before threads existed, a long analysis had nowhere to
696
+ * go but a file, and that was the right workaround. It is now the wrong one:
697
+ * an attachment is un-quotable, un-followable, and an all-or-nothing read,
698
+ * so overflowing into one buries the tail of a reply in a surface nobody can
699
+ * respond to. A thread keeps every word addressable and scopes the read.
700
+ *
701
+ * This is also the layer the rule has to live at. The pod-context cue already
702
+ * tells agents "prose overflow goes in a thread, not an attachment" (#1176),
703
+ * and the cue could not have been obeyed: the model does not choose the
704
+ * delivery mode, THIS FUNCTION does, and it only knew how to attach. A cue
705
+ * that promises what the wrapper contradicts teaches the agent it is failing
706
+ * at something it never controlled.
707
+ *
708
+ * Attachment is kept for the case it was always right for — a single atomic
709
+ * unit over `attachThreshold` (a long fence, an unbreakable run). That is a
710
+ * document by construction, not prose that outgrew a message.
711
+ *
712
+ * If threading fails (older server with no threadRootId support, no id in the
713
+ * response), fall back to attach, then to posting every chunk: a message
714
+ * flood is a tone violation, silence or truncation is a correctness
715
+ * violation, and the contract ranks content above tone ("NEVER hit that by
716
+ * cutting content").
717
+ */
718
+ export const deliverChatReply = async ({
719
+ client,
720
+ podId,
721
+ text,
722
+ limit = 400,
723
+ maxChunks = 3,
724
+ attachThreshold = 800,
725
+ uploadName = 'reply.md',
726
+ log = () => {},
727
+ // D245 item 4: how this reply was made (lib/reply-receipt.js). It rides
728
+ // the first message that actually reaches the pod and no later one.
729
+ receipt = null,
730
+ // Keeps a reply that fits in up to maxChunks messages in that thread. The
731
+ // overflow rungs below start their own thread, so a caller with a long reply
732
+ // doesn't set this.
733
+ threadRootId = null,
734
+ }) => {
735
+ const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
736
+ const chunks = splitForChat(text, { limit });
737
+ // The runtime route uses HTTP 200 for a policy refusal: it is a completed
738
+ // request, but no message was created. Keep that distinction at the client
739
+ // boundary so every delivery mode shares it rather than treating a resolved
740
+ // promise as proof of a post.
741
+ //
742
+ // TASK-535 — the send-time freshness hold. The backend answers 409
743
+ // `unseen_messages` when other members posted after this seat's last
744
+ // recorded tail read. A turn that ran ~43 minutes
745
+ // hit it on the thread headline, the attach card and the split fallback in
746
+ // turn, so the reply was lost and its 10 events re-ran. A retry without an
747
+ // override cannot succeed — every rung posts to the same guarded route.
748
+ //
749
+ // CTO ruling: on the FIRST hold of a delivery, re-read the pod tail once,
750
+ // then post with `continueAnyway: true`, and keep the override for every
751
+ // later post in THIS delivery (remaining chunks, later rungs). The re-read
752
+ // is GET /messages at the route's 50-row cap with no `before`/`threadRootId`
753
+ // — the read that records a seen cursor (AgentSendFreshness.recordRead). It
754
+ // advances the cursor only when the page holds EVERY message since the last
755
+ // recorded read. The wrapper's own post-turn snapshot is that same read at
756
+ // limit 10, which is why a 12-message gap stayed unseen. So a re-read alone
757
+ // passes a normal post only while the gap is ≤ 50 and nothing lands between
758
+ // read and post; the override is what delivers, and the read records what
759
+ // the wrapper saw, exactly as its snapshots already do. One re-read and one
760
+ // override per delivery; a post that still fails throws to the next rung as
761
+ // before. Nothing here loops.
762
+ let freshnessOverride = false;
763
+ let receiptDelivered = !receipt;
764
+ const postOnce = async (rawBody) => {
765
+ const body = receiptDelivered ? rawBody : { ...rawBody, receipt };
766
+ const response = await postGuarded(body);
767
+ if (response?.refused !== true && response?.skipped !== true) receiptDelivered = true;
768
+ return response;
769
+ };
770
+ const postGuarded = async (body) => {
771
+ if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
772
+ try {
773
+ return await client.post(messagesPath, body);
774
+ } catch (error) {
775
+ if (!isUnseenMessagesHold(error)) throw error;
776
+ freshnessOverride = true;
777
+ const unseen = error.body?.unseenCount;
778
+ log(
779
+ `reply held: ${Number.isFinite(unseen) ? unseen : 'some'} unseen message(s) in pod ${podId} `
780
+ + 'arrived during the turn — re-reading the pod once, then posting with continueAnyway (TASK-535)',
781
+ );
782
+ try {
783
+ await client.get(messagesPath, { limit: 50 });
784
+ } catch (readError) {
785
+ // The re-read is bookkeeping, not permission: the override delivers.
786
+ log(`pod re-read before the continueAnyway post failed (${readError.message}) — posting anyway`);
787
+ }
788
+ return client.post(messagesPath, { ...body, continueAnyway: true });
789
+ }
790
+ };
791
+ const refused = (response, messages, attemptedMessages) => ({
792
+ mode: 'refused',
793
+ messages,
794
+ attemptedMessages,
795
+ refused: true,
796
+ reason: response.reason || 'message_refused',
797
+ ...(typeof response.guidance === 'string' && response.guidance
798
+ ? { guidance: response.guidance }
799
+ : {}),
800
+ ...(typeof response.consecutive === 'number'
801
+ ? { consecutive: response.consecutive }
802
+ : {}),
803
+ });
804
+ // The same route also answers HTTP 200 with `{ success: true, skipped: true,
805
+ // reason }` when it decided not to create a row: a duplicate inside the dedupe
806
+ // window, a silent or empty body, a heartbeat it keeps to itself. The wrapper
807
+ // used to read that as a delivery and log "posted … (single)" while nothing
808
+ // reached the pod (hq-support, 2026-10-09 08:10Z), so a silent seat could not
809
+ // be read from its own log. A skip is a completed request and not a delivery,
810
+ // exactly like a refusal, and it stops a sequence the same way.
811
+ const skipped = (response, messages, attemptedMessages) => {
812
+ const duplicate = response.duplicate && typeof response.duplicate === 'object'
813
+ ? response.duplicate
814
+ : null;
815
+ return {
816
+ mode: 'skipped',
817
+ messages,
818
+ attemptedMessages,
819
+ skipped: true,
820
+ reason: response.reason || 'message_skipped',
821
+ ...(duplicate
822
+ ? {
823
+ duplicate: {
824
+ ...(duplicate.id ? { id: String(duplicate.id) } : {}),
825
+ ...(typeof duplicate.dedupeWindowMinutes === 'number'
826
+ ? { dedupeWindowMinutes: duplicate.dedupeWindowMinutes }
827
+ : {}),
828
+ },
829
+ }
830
+ : {}),
831
+ };
832
+ };
833
+ const undelivered = (response) => response?.refused === true || response?.skipped === true;
834
+ const nonDelivery = (response, messages, attemptedMessages) => (
835
+ response?.refused === true
836
+ ? refused(response, messages, attemptedMessages)
837
+ : skipped(response, messages, attemptedMessages)
838
+ );
839
+ // An atomic unit (a fenced block, an unbreakable word-run) can exceed the
840
+ // limit by construction — splitForChat keeps it whole rather than breaking
841
+ // its rendering. The tone contract's own rule covers it: over ~800 chars of
842
+ // ONE indivisible thing is a document, not a message — attach it. Without
843
+ // this check a 659-char fence rode the single/split branches straight past
844
+ // the gate (found by the fleet's implementation audit).
845
+ const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
846
+ const placement = threadRootId ? { threadRootId } : {};
847
+ if (chunks.length <= 1 && !hasIndivisibleOversize) {
848
+ const response = await postOnce({ content: chunks[0] ?? text, ...placement });
849
+ if (undelivered(response)) return nonDelivery(response, 0, 1);
850
+ return { mode: 'single', messages: 1 };
851
+ }
852
+ if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
853
+ let messages = 0;
854
+ for (const chunk of chunks) {
855
+ // eslint-disable-next-line no-await-in-loop
856
+ const response = await postOnce({ content: chunk, ...placement }); // in order, so the reply reads top-down
857
+ if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
858
+ messages += 1;
859
+ }
860
+ return { mode: 'split', messages };
861
+ }
862
+ // PROSE OVERFLOW → THREAD. Only when nothing is indivisibly oversize: a
863
+ // fence too big to split is a document and belongs in the attach rung below.
864
+ if (!hasIndivisibleOversize) {
865
+ // A COUNT, not a flag. The recovery below resumes from here, and a boolean
866
+ // can only distinguish "nothing posted" from "something posted" — it cannot
867
+ // say how much. Fail a continuation at chunk 3 with a boolean and chunks 1
868
+ // and 2 are already in the thread, then get posted again top-level; the
869
+ // reader sees them twice. Getting this wrong is silent, which is also why
870
+ // the attach rung below leads with `chunks[0]`: falling through after a
871
+ // successful headline duplicates the opening line. Two of this suite's
872
+ // existing tests caught that one, and none caught this one, because both
873
+ // fail at the root-id step before any continuation has posted.
874
+ let posted = 0;
875
+ try {
876
+ const rootRes = await postOnce({ content: chunks[0] });
877
+ if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
878
+ posted = 1;
879
+ // The runtime route answers `res.json(result)` with the created row on
880
+ // `result.message`. Accept either id field; refuse to guess if neither
881
+ // is present, because a continuation posted with a missing root would
882
+ // silently become another top-level message — the exact flood this rung
883
+ // exists to prevent.
884
+ // The kernel can file the headline in the thread the turn answers (F245),
885
+ // and a thread has one root, so its root wins over the headline's own id.
886
+ const rootId = rootRes?.message?.thread_root_id
887
+ ?? rootRes?.message?.id ?? rootRes?.message?._id ?? rootRes?.id ?? rootRes?._id;
888
+ if (!rootId) throw new Error('no message id in post response — cannot root the thread');
889
+ for (const chunk of chunks.slice(1)) {
890
+ // eslint-disable-next-line no-await-in-loop
891
+ const response = await postOnce({ content: chunk, threadRootId: String(rootId) });
892
+ if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
893
+ posted += 1;
894
+ }
895
+ return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
896
+ } catch (err) {
897
+ if (posted > 0) {
898
+ // The opening is already in the room. Post the REMAINDER top-level —
899
+ // never the whole text again. This is the old flood, minus the
900
+ // duplicate, and it is still preferable to attaching: content ranks
901
+ // above tone, and the reader would otherwise see the same paragraph
902
+ // twice with the rest hidden in a file.
903
+ log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
904
+ for (const chunk of chunks.slice(posted)) {
905
+ // eslint-disable-next-line no-await-in-loop
906
+ const response = await postOnce({ content: chunk });
907
+ if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
908
+ posted += 1;
909
+ }
910
+ return { mode: 'thread-fallback', messages: chunks.length };
911
+ }
912
+ // Nothing reached the room, so the attach rung below is free to lead
913
+ // with the opening as it always did.
914
+ log(`thread headline failed (${err.message}) — falling back to attach`);
915
+ }
916
+ }
917
+ try {
918
+ const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, {
919
+ fileBuffer: Buffer.from(String(text), 'utf8'),
920
+ fileName: uploadName,
921
+ contentType: 'text/markdown',
922
+ fields: { podId },
923
+ });
924
+ const u = uploaded || {};
925
+ const directive = `[[upload:${u.fileName || uploadName}|${u.originalName || uploadName}|${u.size ?? Buffer.byteLength(String(text))}|${u.kind || 'document'}]]`;
926
+ // Lead with the reply's own opening — unless that opening is itself the
927
+ // oversized atomic unit (a fence-only reply), in which case a generic
928
+ // line keeps the message under the gate and the card carries the content.
929
+ const lead = chunks[0] && chunks[0].length <= limit
930
+ ? chunks[0]
931
+ : '(reply too large for chat — attached in full)';
932
+ const response = await postOnce({ content: `${lead}\n\n${directive}` });
933
+ if (undelivered(response)) return nonDelivery(response, 0, 1);
934
+ return { mode: 'attach', messages: 1 };
935
+ } catch (err) {
936
+ log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
937
+ let messages = 0;
938
+ for (const chunk of chunks) {
939
+ // eslint-disable-next-line no-await-in-loop
940
+ const response = await postOnce({ content: chunk });
941
+ if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
942
+ messages += 1;
943
+ }
944
+ return { mode: 'split-fallback', messages };
945
+ }
946
+ };