@kontextmind/kxm 0.7.4 → 0.7.6

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/README.md +1 -0
  3. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  4. package/docs/agent-skills.md +14 -0
  5. package/docs/browser-automation.md +116 -0
  6. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  7. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  8. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  9. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  10. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  11. package/docs/kb/how-to-take-over-session.md +32 -0
  12. package/docs/kb/why-authentication-disappeared.md +32 -0
  13. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  14. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  15. package/docs/prompts/browser-annotate-feedback.md +41 -0
  16. package/docs/prompts/browser-diagnose-recover.md +38 -0
  17. package/docs/prompts/browser-explore.md +42 -0
  18. package/docs/prompts/browser-repro-fix.md +48 -0
  19. package/docs/prompts/browser-start.md +41 -0
  20. package/docs/prompts/browser-takeover.md +50 -0
  21. package/package.json +1 -1
  22. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  23. package/plugins/kxm/dist/cli.js +421 -155
  24. package/plugins/kxm/dist/mcp-server.js +1 -1
  25. package/plugins/kxm/dist/runtime.js +699 -16
  26. package/plugins/kxm/package.json +1 -1
  27. package/plugins/kxm/skills/hints.json +30 -0
  28. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  29. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  30. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  31. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  32. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  33. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  34. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  35. package/plugins/kxm/src/browser.ts +603 -0
  36. package/plugins/kxm/src/cli.ts +39 -0
  37. package/plugins/kxm/src/mcp-server.ts +1 -1
  38. package/plugins/kxm/src/modes.ts +348 -0
  39. package/plugins/kxm/src/runtime.ts +2 -0
  40. package/schemas/vnext/modes.schema.json +56 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * KXM Browser Automation & Steel Session Client
