@k71n/agent-probe 0.1.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 -0
- package/README.md +324 -0
- package/dist/assets/playbook.md +113 -0
- package/dist/assets/skill/SKILL.md +32 -0
- package/dist/cleanup/cleanup-verify.js +233 -0
- package/dist/constants.js +55 -0
- package/dist/evidence/diff.js +0 -0
- package/dist/evidence/evidence-store.js +238 -0
- package/dist/evidence/query.js +97 -0
- package/dist/index.js +16 -0
- package/dist/ingest/contract.js +94 -0
- package/dist/ingest/ingest.js +211 -0
- package/dist/logger.js +18 -0
- package/dist/node-version.js +18 -0
- package/dist/server.js +90 -0
- package/dist/session/run-boundaries.js +44 -0
- package/dist/session/session-manager.js +354 -0
- package/dist/session/state-dir.js +26 -0
- package/dist/tools.js +242 -0
- package/package.json +38 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe Event contract v1 — FROZEN wire vocabulary.
|
|
3
|
+
* The wire<->code boundary mapping lives HERE and only here:
|
|
4
|
+
* snake_case in, typed camelCase out. No ad-hoc field renaming elsewhere.
|
|
5
|
+
*
|
|
6
|
+
* ## Wire shape (HTTP POST of JSON to http://127.0.0.1:<port>/events)
|
|
7
|
+
*
|
|
8
|
+
* | field | type | required | meaning |
|
|
9
|
+
* |------------|---------|----------|--------------------------------------------|
|
|
10
|
+
* | session_id | string | yes | active session (from start_session) |
|
|
11
|
+
* | probe_id | string | yes | this probe's identity (matches its marker) |
|
|
12
|
+
* | service | string | yes | which service emitted it ("web", "api"...) |
|
|
13
|
+
* | file | string | yes | source file the probe lives in |
|
|
14
|
+
* | line | int | yes | source line |
|
|
15
|
+
* | ts_probe | int | yes | epoch MILLISECONDS at emission |
|
|
16
|
+
* | payload | JSON | yes | any JSON value; stored exactly as received |
|
|
17
|
+
* | trace_id | string | no | W3C-Trace-Context headroom |
|
|
18
|
+
* | parent_id | string | no | W3C-Trace-Context headroom |
|
|
19
|
+
*
|
|
20
|
+
* The server assigns `ts_server` + monotonic `seq` on ingestion.
|
|
21
|
+
* Optional fields are OMITTED when absent, never null.
|
|
22
|
+
*
|
|
23
|
+
* ## The contract is language-agnostic
|
|
24
|
+
*
|
|
25
|
+
* Anything that can POST JSON to localhost conforms. `Content-Type` is
|
|
26
|
+
* recommended but NOT required — the server parses the raw body as JSON
|
|
27
|
+
* regardless (a shell one-liner sets no headers). No per-language library
|
|
28
|
+
* exists or is needed: the LLM is the SDK.
|
|
29
|
+
*
|
|
30
|
+
* ## Timestamps: epoch milliseconds, a one-liner in every language
|
|
31
|
+
*
|
|
32
|
+
* JS/TS: Date.now()
|
|
33
|
+
* Python: int(time.time() * 1000)
|
|
34
|
+
* Shell: $(date +%s%3N)
|
|
35
|
+
* Go: time.Now().UnixMilli()
|
|
36
|
+
*
|
|
37
|
+
* ## JS/TS probe idiom (fire-and-forget)
|
|
38
|
+
*
|
|
39
|
+
* Non-awaited, error-swallowed, NO retries, NO shared logging helpers
|
|
40
|
+
* (probes must stay self-contained one-liners or they aren't removable):
|
|
41
|
+
*
|
|
42
|
+
* fetch(`http://127.0.0.1:${PORT}/events`, { method: "POST", body: JSON.stringify({ session_id: SID, probe_id: "p7", service: "web", file: "form.ts", line: 42, ts_probe: Date.now(), payload: { categoryId } }) }).catch(() => {});
|
|
43
|
+
*
|
|
44
|
+
* Browser, near page unload, add keepalive so the request survives navigation:
|
|
45
|
+
*
|
|
46
|
+
* fetch(`http://127.0.0.1:${PORT}/events`, { method: "POST", keepalive: true, body: JSON.stringify({ ... }) }).catch(() => {});
|
|
47
|
+
*
|
|
48
|
+
* `PORT` and `SID` come from the start_session response — bake them in.
|
|
49
|
+
*
|
|
50
|
+
* ## run_id and "unattributed"
|
|
51
|
+
*
|
|
52
|
+
* `run_id` exists only in the store, never on the wire. SQL NULL there is a
|
|
53
|
+
* semantic value: *unattributed* (the event arrived outside any Run).
|
|
54
|
+
* Query tools (`get_timeline`, `get_span`) accept
|
|
55
|
+
* `run: "unattributed"` to retrieve exactly those events.
|
|
56
|
+
*
|
|
57
|
+
* ## Conformance enforcement
|
|
58
|
+
*
|
|
59
|
+
* Unknown extra keys are ignored, not rejected — probes are hand-written
|
|
60
|
+
* by agents in any language; strictness here would break language-agnosticism. Malformed
|
|
61
|
+
* events are dropped server-side (ring buffer + warnings), never propagated
|
|
62
|
+
* as application errors.
|
|
63
|
+
*/
|
|
64
|
+
import { z } from "zod";
|
|
65
|
+
const jsonValue = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(jsonValue), z.record(z.string(), jsonValue)]));
|
|
66
|
+
export const probeEventWireSchema = z.object({
|
|
67
|
+
session_id: z.string(),
|
|
68
|
+
probe_id: z.string(),
|
|
69
|
+
service: z.string(),
|
|
70
|
+
file: z.string(),
|
|
71
|
+
line: z.number().int(),
|
|
72
|
+
ts_probe: z.number().int(),
|
|
73
|
+
payload: jsonValue,
|
|
74
|
+
trace_id: z.string().optional(),
|
|
75
|
+
parent_id: z.string().optional(),
|
|
76
|
+
});
|
|
77
|
+
/** Parse + map wire->code. Throws ZodError on non-conformant input. */
|
|
78
|
+
export function parseProbeEvent(input) {
|
|
79
|
+
const wire = probeEventWireSchema.parse(input);
|
|
80
|
+
const event = {
|
|
81
|
+
sessionId: wire.session_id,
|
|
82
|
+
probeId: wire.probe_id,
|
|
83
|
+
service: wire.service,
|
|
84
|
+
file: wire.file,
|
|
85
|
+
line: wire.line,
|
|
86
|
+
tsProbe: wire.ts_probe,
|
|
87
|
+
payload: wire.payload,
|
|
88
|
+
};
|
|
89
|
+
if (wire.trace_id !== undefined)
|
|
90
|
+
event.traceId = wire.trace_id;
|
|
91
|
+
if (wire.parent_id !== undefined)
|
|
92
|
+
event.parentId = wire.parent_id;
|
|
93
|
+
return event;
|
|
94
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingestion — POST /events on 127.0.0.1:ephemeral, started at process
|
|
3
|
+
* startup (listener lifecycle is per-process; Validation Issue #2).
|
|
4
|
+
*
|
|
5
|
+
* Non-perturbation is the soul of this module: the server responds 202 BEFORE any
|
|
6
|
+
* validation work, never applies backpressure, and a malformed/oversized/
|
|
7
|
+
* over-cap event NEVER becomes an application-visible error.
|
|
8
|
+
*
|
|
9
|
+
* Pipeline: body -> [202] -> JSON.parse -> contract validate -> caps ->
|
|
10
|
+
* buffer -> flush once per event-loop tick in ONE transaction.
|
|
11
|
+
* Rejections/drops go to an in-memory ring buffer (last 50; NOT a DB
|
|
12
|
+
* table -- rejections fire precisely when no session DB exists) and
|
|
13
|
+
* surface via `warnings` on tool responses + one stderr line each.
|
|
14
|
+
*
|
|
15
|
+
* Bind-level localhost: host hardcoded; remote connections are
|
|
16
|
+
* refused by the kernel, not middleware. No auth, no TLS -- by design.
|
|
17
|
+
*/
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { parseProbeEvent } from "./contract.js";
|
|
20
|
+
import { LIMITS } from "../constants.js";
|
|
21
|
+
import { log } from "../logger.js";
|
|
22
|
+
const RING_CAPACITY = 50;
|
|
23
|
+
/** In-memory rejection/drop ring buffer (last 50), drained via warnings. */
|
|
24
|
+
class RingBuffer {
|
|
25
|
+
entries = [];
|
|
26
|
+
push(message) {
|
|
27
|
+
this.entries.push(message);
|
|
28
|
+
if (this.entries.length > RING_CAPACITY)
|
|
29
|
+
this.entries.shift();
|
|
30
|
+
}
|
|
31
|
+
/** Drain-on-read: each warning is delivered to the agent exactly once. */
|
|
32
|
+
drain() {
|
|
33
|
+
const out = this.entries;
|
|
34
|
+
this.entries = [];
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
get size() {
|
|
38
|
+
return this.entries.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class IngestPipeline {
|
|
42
|
+
deps;
|
|
43
|
+
ring = new RingBuffer();
|
|
44
|
+
limits;
|
|
45
|
+
buffer = [];
|
|
46
|
+
flushScheduled = false;
|
|
47
|
+
/** In-memory event counter for the cap; sessions never resume, so 0 at start is sound. */
|
|
48
|
+
counted = { sessionId: null, n: 0 };
|
|
49
|
+
constructor(deps) {
|
|
50
|
+
this.deps = deps;
|
|
51
|
+
this.limits = deps.limits ?? {
|
|
52
|
+
maxPayloadBytes: LIMITS.MAX_PAYLOAD_BYTES,
|
|
53
|
+
maxEventsPerSession: LIMITS.MAX_EVENTS_PER_SESSION,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Validation + buffering. Runs AFTER the 202 was dispatched. */
|
|
57
|
+
process(rawBody) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(rawBody);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return this.reject("dropped malformed event (invalid JSON)");
|
|
64
|
+
}
|
|
65
|
+
let event;
|
|
66
|
+
try {
|
|
67
|
+
event = parseProbeEvent(parsed);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
const probeId = typeof parsed === "object" && parsed !== null && "probe_id" in parsed
|
|
71
|
+
? String(parsed.probe_id)
|
|
72
|
+
: "unknown";
|
|
73
|
+
return this.reject(`dropped nonconformant event (probe_id=${probeId})`);
|
|
74
|
+
}
|
|
75
|
+
const activeId = this.deps.getActiveSessionId();
|
|
76
|
+
if (activeId === null) {
|
|
77
|
+
return this.reject(`rejected event (probe_id=${event.probeId}): no active session`);
|
|
78
|
+
}
|
|
79
|
+
if (event.sessionId !== activeId) {
|
|
80
|
+
// probes bake in dead session IDs after a restart -- observable, never silent
|
|
81
|
+
return this.reject(`rejected event (probe_id=${event.probeId}): session_id ${event.sessionId} is not the active session`);
|
|
82
|
+
}
|
|
83
|
+
if (this.counted.sessionId !== activeId)
|
|
84
|
+
this.counted = { sessionId: activeId, n: 0 };
|
|
85
|
+
if (this.counted.n >= this.limits.maxEventsPerSession) {
|
|
86
|
+
return this.reject(`dropped event (probe_id=${event.probeId}): session at MAX_EVENTS_PER_SESSION cap`);
|
|
87
|
+
}
|
|
88
|
+
let payloadText = JSON.stringify(event.payload);
|
|
89
|
+
if (Buffer.byteLength(payloadText) > this.limits.maxPayloadBytes) {
|
|
90
|
+
payloadText = Buffer.from(payloadText)
|
|
91
|
+
.subarray(0, this.limits.maxPayloadBytes)
|
|
92
|
+
.toString();
|
|
93
|
+
// event KEPT; truncated text may no longer parse as JSON -- documented
|
|
94
|
+
this.warnOnly(`truncated oversized payload (probe_id=${event.probeId}); event kept`);
|
|
95
|
+
}
|
|
96
|
+
const insert = {
|
|
97
|
+
// attribution at acceptance time: in-run events carry the run_id,
|
|
98
|
+
// outside events stay NULL = unattributed
|
|
99
|
+
runId: this.deps.getActiveRunId?.() ?? null,
|
|
100
|
+
probeId: event.probeId,
|
|
101
|
+
service: event.service,
|
|
102
|
+
file: event.file,
|
|
103
|
+
line: event.line,
|
|
104
|
+
tsProbe: event.tsProbe,
|
|
105
|
+
tsServer: Date.now(), // assigned at acceptance
|
|
106
|
+
payloadText,
|
|
107
|
+
};
|
|
108
|
+
if (event.traceId !== undefined)
|
|
109
|
+
insert.traceId = event.traceId;
|
|
110
|
+
if (event.parentId !== undefined)
|
|
111
|
+
insert.parentId = event.parentId;
|
|
112
|
+
this.buffer.push(insert);
|
|
113
|
+
this.counted.n += 1;
|
|
114
|
+
this.scheduleFlush();
|
|
115
|
+
}
|
|
116
|
+
/** Once per event-loop tick, one transaction. */
|
|
117
|
+
scheduleFlush() {
|
|
118
|
+
if (this.flushScheduled)
|
|
119
|
+
return;
|
|
120
|
+
this.flushScheduled = true;
|
|
121
|
+
setImmediate(() => {
|
|
122
|
+
this.flushScheduled = false;
|
|
123
|
+
this.flush();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
flush() {
|
|
127
|
+
if (this.buffer.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
const batch = this.buffer;
|
|
130
|
+
this.buffer = [];
|
|
131
|
+
const store = this.deps.getStore();
|
|
132
|
+
if (store === null || this.deps.getActiveSessionId() !== this.counted.sessionId) {
|
|
133
|
+
this.ring.push(`dropped ${batch.length} buffered event(s): session ended before flush`);
|
|
134
|
+
log.warn("ingest", `dropped ${batch.length} buffered event(s): session ended before flush`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
store.insertEvents(batch);
|
|
138
|
+
}
|
|
139
|
+
reject(message) {
|
|
140
|
+
this.ring.push(message);
|
|
141
|
+
log.warn("ingest", message);
|
|
142
|
+
}
|
|
143
|
+
/** Body exceeded the listener read cap; cannot be kept without unbounded buffering. */
|
|
144
|
+
rejectOversizedBody(bytes) {
|
|
145
|
+
this.reject(`dropped event: request body ${bytes} bytes exceeds the read cap`);
|
|
146
|
+
}
|
|
147
|
+
warnOnly(message) {
|
|
148
|
+
this.ring.push(message);
|
|
149
|
+
log.warn("ingest", message);
|
|
150
|
+
}
|
|
151
|
+
/** Surfaced via `warnings` on every tool response. */
|
|
152
|
+
drainWarnings() {
|
|
153
|
+
return this.ring.drain();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Stage a warning from outside the ingest path (e.g. the startup stale-
|
|
157
|
+
* session scan) onto the same observability channel.
|
|
158
|
+
*/
|
|
159
|
+
pushWarning(message) {
|
|
160
|
+
this.ring.push(message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function startIngestListener(pipeline) {
|
|
164
|
+
// generous read cap: payloads up to ~2x the payload cap still parse and get
|
|
165
|
+
// truncate-kept; beyond it we cannot keep a valid event without unbounded
|
|
166
|
+
// buffering, so the body is dropped (with warning). 202 is sent regardless.
|
|
167
|
+
const readCap = pipeline.limits.maxPayloadBytes * 2 + 4096;
|
|
168
|
+
const server = createServer((req, res) => {
|
|
169
|
+
if (req.method !== "POST" || req.url !== "/events") {
|
|
170
|
+
res.statusCode = 404;
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const chunks = [];
|
|
175
|
+
let received = 0;
|
|
176
|
+
let overCap = false;
|
|
177
|
+
req.on("data", (chunk) => {
|
|
178
|
+
received += chunk.length;
|
|
179
|
+
if (received > readCap) {
|
|
180
|
+
overCap = true;
|
|
181
|
+
chunks.length = 0; // stop buffering; memory guard
|
|
182
|
+
}
|
|
183
|
+
else if (!overCap) {
|
|
184
|
+
chunks.push(chunk);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
req.on("end", () => {
|
|
188
|
+
// 202 FIRST -- a probe never waits on validation
|
|
189
|
+
res.statusCode = 202;
|
|
190
|
+
res.end();
|
|
191
|
+
setImmediate(() => {
|
|
192
|
+
if (overCap) {
|
|
193
|
+
pipeline.rejectOversizedBody(received);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
pipeline.process(Buffer.concat(chunks).toString());
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
req.on("error", () => {
|
|
200
|
+
/* client vanished mid-request (fire-and-forget near unload) -- never an error */
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
204
|
+
const address = server.address();
|
|
205
|
+
log.info("ingest", `listening on 127.0.0.1:${address.port}`);
|
|
206
|
+
return {
|
|
207
|
+
port: address.port,
|
|
208
|
+
server,
|
|
209
|
+
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
210
|
+
};
|
|
211
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderr-only logger. stdout belongs to MCP stdio framing — writing to it
|
|
3
|
+
* anywhere outside the transport is a build-breaking offense.
|
|
4
|
+
*
|
|
5
|
+
* Format: `[level] component: message`. `[debug]` is gated by the DEBUG env var.
|
|
6
|
+
* Timestamps, when needed, are ISO 8601 (epoch-ms is a wire/store rule only).
|
|
7
|
+
*/
|
|
8
|
+
function write(level, component, message) {
|
|
9
|
+
if (level === "debug" && !process.env.DEBUG)
|
|
10
|
+
return;
|
|
11
|
+
process.stderr.write(`[${level}] ${component}: ${message}\n`);
|
|
12
|
+
}
|
|
13
|
+
export const log = {
|
|
14
|
+
debug: (component, message) => write("debug", component, message),
|
|
15
|
+
info: (component, message) => write("info", component, message),
|
|
16
|
+
warn: (component, message) => write("warn", component, message),
|
|
17
|
+
error: (component, message) => write("error", component, message),
|
|
18
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Node version gate: `engines` is advisory; this
|
|
3
|
+
* check is the real gate. It must run before any module that imports
|
|
4
|
+
* `node:sqlite` loads — keep this module dependency-free.
|
|
5
|
+
*/
|
|
6
|
+
/** Floor where `node:sqlite` runs flag-free. */
|
|
7
|
+
export const MIN_NODE_VERSION = "22.13.0";
|
|
8
|
+
export function meetsMinimumNode(version) {
|
|
9
|
+
const [major = 0, minor = 0, patch = 0] = version
|
|
10
|
+
.split(".")
|
|
11
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
12
|
+
const [minMajor = 0, minMinor = 0, minPatch = 0] = MIN_NODE_VERSION.split(".").map(Number);
|
|
13
|
+
if (major !== minMajor)
|
|
14
|
+
return major > minMajor;
|
|
15
|
+
if (minor !== minMinor)
|
|
16
|
+
return minor > minMinor;
|
|
17
|
+
return patch >= minPatch;
|
|
18
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup. stdout is sacred — it carries stdio MCP framing and
|
|
3
|
+
* nothing else; all diagnostics go through the stderr logger.
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { MARKER_TOKEN, PLAYBOOK_URI, TOOL_NAME } from "./constants.js";
|
|
11
|
+
import { IngestPipeline, startIngestListener } from "./ingest/ingest.js";
|
|
12
|
+
import { log } from "./logger.js";
|
|
13
|
+
import { clientSupportsElicitation } from "./session/run-boundaries.js";
|
|
14
|
+
import { SessionManager } from "./session/session-manager.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
const SERVER_VERSION = "0.1.0";
|
|
17
|
+
let playbookCache = null;
|
|
18
|
+
/**
|
|
19
|
+
* Load the playbook prose. Built layout first (dist/server.js
|
|
20
|
+
* finds its sibling dist/assets/playbook.md, the build-injected artifact),
|
|
21
|
+
* then the repo template (../assets/playbook.md — dev under tsx/vitest).
|
|
22
|
+
* The {{MARKER_TOKEN}} replacement is ALWAYS applied: idempotent on the
|
|
23
|
+
* injected file, and what makes dev mode serve real tokens (belt and
|
|
24
|
+
* braces with build-playbook.mjs). Loaded lazily at first resource read —
|
|
25
|
+
* never on the ingestion hot path; a missing file is a loud error on read,
|
|
26
|
+
* never a crash at connect. `candidates` is a test seam.
|
|
27
|
+
*/
|
|
28
|
+
export function loadPlaybook(candidates) {
|
|
29
|
+
if (candidates === undefined && playbookCache !== null)
|
|
30
|
+
return playbookCache;
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const paths = candidates ?? [
|
|
33
|
+
join(here, "assets", "playbook.md"),
|
|
34
|
+
join(here, "..", "assets", "playbook.md"),
|
|
35
|
+
];
|
|
36
|
+
for (const path of paths) {
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(path, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const text = raw.replaceAll("{{MARKER_TOKEN}}", MARKER_TOKEN);
|
|
45
|
+
if (candidates === undefined)
|
|
46
|
+
playbookCache = text;
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
log.error("server", `playbook not found (looked in: ${paths.join(", ")})`);
|
|
50
|
+
throw new Error("playbook resource unavailable: assets/playbook.md is missing from this installation");
|
|
51
|
+
}
|
|
52
|
+
export function createServer(deps) {
|
|
53
|
+
const server = new McpServer({ name: TOOL_NAME, version: SERVER_VERSION });
|
|
54
|
+
registerTools(server, deps);
|
|
55
|
+
server.registerResource("playbook", PLAYBOOK_URI, {
|
|
56
|
+
title: "Probe Playbook",
|
|
57
|
+
description: "Probe conventions, event contract, and strategy — pull once at session start.",
|
|
58
|
+
mimeType: "text/markdown",
|
|
59
|
+
}, (uri) => ({
|
|
60
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: loadPlaybook() }],
|
|
61
|
+
}));
|
|
62
|
+
return server;
|
|
63
|
+
}
|
|
64
|
+
// Capability detection lives with the ladder; re-exported for callers.
|
|
65
|
+
export { clientSupportsElicitation };
|
|
66
|
+
export async function startStdioServer() {
|
|
67
|
+
// Listener starts at process startup; port is per-process (Validation Issue #2).
|
|
68
|
+
const manager = new SessionManager();
|
|
69
|
+
const pipeline = new IngestPipeline({
|
|
70
|
+
getActiveSessionId: () => manager.activeSessionId,
|
|
71
|
+
getStore: () => manager.activeStore,
|
|
72
|
+
getActiveRunId: () => manager.activeRunId,
|
|
73
|
+
});
|
|
74
|
+
const ingest = await startIngestListener(pipeline);
|
|
75
|
+
const server = createServer({
|
|
76
|
+
manager,
|
|
77
|
+
ingestPort: () => ingest.port,
|
|
78
|
+
drainWarnings: () => pipeline.drainWarnings(),
|
|
79
|
+
});
|
|
80
|
+
// Startup orphan scan: surface stale sessions on stderr now and on
|
|
81
|
+
// the next tool response via the warnings channel.
|
|
82
|
+
for (const orphan of manager.scanOrphans()) {
|
|
83
|
+
const created = orphan.created ? ` created ${new Date(orphan.created).toISOString()}` : "";
|
|
84
|
+
log.warn("session", `stale session found: ${orphan.sessionId}${orphan.goal ? ` ("${orphan.goal}")` : ""}${created}`);
|
|
85
|
+
pipeline.pushWarning(`stale session ${orphan.sessionId}${orphan.goal ? ` ("${orphan.goal}")` : ""} exists; start_session will require a stale decision`);
|
|
86
|
+
}
|
|
87
|
+
await server.connect(new StdioServerTransport());
|
|
88
|
+
log.info("server", `${TOOL_NAME} connected over stdio`);
|
|
89
|
+
log.debug("server", `client elicitation support: ${clientSupportsElicitation(server)}`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ELICIT_TIMEOUT_MS } from "../constants.js";
|
|
2
|
+
import { log } from "../logger.js";
|
|
3
|
+
/**
|
|
4
|
+
* Capability detection: client
|
|
5
|
+
* capabilities advertise elicitation support; checked at call time.
|
|
6
|
+
*/
|
|
7
|
+
export function clientSupportsElicitation(server) {
|
|
8
|
+
return Boolean(server.server.getClientCapabilities()?.elicitation);
|
|
9
|
+
}
|
|
10
|
+
/** Open a run and walk the ladder. Never throws for ladder outcomes. */
|
|
11
|
+
export async function armRun(deps) {
|
|
12
|
+
const { runId, n } = deps.manager.startRun(deps.tag);
|
|
13
|
+
if (!clientSupportsElicitation(deps.server)) {
|
|
14
|
+
// fallback rung: run stays open; end_run closes it when the user says "done"
|
|
15
|
+
return { kind: "open", runId, reason: "no_elicitation" };
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const result = await deps.server.server.elicitInput({
|
|
19
|
+
message: `Run #${n} armed — click through the flow, confirm here when done.`,
|
|
20
|
+
requestedSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
done: { type: "boolean", description: "Confirm the run is complete" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}, { timeout: deps.elicitTimeoutMs ?? ELICIT_TIMEOUT_MS });
|
|
27
|
+
if (result.action === "accept") {
|
|
28
|
+
const closed = deps.manager.closeRun();
|
|
29
|
+
return { kind: "closed", ...closed };
|
|
30
|
+
}
|
|
31
|
+
// decline or cancel: abort — events become unattributed, never discarded
|
|
32
|
+
const { runId: aborted } = deps.manager.abortRun();
|
|
33
|
+
log.warn("run-boundaries", `run ${aborted} ${result.action}d by user; aborted`);
|
|
34
|
+
return { kind: "aborted", runId: aborted };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// timeout or transport failure: the run stays open; end_run closes it
|
|
38
|
+
const reason = err instanceof Error && /timeout|timed out/i.test(err.message)
|
|
39
|
+
? "elicitation_timeout"
|
|
40
|
+
: "elicitation_failed";
|
|
41
|
+
log.warn("run-boundaries", `elicitation for ${runId} failed (${reason}); run stays open`);
|
|
42
|
+
return { kind: "open", runId, reason };
|
|
43
|
+
}
|
|
44
|
+
}
|