agenthub-multiagent-mcp 1.52.0 → 1.53.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.
Files changed (61) hide show
  1. package/dist/captureHookInstall.d.ts +127 -0
  2. package/dist/captureHookInstall.d.ts.map +1 -0
  3. package/dist/captureHookInstall.js +256 -0
  4. package/dist/captureHookInstall.js.map +1 -0
  5. package/dist/captureHookInstall.test.d.ts +10 -0
  6. package/dist/captureHookInstall.test.d.ts.map +1 -0
  7. package/dist/captureHookInstall.test.js +325 -0
  8. package/dist/captureHookInstall.test.js.map +1 -0
  9. package/dist/channel.js +96 -0
  10. package/dist/channel.js.map +1 -1
  11. package/dist/client.d.ts +50 -0
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +90 -0
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands/setup-shell.js +11 -11
  16. package/dist/daemon.js +142 -3
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/index.js +25 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/session-capture.sh +410 -0
  21. package/dist/sessionCapture.d.ts +163 -0
  22. package/dist/sessionCapture.d.ts.map +1 -0
  23. package/dist/sessionCapture.js +279 -0
  24. package/dist/sessionCapture.js.map +1 -0
  25. package/dist/sessionCapture.test.d.ts +9 -0
  26. package/dist/sessionCapture.test.d.ts.map +1 -0
  27. package/dist/sessionCapture.test.js +366 -0
  28. package/dist/sessionCapture.test.js.map +1 -0
  29. package/dist/sessionCaptureScript.test.d.ts +16 -0
  30. package/dist/sessionCaptureScript.test.d.ts.map +1 -0
  31. package/dist/sessionCaptureScript.test.js +92 -0
  32. package/dist/sessionCaptureScript.test.js.map +1 -0
  33. package/dist/steering.test.d.ts +2 -0
  34. package/dist/steering.test.d.ts.map +1 -0
  35. package/dist/steering.test.js +223 -0
  36. package/dist/steering.test.js.map +1 -0
  37. package/dist/streamingRuntimeLib.d.ts +260 -1
  38. package/dist/streamingRuntimeLib.d.ts.map +1 -1
  39. package/dist/streamingRuntimeLib.js +474 -1
  40. package/dist/streamingRuntimeLib.js.map +1 -1
  41. package/dist/streamingRuntimeLib.test.js +87 -1
  42. package/dist/streamingRuntimeLib.test.js.map +1 -1
  43. package/dist/tools/index.d.ts.map +1 -1
  44. package/dist/tools/index.js +8 -0
  45. package/dist/tools/index.js.map +1 -1
  46. package/dist/tools/sessionControl.d.ts +53 -0
  47. package/dist/tools/sessionControl.d.ts.map +1 -0
  48. package/dist/tools/sessionControl.js +265 -0
  49. package/dist/tools/sessionControl.js.map +1 -0
  50. package/dist/tools/sessionControl.test.d.ts +14 -0
  51. package/dist/tools/sessionControl.test.d.ts.map +1 -0
  52. package/dist/tools/sessionControl.test.js +298 -0
  53. package/dist/tools/sessionControl.test.js.map +1 -0
  54. package/dist/tools/tools.test.js +4 -2
  55. package/dist/tools/tools.test.js.map +1 -1
  56. package/native/agenthub-memvid/agenthub-memvid.linux-x64-gnu.node +0 -0
  57. package/package.json +2 -2
  58. package/skills/commands/start-session.md +77 -77
  59. package/skills/skills/deploy-staging/SKILL.md +164 -164
  60. package/skills/skills/deploy-vps-openclaw/SKILL.md +97 -97
  61. package/skills/skills/karpathy-guidelines/SKILL.md +67 -67
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Session steering + takeover MCP tools (add-multiplayer-sessions).
3
+ *
4
+ * §4.7 `session_steer` (+ the `session_steers` read that makes its
5
+ * pending → applied lifecycle observable) and §5.9 `session_claim` /
6
+ * `session_release`.
7
+ *
8
+ * These front the same server endpoints the dashboard uses. Every failure the
9
+ * server can legitimately return — the session cannot accept injection, someone
10
+ * else already holds the claim, the feature is switched off — is surfaced as a
11
+ * structured, non-throwing refusal so the caller can tell them apart. A steer
12
+ * that was refused must never read as a steer that was accepted.
13
+ */
14
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
15
+ import type { ApiClient, SessionControlResult } from "../client.js";
16
+ /** Tool names handled by this module. */
17
+ export declare const SESSION_CONTROL_TOOLS: readonly ["session_steer", "session_steers", "session_claim", "session_release"];
18
+ export type SessionControlToolName = (typeof SESSION_CONTROL_TOOLS)[number];
19
+ /** True when `name` is one of this module's tools. */
20
+ export declare function isSessionControlTool(name: string): name is SessionControlToolName;
21
+ export declare function registerSessionControlTools(): Tool[];
22
+ /** Structured refusal returned to the model instead of a thrown error. */
23
+ export interface SessionControlFailure {
24
+ ok: false;
25
+ error: string;
26
+ status: number;
27
+ message: string;
28
+ /** Why a session cannot be steered (server-supplied). */
29
+ reason?: string;
30
+ /** Who currently holds the claim (server-supplied). */
31
+ holder?: string;
32
+ }
33
+ /**
34
+ * Turn a failed session-control call into a refusal the model can act on.
35
+ *
36
+ * Each known code gets its own sentence because they demand different
37
+ * responses: a disabled feature is not a permission problem, a lost claim race
38
+ * is not an outage, and a non-injectable session will never accept a retry.
39
+ * Unknown codes still carry the HTTP status so a bare 405 or an HTML proxy page
40
+ * is diagnosable rather than mysterious.
41
+ */
42
+ export declare function describeSessionControlFailure(tool: string, sessionId: string, result: Extract<SessionControlResult, {
43
+ ok: false;
44
+ }>): SessionControlFailure;
45
+ /**
46
+ * Handle a session steer/control tool call.
47
+ *
48
+ * Returns a plain object; transport/permission/feature-gate failures come back
49
+ * as `{ok:false,...}` rather than exceptions so the model sees the distinction
50
+ * between "refused" and "crashed".
51
+ */
52
+ export declare function handleSessionControlToolCall(name: SessionControlToolName, args: Record<string, unknown>, client: ApiClient): Promise<unknown>;
53
+ //# sourceMappingURL=sessionControl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionControl.d.ts","sourceRoot":"","sources":["../../src/tools/sessionControl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oCAAoC,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEpE,yCAAyC;AACzC,eAAO,MAAM,qBAAqB,kFAKxB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E,sDAAsD;AACtD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,sBAAsB,CAEjF;AAKD,wBAAgB,2BAA2B,IAAI,IAAI,EAAE,CAiFpD;AAED,0EAA0E;AAC1E,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,OAAO,CAAC,oBAAoB,EAAE;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC,GACnD,qBAAqB,CAwDvB;AAED;;;;;;GAMG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,sBAAsB,EAC5B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,OAAO,CAAC,CAuFlB"}
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Session steering + takeover MCP tools (add-multiplayer-sessions).
3
+ *
4
+ * §4.7 `session_steer` (+ the `session_steers` read that makes its
5
+ * pending → applied lifecycle observable) and §5.9 `session_claim` /
6
+ * `session_release`.
7
+ *
8
+ * These front the same server endpoints the dashboard uses. Every failure the
9
+ * server can legitimately return — the session cannot accept injection, someone
10
+ * else already holds the claim, the feature is switched off — is surfaced as a
11
+ * structured, non-throwing refusal so the caller can tell them apart. A steer
12
+ * that was refused must never read as a steer that was accepted.
13
+ */
14
+ /** Tool names handled by this module. */
15
+ export const SESSION_CONTROL_TOOLS = [
16
+ "session_steer",
17
+ "session_steers",
18
+ "session_claim",
19
+ "session_release",
20
+ ];
21
+ /** True when `name` is one of this module's tools. */
22
+ export function isSessionControlTool(name) {
23
+ return SESSION_CONTROL_TOOLS.includes(name);
24
+ }
25
+ const SESSION_ID_DESCRIPTION = "Session UUID or display number `s-YYYYMMDD-NNN` (e.g. s-20260725-001). Both forms are accepted.";
26
+ export function registerSessionControlTools() {
27
+ return [
28
+ {
29
+ name: "session_steer",
30
+ description: "Send a human instruction into a live agent session (add-multiplayer-sessions §4). " +
31
+ "STEERING IS ASYNCHRONOUS: a successful call QUEUES the instruction and returns status " +
32
+ "`pending`. The session has not read or obeyed it yet — it flips to `applied` only when the " +
33
+ "session consumes it as its next turn. Never report a pending steer as done; use " +
34
+ "`session_steers` to see whether it was applied. " +
35
+ "Failure modes are distinct and are returned as `{ok:false, error, status}` rather than thrown: " +
36
+ "`session_not_injectable` (409) means this session physically cannot be steered — one-shot " +
37
+ "`claude -p` shifts have no channel to inject into — and the server's `reason` explains which; " +
38
+ "`session_steer_disabled` (503) means the steering feature is switched off on this server, which " +
39
+ "is not a permission problem and not something a retry fixes. " +
40
+ "Use `session_claim` first if you need the agent to stop taking its own turns.",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ session_id: { type: "string", description: SESSION_ID_DESCRIPTION },
45
+ text: {
46
+ type: "string",
47
+ description: "The instruction to inject, written as a direct instruction to the session " +
48
+ "(e.g. 'stop refactoring and fix the failing test first').",
49
+ },
50
+ },
51
+ required: ["session_id", "text"],
52
+ },
53
+ },
54
+ {
55
+ name: "session_steers",
56
+ description: "List the steers recorded against a session and their status (`pending` = queued, not yet seen " +
57
+ "by the session; `applied` = consumed as a turn). Read-only. Use this to confirm whether a " +
58
+ "`session_steer` actually took effect instead of assuming it did.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ session_id: { type: "string", description: SESSION_ID_DESCRIPTION },
63
+ },
64
+ required: ["session_id"],
65
+ },
66
+ },
67
+ {
68
+ name: "session_claim",
69
+ description: "Take over a live session (add-multiplayer-sessions §5). On success the agent stops consuming " +
70
+ "new turns and the session becomes `human_driven`; your instructions become its turns. " +
71
+ "A CLAIM IS A LEASE, NOT A LOCK: it carries an `expires_at` and is released automatically when " +
72
+ "it expires, so a holder that walks away cannot strand the session. This tool does NOT renew the " +
73
+ "lease and there is no MCP renewal — plan to finish and call `session_release` before " +
74
+ "`expires_at`, or expect control to revert to the agent at that time. Long human takeovers " +
75
+ "belong in the dashboard, which renews on a timer. " +
76
+ "The claim is atomic: if someone already holds it you get `{ok:false, error:'session_already_claimed'}` " +
77
+ "(409) naming the current `holder` — that is a normal race outcome, not a bug. " +
78
+ "Because a claim only takes effect after the agent's in-flight turn completes, the session may " +
79
+ "report `draining` before it reports `human_driven`.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {
83
+ session_id: { type: "string", description: SESSION_ID_DESCRIPTION },
84
+ },
85
+ required: ["session_id"],
86
+ },
87
+ },
88
+ {
89
+ name: "session_release",
90
+ description: "Hand a claimed session back to its agent (add-multiplayer-sessions §5). The session returns to " +
91
+ "`agent_driven` and the agent resumes consuming queued turns. Call this as soon as you are done " +
92
+ "with a takeover — do not rely on the lease expiring.",
93
+ inputSchema: {
94
+ type: "object",
95
+ properties: {
96
+ session_id: { type: "string", description: SESSION_ID_DESCRIPTION },
97
+ },
98
+ required: ["session_id"],
99
+ },
100
+ },
101
+ ];
102
+ }
103
+ function requireString(args, key) {
104
+ const value = args[key];
105
+ if (typeof value !== "string" || value.trim() === "") {
106
+ throw new Error(`${key} is required`);
107
+ }
108
+ return value;
109
+ }
110
+ function str(data, key) {
111
+ const value = data[key];
112
+ return typeof value === "string" ? value : undefined;
113
+ }
114
+ /**
115
+ * Turn a failed session-control call into a refusal the model can act on.
116
+ *
117
+ * Each known code gets its own sentence because they demand different
118
+ * responses: a disabled feature is not a permission problem, a lost claim race
119
+ * is not an outage, and a non-injectable session will never accept a retry.
120
+ * Unknown codes still carry the HTTP status so a bare 405 or an HTML proxy page
121
+ * is diagnosable rather than mysterious.
122
+ */
123
+ export function describeSessionControlFailure(tool, sessionId, result) {
124
+ const { status, error, holder, reason } = result;
125
+ const serverMessage = result.message;
126
+ let message;
127
+ switch (error) {
128
+ case "session_not_injectable":
129
+ message =
130
+ `REFUSED: session ${sessionId} cannot accept a steer` +
131
+ (reason ? ` — ${reason}` : serverMessage ? ` — ${serverMessage}` : "") +
132
+ `. Nothing was queued and the session was never told anything. Sessions that ` +
133
+ `cannot accept injection (one-shot \`claude -p\` shifts have no transport to ` +
134
+ `inject into) will not accept a retry either.`;
135
+ break;
136
+ case "session_already_claimed":
137
+ message =
138
+ `REFUSED: session ${sessionId} is already claimed by ${holder || "another user"}` +
139
+ `. You do not have control. Wait for them to release it (or for their lease ` +
140
+ `to expire) before claiming again.`;
141
+ break;
142
+ case "session_steer_disabled":
143
+ case "session_streaming_disabled":
144
+ case "session_control_disabled":
145
+ message =
146
+ `UNAVAILABLE: this AgentHub server has the session ${error === "session_steer_disabled" ? "steering" : "multiplayer session"} feature switched off (${error}, HTTP ${status}). This is a server ` +
147
+ `configuration state, not a permission denial and not a missing session — ` +
148
+ `an operator must enable it. Do not retry.`;
149
+ break;
150
+ case "session_not_found":
151
+ message = `Session ${sessionId} was not found (HTTP ${status}). Check the id or display number.`;
152
+ break;
153
+ default:
154
+ if (status === 401 || status === 403) {
155
+ message =
156
+ `DENIED: not permitted to ${tool.replace("session_", "")} session ${sessionId} ` +
157
+ `(${error}, HTTP ${status})${serverMessage ? `: ${serverMessage}` : ""}.`;
158
+ }
159
+ else if (status === 404) {
160
+ message = `Session ${sessionId} was not found (HTTP ${status}).`;
161
+ }
162
+ else {
163
+ message =
164
+ serverMessage ||
165
+ result.detail ||
166
+ `${tool} failed: ${error} (HTTP ${status}).`;
167
+ }
168
+ }
169
+ return {
170
+ ok: false,
171
+ error,
172
+ status,
173
+ message,
174
+ ...(reason ? { reason } : {}),
175
+ ...(holder ? { holder } : {}),
176
+ };
177
+ }
178
+ /**
179
+ * Handle a session steer/control tool call.
180
+ *
181
+ * Returns a plain object; transport/permission/feature-gate failures come back
182
+ * as `{ok:false,...}` rather than exceptions so the model sees the distinction
183
+ * between "refused" and "crashed".
184
+ */
185
+ export async function handleSessionControlToolCall(name, args, client) {
186
+ const sessionId = requireString(args, "session_id");
187
+ switch (name) {
188
+ case "session_steer": {
189
+ const text = requireString(args, "text");
190
+ const result = await client.steerSession(sessionId, text);
191
+ if (!result.ok)
192
+ return describeSessionControlFailure(name, sessionId, result);
193
+ // The server answers 202 Accepted with status "pending". Report exactly
194
+ // that: the instruction is queued, the session has not acted on it.
195
+ const status = str(result.data, "status") || "pending";
196
+ const steerId = str(result.data, "steer_id") || "";
197
+ return {
198
+ ok: true,
199
+ session_id: sessionId,
200
+ steer_id: steerId,
201
+ status,
202
+ applied: status === "applied",
203
+ message: `Steer queued for session ${sessionId}${steerId ? ` (steer ${steerId})` : ""} — ` +
204
+ `status ${status}. ` +
205
+ (status === "applied"
206
+ ? "The session has consumed it."
207
+ : "The session has NOT seen it yet; it applies when the session takes its next turn. " +
208
+ "Call session_steers to check whether it flipped to applied."),
209
+ };
210
+ }
211
+ case "session_steers": {
212
+ const result = await client.listSessionSteers(sessionId);
213
+ if (!result.ok)
214
+ return describeSessionControlFailure(name, sessionId, result);
215
+ const raw = result.data.steers;
216
+ const steers = Array.isArray(raw) ? raw : [];
217
+ const pending = steers.filter((s) => s?.status === "pending").length;
218
+ return {
219
+ ok: true,
220
+ session_id: sessionId,
221
+ count: steers.length,
222
+ pending_count: pending,
223
+ steers,
224
+ message: `${steers.length} steer(s) on session ${sessionId}; ${pending} still pending (not yet seen by the session).`,
225
+ };
226
+ }
227
+ case "session_claim": {
228
+ const result = await client.claimSession(sessionId);
229
+ if (!result.ok)
230
+ return describeSessionControlFailure(name, sessionId, result);
231
+ const state = str(result.data, "state") || "human_driven";
232
+ const holder = str(result.data, "holder") || "";
233
+ const expiresAt = str(result.data, "expires_at") || "";
234
+ return {
235
+ ok: true,
236
+ session_id: sessionId,
237
+ state,
238
+ holder,
239
+ expires_at: expiresAt,
240
+ message: `Claimed session ${sessionId}${holder ? ` as ${holder}` : ""} — state ${state}. ` +
241
+ (state === "draining"
242
+ ? "The agent is finishing its in-flight turn; the claim takes effect once it does. "
243
+ : "") +
244
+ (expiresAt
245
+ ? `This is a LEASE and it expires at ${expiresAt}; it is not renewed by this tool, so ` +
246
+ `control reverts to the agent then unless you release it first. `
247
+ : `This is a LEASE and expires without renewal; this tool does not renew it. `) +
248
+ `Call session_release when you are done.`,
249
+ };
250
+ }
251
+ case "session_release": {
252
+ const result = await client.releaseSession(sessionId);
253
+ if (!result.ok)
254
+ return describeSessionControlFailure(name, sessionId, result);
255
+ const state = str(result.data, "state") || "agent_driven";
256
+ return {
257
+ ok: true,
258
+ session_id: sessionId,
259
+ state,
260
+ message: `Released session ${sessionId} — state ${state}. The agent resumes consuming its queued turns.`,
261
+ };
262
+ }
263
+ }
264
+ }
265
+ //# sourceMappingURL=sessionControl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionControl.js","sourceRoot":"","sources":["../../src/tools/sessionControl.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Unit tests for the session steering + takeover MCP tools.
3
+ * add-multiplayer-sessions §4.7 (`session_steer`) and §5.9
4
+ * (`session_claim`, `session_release`).
5
+ *
6
+ * Two layers are exercised:
7
+ * - the tool handlers, against a mocked ApiClient, for result/refusal shaping;
8
+ * - the real ApiClient with global `fetch` stubbed, for the wire contract
9
+ * (paths, body, id passthrough) and the non-JSON-error-body regression
10
+ * guarded by fix-mcp-nonjson-error-handling.
11
+ * No live server is required by either.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=sessionControl.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionControl.test.d.ts","sourceRoot":"","sources":["../../src/tools/sessionControl.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Unit tests for the session steering + takeover MCP tools.
3
+ * add-multiplayer-sessions §4.7 (`session_steer`) and §5.9
4
+ * (`session_claim`, `session_release`).
5
+ *
6
+ * Two layers are exercised:
7
+ * - the tool handlers, against a mocked ApiClient, for result/refusal shaping;
8
+ * - the real ApiClient with global `fetch` stubbed, for the wire contract
9
+ * (paths, body, id passthrough) and the non-JSON-error-body regression
10
+ * guarded by fix-mcp-nonjson-error-handling.
11
+ * No live server is required by either.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { registerSessionControlTools, handleSessionControlToolCall, isSessionControlTool, } from "./sessionControl.js";
15
+ import { ApiClient } from "../client.js";
16
+ const SESSION_UUID = "3f1c0c2e-1a4b-4a5f-9f01-2f3b4c5d6e7f";
17
+ const SESSION_DISPLAY = "s-20260725-001";
18
+ function buildMockClient(overrides = {}) {
19
+ return {
20
+ steerSession: vi.fn(),
21
+ listSessionSteers: vi.fn(),
22
+ claimSession: vi.fn(),
23
+ releaseSession: vi.fn(),
24
+ ...overrides,
25
+ };
26
+ }
27
+ function ok(data, status = 200) {
28
+ return { ok: true, status, data };
29
+ }
30
+ // ─── registration ──────────────────────────────────────────────────────────
31
+ describe("session control tool registration", () => {
32
+ it("registers session_steer, session_steers, session_claim and session_release", () => {
33
+ const names = registerSessionControlTools().map((t) => t.name);
34
+ expect(names).toEqual([
35
+ "session_steer",
36
+ "session_steers",
37
+ "session_claim",
38
+ "session_release",
39
+ ]);
40
+ });
41
+ it("requires session_id on every tool and text on session_steer", () => {
42
+ const tools = Object.fromEntries(registerSessionControlTools().map((t) => [t.name, t]));
43
+ for (const name of ["session_steer", "session_steers", "session_claim", "session_release"]) {
44
+ const schema = tools[name].inputSchema;
45
+ expect(schema.required).toContain("session_id");
46
+ }
47
+ expect(tools.session_steer.inputSchema.required).toContain("text");
48
+ });
49
+ it("tells the reader that steering is asynchronous and that a claim is an expiring lease", () => {
50
+ const tools = Object.fromEntries(registerSessionControlTools().map((t) => [t.name, t]));
51
+ expect(tools.session_steer.description).toMatch(/ASYNCHRONOUS/);
52
+ expect(tools.session_steer.description).toMatch(/pending/);
53
+ expect(tools.session_claim.description).toMatch(/LEASE/);
54
+ expect(tools.session_claim.description).toMatch(/expires/i);
55
+ expect(tools.session_claim.description).toMatch(/does NOT renew/);
56
+ });
57
+ it("claims only its own tool names", () => {
58
+ expect(isSessionControlTool("session_steer")).toBe(true);
59
+ expect(isSessionControlTool("session_claim")).toBe(true);
60
+ expect(isSessionControlTool("session_get")).toBe(false);
61
+ });
62
+ });
63
+ // ─── session_steer ─────────────────────────────────────────────────────────
64
+ describe("session_steer", () => {
65
+ it("reports a queued steer as pending, not as applied", async () => {
66
+ const client = buildMockClient({
67
+ steerSession: vi
68
+ .fn()
69
+ .mockResolvedValue(ok({ steer_id: "st-1", status: "pending" }, 202)),
70
+ });
71
+ const result = (await handleSessionControlToolCall("session_steer", { session_id: SESSION_UUID, text: "stop and fix the failing test" }, client));
72
+ expect(result.ok).toBe(true);
73
+ expect(result.status).toBe("pending");
74
+ expect(result.applied).toBe(false);
75
+ expect(result.steer_id).toBe("st-1");
76
+ expect(String(result.message)).toMatch(/has NOT seen it yet/);
77
+ expect(client.steerSession).toHaveBeenCalledWith(SESSION_UUID, "stop and fix the failing test");
78
+ });
79
+ it("surfaces 409 session_not_injectable as a refusal carrying the server reason", async () => {
80
+ const client = buildMockClient({
81
+ steerSession: vi.fn().mockResolvedValue({
82
+ ok: false,
83
+ status: 409,
84
+ error: "session_not_injectable",
85
+ reason: "one-shot -p shift has no injection transport",
86
+ }),
87
+ });
88
+ const result = (await handleSessionControlToolCall("session_steer", { session_id: SESSION_DISPLAY, text: "switch branches" }, client));
89
+ expect(result.ok).toBe(false);
90
+ expect(result.error).toBe("session_not_injectable");
91
+ expect(result.status).toBe(409);
92
+ expect(result.reason).toBe("one-shot -p shift has no injection transport");
93
+ expect(String(result.message)).toMatch(/REFUSED/);
94
+ expect(String(result.message)).toMatch(/one-shot -p shift has no injection transport/);
95
+ // A refusal must not read as an accepted steer.
96
+ expect(result).not.toHaveProperty("steer_id");
97
+ expect(result).not.toHaveProperty("applied");
98
+ expect(String(result.message)).not.toMatch(/Steer queued/);
99
+ expect(String(result.message)).toMatch(/Nothing was queued/);
100
+ });
101
+ it("reports 503 session_steer_disabled as the feature being off, not a permission or not-found error", async () => {
102
+ const client = buildMockClient({
103
+ steerSession: vi.fn().mockResolvedValue({
104
+ ok: false,
105
+ status: 503,
106
+ error: "session_steer_disabled",
107
+ }),
108
+ });
109
+ const result = (await handleSessionControlToolCall("session_steer", { session_id: SESSION_UUID, text: "hello" }, client));
110
+ expect(result.ok).toBe(false);
111
+ expect(result.error).toBe("session_steer_disabled");
112
+ expect(result.status).toBe(503);
113
+ const message = String(result.message);
114
+ expect(message).toMatch(/UNAVAILABLE/);
115
+ expect(message).toMatch(/switched off/);
116
+ expect(message).not.toMatch(/DENIED|not permitted|not found/i);
117
+ });
118
+ it("distinguishes a permission denial from the feature being off", async () => {
119
+ const client = buildMockClient({
120
+ steerSession: vi.fn().mockResolvedValue({
121
+ ok: false,
122
+ status: 403,
123
+ error: "forbidden",
124
+ message: "you do not own this session",
125
+ }),
126
+ });
127
+ const result = (await handleSessionControlToolCall("session_steer", { session_id: SESSION_UUID, text: "hello" }, client));
128
+ const message = String(result.message);
129
+ expect(message).toMatch(/DENIED/);
130
+ expect(message).not.toMatch(/switched off/);
131
+ });
132
+ it("rejects a missing or blank instruction before calling the server", async () => {
133
+ const client = buildMockClient();
134
+ await expect(handleSessionControlToolCall("session_steer", { session_id: SESSION_UUID }, client)).rejects.toThrow(/text is required/);
135
+ await expect(handleSessionControlToolCall("session_steer", { text: "x" }, client)).rejects.toThrow(/session_id is required/);
136
+ expect(client.steerSession).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+ // ─── session_steers ────────────────────────────────────────────────────────
140
+ describe("session_steers", () => {
141
+ it("counts pending steers so an applied steer is distinguishable", async () => {
142
+ const client = buildMockClient({
143
+ listSessionSteers: vi.fn().mockResolvedValue(ok({
144
+ steers: [
145
+ { id: "st-1", text: "a", status: "applied", created_by: "u1" },
146
+ { id: "st-2", text: "b", status: "pending", created_by: "u1" },
147
+ ],
148
+ })),
149
+ });
150
+ const result = (await handleSessionControlToolCall("session_steers", { session_id: SESSION_DISPLAY }, client));
151
+ expect(result.ok).toBe(true);
152
+ expect(result.count).toBe(2);
153
+ expect(result.pending_count).toBe(1);
154
+ });
155
+ });
156
+ // ─── session_claim ─────────────────────────────────────────────────────────
157
+ describe("session_claim", () => {
158
+ it("returns the lease expiry and says plainly that the tool does not renew it", async () => {
159
+ const client = buildMockClient({
160
+ claimSession: vi.fn().mockResolvedValue(ok({
161
+ state: "human_driven",
162
+ holder: "user-sumit",
163
+ expires_at: "2026-07-25T12:00:00Z",
164
+ })),
165
+ });
166
+ const result = (await handleSessionControlToolCall("session_claim", { session_id: SESSION_UUID }, client));
167
+ expect(result.ok).toBe(true);
168
+ expect(result.state).toBe("human_driven");
169
+ expect(result.holder).toBe("user-sumit");
170
+ expect(result.expires_at).toBe("2026-07-25T12:00:00Z");
171
+ const message = String(result.message);
172
+ expect(message).toMatch(/LEASE/);
173
+ expect(message).toMatch(/2026-07-25T12:00:00Z/);
174
+ expect(message).toMatch(/not renewed by this tool/);
175
+ });
176
+ it("names the holder when the claim is lost with 409 session_already_claimed", async () => {
177
+ const client = buildMockClient({
178
+ claimSession: vi.fn().mockResolvedValue({
179
+ ok: false,
180
+ status: 409,
181
+ error: "session_already_claimed",
182
+ holder: "user-ankita",
183
+ }),
184
+ });
185
+ const result = (await handleSessionControlToolCall("session_claim", { session_id: SESSION_UUID }, client));
186
+ expect(result.ok).toBe(false);
187
+ expect(result.error).toBe("session_already_claimed");
188
+ expect(result.holder).toBe("user-ankita");
189
+ expect(String(result.message)).toMatch(/user-ankita/);
190
+ expect(String(result.message)).toMatch(/REFUSED/);
191
+ });
192
+ it("reports a draining claim as not yet in effect", async () => {
193
+ const client = buildMockClient({
194
+ claimSession: vi
195
+ .fn()
196
+ .mockResolvedValue(ok({ state: "draining", holder: "u", expires_at: "2026-07-25T12:00:00Z" })),
197
+ });
198
+ const result = (await handleSessionControlToolCall("session_claim", { session_id: SESSION_UUID }, client));
199
+ expect(result.state).toBe("draining");
200
+ expect(String(result.message)).toMatch(/in-flight turn/);
201
+ });
202
+ });
203
+ // ─── session_release ───────────────────────────────────────────────────────
204
+ describe("session_release", () => {
205
+ it("reports the session back under agent control", async () => {
206
+ const client = buildMockClient({
207
+ releaseSession: vi.fn().mockResolvedValue(ok({ state: "agent_driven" })),
208
+ });
209
+ const result = (await handleSessionControlToolCall("session_release", { session_id: SESSION_DISPLAY }, client));
210
+ expect(result.ok).toBe(true);
211
+ expect(result.state).toBe("agent_driven");
212
+ expect(client.releaseSession).toHaveBeenCalledWith(SESSION_DISPLAY);
213
+ });
214
+ });
215
+ // ─── wire contract (real ApiClient, stubbed fetch) ─────────────────────────
216
+ describe("ApiClient session control wire contract", () => {
217
+ let fetchMock;
218
+ function respond(body, status, contentType = "application/json") {
219
+ return new Response(body, { status, headers: { "Content-Type": contentType } });
220
+ }
221
+ beforeEach(() => {
222
+ fetchMock = vi.fn();
223
+ vi.stubGlobal("fetch", fetchMock);
224
+ });
225
+ afterEach(() => {
226
+ vi.unstubAllGlobals();
227
+ });
228
+ it("POSTs the steer body the server contract specifies", async () => {
229
+ fetchMock.mockResolvedValue(respond(JSON.stringify({ steer_id: "st-1", status: "pending" }), 202));
230
+ const client = new ApiClient("https://agenthub.example.com", "k");
231
+ const result = await client.steerSession(SESSION_UUID, "do the thing");
232
+ expect(result.ok).toBe(true);
233
+ const [url, init] = fetchMock.mock.calls[0];
234
+ expect(url).toBe(`https://agenthub.example.com/api/sessions/${SESSION_UUID}/steer`);
235
+ expect(init.method).toBe("POST");
236
+ expect(JSON.parse(init.body)).toEqual({
237
+ text: "do the thing",
238
+ message_type: "steer",
239
+ });
240
+ });
241
+ it("passes a display-number session id through unchanged", async () => {
242
+ fetchMock.mockResolvedValue(respond(JSON.stringify({ state: "human_driven" }), 200));
243
+ const client = new ApiClient("https://agenthub.example.com", "k");
244
+ await client.claimSession(SESSION_DISPLAY);
245
+ const [url] = fetchMock.mock.calls[0];
246
+ expect(url).toBe(`https://agenthub.example.com/api/sessions/${SESSION_DISPLAY}/claim`);
247
+ expect(url).toContain(SESSION_DISPLAY);
248
+ });
249
+ it("passes a UUID session id through unchanged", async () => {
250
+ fetchMock.mockResolvedValue(respond(JSON.stringify({ state: "agent_driven" }), 200));
251
+ const client = new ApiClient("https://agenthub.example.com", "k");
252
+ await client.releaseSession(SESSION_UUID);
253
+ const [url] = fetchMock.mock.calls[0];
254
+ expect(url).toBe(`https://agenthub.example.com/api/sessions/${SESSION_UUID}/release`);
255
+ });
256
+ it("keeps the HTTP status when the error body is empty (chi's bare 405)", async () => {
257
+ fetchMock.mockResolvedValue(new Response("", { status: 405 }));
258
+ const client = new ApiClient("https://agenthub.example.com", "k");
259
+ const result = await client.steerSession(SESSION_UUID, "x");
260
+ expect(result.ok).toBe(false);
261
+ if (result.ok)
262
+ return;
263
+ expect(result.status).toBe(405);
264
+ expect(result.error).toBe("http_405");
265
+ expect(String(result.detail)).toMatch(/405/);
266
+ expect(String(result.detail)).toMatch(/empty response body/);
267
+ });
268
+ it("keeps the HTTP status when the error body is HTML, not a JSON parse error", async () => {
269
+ fetchMock.mockResolvedValue(respond("<html><body>502 Bad Gateway</body></html>", 502, "text/html"));
270
+ const client = new ApiClient("https://agenthub.example.com", "k");
271
+ const result = await client.claimSession(SESSION_DISPLAY);
272
+ expect(result.ok).toBe(false);
273
+ if (result.ok)
274
+ return;
275
+ expect(result.status).toBe(502);
276
+ expect(String(result.detail)).toMatch(/502/);
277
+ expect(String(result.detail)).toMatch(/Bad Gateway/);
278
+ });
279
+ it("surfaces a non-JSON failure through the tool with the status in the message", async () => {
280
+ fetchMock.mockResolvedValue(new Response("", { status: 405 }));
281
+ const client = new ApiClient("https://agenthub.example.com", "k");
282
+ const result = (await handleSessionControlToolCall("session_steer", { session_id: SESSION_UUID, text: "x" }, client));
283
+ expect(result.ok).toBe(false);
284
+ expect(String(result.message)).toMatch(/405/);
285
+ });
286
+ it("sends auth headers on session control calls", async () => {
287
+ fetchMock.mockResolvedValue(respond(JSON.stringify({ steers: [] }), 200));
288
+ const client = new ApiClient("https://agenthub.example.com", "org-key");
289
+ client.setConnectToken("ct-xyz");
290
+ await client.listSessionSteers(SESSION_UUID);
291
+ const [, init] = fetchMock.mock.calls[0];
292
+ const headers = init.headers;
293
+ expect(headers["X-API-Key"]).toBe("org-key");
294
+ expect(headers["X-Connect-Token"]).toBe("ct-xyz");
295
+ expect(headers["X-MCP-Version"]).toBeTruthy();
296
+ });
297
+ });
298
+ //# sourceMappingURL=sessionControl.test.js.map