@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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Session state machine — owns the store lifecycle.
3
+ *
4
+ * Explicit union type + transition table: idle -> instrumented -> run(n) ->
5
+ * analyzing -> cleanup-verify -> destroyed. Illegal transitions throw typed
6
+ * errors. No boolean flags standing in for states.
7
+ *
8
+ * Crash-only design: no state outside the SQLite file; sudden death leaves
9
+ * only the orphan .db (stale-session scan/disposal owns recovery).
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { randomUUID } from "node:crypto";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { scanWorkspace } from "../cleanup/cleanup-verify.js";
16
+ import { ERROR_CODES, LIMITS } from "../constants.js";
17
+ import { EvidenceStore, removeSessionFiles } from "../evidence/evidence-store.js";
18
+ import { log } from "../logger.js";
19
+ import { locksDir, resolveStateRoot, sessionsDir } from "./state-dir.js";
20
+ /** Thrown by manager operations; tools serialize it as { code, message, hint }. */
21
+ export class TypedToolError extends Error {
22
+ code;
23
+ hint;
24
+ constructor(code, message, hint) {
25
+ super(message);
26
+ this.code = code;
27
+ this.hint = hint;
28
+ this.name = "TypedToolError";
29
+ }
30
+ }
31
+ /**
32
+ * The full transition table, defined once. `destroyed` is reachable from
33
+ * every active state — the MARKERS_REMAIN gate sits in front of it
34
+ * (seam: endSession), not in this table.
35
+ */
36
+ export const TRANSITIONS = {
37
+ idle: ["instrumented", "destroyed"],
38
+ instrumented: ["run", "destroyed"],
39
+ run: ["run", "analyzing", "destroyed"],
40
+ analyzing: ["run", "cleanup-verify", "destroyed"],
41
+ "cleanup-verify": ["destroyed"],
42
+ destroyed: [],
43
+ };
44
+ export class SessionManager {
45
+ stateRoot;
46
+ active = null;
47
+ constructor(opts = {}) {
48
+ this.stateRoot = opts.stateRoot ?? resolveStateRoot();
49
+ }
50
+ get activeSessionId() {
51
+ return this.active?.id ?? null;
52
+ }
53
+ /** Store of the active session (ingest writes only through the store). */
54
+ get activeStore() {
55
+ return this.active?.store ?? null;
56
+ }
57
+ get state() {
58
+ return this.active?.state ?? null;
59
+ }
60
+ /** Deterministic lock filename per workspace_root (exposed for tests and orphan disposal). */
61
+ lockName(workspaceRoot) {
62
+ return `${createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16)}.lock`;
63
+ }
64
+ startSession(goal, workspaceRoot, stale) {
65
+ if (this.active) {
66
+ throw new TypedToolError(ERROR_CODES.SESSION_ALREADY_ACTIVE, `Session ${this.active.id} is already active.`, "End the active session with end_session before starting a new one (one active session at a time in v1).");
67
+ }
68
+ // Stale gate: starting anew while stale sessions exist requires an explicit decision
69
+ const orphans = this.scanOrphans();
70
+ if (orphans.length > 0) {
71
+ if (stale === undefined) {
72
+ const listing = orphans
73
+ .map((o) => `${o.sessionId}${o.goal ? ` ("${o.goal}")` : ""}`)
74
+ .join(", ");
75
+ throw new TypedToolError(ERROR_CODES.STALE_SESSION_EXISTS, `Stale debug session(s) found: ${listing}.`, `Stale: ${listing}. Ask the user what to do, then re-call start_session with ` +
76
+ `stale: "dispose" to delete the stale evidence, or stale: "keep" to proceed and ` +
77
+ `leave it (it will surface again). Stale sessions can never be resumed - probes ` +
78
+ `bake in a dead session ID and port.`);
79
+ }
80
+ if (stale === "dispose")
81
+ this.disposeOrphans(orphans);
82
+ // "keep": proceed once; orphans surface again next time
83
+ }
84
+ // a fresh session ALWAYS mints a new ID and file - no resume path exists
85
+ const sessionId = `s-${Date.now()}-${randomUUID().slice(0, 8)}`;
86
+ const lockPath = this.acquireLock(workspaceRoot, sessionId);
87
+ const store = EvidenceStore.create(sessionsDir(this.stateRoot), sessionId, goal);
88
+ this.active = {
89
+ id: sessionId,
90
+ goal,
91
+ workspaceRoot,
92
+ store,
93
+ state: { kind: "idle" },
94
+ lockPath,
95
+ currentRun: null,
96
+ runCount: 0,
97
+ };
98
+ log.info("session", `started ${sessionId} (workspace ${workspaceRoot})`);
99
+ return { sessionId, workspaceRoot };
100
+ }
101
+ /** Enforce the transition table; illegal edges throw INVALID_STATE. */
102
+ transition(to) {
103
+ const session = this.requireActive();
104
+ const from = session.state.kind;
105
+ if (!TRANSITIONS[from].includes(to)) {
106
+ throw new TypedToolError(ERROR_CODES.INVALID_STATE, `Illegal transition ${from} -> ${to}.`, `Legal next states from ${from}: ${TRANSITIONS[from].join(", ") || "(none)"}.`);
107
+ }
108
+ session.state =
109
+ to === "run"
110
+ ? { kind: "run", n: ++session.runCount }
111
+ : { kind: to };
112
+ session.store.updateSessionState(to);
113
+ }
114
+ /**
115
+ * Server-side cleanup verification: scan the workspace
116
+ * RECORDED at start_session and return the scan unmodified — exact
117
+ * locations are the deliverable. Takes NO path argument: accepting one
118
+ * would reintroduce the agent-trust hole this verification exists to close.
119
+ *
120
+ * State bookkeeping: analyzing -> cleanup-verify via the table; already
121
+ * cleanup-verify stays (re-verification after another removal pass is the
122
+ * normal loop); any other active state scans WITHOUT transitioning — the
123
+ * scan is read-only and the endSession gate enforces ordering.
124
+ */
125
+ verifyCleanup() {
126
+ const session = this.requireActive();
127
+ const scan = this.scanRecordedRoot(session);
128
+ if (session.state.kind === "analyzing")
129
+ this.transition("cleanup-verify");
130
+ return scan;
131
+ }
132
+ /** Fail closed: an unscannable root is markers-UNKNOWN, never markers-zero. */
133
+ scanRecordedRoot(session) {
134
+ try {
135
+ readdirSync(session.workspaceRoot); // accessibility probe: deleted/unreadable throws
136
+ }
137
+ catch {
138
+ throw new TypedToolError(ERROR_CODES.MARKERS_REMAIN, `workspace_root ${session.workspaceRoot} could not be scanned — residual probe markers cannot be ruled out.`, "Restore access to the workspace and re-verify with verify_cleanup; or — only after asking the user — re-call end_session with override: true.");
139
+ }
140
+ return scanWorkspace(session.workspaceRoot);
141
+ }
142
+ /**
143
+ * Destructive end: close + unlink the store, release the lock —
144
+ * guarded by the MARKERS_REMAIN gate. The gate counts ANY token
145
+ * occurrence (paired markers AND orphans: the standard is grep-for-token
146
+ * count zero, not zero pairs). `override` is the USER's decision, never
147
+ * the agent's shortcut — it waives VERIFICATION, never DELETION, and is
148
+ * reported back in the result, never silent.
149
+ */
150
+ endSession(override = false) {
151
+ const session = this.requireActive();
152
+ let hits = null; // null = unknown (unscannable root)
153
+ try {
154
+ const scan = this.scanRecordedRoot(session);
155
+ hits = [...scan.markers, ...scan.orphans];
156
+ }
157
+ catch (err) {
158
+ if (!override)
159
+ throw err; // fail closed; override is the user's escape hatch
160
+ }
161
+ if (hits !== null && hits.length > 0 && !override) {
162
+ const examples = hits
163
+ .slice(0, 3)
164
+ .map((h) => `${h.file}:${h.line}`)
165
+ .join(", ");
166
+ throw new TypedToolError(ERROR_CODES.MARKERS_REMAIN, `${hits.length} probe marker(s) remain in ${session.workspaceRoot} (e.g. ${examples}).`, "Run verify_cleanup for exact locations, remove the probes (delete marked ranges inclusive, bottom-up), and re-verify; or — only after asking the user — re-call end_session with override: true.");
167
+ }
168
+ // destruction EXACTLY as before the gate (destruction is never weakened by the override)
169
+ session.state = { kind: "destroyed" };
170
+ session.store.destroy();
171
+ if (existsSync(session.lockPath))
172
+ unlinkSync(session.lockPath);
173
+ log.info("session", `destroyed ${session.id} (db unlinked)`);
174
+ this.active = null;
175
+ if (hits === null || hits.length > 0) {
176
+ log.warn("session", `closed with override: ${hits === null ? "workspace unscannable" : `${hits.length} residual marker(s)`}`);
177
+ return {
178
+ override: {
179
+ residual_count: hits === null ? null : hits.length,
180
+ locations: (hits ?? []).slice(0, LIMITS.MAX_QUERY_EVENTS),
181
+ },
182
+ };
183
+ }
184
+ return {};
185
+ }
186
+ /** ID of the open run, or null (drives ingest attribution). */
187
+ get activeRunId() {
188
+ return this.active?.currentRun?.id ?? null;
189
+ }
190
+ /**
191
+ * Open a run with a server-owned start boundary. First run from `idle`
192
+ * implies instrumentation happened agent-side: idle -> instrumented -> run
193
+ * in one step (nothing observable marks "instrumented" alone).
194
+ */
195
+ startRun(tag) {
196
+ const session = this.requireActive();
197
+ if (session.currentRun) {
198
+ throw new TypedToolError(ERROR_CODES.INVALID_STATE, `Run ${session.currentRun.id} is already open.`, "Close it with end_run (or confirm the pending elicitation) before starting another run.");
199
+ }
200
+ if (session.state.kind === "idle")
201
+ this.transition("instrumented");
202
+ this.transition("run");
203
+ const n = session.state.n;
204
+ const runId = `r${n}`;
205
+ const started = Date.now();
206
+ session.store.insertRun(runId, started, tag);
207
+ session.currentRun = { id: runId, started };
208
+ log.info("session", `run ${runId} opened${tag ? ` (tag: ${tag})` : ""}`);
209
+ return { runId, n };
210
+ }
211
+ /** Close the open run with a server-owned end boundary; close-tag overwrites start-tag. */
212
+ closeRun(tag) {
213
+ const session = this.requireActive();
214
+ const run = this.requireRun(session);
215
+ const ended = Date.now();
216
+ session.store.closeRun(run.id, ended, tag);
217
+ session.currentRun = null;
218
+ this.transition("analyzing");
219
+ log.info("session", `run ${run.id} closed`);
220
+ return { runId: run.id, started: run.started, ended };
221
+ }
222
+ /** Every run with tag, boundaries, and event count. */
223
+ listRuns() {
224
+ return this.requireActive().store.listRunsWithCounts();
225
+ }
226
+ /** Abort the open run (elicitation declined): row deleted, events unattributed. */
227
+ abortRun() {
228
+ const session = this.requireActive();
229
+ const run = this.requireRun(session);
230
+ session.store.abortRun(run.id);
231
+ session.currentRun = null;
232
+ this.transition("analyzing");
233
+ log.warn("session", `run ${run.id} aborted; its events are now unattributed`);
234
+ return { runId: run.id };
235
+ }
236
+ requireRun(session) {
237
+ if (!session.currentRun) {
238
+ throw new TypedToolError(ERROR_CODES.NO_ACTIVE_RUN, "No run is currently open.", "Open one with start_run.");
239
+ }
240
+ return session.currentRun;
241
+ }
242
+ /**
243
+ * Orphan = a session .db NOT claimed by any lockfile with a live PID.
244
+ * The sessions dir is machine-global: a file claimed by another LIVING
245
+ * instance (different workspace) is NOT stale and must never be touched.
246
+ * A claim by our own PID for a non-active session is stale by definition.
247
+ */
248
+ scanOrphans() {
249
+ const dir = sessionsDir(this.stateRoot);
250
+ if (!existsSync(dir))
251
+ return [];
252
+ const claims = this.readClaims();
253
+ const orphans = [];
254
+ for (const file of readdirSync(dir)) {
255
+ if (!file.endsWith(".db"))
256
+ continue; // -wal/-shm sidecars follow their .db
257
+ const sessionId = file.slice(0, -3);
258
+ if (sessionId === this.active?.id)
259
+ continue; // our own live session
260
+ const claimPid = claims.get(sessionId);
261
+ const claimedByLiveOther = claimPid !== undefined && claimPid !== process.pid && isPidAlive(claimPid);
262
+ if (claimedByLiveOther)
263
+ continue;
264
+ const path = join(dir, file);
265
+ const info = EvidenceStore.inspectSession(path); // null for corrupt = still disposable
266
+ const orphan = { sessionId, path };
267
+ if (info?.goal !== undefined)
268
+ orphan.goal = info.goal;
269
+ if (info?.created !== undefined)
270
+ orphan.created = info.created;
271
+ orphans.push(orphan);
272
+ }
273
+ return orphans;
274
+ }
275
+ /** Destructive deletion semantics for orphans: db + sidecars + any lock claiming them. */
276
+ disposeOrphans(orphans) {
277
+ const orphanIds = new Set(orphans.map((o) => o.sessionId));
278
+ for (const orphan of orphans) {
279
+ removeSessionFiles(orphan.path);
280
+ log.warn("session", `disposed stale session ${orphan.sessionId}`);
281
+ }
282
+ const lDir = locksDir(this.stateRoot);
283
+ if (!existsSync(lDir))
284
+ return;
285
+ for (const file of readdirSync(lDir)) {
286
+ const lock = this.readLock(join(lDir, file));
287
+ if (lock?.session_id !== undefined && orphanIds.has(lock.session_id)) {
288
+ unlinkSync(join(lDir, file));
289
+ }
290
+ }
291
+ }
292
+ /** session_id -> holder PID, from all parseable lockfiles. */
293
+ readClaims() {
294
+ const claims = new Map();
295
+ const dir = locksDir(this.stateRoot);
296
+ if (!existsSync(dir))
297
+ return claims;
298
+ for (const file of readdirSync(dir)) {
299
+ const lock = this.readLock(join(dir, file));
300
+ if (lock?.session_id !== undefined)
301
+ claims.set(lock.session_id, lock.pid);
302
+ }
303
+ return claims;
304
+ }
305
+ /** The active session's store, or a typed NO_ACTIVE_SESSION error (query-tool entry point). */
306
+ requireStore() {
307
+ return this.requireActive().store;
308
+ }
309
+ requireActive() {
310
+ if (!this.active) {
311
+ throw new TypedToolError(ERROR_CODES.NO_ACTIVE_SESSION, "No active debug session.", "Start one with start_session(goal, workspace_root).");
312
+ }
313
+ return this.active;
314
+ }
315
+ /** PID lockfile per workspace_root; stale locks reaped. */
316
+ acquireLock(workspaceRoot, sessionId) {
317
+ const dir = locksDir(this.stateRoot);
318
+ mkdirSync(dir, { recursive: true });
319
+ const lockPath = join(dir, this.lockName(workspaceRoot));
320
+ if (existsSync(lockPath)) {
321
+ const holder = this.readLock(lockPath);
322
+ if (holder && isPidAlive(holder.pid) && holder.pid !== process.pid) {
323
+ throw new TypedToolError(ERROR_CODES.INSTANCE_CONFLICT, `Another instance is already debugging ${holder.workspace_root}.`, `Held by PID ${holder.pid}. End that session (or stop that process) first; one session per workspace.`);
324
+ }
325
+ // dead PID (or our own) -> stale lock, reap it
326
+ log.warn("session", `reaped stale lock for ${workspaceRoot}`);
327
+ unlinkSync(lockPath);
328
+ }
329
+ const content = {
330
+ pid: process.pid,
331
+ workspace_root: workspaceRoot,
332
+ session_id: sessionId,
333
+ };
334
+ writeFileSync(lockPath, JSON.stringify(content));
335
+ return lockPath;
336
+ }
337
+ readLock(lockPath) {
338
+ try {
339
+ return JSON.parse(readFileSync(lockPath, "utf8"));
340
+ }
341
+ catch {
342
+ return null; // corrupt lock = stale
343
+ }
344
+ }
345
+ }
346
+ function isPidAlive(pid) {
347
+ try {
348
+ process.kill(pid, 0);
349
+ return true;
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * OS state-dir resolution: $XDG_STATE_HOME if set; else
3
+ * Linux ~/.local/state, macOS ~/Library/Application Support, Windows
4
+ * %LOCALAPPDATA% — with `<tool>/sessions/` under each.
5
+ */
6
+ import { join } from "node:path";
7
+ import { TOOL_NAME } from "../constants.js";
8
+ export function resolveStateRoot(platform = process.platform, env = process.env) {
9
+ if (env.XDG_STATE_HOME)
10
+ return join(env.XDG_STATE_HOME, TOOL_NAME);
11
+ const home = env.HOME ?? "";
12
+ switch (platform) {
13
+ case "darwin":
14
+ return join(home, "Library", "Application Support", TOOL_NAME);
15
+ case "win32":
16
+ return join(env.LOCALAPPDATA ?? join(home, "AppData", "Local"), TOOL_NAME);
17
+ default:
18
+ return join(home, ".local", "state", TOOL_NAME);
19
+ }
20
+ }
21
+ export function sessionsDir(stateRoot) {
22
+ return join(stateRoot, "sessions");
23
+ }
24
+ export function locksDir(stateRoot) {
25
+ return join(stateRoot, "locks");
26
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,242 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { z } from "zod";
3
+ import { LIMITS, PLAYBOOK_URI } from "./constants.js";
4
+ import { diffRuns } from "./evidence/diff.js";
5
+ import { getSpan, getTimeline } from "./evidence/query.js";
6
+ import { armRun } from "./session/run-boundaries.js";
7
+ import { TypedToolError } from "./session/session-manager.js";
8
+ function errorResult(error) {
9
+ return {
10
+ isError: true,
11
+ content: [{ type: "text", text: JSON.stringify(error) }],
12
+ };
13
+ }
14
+ /** Serialize TypedToolError as the standard error shape; rethrow the rest. */
15
+ function asToolError(err) {
16
+ if (err instanceof TypedToolError) {
17
+ return errorResult({ code: err.code, message: err.message, hint: err.hint });
18
+ }
19
+ throw err;
20
+ }
21
+ export function registerTools(server, deps) {
22
+ function okResult(data, total = 1, truncated = false) {
23
+ const envelope = { data, truncated, total };
24
+ const warnings = deps.drainWarnings?.() ?? [];
25
+ if (warnings.length > 0)
26
+ envelope.warnings = warnings;
27
+ return { content: [{ type: "text", text: JSON.stringify(envelope) }] };
28
+ }
29
+ server.registerTool("start_session", {
30
+ description: "Start a Debug Session scoped to a stated goal.",
31
+ inputSchema: {
32
+ goal: z.string().describe("What this debug session is trying to find out"),
33
+ workspace_root: z
34
+ .string()
35
+ .refine(isAbsolute, "workspace_root must be an absolute path")
36
+ .describe("Absolute path to the workspace being debugged"),
37
+ stale: z
38
+ .enum(["dispose", "keep"])
39
+ .optional()
40
+ .describe("Decision about stale sessions (required if STALE_SESSION_EXISTS was returned; ask the user first): dispose deletes them, keep proceeds once"),
41
+ },
42
+ }, async ({ goal, workspace_root, stale }) => {
43
+ try {
44
+ const { sessionId, workspaceRoot } = deps.manager.startSession(goal, workspace_root, stale);
45
+ return okResult({
46
+ session_id: sessionId,
47
+ port: deps.ingestPort(),
48
+ workspace_root: workspaceRoot,
49
+ // Discoverability: hosts don't reliably surface resource lists,
50
+ // so the pull-once playbook URI rides the start_session response.
51
+ playbook: PLAYBOOK_URI,
52
+ });
53
+ }
54
+ catch (err) {
55
+ return asToolError(err);
56
+ }
57
+ });
58
+ server.registerTool("start_run", {
59
+ description: "Arm a Run: the user reproduces the flow while probe events are attributed to it. " +
60
+ "With elicitation support this call waits for the user's confirm and returns the closed run; " +
61
+ "otherwise the run stays open and end_run closes it.",
62
+ inputSchema: {
63
+ tag: z.string().min(1).optional().describe('Run tag, e.g. "buggy" or "clean"'),
64
+ },
65
+ }, async ({ tag }) => {
66
+ try {
67
+ const outcome = await armRun({
68
+ server,
69
+ manager: deps.manager,
70
+ ...(tag !== undefined ? { tag } : {}),
71
+ ...(deps.elicitTimeoutMs !== undefined ? { elicitTimeoutMs: deps.elicitTimeoutMs } : {}),
72
+ });
73
+ const port = deps.ingestPort();
74
+ switch (outcome.kind) {
75
+ case "closed":
76
+ return okResult({
77
+ run_id: outcome.runId,
78
+ status: "closed",
79
+ started: outcome.started,
80
+ ended: outcome.ended,
81
+ port,
82
+ });
83
+ case "aborted":
84
+ return okResult({
85
+ run_id: outcome.runId,
86
+ status: "aborted",
87
+ note: "User declined; the run's events are now unattributed.",
88
+ port,
89
+ });
90
+ case "open":
91
+ return okResult({
92
+ run_id: outcome.runId,
93
+ status: "open",
94
+ ...(outcome.reason !== "no_elicitation" ? { reason: outcome.reason } : {}),
95
+ note: "Run is open; call end_run when the user says the reproduction is done.",
96
+ port,
97
+ });
98
+ }
99
+ }
100
+ catch (err) {
101
+ return asToolError(err);
102
+ }
103
+ });
104
+ server.registerTool("end_run", {
105
+ description: "Close the open Run with a server-owned end boundary.",
106
+ inputSchema: {
107
+ tag: z.string().min(1).optional().describe('Run tag, e.g. "buggy" or "clean" (overwrites a tag set at start)'),
108
+ },
109
+ }, async ({ tag }) => {
110
+ try {
111
+ const closed = deps.manager.closeRun(tag);
112
+ return okResult({
113
+ run_id: closed.runId,
114
+ status: "closed",
115
+ started: closed.started,
116
+ ended: closed.ended,
117
+ });
118
+ }
119
+ catch (err) {
120
+ return asToolError(err);
121
+ }
122
+ });
123
+ server.registerTool("list_runs", {
124
+ description: "List every Run in the session with tag, boundaries, and event count.",
125
+ inputSchema: {},
126
+ }, async () => {
127
+ try {
128
+ const runs = deps.manager.listRuns().map((r) => ({
129
+ run_id: r.id,
130
+ tag: r.tag,
131
+ started: r.started,
132
+ ended: r.ended,
133
+ event_count: r.event_count,
134
+ }));
135
+ return okResult({ runs }, runs.length);
136
+ }
137
+ catch (err) {
138
+ return asToolError(err);
139
+ }
140
+ });
141
+ server.registerTool("get_timeline", {
142
+ description: "Time-ordered Timeline of all probe events across all services for a Run.",
143
+ inputSchema: {
144
+ run: z.string().describe('Run ID (see list_runs) or "unattributed"'),
145
+ limit: z.number().int().positive().optional().describe("Max events to return (clamped server-side)"),
146
+ },
147
+ }, async ({ run, limit }) => {
148
+ try {
149
+ const { events, truncated, total } = getTimeline(deps.manager.requireStore(), run, limit);
150
+ return okResult({ events }, total, truncated);
151
+ }
152
+ catch (err) {
153
+ return asToolError(err);
154
+ }
155
+ });
156
+ server.registerTool("get_span", {
157
+ description: "Bounded span of probe events by any combination of run, probe, service, and time range.",
158
+ inputSchema: {
159
+ run: z.string().optional().describe('Run ID (see list_runs) or "unattributed"'),
160
+ probe: z.string().optional().describe("Probe ID to match"),
161
+ service: z.string().optional().describe("Service name to match"),
162
+ from: z.number().int().optional().describe("Inclusive lower bound on ts_probe (epoch ms)"),
163
+ to: z.number().int().optional().describe("Inclusive upper bound on ts_probe (epoch ms)"),
164
+ limit: z.number().int().positive().optional().describe("Max events to return (clamped server-side)"),
165
+ },
166
+ }, async ({ run, probe, service, from, to, limit }) => {
167
+ try {
168
+ const filters = {};
169
+ if (run !== undefined)
170
+ filters.run = run;
171
+ if (probe !== undefined)
172
+ filters.probe = probe;
173
+ if (service !== undefined)
174
+ filters.service = service;
175
+ if (from !== undefined)
176
+ filters.from = from;
177
+ if (to !== undefined)
178
+ filters.to = to;
179
+ if (limit !== undefined)
180
+ filters.limit = limit;
181
+ const { events, truncated, total } = getSpan(deps.manager.requireStore(), filters);
182
+ return okResult({ events }, total, truncated);
183
+ }
184
+ catch (err) {
185
+ return asToolError(err);
186
+ }
187
+ });
188
+ server.registerTool("diff_runs", {
189
+ description: "Structured account of differences between two Runs: presence/absence, ordering changes, payload deltas.",
190
+ inputSchema: {
191
+ a: z.string().describe("First run ID (e.g. the clean run)"),
192
+ b: z.string().describe("Second run ID (e.g. the buggy run)"),
193
+ },
194
+ }, async ({ a, b }) => {
195
+ try {
196
+ const { data, total, truncated } = diffRuns(deps.manager.requireStore(), a, b);
197
+ return okResult(data, total, truncated);
198
+ }
199
+ catch (err) {
200
+ return asToolError(err);
201
+ }
202
+ });
203
+ server.registerTool("verify_cleanup", {
204
+ description: "Server-side scan of the session workspace for residual probe markers; returns exact locations.",
205
+ inputSchema: {},
206
+ }, async () => {
207
+ try {
208
+ const scan = deps.manager.verifyCleanup();
209
+ const total = scan.markers.length + scan.orphans.length;
210
+ const markers = scan.markers.slice(0, LIMITS.MAX_QUERY_EVENTS);
211
+ const orphans = scan.orphans.slice(0, LIMITS.MAX_QUERY_EVENTS);
212
+ const truncated = markers.length < scan.markers.length || orphans.length < scan.orphans.length;
213
+ return okResult({ clean: total === 0, markers, orphans, files_scanned: scan.files_scanned }, total, truncated);
214
+ }
215
+ catch (err) {
216
+ return asToolError(err);
217
+ }
218
+ });
219
+ server.registerTool("end_session", {
220
+ description: "End the active Debug Session destructively: all stored probe events are deleted. " +
221
+ "Refused with MARKERS_REMAIN while probe markers remain in the workspace.",
222
+ inputSchema: {
223
+ override: z
224
+ .boolean()
225
+ .optional()
226
+ .describe("Close despite residual probe markers. Requires the user's explicit choice — ask first; the override is reported in the result."),
227
+ },
228
+ }, async ({ override }) => {
229
+ try {
230
+ const sessionId = deps.manager.activeSessionId;
231
+ const result = deps.manager.endSession(override ?? false);
232
+ return okResult({
233
+ session_id: sessionId,
234
+ destroyed: true,
235
+ ...(result.override !== undefined ? { override: result.override } : {}),
236
+ });
237
+ }
238
+ catch (err) {
239
+ return asToolError(err);
240
+ }
241
+ });
242
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@k71n/agent-probe",
3
+ "version": "0.1.0",
4
+ "description": "LLM-in-the-loop multi-service debugger - MCP server with leave-no-trace probe instrumentation",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.13.0"
9
+ },
10
+ "bin": {
11
+ "agent-probe": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json && node build-playbook.mjs",
18
+ "prepublishOnly": "npm run build",
19
+ "dev": "tsx src/index.ts",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "typecheck": "tsc --noEmit",
23
+ "lint": "eslint src *.js *.mjs *.ts"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "zod": "^4.4.3"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.9.2",
31
+ "eslint": "^10.4.1",
32
+ "prettier": "^3.8.3",
33
+ "tsx": "^4.22.4",
34
+ "typescript": "^6.0.3",
35
+ "typescript-eslint": "^8.60.1",
36
+ "vitest": "^4.1.8"
37
+ }
38
+ }