@ravefox/space 0.2.1 → 0.4.0
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/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
package/src/lib/enforcement.js
DELETED
|
@@ -1,946 +0,0 @@
|
|
|
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
|
-
};
|