@50arms/platform-mcp 0.0.0-stage → 0.1.436
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/README.md +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +93 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
package/dist/listen.js
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session event STREAM READER — holds ONE listener ticket's connection to the
|
|
3
|
+
* dashboard's session event stream and emits one record per event.
|
|
4
|
+
*
|
|
5
|
+
* WHO USES IT. Only the in-process event relay (`plan-events-relay.ts`), which mints the
|
|
6
|
+
* ticket, runs this reader inside the MCP server, and re-mints when a ticket's lease runs out.
|
|
7
|
+
* The ticket therefore never leaves the server's memory: no command line, no
|
|
8
|
+
* environment variable.
|
|
9
|
+
*
|
|
10
|
+
* THE OUTPUT CONTRACT (`ListenOutput`), because every event record reaches an agent:
|
|
11
|
+
* - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
|
|
12
|
+
* `text` is what the session reads; an event it cannot read still produces one
|
|
13
|
+
* (`could not read event …`), never silence. `id` is `null` only for a frame
|
|
14
|
+
* that carried no usable id. DX-2885's `nudge` frame (`formatNudgeLine`), DX-4340's `pacing` frame (`formatPacingLine`) and
|
|
15
|
+
* DX-4342's `resume` frame (`formatResumeLine`) and DX-5690's `owed_comment` frame (`formatOwedCommentLine`) are advisory and ride this same shape rather than growing a THIRD output
|
|
16
|
+
* variant. DX-5007: the dashboard stores each with an id and replays it until the id is reported, so they carry their id like
|
|
17
|
+
* an activity line (an id-less one, from an older dashboard, carries `id: null`).
|
|
18
|
+
* - `{type:"stopped", reason, detail}` — once, last. `reason`
|
|
19
|
+
* is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
|
|
20
|
+
* `not_connected`), `refused` (the ticket was not admitted), or
|
|
21
|
+
* `lease_expired` (no healthy connection for the lease). A wire `reason` value this build does not recognize (a
|
|
22
|
+
* newer dashboard's reason an older client hasn't shipped yet)
|
|
23
|
+
* normalizes to `"unknown"` rather than passing the raw string through,
|
|
24
|
+
* so the closed set stays closed — `fixForStop`'s generic
|
|
25
|
+
* `DEFAULT_STOP_FIX` handles it exactly as it already handled a verbatim
|
|
26
|
+
* unrecognized string.
|
|
27
|
+
* - nothing for keep-alives, the connect marker, or a successful reconnect.
|
|
28
|
+
*
|
|
29
|
+
* RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
|
|
30
|
+
* also die silently (sleep, NAT, proxy). A read-idle timeout of three missed
|
|
31
|
+
* keep-alives turns silent death into a drop. Retries use capped exponential
|
|
32
|
+
* backoff and send `Last-Event-ID`; the dashboard replays what was missed, with a
|
|
33
|
+
* commit-order overlap that re-sends recently delivered ids, and an id already
|
|
34
|
+
* emitted is never emitted twice. `resumeIds` carries that memory across a ticket
|
|
35
|
+
* or process restart: the ids already delivered seed the duplicate guard, and the
|
|
36
|
+
* highest is the first `Last-Event-ID`. The backoff only resets once a connection
|
|
37
|
+
* proves healthy — it delivered an event, or stayed up past one keep-alive — so a
|
|
38
|
+
* dashboard that admits and immediately drops is not hammered.
|
|
39
|
+
*
|
|
40
|
+
* Only the event shape is hand-copied from the dashboard's `IssueActivityEvent`
|
|
41
|
+
* (`src/issues/db/issue-activity.ts`); this published package cannot import
|
|
42
|
+
* danxbot source.
|
|
43
|
+
*/
|
|
44
|
+
import { controlOfFrame, invalidCompactionReason } from "./compaction-control.js";
|
|
45
|
+
import { clearControlOfFrame, invalidClearReason } from "./clear-control.js";
|
|
46
|
+
import { LINE_PREFIX, oneLine } from "./one-line.js";
|
|
47
|
+
import { MCP_STREAM_MIN_VERSION_HEADER, ownVersionBelow, readOwnVersion } from "./version-floor.js";
|
|
48
|
+
import { pacingRelayLine } from "./pacing-relay.js";
|
|
49
|
+
import { isUrgent } from "./urgent-records.js";
|
|
50
|
+
export const INITIAL_BACKOFF_MS = 1_000;
|
|
51
|
+
export const MAX_BACKOFF_MS = 30_000;
|
|
52
|
+
/** Three of the dashboard's 15 s keep-alives without a byte means the connection is dead. */
|
|
53
|
+
export const READ_IDLE_TIMEOUT_MS = 45_000;
|
|
54
|
+
/** A connection that stays up this long has proven the dashboard healthy. */
|
|
55
|
+
export const HEALTHY_CONNECTION_MS = 20_000;
|
|
56
|
+
/** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
|
|
57
|
+
export const DELIVERED_ID_MEMORY = 1_000;
|
|
58
|
+
/**
|
|
59
|
+
* DX-2829 — the ONE place that caps a resume-id list to the last
|
|
60
|
+
* `DELIVERED_ID_MEMORY` entries. Both `runListener` (seeding its own
|
|
61
|
+
* duplicate-detection `Set` below) and the relay (`plan-events-relay.ts`) (seeding its
|
|
62
|
+
* own delivered-id array) used to each carry their own `slice(-DELIVERED_ID_MEMORY)`
|
|
63
|
+
* — the same cap, on the same kind of input, computed twice.
|
|
64
|
+
*/
|
|
65
|
+
export function capResumeIds(ids) {
|
|
66
|
+
return ids.slice(-DELIVERED_ID_MEMORY);
|
|
67
|
+
}
|
|
68
|
+
const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
|
|
69
|
+
function quoted(value) {
|
|
70
|
+
return `"${value.text}${value.truncated ? "…" : ""}"`;
|
|
71
|
+
}
|
|
72
|
+
function isRecord(value) {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
function isCappedText(value) {
|
|
76
|
+
return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
|
|
77
|
+
}
|
|
78
|
+
function isAckList(value) {
|
|
79
|
+
return (Array.isArray(value) &&
|
|
80
|
+
value.length > 0 &&
|
|
81
|
+
value.every((a) => isRecord(a) && Number.isSafeInteger(a.eventId) && a.eventId > 0 && Number.isSafeInteger(a.attempt) && a.attempt >= 1));
|
|
82
|
+
}
|
|
83
|
+
/** DX-4078 - hand-copied from `DESIGN_DECISION_OUTCOMES` (`src/issues/view-types.ts`): this published package cannot import danxbot source. */
|
|
84
|
+
const DESIGN_OUTCOMES = ["approved", "rejected", "changes_requested"];
|
|
85
|
+
function isDesignOutcome(value) {
|
|
86
|
+
return typeof value === "string" && DESIGN_OUTCOMES.includes(value);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* DX-3880 — the part `solution_answered` and `problem_answer_changed` share: the
|
|
90
|
+
* decision's `outcome` and the either/or body it implies. A rejection (the
|
|
91
|
+
* operator DECLINED an action) always names the solution and carries its reason
|
|
92
|
+
* as `solution.note`; an answer is a solution (optional note) or free-form text.
|
|
93
|
+
*/
|
|
94
|
+
function invalidDecisionBody(d) {
|
|
95
|
+
if (d.outcome !== "answered" && d.outcome !== "rejected")
|
|
96
|
+
return 'detail.outcome is not "answered" or "rejected"';
|
|
97
|
+
if (d.solution === null) {
|
|
98
|
+
if (d.outcome === "rejected")
|
|
99
|
+
return "detail.solution is null on a rejection";
|
|
100
|
+
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
103
|
+
return "detail.solution is not {title, note}";
|
|
104
|
+
if (d.outcome === "rejected") {
|
|
105
|
+
return isCappedText(d.solution.note) ? null : "detail.solution.note (the rejection reason) is not capped text";
|
|
106
|
+
}
|
|
107
|
+
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* DX-2735 — why an event cannot be described, or `null` when it can.
|
|
111
|
+
*
|
|
112
|
+
* Checked BEFORE describing, so a malformed event is reported by what is
|
|
113
|
+
* actually wrong with it — never by whichever TypeError a missing field happens
|
|
114
|
+
* to raise (whose wording is the JS engine's, not ours). The envelope is checked
|
|
115
|
+
* for every kind; the detail only for the kinds `describe` reads, so a kind newer
|
|
116
|
+
* than this listener still produces its generic line.
|
|
117
|
+
*/
|
|
118
|
+
export function invalidEventReason(value) {
|
|
119
|
+
if (!isRecord(value))
|
|
120
|
+
return "the event is not an object";
|
|
121
|
+
if (typeof value.id !== "number" || !Number.isSafeInteger(value.id))
|
|
122
|
+
return "id is not an integer";
|
|
123
|
+
// DX-6211: the subject is a card (`cardId` a string) or the plan itself (`planId` a positive integer and `cardId` null), never both and never neither.
|
|
124
|
+
if (value.planId !== null && !(typeof value.planId === "number" && Number.isSafeInteger(value.planId) && value.planId > 0)) {
|
|
125
|
+
return "planId is not null or a positive integer";
|
|
126
|
+
}
|
|
127
|
+
if (typeof value.planId === "number") {
|
|
128
|
+
if (value.cardId !== null)
|
|
129
|
+
return "cardId is not null on a plan event";
|
|
130
|
+
if (typeof value.planRef !== "string" || value.planRef === "")
|
|
131
|
+
return "planRef is not a string on a plan event";
|
|
132
|
+
}
|
|
133
|
+
else if (typeof value.cardId !== "string") {
|
|
134
|
+
return "cardId is not a string";
|
|
135
|
+
}
|
|
136
|
+
for (const field of ["cardTitle", "actor", "kind"]) {
|
|
137
|
+
if (typeof value[field] !== "string")
|
|
138
|
+
return `${field} is not a string`;
|
|
139
|
+
}
|
|
140
|
+
// DX-5466: null is legal only on a pause release (a plan/session anchor has no board); no text marker ever travels here.
|
|
141
|
+
const isReleaseWithoutBoard = value.boardId === null && value.release === true;
|
|
142
|
+
const isBoardId = typeof value.boardId === "number" && Number.isSafeInteger(value.boardId);
|
|
143
|
+
if (!isReleaseWithoutBoard && !isBoardId)
|
|
144
|
+
return "boardId is not an integer";
|
|
145
|
+
// DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
|
|
146
|
+
// detail check, exactly like every other envelope field above.
|
|
147
|
+
if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
|
|
148
|
+
return "origin is not one of operator, agent, machine";
|
|
149
|
+
}
|
|
150
|
+
// DX-4246 — the key snapshot is part of every event the dashboard sends, `null` when no key wrote it.
|
|
151
|
+
if (value.viaKey !== null && !(isRecord(value.viaKey) && typeof value.viaKey.id === "number" && typeof value.viaKey.name === "string")) {
|
|
152
|
+
return "viaKey is not null or {id, name}";
|
|
153
|
+
}
|
|
154
|
+
if (value.release !== undefined && value.release !== true)
|
|
155
|
+
return "release is not true";
|
|
156
|
+
// DX-5846: an event that asks for an acknowledgement and names no (or a wrong) event to acknowledge is a fault line, never a record the session cannot answer.
|
|
157
|
+
if (value.acks !== undefined && !isAckList(value.acks))
|
|
158
|
+
return "acks is not a list of {eventId, attempt}";
|
|
159
|
+
const d = value.detail;
|
|
160
|
+
if (!isRecord(d))
|
|
161
|
+
return "detail is not an object";
|
|
162
|
+
switch (value.kind) {
|
|
163
|
+
case "comment_added": {
|
|
164
|
+
if (!isCappedText(d.excerpt))
|
|
165
|
+
return "detail.excerpt is not capped text";
|
|
166
|
+
// DX-5435: the line tells the agent which comment to signal on, so a person's comment must name it.
|
|
167
|
+
if (isPersonsComment(value) && typeof d.comment_id !== "number")
|
|
168
|
+
return "detail.comment_id is not a number";
|
|
169
|
+
// DX-2906 — `problem` is OPTIONAL (only present when the comment named
|
|
170
|
+
// one); absent is valid, but a present-and-malformed one is not.
|
|
171
|
+
if (d.problem === undefined)
|
|
172
|
+
return null;
|
|
173
|
+
const problem = d.problem;
|
|
174
|
+
return isRecord(problem) && typeof problem.id === "number" && isCappedText(problem.statement)
|
|
175
|
+
? null
|
|
176
|
+
: "detail.problem is not {id, statement}";
|
|
177
|
+
}
|
|
178
|
+
case "solution_answered": {
|
|
179
|
+
const problem = d.problem;
|
|
180
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
181
|
+
return "detail.problem is not {id, statement}";
|
|
182
|
+
}
|
|
183
|
+
return invalidDecisionBody(d);
|
|
184
|
+
}
|
|
185
|
+
case "problem_added": {
|
|
186
|
+
const problem = d.problem;
|
|
187
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
188
|
+
return "detail.problem is not {id, statement}";
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
// DX-2935 — the operator retracted a problem's answer with no new one yet.
|
|
193
|
+
case "problem_unanswered": {
|
|
194
|
+
const problem = d.problem;
|
|
195
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
196
|
+
return "detail.problem is not {id, statement}";
|
|
197
|
+
}
|
|
198
|
+
return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
|
|
199
|
+
}
|
|
200
|
+
// DX-2935 — the operator retracted a problem's answer AND recorded a new
|
|
201
|
+
// one, in one transaction — same either/or solution/freeform shape
|
|
202
|
+
// `solution_answered` carries, plus which decision was retracted.
|
|
203
|
+
case "problem_answer_changed": {
|
|
204
|
+
const problem = d.problem;
|
|
205
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
206
|
+
return "detail.problem is not {id, statement}";
|
|
207
|
+
}
|
|
208
|
+
if (typeof d.retracted_decision_id !== "number")
|
|
209
|
+
return "detail.retracted_decision_id is not a number";
|
|
210
|
+
if (typeof d.decision_id !== "number")
|
|
211
|
+
return "detail.decision_id is not a number";
|
|
212
|
+
return invalidDecisionBody(d);
|
|
213
|
+
}
|
|
214
|
+
case "blocked":
|
|
215
|
+
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
216
|
+
// DX-5323: an agent wrote a note in its working log on the card (never a comment).
|
|
217
|
+
case "agent_note_added":
|
|
218
|
+
if (typeof d.note_id !== "number")
|
|
219
|
+
return "detail.note_id is not a number";
|
|
220
|
+
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
221
|
+
// DX-4622: a reviewer asked for changes on the card's pull request (GitHub review webhook).
|
|
222
|
+
case "pull_request_changes_requested":
|
|
223
|
+
if (typeof d.reviewer !== "string")
|
|
224
|
+
return "detail.reviewer is not a string";
|
|
225
|
+
if (typeof d.review_url !== "string")
|
|
226
|
+
return "detail.review_url is not a string";
|
|
227
|
+
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
228
|
+
// DX-6478: a designer published a revision it could not check in a browser.
|
|
229
|
+
case "design_published_unchecked":
|
|
230
|
+
if (typeof d.design_no !== "number")
|
|
231
|
+
return "detail.design_no is not a number";
|
|
232
|
+
if (typeof d.design_title !== "string")
|
|
233
|
+
return "detail.design_title is not a string";
|
|
234
|
+
if (typeof d.revision_no !== "number")
|
|
235
|
+
return "detail.revision_no is not a number";
|
|
236
|
+
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
237
|
+
// DX-4078: the operator's verdict on a design revision. An outcome this listener does not know is refused, never rendered as the bare
|
|
238
|
+
// kind line, which would name no outcome and no note and leave the agent to re-read the card. `rejected` and `changes_requested` always
|
|
239
|
+
// carry their note (the table CHECK); `approved` may carry none.
|
|
240
|
+
case "design_decided": {
|
|
241
|
+
if (typeof d.decision_id !== "number")
|
|
242
|
+
return "detail.decision_id is not a number";
|
|
243
|
+
if (typeof d.design_no !== "number")
|
|
244
|
+
return "detail.design_no is not a number";
|
|
245
|
+
if (typeof d.design_title !== "string")
|
|
246
|
+
return "detail.design_title is not a string";
|
|
247
|
+
if (typeof d.revision_no !== "number")
|
|
248
|
+
return "detail.revision_no is not a number";
|
|
249
|
+
if (!isDesignOutcome(d.outcome))
|
|
250
|
+
return 'detail.outcome is not "approved", "rejected" or "changes_requested"';
|
|
251
|
+
if (d.note === null)
|
|
252
|
+
return d.outcome === "approved" ? null : `detail.note (the ${d.outcome} reason) is null`;
|
|
253
|
+
return isCappedText(d.note) ? null : "detail.note is not null or capped text";
|
|
254
|
+
}
|
|
255
|
+
default:
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
|
|
261
|
+
* `machine` one (both stamp `actor: "danxbot"` today), so every line names
|
|
262
|
+
* the origin next to the actor rather than leaving it to a separate field a
|
|
263
|
+
* reader would have to cross-reference.
|
|
264
|
+
*/
|
|
265
|
+
function actorLabel(event) {
|
|
266
|
+
// DX-4246 — a write made through an API key names the user AND the key ("dan via claude-laptop (agent)").
|
|
267
|
+
const via = event.viaKey === null ? "" : ` via ${event.viaKey.name}`;
|
|
268
|
+
return `${event.actor}${via} (${event.origin})`;
|
|
269
|
+
}
|
|
270
|
+
/** DX-5435: a comment a person wrote (not the session's own pause notice, not an agent's or the machine's): the one an agent answers through its reply message. */
|
|
271
|
+
function isPersonsComment(event) {
|
|
272
|
+
return event.origin === "operator" && event.release !== true;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* DX-5799: what the agent does with a person's comment. The person's comment shows the agent that received it, and the reply is ONE message the agent writes
|
|
276
|
+
* twice: first a short acknowledgement of its own (`phase: "ack"`, so the person sees the comment was received), then its full answer (`phase: "reply"`) written
|
|
277
|
+
* over the same message, so the answer never arrives as a separate comment. The text is written here, with the line the agent reads, so every session that
|
|
278
|
+
* updates this package follows it without a plugin change.
|
|
279
|
+
*/
|
|
280
|
+
export function replyInstruction(cardId, commentId) {
|
|
281
|
+
return replyInstructionAt(`/api/issues/${cardId}/comments/${commentId}/reply`);
|
|
282
|
+
}
|
|
283
|
+
/** DX-6211: {@link replyInstruction} for a person's comment written on the plan itself, answered by the plan's session through the plan's reply route. */
|
|
284
|
+
export function planReplyInstruction(planId, commentId) {
|
|
285
|
+
return replyInstructionAt(`/api/plans/${planId}/comments/${commentId}/reply`);
|
|
286
|
+
}
|
|
287
|
+
/** DX-6211: the event's subject as the line names it: the card's id, or the plan's reference (`PLAN-72`, sent by the dashboard as `planRef`) for a plan event. Only called on an event `invalidEventReason` accepted. */
|
|
288
|
+
function subjectRef(event) {
|
|
289
|
+
return typeof event.planId === "number" ? event.planRef : event.cardId;
|
|
290
|
+
}
|
|
291
|
+
function replyInstructionAt(path) {
|
|
292
|
+
return `Answer it through its reply message: first call 50arms_api POST ${path} with {"phase":"ack","body":"<one short sentence of your own acknowledging it>"} so the person sees it was received, then do the work and call POST ${path} again with {"phase":"reply","body":"<your full answer>"}, which overwrites the same message; never answer with a separate comment`;
|
|
293
|
+
}
|
|
294
|
+
/** DX-6064: what a session does with a person's answer (or changed answer), so a decision never lives only in its conversation. */
|
|
295
|
+
export const UPDATE_RECORDS_NOW = "Update every plan record and card this answer changes, this turn";
|
|
296
|
+
/** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
|
|
297
|
+
function describe(event) {
|
|
298
|
+
const d = event.detail;
|
|
299
|
+
const who = actorLabel(event);
|
|
300
|
+
switch (event.kind) {
|
|
301
|
+
case "comment_added": {
|
|
302
|
+
// DX-2906 — a comment naming a problem (a follow-up question, without
|
|
303
|
+
// answering it) relays distinctly from a plain comment, so an agent
|
|
304
|
+
// listening to the stream can tell which of its open problems just
|
|
305
|
+
// got a reply.
|
|
306
|
+
const problem = d.problem;
|
|
307
|
+
if (event.release === true)
|
|
308
|
+
return `${who} notice: ${quoted(d.excerpt)}`;
|
|
309
|
+
// DX-6211: a comment written on the plan itself is answered through the plan's reply route, and the line says it was written on the plan.
|
|
310
|
+
const onPlan = typeof event.planId === "number";
|
|
311
|
+
const reply = !isPersonsComment(event) ? "" : `. ${onPlan ? planReplyInstruction(event.planId, d.comment_id) : replyInstruction(event.cardId, d.comment_id)}`;
|
|
312
|
+
if (problem === undefined)
|
|
313
|
+
return `${who} commented${onPlan ? " on the plan" : ""}: ${quoted(d.excerpt)}${reply}`;
|
|
314
|
+
return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}${reply}`;
|
|
315
|
+
}
|
|
316
|
+
case "solution_answered": {
|
|
317
|
+
// DX-2735: every answer answers ONE problem on a card that may carry several,
|
|
318
|
+
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
319
|
+
// questions just closed.
|
|
320
|
+
const problem = d.problem;
|
|
321
|
+
const solution = d.solution;
|
|
322
|
+
// DX-3880 — a REJECTION is the opposite of work done: the operator declined
|
|
323
|
+
// the action, so the line says so and names the reason; the agent must not
|
|
324
|
+
// perform it and re-plans from the note.
|
|
325
|
+
if (d.outcome === "rejected")
|
|
326
|
+
return `${who} rejected the action "${solution.title}" for ${quoted(problem.statement)} — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
|
|
327
|
+
const answered = `${who} answered ${quoted(problem.statement)}:`;
|
|
328
|
+
if (solution === null)
|
|
329
|
+
return `${answered} ${quoted(d.freeform)}. ${UPDATE_RECORDS_NOW}`;
|
|
330
|
+
const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
|
|
331
|
+
return `${answered} chose "${solution.title}"${note}. ${UPDATE_RECORDS_NOW}`;
|
|
332
|
+
}
|
|
333
|
+
case "problem_added": {
|
|
334
|
+
// DX-2830 — this IS the "needs a human" signal now: a card needs one
|
|
335
|
+
// exactly when it has an open problem, and this is one being opened.
|
|
336
|
+
const problem = d.problem;
|
|
337
|
+
return `${who} opened a problem: ${quoted(problem.statement)}`;
|
|
338
|
+
}
|
|
339
|
+
case "problem_unanswered": {
|
|
340
|
+
// DX-2935 — MUST read as "a previous answer was withdrawn", never as a
|
|
341
|
+
// brand-new problem: a connected session that already acted on the
|
|
342
|
+
// retracted answer has to stop, not treat this as fresh work to start.
|
|
343
|
+
const problem = d.problem;
|
|
344
|
+
return `${who} retracted the answer to ${quoted(problem.statement)}`;
|
|
345
|
+
}
|
|
346
|
+
case "problem_answer_changed": {
|
|
347
|
+
// DX-2935 — ONE line for the whole retract-and-reanswer (never two
|
|
348
|
+
// separate events), naming the statement so a listener with several
|
|
349
|
+
// open questions knows which one just moved.
|
|
350
|
+
const problem = d.problem;
|
|
351
|
+
const solution = d.solution;
|
|
352
|
+
if (d.outcome === "rejected")
|
|
353
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now REJECTED the action "${solution.title}" — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
|
|
354
|
+
const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
|
|
355
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}. ${UPDATE_RECORDS_NOW}`;
|
|
356
|
+
}
|
|
357
|
+
case "blocked":
|
|
358
|
+
return `${who} blocked the card: ${quoted(d.reason)}`;
|
|
359
|
+
// DX-5323: a working-log entry for the next agent; the full note is the card's `agent_notes`.
|
|
360
|
+
case "agent_note_added":
|
|
361
|
+
return `${who} wrote an agent note: ${quoted(d.excerpt)}`;
|
|
362
|
+
case "unblocked":
|
|
363
|
+
return `${who} unblocked the card`;
|
|
364
|
+
// DX-4622: the agent must act on the review, so the line names who, where, and what to do next.
|
|
365
|
+
case "pull_request_changes_requested":
|
|
366
|
+
return `${who}: ${d.reviewer} requested changes on the pull request (${d.review_url})${d.excerpt.text === "" ? "" : `: ${quoted(d.excerpt)}`}. Address the review, push, and reply on the pull request`;
|
|
367
|
+
// DX-6478: names the design, the revision and the reason, so the operator can correct the behaviour.
|
|
368
|
+
case "design_published_unchecked":
|
|
369
|
+
return `${who} published revision ${d.revision_no} of design ${d.design_no} "${d.design_title}" WITHOUT checking it in a browser — reason: ${quoted(d.reason)}. It carries a red warning on the card; correct why the designer had no browser`;
|
|
370
|
+
// DX-4078: names the outcome, the revision and the note, and what the agent does next, so it never has to re-read the card to learn the verdict.
|
|
371
|
+
case "design_decided": {
|
|
372
|
+
const note = d.note;
|
|
373
|
+
// DX-5193: a card holds several designs, so the line names which one the verdict closes.
|
|
374
|
+
const revision = `revision ${d.revision_no} of design ${d.design_no} "${d.design_title}"`;
|
|
375
|
+
if (d.outcome === "approved")
|
|
376
|
+
return `${who} APPROVED ${revision}${note === null ? "" : ` — note: ${quoted(note)}`}. That design is the one to build from; the person can still withdraw the choice until the build is picked up, so build from the design the card holds now`;
|
|
377
|
+
if (d.outcome === "rejected")
|
|
378
|
+
return `${who} REJECTED ${revision} — reason: ${quoted(note)}. That design is closed; do not continue it`;
|
|
379
|
+
return `${who} requested changes on ${revision} — ${quoted(note)}. Publish a new revision that addresses it`;
|
|
380
|
+
}
|
|
381
|
+
default:
|
|
382
|
+
// A kind newer than this listener still produces a line: an event the
|
|
383
|
+
// agent is never told about is exactly the failure this reader prevents.
|
|
384
|
+
return `${who}: ${event.kind}`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* DX-5846: what the agent does with an event that must be acknowledged. The first send says to confirm receipt, then act; a re-send (the dashboard got no acknowledgement for an earlier copy) says it is a repeat, so an agent that
|
|
389
|
+
* already acted does only the acknowledgement. The text is written here, with the line the agent reads, so every session that updates this package follows it without a plugin change.
|
|
390
|
+
*/
|
|
391
|
+
export function ackInstruction(ack) {
|
|
392
|
+
const path = `/api/plan-sessions/me/events/${ack.eventId}/ack`;
|
|
393
|
+
if (ack.attempt <= 1)
|
|
394
|
+
return `[ack-required event ${ack.eventId}] First call 50arms_api POST ${path} (no body) to confirm you received it, then act on it.`;
|
|
395
|
+
return `[ack-required event ${ack.eventId}, sent again (attempt ${ack.attempt})] Not acknowledged yet. If you already acted on it, only acknowledge it: 50arms_api POST ${path} (no body).`;
|
|
396
|
+
}
|
|
397
|
+
/** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
|
|
398
|
+
export function formatActivityLine(event) {
|
|
399
|
+
const invalid = invalidEventReason(event);
|
|
400
|
+
if (invalid !== null)
|
|
401
|
+
throw new Error(`malformed activity event: ${invalid}`);
|
|
402
|
+
// DX-5846: the acknowledgement comes first, so it is done before the event's own instructions (a comment's "responding" call).
|
|
403
|
+
const acks = (event.acks ?? []).map((a) => `${ackInstruction(a)} `).join("");
|
|
404
|
+
return oneLine(`${acks}[${subjectRef(event)} "${event.cardTitle}"${event.boardId === null ? "" : ` ${event.boardId}`}] ${describe(event)}`);
|
|
405
|
+
}
|
|
406
|
+
/** DX-6277 - the instruction a line carries when it names filed-not-readied cards; the dashboard's own wording (`FILED_NOT_READIED_INSTRUCTION`) for the same cards. */
|
|
407
|
+
const FILED_NOT_READIED_INSTRUCTION = "Ready each now (order: depends_on), or open a problem if only a person can decide.";
|
|
408
|
+
/** DX-6277 - the label the filed ids sit under; the same words as the dashboard's `FILED_NOT_READIED_LABEL` (src/issues/agent-pause.ts). */
|
|
409
|
+
const FILED_NOT_READIED_LABEL = "Filed, not readied";
|
|
410
|
+
function isNudgeCardRef(value) {
|
|
411
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
|
|
412
|
+
}
|
|
413
|
+
function isNudgeCardRefArray(value) {
|
|
414
|
+
return Array.isArray(value) && value.every(isNudgeCardRef);
|
|
415
|
+
}
|
|
416
|
+
function isQueuedNudgeCardRefArray(value) {
|
|
417
|
+
return Array.isArray(value) && value.every((c) => isNudgeCardRef(c) && typeof c.queueState === "string");
|
|
418
|
+
}
|
|
419
|
+
/** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
|
|
420
|
+
export function invalidNudgeReason(value) {
|
|
421
|
+
if (!isRecord(value))
|
|
422
|
+
return "the nudge is not an object";
|
|
423
|
+
if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
|
|
424
|
+
return "idleMinutes is not a number";
|
|
425
|
+
}
|
|
426
|
+
if (!isQueuedNudgeCardRefArray(value.startable))
|
|
427
|
+
return "startable is not a {id,title,queueState}[] array";
|
|
428
|
+
if (!isNudgeCardRefArray(value.held))
|
|
429
|
+
return "held is not a {id,title}[] array";
|
|
430
|
+
if (!isNudgeCardRefArray(value.unreadied))
|
|
431
|
+
return "unreadied is not a {id,title}[] array";
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
function cardList(cards) {
|
|
435
|
+
return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
|
|
436
|
+
}
|
|
437
|
+
function queuedCardList(cards) {
|
|
438
|
+
return cards.map((c) => `${c.id} "${c.title}" [${c.queueState}]`).join(", ");
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The one readable line for a nudge (DX-2885). Always names the actual
|
|
442
|
+
* waiting cards — never a generic "keep going" — because that is the whole
|
|
443
|
+
* point of the feature: a session that only sees "you're idle" learns
|
|
444
|
+
* nothing a bare timer couldn't have told it. Always a single line; throws,
|
|
445
|
+
* naming the fault, on a malformed nudge.
|
|
446
|
+
*
|
|
447
|
+
* DX-3274 — the line asserts only what `evaluateIdleNudge` actually measured
|
|
448
|
+
* (no real MCP call from THIS session in `idleMinutes`), never "work is
|
|
449
|
+
* waiting" or "work has stalled". `last_agent_call_at` cannot see a
|
|
450
|
+
* sub-agent/dispatch working the same card from underneath an orchestrating
|
|
451
|
+
* session — an orchestrator that delegates and then correctly waits without
|
|
452
|
+
* polling (the project's own mandated style, `base:monitor-polling`) looks
|
|
453
|
+
* identical, on this signal, to one that actually stalled. The card names
|
|
454
|
+
* are still worth reporting (a real signal: these are the cards this session
|
|
455
|
+
* itself has not touched), but the claim about THEM stops at "not checked
|
|
456
|
+
* in" — never a claim about whether the work itself is progressing, which
|
|
457
|
+
* this signal cannot support. See `evaluateIdleNudge`'s own doc comment
|
|
458
|
+
* (`src/issues/db/session-idle-nudge.ts`) for why a sub-agent-awareness
|
|
459
|
+
* special case was rejected instead of this wording fix.
|
|
460
|
+
*
|
|
461
|
+
* DX-3676 — every named card clears every dispatch gate, so if the session
|
|
462
|
+
* still cannot work one, the card's recorded state is wrong (DX-3400 sat in
|
|
463
|
+
* ToDo with its real blocker only in a comment). For the cards this session
|
|
464
|
+
* holds, the line therefore ends by naming each state-correcting move, which is
|
|
465
|
+
* also what stops the nudge. DX-5154 / DX-6071: a startable card is run by its queue item and is named only while that item is held, so for
|
|
466
|
+
* those the line says why the item is held (bracketed) and that clearing the reason or removing the item is the move, never working the card; a nudge naming only
|
|
467
|
+
* startable cards says nothing about working them.
|
|
468
|
+
*/
|
|
469
|
+
export function formatNudgeLine(event) {
|
|
470
|
+
const invalid = invalidNudgeReason(event);
|
|
471
|
+
if (invalid !== null)
|
|
472
|
+
throw new Error(`malformed nudge event: ${invalid}`);
|
|
473
|
+
// DX-6277 - the filed label and instruction come before the ids, and the startable and held parts after them, so the instruction is never the part a cut takes.
|
|
474
|
+
const parts = [];
|
|
475
|
+
if (event.unreadied.length > 0)
|
|
476
|
+
parts.push(`${FILED_NOT_READIED_LABEL} (${FILED_NOT_READIED_INSTRUCTION}): ${cardList(event.unreadied)}`);
|
|
477
|
+
if (event.startable.length > 0)
|
|
478
|
+
parts.push(`startable: ${queuedCardList(event.startable)}`);
|
|
479
|
+
if (event.held.length > 0)
|
|
480
|
+
parts.push(`held: ${cardList(event.held)}`);
|
|
481
|
+
// DX-5154 / DX-6071: a startable card is run by its dispatch queue item, not by this session, and is named only while that item is held, so the line says what to do
|
|
482
|
+
// about THAT and keeps the work-or-correct-it instruction for the cards this session itself holds.
|
|
483
|
+
const advice = [];
|
|
484
|
+
if (event.startable.length > 0) {
|
|
485
|
+
advice.push("Startable cards are run by their queue item, not by this session: never work or rollback_pickup them. The bracketed text says where the item stands; a held item waits on you: clear the reason it names, or remove the item.");
|
|
486
|
+
}
|
|
487
|
+
if (event.held.length > 0) {
|
|
488
|
+
advice.push("Work the held cards; any you cannot work, update to its true state: open a problem, add depends_on or conflict_on, block it with the real reason, or rollback_pickup.");
|
|
489
|
+
}
|
|
490
|
+
const tail = advice.length > 0 ? `. ${advice.join(" ")}` : ".";
|
|
491
|
+
return oneLine(`${LINE_PREFIX} this session has not made an MCP call in ${event.idleMinutes}m — ${parts.join("; ")}${tail}`);
|
|
492
|
+
}
|
|
493
|
+
const PACING_LEVELS = ["on_pace", "over_pace", "critical"];
|
|
494
|
+
/** Why a pacing message cannot be read, or `null` when it can. */
|
|
495
|
+
export function invalidPacingReason(value) {
|
|
496
|
+
if (!isRecord(value))
|
|
497
|
+
return "the pacing message is not an object";
|
|
498
|
+
if (!PACING_LEVELS.includes(value.level))
|
|
499
|
+
return "level is not on_pace, over_pace or critical";
|
|
500
|
+
if (value.budget !== null && !(typeof value.budget === "number" && Number.isInteger(value.budget) && value.budget >= 0))
|
|
501
|
+
return "budget is not a count or null";
|
|
502
|
+
if (typeof value.resets_at !== "string" || Number.isNaN(Date.parse(value.resets_at)))
|
|
503
|
+
return "resets_at is not a timestamp";
|
|
504
|
+
if (typeof value.text !== "string" || value.text.trim() === "")
|
|
505
|
+
return "text is empty";
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
/** The one readable line for a pacing message: the dashboard's text, on one line. Throws, naming the fault, on a malformed message. */
|
|
509
|
+
export function formatPacingLine(event) {
|
|
510
|
+
const invalid = invalidPacingReason(event);
|
|
511
|
+
if (invalid !== null)
|
|
512
|
+
throw new Error(`malformed pacing message: ${invalid}`);
|
|
513
|
+
return pacingRelayLine(event.text);
|
|
514
|
+
}
|
|
515
|
+
/** Why a resume message cannot be read, or `null` when it can. */
|
|
516
|
+
export function invalidResumeReason(value) {
|
|
517
|
+
if (!isRecord(value))
|
|
518
|
+
return "the resume message is not an object";
|
|
519
|
+
if (typeof value.text !== "string" || value.text.trim() === "")
|
|
520
|
+
return "text is empty";
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
/** The one readable line for a resume message: the dashboard's text, on one line, cut like a pacing line. Throws, naming the fault, on a malformed message. */
|
|
524
|
+
export function formatResumeLine(event) {
|
|
525
|
+
const invalid = invalidResumeReason(event);
|
|
526
|
+
if (invalid !== null)
|
|
527
|
+
throw new Error(`malformed resume message: ${invalid}`);
|
|
528
|
+
return pacingRelayLine(event.text);
|
|
529
|
+
}
|
|
530
|
+
/** Why an assignment message cannot be read, or `null` when it can. */
|
|
531
|
+
export function invalidAssignmentReason(value) {
|
|
532
|
+
if (!isRecord(value))
|
|
533
|
+
return "the assignment message is not an object";
|
|
534
|
+
if (typeof value.text !== "string" || value.text.trim() === "")
|
|
535
|
+
return "text is empty";
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
/** The one readable line for an assignment message: the dashboard's text, on one line, cut like a pacing line. Throws, naming the fault, on a malformed message. */
|
|
539
|
+
export function formatAssignmentLine(event) {
|
|
540
|
+
const invalid = invalidAssignmentReason(event);
|
|
541
|
+
if (invalid !== null)
|
|
542
|
+
throw new Error(`malformed assignment message: ${invalid}`);
|
|
543
|
+
return pacingRelayLine(event.text);
|
|
544
|
+
}
|
|
545
|
+
/** Why an owed-comment message cannot be read, or `null` when it can. */
|
|
546
|
+
export function invalidOwedCommentReason(value) {
|
|
547
|
+
if (!isRecord(value))
|
|
548
|
+
return "the owed-comment message is not an object";
|
|
549
|
+
if (typeof value.text !== "string" || value.text.trim() === "")
|
|
550
|
+
return "text is empty";
|
|
551
|
+
if (!Array.isArray(value.comment_ids) || !value.comment_ids.every((id) => Number.isSafeInteger(id)))
|
|
552
|
+
return "comment_ids is not a list of comment ids";
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
/** The one readable line for an owed-comment message: the dashboard's text, on one line, cut like a pacing line. Throws, naming the fault, on a malformed message. */
|
|
556
|
+
export function formatOwedCommentLine(event) {
|
|
557
|
+
const invalid = invalidOwedCommentReason(event);
|
|
558
|
+
if (invalid !== null)
|
|
559
|
+
throw new Error(`malformed owed-comment message: ${invalid}`);
|
|
560
|
+
return pacingRelayLine(event.text);
|
|
561
|
+
}
|
|
562
|
+
/** Why an agent-created frame cannot be read, or `null` when it can. */
|
|
563
|
+
export function invalidAgentCreatedReason(value) {
|
|
564
|
+
if (!isRecord(value))
|
|
565
|
+
return "the agent-created frame is not an object";
|
|
566
|
+
if (!isRecord(value.agent))
|
|
567
|
+
return "agent is not an object";
|
|
568
|
+
if (!Number.isSafeInteger(value.agent.id) || value.agent.id <= 0)
|
|
569
|
+
return "agent.id is not a positive integer";
|
|
570
|
+
if (typeof value.agent.name !== "string" || value.agent.name.trim() === "")
|
|
571
|
+
return "agent.name is empty";
|
|
572
|
+
if (typeof value.agentInstruction !== "string" || value.agentInstruction.trim() === "")
|
|
573
|
+
return "agentInstruction is empty";
|
|
574
|
+
if (value.plan !== null && (!isRecord(value.plan) || typeof value.plan.ref !== "string" || typeof value.plan.name !== "string"))
|
|
575
|
+
return "plan is neither null nor a {ref, name} object";
|
|
576
|
+
if (value.card !== null && (!isRecord(value.card) || typeof value.card.id !== "string" || typeof value.card.title !== "string"))
|
|
577
|
+
return "card is neither null nor a {id, title} object";
|
|
578
|
+
if (value.naming !== null) {
|
|
579
|
+
if (!isRecord(value.naming) || (value.naming.status !== "ok" && value.naming.status !== "needed"))
|
|
580
|
+
return "naming is neither null nor a naming block";
|
|
581
|
+
if (value.naming.status === "needed" && (typeof value.naming.instruction !== "string" || value.naming.instruction.trim() === ""))
|
|
582
|
+
return "naming.instruction is empty";
|
|
583
|
+
}
|
|
584
|
+
if (value.titleInstruction !== null && typeof value.titleInstruction !== "string")
|
|
585
|
+
return "titleInstruction is neither null nor a string";
|
|
586
|
+
if (value.rolePointer !== null && typeof value.rolePointer !== "string")
|
|
587
|
+
return "rolePointer is neither null nor a string";
|
|
588
|
+
if (value.owedComments !== null && typeof value.owedComments !== "string")
|
|
589
|
+
return "owedComments is neither null nor a string";
|
|
590
|
+
if (value.missedEvents !== null) {
|
|
591
|
+
if (!isRecord(value.missedEvents) || !Array.isArray(value.missedEvents.events) || !Number.isSafeInteger(value.missedEvents.omitted))
|
|
592
|
+
return "missedEvents is neither null nor an {events, omitted} object";
|
|
593
|
+
for (const event of value.missedEvents.events) {
|
|
594
|
+
const bad = invalidEventReason(event);
|
|
595
|
+
if (bad !== null)
|
|
596
|
+
return `missedEvents holds an unreadable event: ${bad}`;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The one line that wakes a proposing session: its agent was created, it is signed in as that agent (and connected to its plan or card), and the instruction and thread name to carry on with. The caller has already
|
|
603
|
+
* validated the frame ({@link invalidAgentCreatedReason}).
|
|
604
|
+
*/
|
|
605
|
+
export function formatAgentCreatedLine(event) {
|
|
606
|
+
const where = event.plan !== null ? ` and connected to ${event.plan.ref} ${oneLine(event.plan.name, 80)}` : event.card !== null ? ` and connected to card ${event.card.id}` : "";
|
|
607
|
+
const thread = event.naming !== null ? (event.naming.status === "needed" ? event.naming.instruction : "") : (event.titleInstruction ?? "");
|
|
608
|
+
const missed = event.missedEvents;
|
|
609
|
+
const missedLines = missed === null || missed.events.length === 0
|
|
610
|
+
? ""
|
|
611
|
+
: `Missed while you waited for your agent: ${missed.events.map((e) => formatActivityLine(e)).join(" ")}${missed.omitted > 0 ? ` (${missed.omitted} more not shown)` : ""}`;
|
|
612
|
+
// DX-6157: no connect is needed to take what Create gave the session; a naming or role step that follows still makes its own call.
|
|
613
|
+
const held = event.plan !== null ? "this agent and plan" : event.card !== null ? "this agent and card" : "this agent";
|
|
614
|
+
const ownCalls = thread !== "" || event.rolePointer !== null ? "; the steps below keep their own calls" : "";
|
|
615
|
+
const parts = [
|
|
616
|
+
`${LINE_PREFIX} The person created your team agent ${oneLine(event.agent.name, 60)}: this session is now signed in as it${where}. Taking ${held} needs no \`plan_connect\`${ownCalls}.`,
|
|
617
|
+
oneLine(event.agentInstruction, 800),
|
|
618
|
+
thread === "" ? "" : oneLine(thread, 800),
|
|
619
|
+
event.owedComments === null ? "" : oneLine(event.owedComments),
|
|
620
|
+
missedLines,
|
|
621
|
+
event.rolePointer === null ? "" : oneLine(event.rolePointer, 800),
|
|
622
|
+
"Then carry on with the work you were asked to do.",
|
|
623
|
+
];
|
|
624
|
+
return parts.filter((part) => part !== "").join(" ");
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
|
|
628
|
+
* field-less blocks produce no message, which is what keeps them out of the output.
|
|
629
|
+
*/
|
|
630
|
+
export class SseParser {
|
|
631
|
+
buffer = "";
|
|
632
|
+
push(chunk) {
|
|
633
|
+
this.buffer += chunk.replace(/\r\n?/g, "\n");
|
|
634
|
+
const messages = [];
|
|
635
|
+
let boundary = this.buffer.indexOf("\n\n");
|
|
636
|
+
while (boundary !== -1) {
|
|
637
|
+
const message = parseBlock(this.buffer.slice(0, boundary));
|
|
638
|
+
this.buffer = this.buffer.slice(boundary + 2);
|
|
639
|
+
if (message !== null)
|
|
640
|
+
messages.push(message);
|
|
641
|
+
boundary = this.buffer.indexOf("\n\n");
|
|
642
|
+
}
|
|
643
|
+
return messages;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function parseBlock(block) {
|
|
647
|
+
let id = null;
|
|
648
|
+
let event = "message";
|
|
649
|
+
const data = [];
|
|
650
|
+
let sawField = false;
|
|
651
|
+
for (const line of block.split("\n")) {
|
|
652
|
+
if (line === "" || line.startsWith(":"))
|
|
653
|
+
continue;
|
|
654
|
+
const colon = line.indexOf(":");
|
|
655
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
656
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
657
|
+
sawField = true;
|
|
658
|
+
if (field === "id")
|
|
659
|
+
id = value;
|
|
660
|
+
else if (field === "event")
|
|
661
|
+
event = value;
|
|
662
|
+
else if (field === "data")
|
|
663
|
+
data.push(value);
|
|
664
|
+
}
|
|
665
|
+
return sawField ? { id, event, data: data.join("\n") } : null;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
|
|
669
|
+
* client recognizes on the wire. Hand-copied from `StreamEndReason` in
|
|
670
|
+
* `src/issues/plan-session-listeners.ts` — this published package cannot
|
|
671
|
+
* import danxbot source. A wire `reason` this set does not contain (a newer
|
|
672
|
+
* dashboard's reason this build predates) normalizes to `"unknown"` rather
|
|
673
|
+
* than passing the raw string through, which is what lets `Outcome`'s
|
|
674
|
+
* `reason` field below be a closed union instead of `string`.
|
|
675
|
+
*/
|
|
676
|
+
const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected", "plan_archived"]);
|
|
677
|
+
function isKnownWireEndReason(reason) {
|
|
678
|
+
return KNOWN_WIRE_END_REASONS.has(reason);
|
|
679
|
+
}
|
|
680
|
+
/** A thrown value's message, for the one drop path that carries a real JS error — never re-derived elsewhere. */
|
|
681
|
+
function describeDropError(err) {
|
|
682
|
+
return err instanceof Error ? err.message : String(err);
|
|
683
|
+
}
|
|
684
|
+
/** Statuses that describe the moment, not the ticket — retry them. */
|
|
685
|
+
const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
|
|
686
|
+
/**
|
|
687
|
+
* Run until the dashboard ends the stream or the reader gives up, always ending
|
|
688
|
+
* with exactly one `stopped` record. Returns the exit code: 0 when a newer
|
|
689
|
+
* listener took over, 1 otherwise.
|
|
690
|
+
*/
|
|
691
|
+
export async function runListener(options, deps) {
|
|
692
|
+
const delivered = new Set();
|
|
693
|
+
let lastEventId = null;
|
|
694
|
+
let unhealthySince = null;
|
|
695
|
+
let attempt = 0;
|
|
696
|
+
// DX-2829 — the most recent `dropped` outcome's cause, carried across
|
|
697
|
+
// reconnect attempts so the eventual `lease_expired` stop (the only outcome
|
|
698
|
+
// reached exclusively via repeated drops) can say what actually failed
|
|
699
|
+
// instead of only "no healthy connection ... past the ticket's lease".
|
|
700
|
+
let lastDropDetail = null;
|
|
701
|
+
const remember = (id) => {
|
|
702
|
+
delivered.add(id);
|
|
703
|
+
if (delivered.size > DELIVERED_ID_MEMORY)
|
|
704
|
+
delivered.delete(delivered.values().next().value);
|
|
705
|
+
lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
|
|
706
|
+
};
|
|
707
|
+
for (const id of capResumeIds(options.resumeIds))
|
|
708
|
+
remember(id);
|
|
709
|
+
function stop(reason, detail, code) {
|
|
710
|
+
deps.write({ type: "stopped", reason, detail });
|
|
711
|
+
return code;
|
|
712
|
+
}
|
|
713
|
+
// DX-5554: the one parse of a frame's JSON plus its shape check (advisory, compaction and agent-created frames all read this way); a parse failure is the reason, never a throw.
|
|
714
|
+
const readFrame = (message, invalidReason) => {
|
|
715
|
+
try {
|
|
716
|
+
const parsed = JSON.parse(message.data);
|
|
717
|
+
return { parsed, invalid: invalidReason(parsed) };
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
return { parsed: undefined, invalid: `not JSON: ${err.message}` };
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
const faultText = (what, invalid, message) => `${LINE_PREFIX} could not read ${what} (${invalid}): ${oneLine(message.data, 300)}. This session's platform MCP (version ${readOwnVersion()}) cannot read what the dashboard sends: ` +
|
|
724
|
+
"restart this session, or reconnect its platform server in /mcp, to install the current version.";
|
|
725
|
+
// DX-6272: an unreadable frame wakes the session once per cause. A frame the dashboard repeats (the compaction control comes with every stats write) that fails for the same
|
|
726
|
+
// reason again is dropped; a readable frame of the kind clears the memory, so a later fault is reported again. Keyed by frame kind, valued by the reason last reported.
|
|
727
|
+
const reportedFaults = new Map();
|
|
728
|
+
/** True when this (kind, reason) fault is new and so is to be written; records it. */
|
|
729
|
+
const faultIsNew = (kind, reason) => {
|
|
730
|
+
if (reportedFaults.get(kind) === reason)
|
|
731
|
+
return false;
|
|
732
|
+
reportedFaults.set(kind, reason);
|
|
733
|
+
return true;
|
|
734
|
+
};
|
|
735
|
+
/**
|
|
736
|
+
* The shared path of an ADVISORY frame (DX-2885 `nudge`, DX-4340 `pacing`, DX-4342 `resume`): the dashboard stores each with an id (DX-5007,
|
|
737
|
+
* `plan-session-stream.ts`'s module doc), so it is deduplicated and remembered by that id exactly like an activity event: the id is reported as
|
|
738
|
+
* `Last-Event-ID` on reconnect, which is what stops the dashboard replaying a frame this reader already read. It rides the SAME
|
|
739
|
+
* `{type:"event", id, text}` shape an ordinary activity line uses. That is what lets the relay (`plan-events-relay.ts`) forward it with no change: it forwards any
|
|
740
|
+
* `{type:"event", id, text}` record, keyed only on `type`.
|
|
741
|
+
*
|
|
742
|
+
* `origin: null` is DX-2730 D4's exemption: the frame is delivered at once, never held for a digest flush (a nudge exists to interrupt a quiet
|
|
743
|
+
* session; a pacing change is an instruction to act now). A frame that is not JSON or not the right shape is reported from an explicit check
|
|
744
|
+
* naming the fault, and the next frame still emits.
|
|
745
|
+
*/
|
|
746
|
+
const emitAdvisory = (message, what, invalidReason, format, sourceOf, scope) => {
|
|
747
|
+
const { parsed, invalid } = readFrame(message, invalidReason);
|
|
748
|
+
// `Number(null)` is 0: a frame with no id must never be read as id 0 (see the activity path below).
|
|
749
|
+
const id = message.id === null ? Number.NaN : Number(message.id);
|
|
750
|
+
const knownId = Number.isSafeInteger(id) && id > 0;
|
|
751
|
+
if (knownId && delivered.has(id))
|
|
752
|
+
return null;
|
|
753
|
+
// DX-6272: a fault on a frame with no id is a repeat-prone one (an id'd frame is remembered by its id); report it once per cause.
|
|
754
|
+
if (invalid === null)
|
|
755
|
+
reportedFaults.delete(what);
|
|
756
|
+
else if (!knownId && !faultIsNew(what, invalid))
|
|
757
|
+
return null;
|
|
758
|
+
const text = invalid === null ? format(parsed) : faultText(what, invalid, message);
|
|
759
|
+
// DX-4721: an unreadable frame is a fault line, never the frame it failed to be (a malformed `critical` pacing frame is not a stop).
|
|
760
|
+
const urgent = isUrgent(invalid === null ? sourceOf(parsed) : { source: "fault" });
|
|
761
|
+
deps.write({ type: "event", id: knownId ? id : null, text, origin: null, urgent, acks: [], ...(scope === undefined ? {} : { scope }) });
|
|
762
|
+
if (knownId)
|
|
763
|
+
remember(id);
|
|
764
|
+
return null;
|
|
765
|
+
};
|
|
766
|
+
/** DX-4993 / DX-6692: a level-triggered control frame (`compaction`, `clear`): its output when it reads, else a fault line once per cause (never silence); the next frame still lands. */
|
|
767
|
+
const emitControl = (message, what, invalidReason, outputOf) => {
|
|
768
|
+
const { parsed, invalid } = readFrame(message, invalidReason);
|
|
769
|
+
if (invalid === null) {
|
|
770
|
+
reportedFaults.delete(what);
|
|
771
|
+
deps.write(outputOf(parsed));
|
|
772
|
+
}
|
|
773
|
+
else if (faultIsNew(what, invalid))
|
|
774
|
+
deps.write({ type: "event", id: null, text: faultText(what, invalid, message), origin: null, urgent: isUrgent({ source: "fault" }), acks: [], scope: "session" });
|
|
775
|
+
return null;
|
|
776
|
+
};
|
|
777
|
+
// DX-5554 (AC6): the agent ids already announced to the session. The frame is level-triggered (written again on every reconnect while the agent is untaken), so one agent wakes the session once.
|
|
778
|
+
const announcedAgentIds = new Set();
|
|
779
|
+
const emitAgentCreated = (message) => {
|
|
780
|
+
const { parsed, invalid } = readFrame(message, invalidAgentCreatedReason);
|
|
781
|
+
if (invalid !== null) {
|
|
782
|
+
if (faultIsNew("agent-created frame", invalid))
|
|
783
|
+
deps.write({ type: "event", id: null, text: faultText("agent-created frame", invalid, message), origin: null, urgent: isUrgent({ source: "fault" }), acks: [], scope: "session" });
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
reportedFaults.delete("agent-created frame");
|
|
787
|
+
const created = parsed;
|
|
788
|
+
const agent = created.agent;
|
|
789
|
+
if (announcedAgentIds.has(agent.id))
|
|
790
|
+
return null;
|
|
791
|
+
announcedAgentIds.add(agent.id);
|
|
792
|
+
deps.onAgentCreated?.(created);
|
|
793
|
+
deps.write({ type: "event", id: null, text: formatAgentCreatedLine(created), origin: null, urgent: isUrgent({ source: "agent_created" }), acks: [], scope: "session" });
|
|
794
|
+
return null;
|
|
795
|
+
};
|
|
796
|
+
const handle = (message) => {
|
|
797
|
+
if (message.event === "end") {
|
|
798
|
+
// DX-2920 (round 3) — a JSON.parse failure here falls through to the
|
|
799
|
+
// pre-existing "unknown" reason below via `isRecord(null)`.
|
|
800
|
+
let parsed;
|
|
801
|
+
try {
|
|
802
|
+
parsed = JSON.parse(message.data);
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
parsed = null;
|
|
806
|
+
}
|
|
807
|
+
const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
|
|
808
|
+
const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
|
|
809
|
+
// DX-2920 (round 4) — a wire reason this build does not recognize (a
|
|
810
|
+
// newer dashboard's reason an older client hasn't shipped yet)
|
|
811
|
+
// normalizes to "unknown" rather than passing the raw string through:
|
|
812
|
+
// `Outcome`'s "ended" reason is a closed set (`EndedReason`).
|
|
813
|
+
return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
|
|
814
|
+
}
|
|
815
|
+
if (message.event === "nudge")
|
|
816
|
+
return emitAdvisory(message, "nudge", invalidNudgeReason, (p) => formatNudgeLine(p), () => ({ source: "nudge" }));
|
|
817
|
+
// DX-4340 - the pacing tick's one message for this session's Claude account (level or budget changed): advisory like a nudge.
|
|
818
|
+
if (message.event === "pacing")
|
|
819
|
+
return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p), (p) => ({ source: "pacing", level: p.level }), "session");
|
|
820
|
+
// DX-4342 - the auto-resume's message once the limit that paused or stopped this session has reset: advisory like a pacing message.
|
|
821
|
+
if (message.event === "resume")
|
|
822
|
+
return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), () => ({ source: "resume" }), "session");
|
|
823
|
+
// DX-5690 - a person's comment this session is to answer is still unanswered: advisory like a resume message, session-scoped (it is about the session's comments, not a plan move).
|
|
824
|
+
if (message.event === "owed_comment")
|
|
825
|
+
return emitAdvisory(message, "owed-comment message", invalidOwedCommentReason, (p) => formatOwedCommentLine(p), () => ({ source: "owed_comment" }), "session");
|
|
826
|
+
// DX-6611 - a person assigned this ready session to a plan or card: advisory like a resume message, session-scoped (the session is on no plan, and connecting is what it is told to do).
|
|
827
|
+
// DX-4529 - a docs land's conflict job: its line tells the session where to read the job.
|
|
828
|
+
if (message.event === "docs_land")
|
|
829
|
+
return emitAdvisory(message, "docs land message", invalidAssignmentReason, (p) => formatAssignmentLine(p), () => ({ source: "docs_land" }));
|
|
830
|
+
// DX-6866 - a docs draft owed on this session's plan: the same one-line frame.
|
|
831
|
+
if (message.event === "docs_draft")
|
|
832
|
+
return emitAdvisory(message, "docs draft message", invalidAssignmentReason, (p) => formatAssignmentLine(p), () => ({ source: "docs_draft" }));
|
|
833
|
+
if (message.event === "assignment")
|
|
834
|
+
return emitAdvisory(message, "assignment message", invalidAssignmentReason, (p) => formatAssignmentLine(p), () => ({ source: "assignment" }), "session");
|
|
835
|
+
// DX-4993 - the compaction control: level-triggered, no id, no replay. A frame that cannot be read is a fault line (never silence), and the next frame still lands.
|
|
836
|
+
if (message.event === "compaction")
|
|
837
|
+
return emitControl(message, "compaction control", invalidCompactionReason, (p) => ({ type: "compaction", control: controlOfFrame(p) }));
|
|
838
|
+
// DX-6692 - the clear control of a ready-list session whose job has ended: level-triggered like `compaction`.
|
|
839
|
+
if (message.event === "clear")
|
|
840
|
+
return emitControl(message, "clear control", invalidClearReason, (p) => ({ type: "clear", control: clearControlOfFrame(p) }));
|
|
841
|
+
if (message.event === "team_agent_created")
|
|
842
|
+
return emitAgentCreated(message);
|
|
843
|
+
if (message.event !== "activity")
|
|
844
|
+
return null;
|
|
845
|
+
// `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
|
|
846
|
+
// would become a `Last-Event-ID` the dashboard refuses, ending the listener.
|
|
847
|
+
const id = message.id === null ? Number.NaN : Number(message.id);
|
|
848
|
+
const knownId = Number.isSafeInteger(id) && id > 0;
|
|
849
|
+
if (knownId && delivered.has(id))
|
|
850
|
+
return null;
|
|
851
|
+
// DX-2735: an event that is not JSON, or JSON of the wrong shape, is reported
|
|
852
|
+
// from an explicit check naming the fault — never from a TypeError thrown
|
|
853
|
+
// partway through describing it. Never silence, and never a reconnect loop on
|
|
854
|
+
// the same bad event: say so in one line and move past it.
|
|
855
|
+
let parsed;
|
|
856
|
+
let invalid;
|
|
857
|
+
try {
|
|
858
|
+
parsed = JSON.parse(message.data);
|
|
859
|
+
invalid = invalidEventReason(parsed);
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
invalid = `not JSON: ${err.message}`;
|
|
863
|
+
}
|
|
864
|
+
const text = invalid === null
|
|
865
|
+
? formatActivityLine(parsed)
|
|
866
|
+
: `${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${invalid}): ` +
|
|
867
|
+
// DX-2735: oneLine caps by code point, so a bad event carrying an emoji
|
|
868
|
+
// at the cut can never leave a lone surrogate in the line.
|
|
869
|
+
oneLine(message.data, 300);
|
|
870
|
+
// DX-2730 D4 — an event this listener could not parse carries `origin: null`
|
|
871
|
+
// (emit immediately) rather than the unreadable payload's own claimed
|
|
872
|
+
// origin: a fault worth surfacing at all must never be hidden behind a
|
|
873
|
+
// 10-minute digest wait just because it happened to come from an agent or
|
|
874
|
+
// a machine writer.
|
|
875
|
+
const release = invalid === null && parsed.release === true;
|
|
876
|
+
const acks = invalid === null ? (parsed.acks ?? []) : [];
|
|
877
|
+
// DX-5846: a repeat of an event nobody acknowledged is the dashboard asking again: emitted at once like a pause release (never held for a digest, which would wait out the next attempt too).
|
|
878
|
+
const resent = acks.some((a) => a.attempt > 1);
|
|
879
|
+
// DX-4951: a pause release is the session's own notice: emitted at once (`origin: null`), and kept across a plan move (`scope: "session"`).
|
|
880
|
+
const origin = invalid === null && !release && !resent ? parsed.origin : null;
|
|
881
|
+
// DX-4721: an unreadable event is a fault line; a readable one is urgent by its kind (none is today).
|
|
882
|
+
const urgent = isUrgent(invalid === null ? { source: "activity", kind: parsed.kind } : { source: "fault" });
|
|
883
|
+
deps.write({ type: "event", id: knownId ? id : null, text, origin, urgent, acks, ...(release ? { scope: "session" } : {}) });
|
|
884
|
+
if (knownId)
|
|
885
|
+
remember(id);
|
|
886
|
+
return null;
|
|
887
|
+
};
|
|
888
|
+
const connectOnce = async () => {
|
|
889
|
+
const controller = new AbortController();
|
|
890
|
+
// DX-3099 — hand THIS attempt's own abort to the caller before doing
|
|
891
|
+
// anything else, so a force-reconnect requested at any point during the
|
|
892
|
+
// attempt (including before the fetch even starts) targets exactly this
|
|
893
|
+
// controller, never a stale one from a previous attempt.
|
|
894
|
+
deps.onConnectionAttempt?.(() => controller.abort());
|
|
895
|
+
deps.signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
896
|
+
let idle;
|
|
897
|
+
const armIdle = () => {
|
|
898
|
+
clearTimeout(idle);
|
|
899
|
+
idle = setTimeout(() => controller.abort(), deps.readIdleTimeoutMs);
|
|
900
|
+
};
|
|
901
|
+
const headers = {
|
|
902
|
+
Authorization: `Bearer ${options.ticket}`,
|
|
903
|
+
Accept: "text/event-stream",
|
|
904
|
+
// DX-5846: says this reader understands `acks` and shows the instruction to acknowledge; the dashboard stamps and re-sends only to a connection that sends it (an older reader would drop a repeat it cannot act on).
|
|
905
|
+
"X-Danx-Ack": "1",
|
|
906
|
+
};
|
|
907
|
+
if (lastEventId !== null)
|
|
908
|
+
headers["Last-Event-ID"] = String(lastEventId);
|
|
909
|
+
const openedAt = deps.now();
|
|
910
|
+
let gotEvent = false;
|
|
911
|
+
const healthy = () => gotEvent || deps.now() - openedAt >= HEALTHY_CONNECTION_MS;
|
|
912
|
+
try {
|
|
913
|
+
armIdle();
|
|
914
|
+
const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
|
|
915
|
+
if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
|
|
916
|
+
const text = await response.text();
|
|
917
|
+
return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(text, 300)}` };
|
|
918
|
+
}
|
|
919
|
+
if (!response.ok || response.body === null) {
|
|
920
|
+
await response.body?.cancel();
|
|
921
|
+
return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
|
|
922
|
+
}
|
|
923
|
+
// DX-6272: the stream says which relay version it needs; a relay below it reads no frame, so it cannot misread one into a line that wakes the session. A malformed floor throws (a named drop), never reads as no floor.
|
|
924
|
+
const floor = response.headers.get(MCP_STREAM_MIN_VERSION_HEADER);
|
|
925
|
+
if (floor !== null && ownVersionBelow(floor)) {
|
|
926
|
+
await response.body?.cancel();
|
|
927
|
+
return { kind: "outdated", detail: floor };
|
|
928
|
+
}
|
|
929
|
+
// DX-3028 — the connection is genuinely open and readable now; see
|
|
930
|
+
// `ListenDeps.onState`'s docblock for exactly what this reports.
|
|
931
|
+
deps.onState?.("streaming");
|
|
932
|
+
const parser = new SseParser();
|
|
933
|
+
const decoder = new TextDecoder();
|
|
934
|
+
for await (const chunk of response.body) {
|
|
935
|
+
armIdle();
|
|
936
|
+
for (const message of parser.push(decoder.decode(chunk, { stream: true }))) {
|
|
937
|
+
const outcome = handle(message);
|
|
938
|
+
if (outcome !== null)
|
|
939
|
+
return outcome;
|
|
940
|
+
if (message.event === "activity")
|
|
941
|
+
gotEvent = true;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { kind: "dropped", healthy: healthy() };
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
// DX-2829 — was a bare `catch { ... }` that discarded the causing error
|
|
948
|
+
// entirely: a connection that failed the same way on every retry (DNS
|
|
949
|
+
// failure, TLS reset, the idle-timeout abort) surfaced nothing beyond
|
|
950
|
+
// "no healthy connection" once the lease finally expired. Preserving the
|
|
951
|
+
// message here is what lets `runListener` name the actual last failure
|
|
952
|
+
// in its final `lease_expired` stop record below.
|
|
953
|
+
return { kind: "dropped", healthy: healthy(), detail: describeDropError(err) };
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
clearTimeout(idle);
|
|
957
|
+
controller.abort();
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
for (;;) {
|
|
961
|
+
if (deps.signal?.aborted)
|
|
962
|
+
return 0;
|
|
963
|
+
const outcome = await connectOnce();
|
|
964
|
+
// DX-4233 - cancelled while connected: the outcome of an attempt the owner abandoned is not the owner's to hear.
|
|
965
|
+
if (deps.signal?.aborted)
|
|
966
|
+
return 0;
|
|
967
|
+
if (outcome.kind === "ended") {
|
|
968
|
+
if (outcome.reason === "superseded" || outcome.reason === "replaced") {
|
|
969
|
+
return stop(outcome.reason, "another listener for this session took over", 0);
|
|
970
|
+
}
|
|
971
|
+
return stop(outcome.reason, "the dashboard ended this listener", 1);
|
|
972
|
+
}
|
|
973
|
+
if (outcome.kind === "outdated") {
|
|
974
|
+
return stop("mcp_outdated", `this session's platform MCP is version ${readOwnVersion()}, and the dashboard's event stream needs ${outcome.detail} or newer, so this session's events stop here`, 1);
|
|
975
|
+
}
|
|
976
|
+
if (outcome.kind === "refused") {
|
|
977
|
+
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
|
|
978
|
+
}
|
|
979
|
+
// DX-5088: the stream is down from here until the next attempt opens; reporting `streaming` through the backoff made the heartbeat watchdog
|
|
980
|
+
// call a dashboard outage "a stream that looks open" and abort the reconnect it was waiting on.
|
|
981
|
+
deps.onState?.("reconnecting");
|
|
982
|
+
if (outcome.detail !== undefined)
|
|
983
|
+
lastDropDetail = outcome.detail;
|
|
984
|
+
if (outcome.healthy) {
|
|
985
|
+
unhealthySince = null;
|
|
986
|
+
attempt = 0;
|
|
987
|
+
}
|
|
988
|
+
const now = deps.now();
|
|
989
|
+
unhealthySince ??= now;
|
|
990
|
+
if (now - unhealthySince >= options.leaseMs) {
|
|
991
|
+
// DX-2829 — names the last connection failure when one was ever recorded,
|
|
992
|
+
// instead of only reporting the timeout itself.
|
|
993
|
+
const cause = lastDropDetail === null ? "" : ` — last connection attempt failed: ${lastDropDetail}`;
|
|
994
|
+
return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease${cause}`, 1);
|
|
995
|
+
}
|
|
996
|
+
await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
|
|
997
|
+
attempt += 1;
|
|
998
|
+
}
|
|
999
|
+
}
|