3
+ *
4
+ * Lightweight, harness-agnostic client for self-hosted Steel on DOKS.
5
+ * Manages remote browser sessions, CDP endpoints, human takeover handoffs,
6
+ * pass-cli credential references, and automated cleanup without leaking secrets.
7
+ */
8
+
9
+ import { execSync } from "node:child_process";
10
+
11
+ export type SessionState =
12
+ | "AGENT_CONTROL"
13
+ | "AUTH_REQUIRED"
14
+ | "HUMAN_CONTROL"
15
+ | "VERIFY_AUTHENTICATION"
16
+ | "RELEASED"
17
+ | "EXPIRED"
18
+ | "FAILED";
19
+
20
+ export interface SteelSession {
21
+ id: string;
22
+ createdAt: string;
23
+ status: "idle" | "live" | "released" | "failed";
24
+ state: SessionState;
25
+ websocketUrl: string;
26
+ debugUrl: string;
27
+ debuggerUrl: string;
28
+ sessionViewerUrl: string;
29
+ timeoutMs: number;
30
+ lastActiveAt: number;
31
+ activeController: "agent" | "human" | "none";
32
+ takeoverReason?: string | undefined;
33
+ userAgent?: string | undefined;
34
+ }
35
+
36
+ export interface ViewportPreset {
37
+ name: string;
38
+ category: "mobile" | "tablet" | "desktop" | "laptop";
39
+ width: number;
40
+ height: number;
41
+ deviceScaleFactor?: number | undefined;
42
+ isMobile?: boolean | undefined;
43
+ hasTouch?: boolean | undefined;
44
+ }
45
+
46
+ export const VIEWPORT_PRESETS: Record<string, ViewportPreset> = Object.freeze({
47
+ "mobile-sm": { name: "Mobile Small (SE)", category: "mobile", width: 375, height: 667, isMobile: true, hasTouch: true },
48
+ "mobile": { name: "Mobile (iPhone 16 / 15 Pro)", category: "mobile", width: 393, height: 852, isMobile: true, hasTouch: true },
49
+ "mobile-lg": { name: "Mobile Large (Pro Max)", category: "mobile", width: 430, height: 932, isMobile: true, hasTouch: true },
50
+ "pixel": { name: "Google Pixel 8/9", category: "mobile", width: 412, height: 924, isMobile: true, hasTouch: true },
51
+ "galaxy": { name: "Samsung Galaxy S24", category: "mobile", width: 360, height: 780, isMobile: true, hasTouch: true },
52
+ "tablet": { name: "Tablet (iPad Air / Mini)", category: "tablet", width: 820, height: 1180, isMobile: true, hasTouch: true },
53
+ "tablet-lg": { name: "Tablet Large (iPad Pro 12.9)", category: "tablet", width: 1024, height: 1366, isMobile: true, hasTouch: true },
54
+ "laptop": { name: "Standard Laptop", category: "laptop", width: 1366, height: 768 },
55
+ "macbook-13": { name: "MacBook Air 13", category: "laptop", width: 1440, height: 900 },
56
+ "macbook-16": { name: "MacBook Pro 16", category: "laptop", width: 1728, height: 1117 },
57
+ "desktop": { name: "Desktop FHD (1080p)", category: "desktop", width: 1920, height: 1080 },
58
+ "desktop-2k": { name: "Desktop QHD (1440p)", category: "desktop", width: 2560, height: 1440 },
59
+ "desktop-4k": { name: "Desktop 4K UHD", category: "desktop", width: 3840, height: 2160 },
60
+ });
61
+
62
+ export function resolveViewportDimensions(
63
+ presetOrDims?: string | { width: number; height: number } | undefined
64
+ ): { width: number; height: number } {
65
+ if (!presetOrDims) {
66
+ return { width: 1920, height: 1080 };
67
+ }
68
+ if (typeof presetOrDims === "string") {
69
+ const matched = VIEWPORT_PRESETS[presetOrDims.toLowerCase()];
70
+ if (matched) {
71
+ return { width: matched.width, height: matched.height };
72
+ }
73
+ return { width: 1920, height: 1080 };
74
+ }
75
+ return presetOrDims;
76
+ }
77
+
78
+ export interface CreateSessionOptions {
79
+ timeoutMs?: number | undefined;
80
+ dimensions?: { width: number; height: number } | undefined;
81
+ viewportPreset?: string | undefined;
82
+ userAgent?: string | undefined;
83
+ proxy?: string | undefined;
84
+ }
85
+
86
+ export interface ScrapeResult {
87
+ content: {
88
+ html: string;
89
+ };
90
+ metadata: {
91
+ statusCode: number;
92
+ title: string;
93
+ description?: string | undefined;
94
+ language?: string | undefined;
95
+ urlSource: string;
96
+ timestamp: string;
97
+ };
98
+ links: Array<{ url: string; text: string }>;
99
+ }
100
+
101
+ export interface ScreenshotResult {
102
+ path?: string | undefined;
103
+ base64?: string | undefined;
104
+ url: string;
105
+ }
106
+
107
+ export interface BoundingBox {
108
+ x: number;
109
+ y: number;
110
+ width: number;
111
+ height: number;
112
+ }
113
+
114
+ export interface AnnotationItem {
115
+ id?: string | undefined;
116
+ label: string;
117
+ note: string;
118
+ selector?: string | undefined;
119
+ boundingBox?: BoundingBox | undefined;
120
+ severity?: "suggestion" | "fix" | "blocker" | undefined;
121
+ }
122
+
123
+ export interface AnnotationFeedback {
124
+ sessionId?: string | undefined;
125
+ url: string;
126
+ sectionSelector?: string | undefined;
127
+ screenshotBase64?: string | undefined;
128
+ screenshotPath?: string | undefined;
129
+ annotations: AnnotationItem[];
130
+ overallSummary: string;
131
+ requestedChanges: string[];
132
+ capturedAt: string;
133
+ }
134
+
135
+ export interface SteelConfig {
136
+ apiUrl: string;
137
+ apiKey?: string | undefined;
138
+ uiUrl?: string | undefined;
139
+ timeoutMs?: number | undefined;
140
+ }
141
+
142
+ export function resolvePassCliApiKey(
143
+ execFn: (cmd: string) => string = (cmd) =>
144
+ execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 5000 }),
145
+ ): string | undefined {
146
+ if (typeof process === "undefined" || process.env.USE_PASS_CLI === "false") {
147
+ return undefined;
148
+ }
149
+ if (process.env.PASS_CLI_OUTPUT_MOCK) {
150
+ try {
151
+ const parsed = JSON.parse(process.env.PASS_CLI_OUTPUT_MOCK);
152
+ const extraFields = parsed?.item?.content?.extra_fields || [];
153
+ const customSections = parsed?.item?.content?.content?.Custom?.sections || [];
154
+ const sectionFields = customSections.flatMap((s: any) => s.section_fields || []);
155
+ const allFields = [...extraFields, ...sectionFields];
156
+ return allFields.find((f: any) => f.name === "STEEL_API_KEY")?.content?.Hidden;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+ try {
162
+ const output = execFn(
163
+ 'pass-cli item view --vault-name "AI Provider Keys" --item-title "Steel Browser (KontextMind DOKS)" --output json',
164
+ );
165
+ const parsed = JSON.parse(output);
166
+ const extraFields = parsed?.item?.content?.extra_fields || [];
167
+ const customSections = parsed?.item?.content?.content?.Custom?.sections || [];
168
+ const sectionFields = customSections.flatMap((s: any) => s.section_fields || []);
169
+ const allFields = [...extraFields, ...sectionFields];
170
+ return allFields.find((f: any) => f.name === "STEEL_API_KEY")?.content?.Hidden;
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Resolve Steel configuration from environment or pass-cli.
178
+ * Does not write secrets to disk or logs.
179
+ */
180
+ export function resolveSteelConfig(overrides?: Partial<SteelConfig>): SteelConfig {
181
+ const apiUrl =
182
+ overrides?.apiUrl ||
183
+ process.env.STEEL_API_URL ||
184
+ "https://steel.kontextmind.com";
185
+
186
+ const apiKey = overrides?.apiKey || process.env.STEEL_API_KEY || resolvePassCliApiKey();
187
+
188
+ const uiUrl =
189
+ overrides?.uiUrl ||
190
+ (overrides?.apiUrl ? `${overrides.apiUrl.replace(/\/$/, "")}/ui` : undefined) ||
191
+ process.env.STEEL_UI_URL ||
192
+ `${apiUrl.replace(/\/$/, "")}/ui`;
193
+
194
+ return {
195
+ apiUrl: apiUrl.replace(/\/$/, ""),
196
+ apiKey,
197
+ uiUrl,
198
+ timeoutMs: overrides?.timeoutMs || 300000, // 5 minutes default
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Format a remote CDP connection URL for Playwright or agent-browser.
204
+ */
205
+ export function formatCDPEndpoint(session: Pick<SteelSession, "id" | "websocketUrl">, config: SteelConfig): string {
206
+ const baseApi = config.apiUrl;
207
+ const urlObj = new URL(baseApi);
208
+ const isSecure = urlObj.protocol === "https:";
209
+ const wsProtocol = isSecure ? "wss:" : "ws:";
210
+ const host = urlObj.host;
211
+
212
+ const searchParams = new URLSearchParams();
213
+ searchParams.set("sessionId", session.id);
214
+ if (config.apiKey) {
215
+ searchParams.set("apiKey", config.apiKey);
216
+ }
217
+
218
+ return `${wsProtocol}//${host}/v1/devtools?${searchParams.toString()}`;
219
+ }
220
+
221
+ /**
222
+ * Redact sensitive API keys and tokens from URLs and objects for logging.
223
+ */
224
+ export function sanitizeLogOutput<T>(input: T): T {
225
+ if (typeof input === "string") {
226
+ return input.replace(/apiKey=[^&]+/g, "apiKey=[REDACTED]").replace(/steel_[a-f0-9]+/g, "steel_[REDACTED]") as unknown as T;
227
+ }
228
+ if (Array.isArray(input)) {
229
+ return input.map(sanitizeLogOutput) as unknown as T;
230
+ }
231
+ if (input !== null && typeof input === "object") {
232
+ const copy: Record<string, any> = {};
233
+ for (const [k, v] of Object.entries(input)) {
234
+ if (/key|secret|token|auth|password/i.test(k) && typeof v === "string") {
235
+ copy[k] = "[REDACTED]";
236
+ } else {
237
+ copy[k] = sanitizeLogOutput(v);
238
+ }
239
+ }
240
+ return copy as T;
241
+ }
242
+ return input;
243
+ }
244
+
245
+ /**
246
+ * Create a structured annotation feedback package from captured section data.
247
+ */
248
+ export function createAnnotationFeedback(
249
+ feedback: Omit<AnnotationFeedback, "capturedAt"> & { capturedAt?: string }
250
+ ): AnnotationFeedback {
251
+ return {
252
+ ...feedback,
253
+ capturedAt: feedback.capturedAt || new Date().toISOString(),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Format structured visual annotations and requested UI changes into a
259
+ * clear, actionable prompt block that an agent can parse and execute.
260
+ */
261
+ export function formatAnnotationFeedbackPrompt(feedback: AnnotationFeedback): string {
262
+ let output = `## Visual Feedback & Section Annotation\n\n`;
263
+ output += `- **Target URL**: ${feedback.url}\n`;
264
+ if (feedback.sessionId) {
265
+ output += `- **Session ID**: ${feedback.sessionId}\n`;
266
+ }
267
+ if (feedback.sectionSelector) {
268
+ output += `- **Section Target Selector**: \`${feedback.sectionSelector}\`\n`;
269
+ }
270
+ if (feedback.screenshotPath) {
271
+ output += `- **Screenshot Artifact**: \`${feedback.screenshotPath}\`\n`;
272
+ }
273
+ output += `- **Captured At**: ${feedback.capturedAt}\n\n`;
274
+
275
+ output += `### Summary\n${feedback.overallSummary}\n\n`;
276
+
277
+ if (feedback.annotations.length > 0) {
278
+ output += `### Annotated Elements & Notes\n\n`;
279
+ feedback.annotations.forEach((item, idx) => {
280
+ output += `${idx + 1}. **${item.label}**`;
281
+ if (item.severity) {
282
+ output += ` [${item.severity.toUpperCase()}]`;
283
+ }
284
+ output += `\n - **Note**: ${item.note}\n`;
285
+ if (item.selector) {
286
+ output += ` - **Selector**: \`${item.selector}\`\n`;
287
+ }
288
+ if (item.boundingBox) {
289
+ output += ` - **Region (Box)**: x=${item.boundingBox.x}, y=${item.boundingBox.y}, w=${item.boundingBox.width}, h=${item.boundingBox.height}\n`;
290
+ }
291
+ });
292
+ output += `\n`;
293
+ }
294
+
295
+ if (feedback.requestedChanges.length > 0) {
296
+ output += `### Actionable Change List\n\n`;
297
+ feedback.requestedChanges.forEach((change, idx) => {
298
+ output += `- [ ] ${change}\n`;
299
+ });
300
+ }
301
+
302
+ return output;
303
+ }
304
+
305
+ /**
306
+ * Steel Browser Session Manager & Handoff Orchestrator.
307
+ */
308
+ export class SteelClient {
309
+ private config: SteelConfig;
310
+ private activeSessions = new Map<string, SteelSession>();
311
+
312
+ constructor(config?: Partial<SteelConfig>) {
313
+ this.config = resolveSteelConfig(config);
314
+ }
315
+
316
+ getConfig(): Readonly<SteelConfig> {
317
+ return { ...this.config };
318
+ }
319
+
320
+ private headers(): Record<string, string> {
321
+ const h: Record<string, string> = {
322
+ "Content-Type": "application/json",
323
+ };
324
+ if (this.config.apiKey) {
325
+ h["x-steel-api-key"] = this.config.apiKey;
326
+ }
327
+ return h;
328
+ }
329
+
330
+ /**
331
+ * Launch a new Steel browser session on DOKS.
332
+ */
333
+ async createSession(options?: CreateSessionOptions): Promise<SteelSession> {
334
+ const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs ?? 300000;
335
+ const body: Record<string, any> = {
336
+ timeout: timeoutMs,
337
+ };
338
+ const dimensions = options?.dimensions || (options?.viewportPreset ? resolveViewportDimensions(options.viewportPreset) : undefined);
339
+ if (dimensions) {
340
+ body.dimensions = dimensions;
341
+ }
342
+ if (options?.userAgent) {
343
+ body.userAgent = options.userAgent;
344
+ }
345
+ if (options?.proxy) {
346
+ body.proxy = options.proxy;
347
+ }
348
+
349
+ const res = await fetch(`${this.config.apiUrl}/v1/sessions`, {
350
+ method: "POST",
351
+ headers: this.headers(),
352
+ body: JSON.stringify(body),
353
+ });
354
+
355
+ if (!res.ok) {
356
+ const errText = await res.text();
357
+ throw new Error(`Failed to create Steel session (${res.status}): ${errText}`);
358
+ }
359
+
360
+ const data = await res.json();
361
+ const session: SteelSession = {
362
+ id: data.id,
363
+ createdAt: data.createdAt || new Date().toISOString(),
364
+ status: "live",
365
+ state: "AGENT_CONTROL",
366
+ websocketUrl: data.websocketUrl || "",
367
+ debugUrl: data.debugUrl || "",
368
+ debuggerUrl: data.debuggerUrl || "",
369
+ sessionViewerUrl: data.sessionViewerUrl || `${this.config.uiUrl}?sessionId=${data.id}`,
370
+ timeoutMs,
371
+ lastActiveAt: Date.now(),
372
+ activeController: "agent",
373
+ userAgent: data.userAgent,
374
+ };
375
+
376
+ this.activeSessions.set(session.id, session);
377
+ return session;
378
+ }
379
+
380
+ /**
381
+ * Get details of an existing session.
382
+ */
383
+ async getSession(sessionId: string): Promise<SteelSession | null> {
384
+ const res = await fetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
385
+ method: "GET",
386
+ headers: this.headers(),
387
+ });
388
+
389
+ if (res.status === 404) {
390
+ const cached = this.activeSessions.get(sessionId);
391
+ if (cached) {
392
+ cached.state = "EXPIRED";
393
+ cached.status = "released";
394
+ cached.activeController = "none";
395
+ }
396
+ return null;
397
+ }
398
+
399
+ if (!res.ok) {
400
+ throw new Error(`Failed to fetch Steel session (${res.status})`);
401
+ }
402
+
403
+ const data = await res.json();
404
+ const existing = this.activeSessions.get(sessionId);
405
+ const session: SteelSession = {
406
+ id: data.id,
407
+ createdAt: data.createdAt,
408
+ status: data.status,
409
+ state: existing?.state ?? (data.status === "live" ? "AGENT_CONTROL" : "RELEASED"),
410
+ websocketUrl: data.websocketUrl || existing?.websocketUrl || "",
411
+ debugUrl: data.debugUrl || existing?.debugUrl || "",
412
+ debuggerUrl: data.debuggerUrl || existing?.debuggerUrl || "",
413
+ sessionViewerUrl: existing?.sessionViewerUrl || `${this.config.uiUrl}?sessionId=${data.id}`,
414
+ timeoutMs: data.timeout || existing?.timeoutMs || 300000,
415
+ lastActiveAt: Date.now(),
416
+ activeController: existing?.activeController ?? (data.status === "live" ? "agent" : "none"),
417
+ userAgent: data.userAgent,
418
+ };
419
+
420
+ this.activeSessions.set(session.id, session);
421
+ return session;
422
+ }
423
+
424
+ /**
425
+ * Request human takeover for MFA, login, or consent.
426
+ * Pauses agent automation and sets state to HUMAN_CONTROL.
427
+ */
428
+ requestHumanTakeover(sessionId: string, reason: string): {
429
+ session: SteelSession;
430
+ takeoverUrl: string;
431
+ instructions: string;
432
+ } {
433
+ const session = this.activeSessions.get(sessionId);
434
+ if (!session) {
435
+ throw new Error(`Session ${sessionId} not tracked or already released`);
436
+ }
437
+ if (session.state === "RELEASED" || session.state === "EXPIRED" || session.state === "FAILED") {
438
+ throw new Error(`Cannot initiate takeover on session in state ${session.state}`);
439
+ }
440
+
441
+ session.state = "HUMAN_CONTROL";
442
+ session.activeController = "human";
443
+ session.takeoverReason = reason;
444
+ session.lastActiveAt = Date.now();
445
+
446
+ const takeoverUrl = `${this.config.uiUrl}?sessionId=${encodeURIComponent(sessionId)}`;
447
+ const instructions =
448
+ `[HUMAN TAKEOVER REQUIRED]\n` +
449
+ `Reason: ${reason}\n` +
450
+ `Session ID: ${session.id}\n` +
451
+ `Takeover URL: ${takeoverUrl}\n\n` +
452
+ `Instructions for Operator:\n` +
453
+ `1. Open the URL above to access the session UI.\n` +
454
+ `2. Perform the required authentication / MFA / consent action.\n` +
455
+ `3. Return to the terminal and signal completion. Automation is paused until you confirm.`;
456
+
457
+ return {
458
+ session,
459
+ takeoverUrl,
460
+ instructions,
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Signal that human takeover is complete.
466
+ * Moves state to VERIFY_AUTHENTICATION before transitioning back to AGENT_CONTROL.
467
+ */
468
+ signalHumanComplete(sessionId: string): {
469
+ session: SteelSession;
470
+ state: SessionState;
471
+ } {
472
+ const session = this.activeSessions.get(sessionId);
473
+ if (!session) {
474
+ throw new Error(`Session ${sessionId} not tracked or already released`);
475
+ }
476
+ if (session.state !== "HUMAN_CONTROL") {
477
+ throw new Error(`Session ${sessionId} is not in HUMAN_CONTROL state (currently ${session.state})`);
478
+ }
479
+
480
+ session.state = "VERIFY_AUTHENTICATION";
481
+ session.activeController = "agent";
482
+ session.lastActiveAt = Date.now();
483
+
484
+ return {
485
+ session,
486
+ state: session.state,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Confirm authentication verification passed and restore AGENT_CONTROL.
492
+ */
493
+ confirmAuthenticationVerified(sessionId: string): SteelSession {
494
+ const session = this.activeSessions.get(sessionId);
495
+ if (!session) {
496
+ throw new Error(`Session ${sessionId} not tracked or already released`);
497
+ }
498
+ if (session.state !== "VERIFY_AUTHENTICATION") {
499
+ throw new Error(`Session ${sessionId} is not in VERIFY_AUTHENTICATION state (currently ${session.state})`);
500
+ }
501
+
502
+ session.state = "AGENT_CONTROL";
503
+ session.activeController = "agent";
504
+ session.takeoverReason = undefined;
505
+ session.lastActiveAt = Date.now();
506
+
507
+ return session;
508
+ }
509
+
510
+ /**
511
+ * Gracefully release a session.
512
+ */
513
+ async releaseSession(sessionId: string): Promise<boolean> {
514
+ try {
515
+ const res = await fetch(`${this.config.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}/release`, {
516
+ method: "POST",
517
+ headers: this.headers(),
518
+ });
519
+
520
+ const session = this.activeSessions.get(sessionId);
521
+ if (session) {
522
+ session.status = "released";
523
+ session.state = "RELEASED";
524
+ session.activeController = "none";
525
+ }
526
+ this.activeSessions.delete(sessionId);
527
+ return res.ok;
528
+ } catch {
529
+ this.activeSessions.delete(sessionId);
530
+ return false;
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Perform a direct stateless scrape without manual session management.
536
+ */
537
+ async scrape(url: string): Promise<ScrapeResult> {
538
+ const res = await fetch(`${this.config.apiUrl}/v1/scrape`, {
539
+ method: "POST",
540
+ headers: this.headers(),
541
+ body: JSON.stringify({ url }),
542
+ });
543
+
544
+ if (!res.ok) {
545
+ const err = await res.text();
546
+ throw new Error(`Scrape failed (${res.status}): ${err}`);
547
+ }
548
+
549
+ return res.json();
550
+ }
551
+
552
+ /**
553
+ * Perform a direct screenshot action.
554
+ */
555
+ async screenshot(url: string, fullPage = false): Promise<ScreenshotResult> {
556
+ const res = await fetch(`${this.config.apiUrl}/v1/screenshot`, {
557
+ method: "POST",
558
+ headers: this.headers(),
559
+ body: JSON.stringify({ url, fullPage }),
560
+ });
561
+
562
+ if (!res.ok) {
563
+ const err = await res.text();
564
+ throw new Error(`Screenshot failed (${res.status}): ${err}`);
565
+ }
566
+
567
+ return res.json();
568
+ }
569
+
570
+ /**
571
+ * Detect and list orphaned or timed-out active sessions.
572
+ */
573
+ async checkOrphanedSessions(maxIdleMs = 600000): Promise<string[]> {
574
+ const res = await fetch(`${this.config.apiUrl}/v1/sessions`, {
575
+ method: "GET",
576
+ headers: this.headers(),
577
+ });
578
+
579
+ if (!res.ok) {
580
+ return [];
581
+ }
582
+
583
+ const data = await res.json();
584
+ const remoteSessions: Array<{ id: string; status: string; createdAt: string; duration: number }> =
585
+ data.sessions || [];
586
+
587
+ const now = Date.now();
588
+ const orphaned: string[] = [];
589
+
590
+ for (const rs of remoteSessions) {
591
+ if (rs.status === "live" || rs.status === "idle") {
592
+ const tracked = this.activeSessions.get(rs.id);
593
+ if (!tracked && rs.duration > maxIdleMs) {
594
+ orphaned.push(rs.id);
595
+ } else if (tracked && now - tracked.lastActiveAt > maxIdleMs && tracked.state !== "HUMAN_CONTROL") {
596
+ orphaned.push(rs.id);
597
+ }
598
+ }
599
+ }
600
+
601
+ return orphaned;
602
+ }
603
+ }
@@ -113,6 +113,12 @@ import {
113
113
  import { generateShellCompletion, type SupportedShell } from "./autocomplete.ts";
114
114
  import { completionRcTarget, completionScriptPath, detectShell, installPathEntry, installShellCompletion, kxmBinDir } from "./completion-install.ts";
115
115
  import { suggestWorkflowAndRoles } from "./suggest.ts";
116
+ import {
117
+ loadModesConfig,
118
+ resolveActiveMode,
119
+ calculatePromptFootprint,
120
+ formatModesExplainReport,
121
+ } from "./modes.ts";
116
122
  import {
117
123
  createGoal,
118
124
  createTask,
@@ -1261,6 +1267,28 @@ async function cmdModelInventoryRefresh(runtime: Runtime): Promise<number> {
1261
1267
  return failed ? 1 : 0;
1262
1268
  }
1263
1269
 
1270
+ async function cmdExplain(
1271
+ runtime: Runtime,
1272
+ options: { mode?: string; domains?: string; model?: string },
1273
+ ): Promise<number> {
1274
+ const modesConfig = loadModesConfig(runtime.dirs.workdir);
1275
+ const majorMode = options.mode || "coder";
1276
+ const domains = options.domains
1277
+ ? options.domains.split(",").map((s) => s.trim()).filter(Boolean)
1278
+ : [];
1279
+ const resolved = resolveActiveMode(modesConfig, majorMode, domains);
1280
+ if (options.model) {
1281
+ resolved.model = options.model;
1282
+ }
1283
+ const footprint = calculatePromptFootprint(resolved, runtime.dirs.workdir);
1284
+ if (runtime.json) {
1285
+ print(runtime.io, runtime.json, { ok: true, command: "explain", ...footprint }, "");
1286
+ } else {
1287
+ runtime.io.stdout(formatModesExplainReport(footprint) + "\n");
1288
+ }
1289
+ return 0;
1290
+ }
1291
+
1264
1292
  async function cmdUpdate(runtime: Runtime, harness: string | undefined, options: {
1265
1293
  self?: boolean;
1266
1294
  extensions?: boolean;
@@ -5080,6 +5108,17 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
5080
5108
  result.code = await cmdRoutingBenchmark(runtimeFrom(ctx, this), options);
5081
5109
  });
5082
5110
 
5111
+ addGlobalOptions(
5112
+ program
5113
+ .command("explain")
5114
+ .description("Pre-flight context footprint and token cost inspection for workflow modes")
5115
+ .option("--mode <name>", "Major mode (coder, planner, auditor, browser)", "coder")
5116
+ .option("--domains <list>", "Comma-separated domain modules (git, k8s, database, browser)")
5117
+ .option("--model <id>", "Target model identifier (e.g. grok/grok-4.6, claude/fable)")
5118
+ ).action(async function explainAction(this: Command, options: { mode?: string; domains?: string; model?: string }) {
5119
+ result.code = await cmdExplain(runtimeFrom(ctx, this), options);
5120
+ });
5121
+
5083
5122
  const hub = addGlobalOptions(program.command("hub").description("Start, inspect, and stop the local KXM hub"));
5084
5123
  hub.helpCommand("help", "Show hub help");
5085
5124
  addGlobalOptions(hub.command("view").description("Check hub /health and /ready")).action(bind(cmdStatus));
@@ -7,7 +7,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
7
7
  import { deliverInboxNotification } from "./inbox.ts";
8
8
  import type { HubEvent, MessageRecord } from "./protocol.ts";
9
9
 
10
- const VERSION = "0.7.4";
10
+ const VERSION = "0.7.6";
11
11
  const inbox = new Map<string, MessageRecord>();
12
12
  const notifiedInbox = new Set<string>();
13
13
  let meshClient: HubClient | undefined;