negotium 0.1.37 → 0.1.39

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 (64) hide show
  1. package/README.md +19 -0
  2. package/dist/agent-helpers.js +15637 -5674
  3. package/dist/agent-helpers.js.map +85 -37
  4. package/dist/browser-runtime.js +1600 -0
  5. package/dist/browser-runtime.js.map +28 -0
  6. package/dist/hosted-agent.js +8 -8
  7. package/dist/hosted-agent.js.map +6 -5
  8. package/dist/main.js +3790 -3113
  9. package/dist/main.js.map +33 -29
  10. package/dist/mcp-factories.js +15141 -301
  11. package/dist/mcp-factories.js.map +100 -7
  12. package/dist/prompts.js +117 -49
  13. package/dist/prompts.js.map +3 -3
  14. package/dist/query-runtime.js +310 -1
  15. package/dist/query-runtime.js.map +10 -5
  16. package/dist/runtime/src/agents/archiver.ts +407 -296
  17. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +440 -274
  18. package/dist/runtime/src/agents/mcp-tools/self-config.ts +139 -65
  19. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +1037 -816
  20. package/dist/runtime/src/agents/public-helpers.ts +82 -0
  21. package/dist/runtime/src/agents/self-config-core.ts +574 -278
  22. package/dist/runtime/src/agents/topic-cleanup.ts +167 -129
  23. package/dist/runtime/src/index.ts +4 -1
  24. package/dist/runtime/src/mcp/factories/index.ts +9 -0
  25. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +132 -0
  26. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -57
  27. package/dist/runtime/src/platform/mcp-config.ts +2 -4
  28. package/dist/runtime/src/platform/playwright/browser-processes.ts +3 -2
  29. package/dist/runtime/src/platform/playwright/capability.ts +5 -0
  30. package/dist/runtime/src/platform/playwright/manager.ts +380 -66
  31. package/dist/runtime/src/platform/playwright/public-runtime.ts +45 -0
  32. package/dist/runtime/src/platform/playwright/transport-probe.ts +89 -0
  33. package/dist/runtime/src/prompts/builders.ts +192 -51
  34. package/dist/runtime/src/query/active-rooms.ts +2 -0
  35. package/dist/runtime/src/query/public-runtime.ts +8 -0
  36. package/dist/runtime/src/query/state.ts +69 -33
  37. package/dist/runtime/src/query/types.ts +1 -0
  38. package/dist/runtime/src/runtime/playwright-turn-abort.ts +59 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +23 -6
  40. package/dist/runtime/src/runtime/turn-runner.ts +6 -1
  41. package/dist/runtime/src/version.ts +1 -1
  42. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +38 -0
  43. package/dist/types/packages/core/src/agents/archiver.d.ts +52 -16
  44. package/dist/types/packages/core/src/agents/mcp-tools/ask-user.d.ts +75 -4
  45. package/dist/types/packages/core/src/agents/mcp-tools/self-config.d.ts +17 -0
  46. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +112 -25
  47. package/dist/types/packages/core/src/agents/public-helpers.d.ts +7 -0
  48. package/dist/types/packages/core/src/agents/self-config-core.d.ts +185 -0
  49. package/dist/types/packages/core/src/agents/topic-cleanup.d.ts +30 -24
  50. package/dist/types/packages/core/src/mcp/factories/index.d.ts +1 -0
  51. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +46 -0
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +2 -1
  53. package/dist/types/packages/core/src/platform/playwright/capability.d.ts +1 -0
  54. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +65 -5
  55. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +4 -0
  56. package/dist/types/packages/core/src/platform/playwright/transport-probe.d.ts +13 -0
  57. package/dist/types/packages/core/src/prompts/builders.d.ts +29 -3
  58. package/dist/types/packages/core/src/query/active-rooms.d.ts +2 -0
  59. package/dist/types/packages/core/src/query/public-runtime.d.ts +1 -0
  60. package/dist/types/packages/core/src/query/state.d.ts +13 -0
  61. package/dist/types/packages/core/src/query/types.d.ts +2 -1
  62. package/dist/types/packages/core/src/runtime/playwright-turn-abort.d.ts +12 -0
  63. package/dist/types/packages/core/src/version.d.ts +1 -1
  64. package/package.json +6 -1
@@ -0,0 +1,45 @@
1
+ export {
2
+ isBrowserJanitorOwner,
3
+ selectOrphanBrowserPids,
4
+ } from "./browser-processes";
5
+ export {
6
+ closeBrowserOwnerTabs,
7
+ configurePlaywrightManagerHost,
8
+ ensureBrowserProfile,
9
+ ensurePlaywright,
10
+ getPlaywrightCapability,
11
+ getPlaywrightManagerHost,
12
+ killAllPlaywright,
13
+ makeBrowserProfileInstanceKey,
14
+ makeInstanceKey,
15
+ onPlaywrightFailure,
16
+ type PlaywrightChildEnvironmentContext,
17
+ type PlaywrightFailure,
18
+ type PlaywrightMaintenanceControl,
19
+ type PlaywrightManagerHost,
20
+ type PlaywrightProfileBinding,
21
+ pinPlaywrightInstance,
22
+ reapPlaywrightOrphans,
23
+ resetPlaywrightManagerHost,
24
+ resolvePlaywrightCapabilityOwner,
25
+ resolvePlaywrightProfileBinding,
26
+ resolvePlaywrightTopicBinding,
27
+ resolveTopicProfileDir,
28
+ stopPlaywrightInstance,
29
+ stopPlaywrightProfile,
30
+ unpinPlaywrightInstance,
31
+ withPlaywrightInstanceMaintenance,
32
+ withPlaywrightProfileMaintenance,
33
+ } from "./manager";
34
+ export {
35
+ browserProcessMatchesExpectedProfile,
36
+ extractUserDataDirArg,
37
+ selectIdleEvictionKey,
38
+ selectReusablePort,
39
+ waitForChildProcessExit,
40
+ waitForChildProcessSpawnError,
41
+ } from "./manager-utils";
42
+ export {
43
+ probeMcpTransport,
44
+ probePlaywrightMcpTransports,
45
+ } from "./transport-probe";
@@ -0,0 +1,89 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import { browserOwnerCapability } from "#platform/playwright/capability";
6
+
7
+ const PROBE_OWNER = "__negotium_transport_probe__";
8
+ const PROBE_TIMEOUT_MS = 3000;
9
+
10
+ interface ProbeTransportOptions {
11
+ terminate?: () => Promise<void>;
12
+ timeoutMs?: number;
13
+ }
14
+
15
+ export async function probeMcpTransport(
16
+ transport: Transport,
17
+ options: ProbeTransportOptions = {},
18
+ ): Promise<boolean> {
19
+ const client = new Client({ name: "negotium-browser-healthcheck", version: "1.0.0" });
20
+ let timeout: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ try {
23
+ const tools = await Promise.race([
24
+ (async () => {
25
+ await client.connect(transport);
26
+ const result = await client.listTools();
27
+ if (result.tools.length === 0) throw new Error("browser MCP returned no tools");
28
+ await options.terminate?.();
29
+ return result.tools;
30
+ })(),
31
+ new Promise<never>((_, reject) => {
32
+ timeout = setTimeout(
33
+ () => reject(new Error("browser MCP transport probe timed out")),
34
+ options.timeoutMs ?? PROBE_TIMEOUT_MS,
35
+ );
36
+ timeout.unref?.();
37
+ }),
38
+ ]);
39
+ return tools.length > 0;
40
+ } catch {
41
+ return false;
42
+ } finally {
43
+ if (timeout) clearTimeout(timeout);
44
+ try {
45
+ await client.close();
46
+ } catch {
47
+ // The probe already failed; close is best-effort for partially opened transports.
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Exercise both authenticated transports used by browser-enabled agents.
54
+ * A process-only response or partial SSE handshake is insufficient: each
55
+ * transport must initialize, complete tools/list, and close cleanly.
56
+ */
57
+ export async function probePlaywrightMcpTransports(
58
+ port: number,
59
+ capability: string,
60
+ fetchImpl: FetchLike = fetch as FetchLike,
61
+ ): Promise<boolean> {
62
+ const query = new URLSearchParams({ owner: PROBE_OWNER });
63
+ const baseUrl = `http://127.0.0.1:${port}`;
64
+ const ownerCapability = browserOwnerCapability(capability, PROBE_OWNER);
65
+ const capabilityHeaders = { "X-Browser-Capability": ownerCapability };
66
+ const authenticatedFetch: FetchLike = (input, init) => {
67
+ const headers = new Headers(init?.headers);
68
+ headers.set("X-Browser-Capability", ownerCapability);
69
+ return fetchImpl(input, { ...init, headers });
70
+ };
71
+
72
+ const sseTransport = new SSEClientTransport(new URL(`${baseUrl}/sse?${query}`), {
73
+ fetch: authenticatedFetch,
74
+ eventSourceInit: { fetch: authenticatedFetch },
75
+ requestInit: { headers: capabilityHeaders },
76
+ });
77
+ if (!(await probeMcpTransport(sseTransport))) return false;
78
+
79
+ const streamableTransport = new StreamableHTTPClientTransport(
80
+ new URL(`${baseUrl}/mcp?${query}`),
81
+ {
82
+ fetch: authenticatedFetch,
83
+ requestInit: { headers: capabilityHeaders },
84
+ },
85
+ );
86
+ return probeMcpTransport(streamableTransport, {
87
+ terminate: () => streamableTransport.terminateSession(),
88
+ });
89
+ }
@@ -175,6 +175,44 @@ export interface SessionSystemPromptOpts {
175
175
  fileDeliveryTools?: boolean;
176
176
  }
177
177
 
178
+ export type SessionPromptKind = "topic" | "channel" | "manager";
179
+ export type PromptSectionSlot =
180
+ | "after-runtime-tools"
181
+ | "after-shared-tasks"
182
+ | "before-session-communication"
183
+ | "after-session-communication"
184
+ | "before-topic-configuration"
185
+ | "after-topic-configuration"
186
+ | "after-system-prompt";
187
+
188
+ export interface PromptSectionContext extends SessionSystemPromptOpts {
189
+ sessionKind: SessionPromptKind;
190
+ }
191
+
192
+ export interface PromptExtraSection {
193
+ id: string;
194
+ slot: PromptSectionSlot;
195
+ order?: number;
196
+ render(context: PromptSectionContext): string | null | undefined;
197
+ }
198
+
199
+ export interface PromptTemplateRequest {
200
+ kind: "topic-system" | "channel-system" | "manager-system" | "visual-design";
201
+ filename: string;
202
+ fallback: string;
203
+ }
204
+
205
+ export interface PromptBuilderHost {
206
+ readonly loadTemplate?: (request: PromptTemplateRequest) => string | null | undefined;
207
+ readonly extraSections?: readonly PromptExtraSection[];
208
+ }
209
+
210
+ export interface PromptBuilders {
211
+ buildTopicSystemPrompt(opts: SessionSystemPromptOpts): string;
212
+ buildChannelSystemPrompt(opts: SessionSystemPromptOpts): string;
213
+ buildManagerSystemPrompt(opts: SessionSystemPromptOpts): string;
214
+ }
215
+
178
216
  interface RuntimeToolSectionOpts {
179
217
  agentKind: AgentKind;
180
218
  canSpawnSubagents?: boolean;
@@ -187,7 +225,15 @@ interface RuntimeToolSectionOpts {
187
225
  subagentReportMode?: SubagentReportMode;
188
226
  }
189
227
 
190
- function buildRuntimeToolSection(opts: RuntimeToolSectionOpts): string {
228
+ interface RuntimeToolSectionExtensions {
229
+ render(slot: PromptSectionSlot): string[];
230
+ visualDesignGuide: string;
231
+ }
232
+
233
+ function buildRuntimeToolSection(
234
+ opts: RuntimeToolSectionOpts,
235
+ extensions: RuntimeToolSectionExtensions,
236
+ ): string {
191
237
  const {
192
238
  agentKind,
193
239
  canSpawnSubagents = false,
@@ -282,13 +328,16 @@ function buildRuntimeToolSection(opts: RuntimeToolSectionOpts): string {
282
328
  'Do not use provider built-in "AskUserQuestion"; it is disabled or unsupported in this headless chat runtime. Use the runtime ask_user_question tool instead.',
283
329
  ]),
284
330
  scheduleSelfToolLine,
285
- ...(visualTools ? ["", visualDesignGuide()] : []),
331
+ ...(visualTools && extensions.visualDesignGuide ? ["", extensions.visualDesignGuide] : []),
332
+ ...extensions.render("after-runtime-tools"),
286
333
  "",
287
334
  "## Shared Tasks",
288
335
  taskToolLine,
289
336
  "Use this shared task store for plans, task progress, and checklist updates. It is visible across claude/codex/maestro turns and drives the live task panel.",
290
337
  nativeTaskPolicyLine,
338
+ ...extensions.render("after-shared-tasks"),
291
339
  ...fileDeliverySection,
340
+ ...extensions.render("before-session-communication"),
292
341
  "",
293
342
  "## Session Communication",
294
343
  ...(subagentParentTitle
@@ -313,6 +362,7 @@ function buildRuntimeToolSection(opts: RuntimeToolSectionOpts): string {
313
362
  : []),
314
363
  "Do not use session communication to make another topic perform destructive changes without the user's clear intent.",
315
364
  ...spawnSubagentSection,
365
+ ...extensions.render("after-session-communication"),
316
366
  ];
317
367
 
318
368
  const modelCatalog = SELECTABLE_MODELS.map(
@@ -343,69 +393,160 @@ function buildRuntimeToolSection(opts: RuntimeToolSectionOpts): string {
343
393
  if (agentKind !== "claude") {
344
394
  return [
345
395
  ...shared,
396
+ ...extensions.render("before-topic-configuration"),
346
397
  ...topicConfig,
398
+ ...extensions.render("after-topic-configuration"),
347
399
  "",
348
400
  "## Runtime Tool Limits",
349
401
  "If file delivery or topic configuration tools are not present in your available tools for this session, do not claim you used them. Tell the user this session does not expose that in-chat tool action.",
350
402
  ].join("\n");
351
403
  }
352
404
 
353
- return [...shared, ...topicConfig].join("\n");
405
+ return [
406
+ ...shared,
407
+ ...extensions.render("before-topic-configuration"),
408
+ ...topicConfig,
409
+ ...extensions.render("after-topic-configuration"),
410
+ ].join("\n");
354
411
  }
355
412
 
356
- export function buildTopicSystemPrompt(opts: SessionSystemPromptOpts): string {
357
- const uploadsDir = `${opts.workspaceCwd}/attachments`;
358
- let prompt =
359
- replaceVars(topicSystemPromptTemplate(), {
360
- AI_LABEL: opts.aiLabel,
361
- TOPIC_TITLE: opts.topicTitle,
362
- WORKSPACE_CWD: opts.workspaceCwd,
363
- UPLOADS_DIR: uploadsDir,
364
- }) +
365
- buildRuntimeToolSection({
366
- agentKind: opts.agentKind,
367
- canSpawnSubagents: opts.canSpawnSubagents,
368
- canStageSubagents: opts.canStageSubagents,
369
- visualTools: opts.visualTools,
370
- fileDeliveryTools: opts.fileDeliveryTools,
371
- currentModel: opts.currentModel,
372
- currentEffort: opts.currentEffort,
373
- subagentParentTitle: opts.subagentParentTitle,
374
- subagentReportMode: opts.subagentReportMode,
375
- });
376
- if (opts.description?.trim()) {
377
- prompt += `\n\n## Topic-Specific Instructions\n${opts.description.trim()}`;
413
+ export function createPromptBuilders(host: PromptBuilderHost = {}): PromptBuilders {
414
+ const loadTemplate = host.loadTemplate;
415
+ const sections = (host.extraSections ?? []).map((section): PromptExtraSection => {
416
+ const render = section.render;
417
+ const snapshot: PromptExtraSection = {
418
+ id: section.id,
419
+ slot: section.slot,
420
+ ...(section.order === undefined ? {} : { order: section.order }),
421
+ render(context) {
422
+ return render.call(snapshot, context);
423
+ },
424
+ };
425
+ return Object.freeze(snapshot);
426
+ });
427
+ const ids = new Set<string>();
428
+ for (const section of sections) {
429
+ if (!section.id.trim()) throw new Error("prompt extra section id is required");
430
+ if (ids.has(section.id)) throw new Error(`duplicate prompt extra section id: ${section.id}`);
431
+ ids.add(section.id);
378
432
  }
379
- return prompt;
380
- }
433
+ sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
434
+
435
+ const templateCache = new Map<PromptTemplateRequest["kind"], string>();
436
+ const template = (request: PromptTemplateRequest): string => {
437
+ const cached = templateCache.get(request.kind);
438
+ if (cached !== undefined) return cached;
439
+ const loaded = loadTemplate?.(request);
440
+ const value =
441
+ loaded ??
442
+ (request.kind === "topic-system"
443
+ ? topicSystemPromptTemplate()
444
+ : request.kind === "channel-system"
445
+ ? channelSystemPromptTemplate()
446
+ : request.kind === "manager-system"
447
+ ? managerSystemPromptTemplate()
448
+ : visualDesignGuide());
449
+ templateCache.set(request.kind, value);
450
+ return value;
451
+ };
381
452
 
382
- export function buildChannelSystemPrompt(opts: SessionSystemPromptOpts): string {
383
- const uploadsDir = `${opts.workspaceCwd}/attachments`;
384
- return (
385
- replaceVars(channelSystemPromptTemplate(), {
386
- AI_LABEL: opts.aiLabel,
387
- TOPIC_TITLE: opts.topicTitle,
388
- WORKSPACE_CWD: opts.workspaceCwd,
389
- UPLOADS_DIR: uploadsDir,
390
- }) +
391
- buildRuntimeToolSection({
392
- agentKind: opts.agentKind,
393
- canSpawnSubagents: false,
394
- canStageSubagents: false,
395
- visualTools: opts.visualTools,
396
- fileDeliveryTools: opts.fileDeliveryTools,
397
- currentModel: opts.currentModel,
398
- currentEffort: opts.currentEffort,
399
- subagentParentTitle: opts.subagentParentTitle,
400
- subagentReportMode: opts.subagentReportMode,
401
- })
402
- );
403
- }
453
+ const build = (
454
+ sessionKind: SessionPromptKind,
455
+ opts: SessionSystemPromptOpts,
456
+ sessionTemplate: string,
457
+ ): string => {
458
+ const context = Object.freeze({ ...opts, sessionKind });
459
+ const render = (slot: PromptSectionSlot): string[] =>
460
+ sections
461
+ .filter((section) => section.slot === slot)
462
+ .map((section) => section.render(context)?.trim())
463
+ .filter((section): section is string => Boolean(section))
464
+ .flatMap((section) => ["", section]);
465
+ const uploadsDir = `${opts.workspaceCwd}/attachments`;
466
+ let prompt =
467
+ replaceVars(sessionTemplate, {
468
+ AI_LABEL: opts.aiLabel,
469
+ TOPIC_TITLE: opts.topicTitle,
470
+ WORKSPACE_CWD: opts.workspaceCwd,
471
+ UPLOADS_DIR: uploadsDir,
472
+ }) +
473
+ buildRuntimeToolSection(
474
+ {
475
+ agentKind: opts.agentKind,
476
+ canSpawnSubagents: sessionKind === "channel" ? false : opts.canSpawnSubagents,
477
+ canStageSubagents: sessionKind === "channel" ? false : opts.canStageSubagents,
478
+ visualTools: opts.visualTools,
479
+ fileDeliveryTools: opts.fileDeliveryTools,
480
+ currentModel: opts.currentModel,
481
+ currentEffort: opts.currentEffort,
482
+ subagentParentTitle: opts.subagentParentTitle,
483
+ subagentReportMode: opts.subagentReportMode,
484
+ },
485
+ {
486
+ render,
487
+ visualDesignGuide: template({
488
+ kind: "visual-design",
489
+ filename: "visual-design.md",
490
+ fallback: "",
491
+ }),
492
+ },
493
+ );
494
+ if (sessionKind !== "channel" && opts.description?.trim()) {
495
+ prompt += `\n\n## Topic-Specific Instructions\n${opts.description.trim()}`;
496
+ }
497
+ if (sessionKind === "manager") {
498
+ prompt += `\n\n${template({
499
+ kind: "manager-system",
500
+ filename: "manager-system.md",
501
+ fallback: FALLBACK_MANAGER_SYSTEM_PROMPT_TEMPLATE,
502
+ })}`;
503
+ }
504
+ return `${prompt}${render("after-system-prompt").join("\n")}`;
505
+ };
404
506
 
405
- export function buildManagerSystemPrompt(opts: SessionSystemPromptOpts): string {
406
- return `${buildTopicSystemPrompt(opts)}\n\n${managerSystemPromptTemplate()}`;
507
+ return Object.freeze({
508
+ buildTopicSystemPrompt(opts: SessionSystemPromptOpts) {
509
+ return build(
510
+ "topic",
511
+ opts,
512
+ template({
513
+ kind: "topic-system",
514
+ filename: "topic-system.md",
515
+ fallback: FALLBACK_TOPIC_SYSTEM_PROMPT_TEMPLATE,
516
+ }),
517
+ );
518
+ },
519
+ buildChannelSystemPrompt(opts: SessionSystemPromptOpts) {
520
+ return build(
521
+ "channel",
522
+ opts,
523
+ template({
524
+ kind: "channel-system",
525
+ filename: "channel-system.md",
526
+ fallback: FALLBACK_CHANNEL_SYSTEM_PROMPT_TEMPLATE,
527
+ }),
528
+ );
529
+ },
530
+ buildManagerSystemPrompt(opts: SessionSystemPromptOpts) {
531
+ return build(
532
+ "manager",
533
+ opts,
534
+ template({
535
+ kind: "topic-system",
536
+ filename: "topic-system.md",
537
+ fallback: FALLBACK_TOPIC_SYSTEM_PROMPT_TEMPLATE,
538
+ }),
539
+ );
540
+ },
541
+ });
407
542
  }
408
543
 
544
+ const defaultPromptBuilders = createPromptBuilders();
545
+
546
+ export const buildTopicSystemPrompt = defaultPromptBuilders.buildTopicSystemPrompt;
547
+ export const buildChannelSystemPrompt = defaultPromptBuilders.buildChannelSystemPrompt;
548
+ export const buildManagerSystemPrompt = defaultPromptBuilders.buildManagerSystemPrompt;
549
+
409
550
  export function buildMemoryPromptSection(opts: {
410
551
  briefFile: string;
411
552
  wikiDir: string;
@@ -119,6 +119,8 @@ export interface RoomQueryControl {
119
119
  sessionId?: string | null;
120
120
  abortController: AbortController;
121
121
  abortReason: AbortReason;
122
+ /** Specific failure surfaced when infrastructure aborts the turn. */
123
+ abortError?: string;
122
124
  /** Epoch ms when this turn claimed the room (stale-abort guard). */
123
125
  startedAt?: number;
124
126
  /**
@@ -17,6 +17,14 @@ export {
17
17
  type RoomQueryRegistryHost,
18
18
  type RuntimeTurnLeaseLike,
19
19
  } from "./room-query-registry";
20
+ export {
21
+ clearQueryState,
22
+ createQueryStateStore,
23
+ type QueryStateStore,
24
+ type QueryStateStoreLogger,
25
+ type QueryStateStoreOptions,
26
+ writeQueryState,
27
+ } from "./state";
20
28
  export {
21
29
  AbortReason,
22
30
  type HandleAgentQueryOutcome,
@@ -7,38 +7,86 @@ import type { QueryState } from "#types";
7
7
 
8
8
  type QueryStateUserId = number | string;
9
9
 
10
- function queryStateDirPath(userId: QueryStateUserId): string {
11
- return join(USERS_LOG_DIR, String(userId), "active-queries");
10
+ export interface QueryStateStoreLogger {
11
+ warn(context: Record<string, unknown>, message: string): void;
12
12
  }
13
13
 
14
- function queryStateFile(userId: QueryStateUserId, topicId: string): string {
15
- return join(queryStateDirPath(userId), `${sanitizeId(topicId)}.json`);
14
+ export interface QueryStateStoreOptions {
15
+ usersLogDir: string;
16
+ logger?: QueryStateStoreLogger;
17
+ sanitizeTopicId?: (topicId: string) => string;
16
18
  }
17
19
 
18
- function legacyQueryStateFile(
19
- userId: QueryStateUserId,
20
- topicName: string | undefined,
21
- ): string | null {
22
- if (!topicName || topicName === "." || topicName === ".." || basename(topicName) !== topicName) {
23
- return null;
24
- }
25
- return join(queryStateDirPath(userId), `${topicName}.json`);
20
+ export interface QueryStateStore {
21
+ write(userId: QueryStateUserId, topicId: string, topicName: string, task?: string): void;
22
+ clear(userId: QueryStateUserId, topicId: string, legacyTopicName?: string): void;
26
23
  }
27
24
 
25
+ export function createQueryStateStore(options: QueryStateStoreOptions): QueryStateStore {
26
+ const sanitize = options.sanitizeTopicId ?? sanitizeId;
27
+ const queryStateDirPath = (userId: QueryStateUserId): string =>
28
+ join(options.usersLogDir, String(userId), "active-queries");
29
+ const queryStateFile = (userId: QueryStateUserId, topicId: string): string =>
30
+ join(queryStateDirPath(userId), `${sanitize(topicId)}.json`);
31
+ const legacyQueryStateFile = (
32
+ userId: QueryStateUserId,
33
+ topicName: string | undefined,
34
+ ): string | null => {
35
+ if (
36
+ !topicName ||
37
+ topicName === "." ||
38
+ topicName === ".." ||
39
+ basename(topicName) !== topicName
40
+ ) {
41
+ return null;
42
+ }
43
+ return join(queryStateDirPath(userId), `${topicName}.json`);
44
+ };
45
+
46
+ return {
47
+ write(userId, topicId, topicName, task) {
48
+ const dir = queryStateDirPath(userId);
49
+ mkdirSync(dir, { recursive: true });
50
+ const state: QueryState = { topicId, topicName, since: new Date().toISOString() };
51
+ if (task) state.task = [...task.replace(/\n+/g, " ").trim()].slice(0, 100).join("");
52
+ const target = queryStateFile(userId, topicId);
53
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
54
+ writeFileSync(tmp, JSON.stringify(state));
55
+ renameSync(tmp, target);
56
+ },
57
+ clear(userId, topicId, legacyTopicName) {
58
+ const paths = [
59
+ queryStateFile(userId, topicId),
60
+ legacyQueryStateFile(userId, legacyTopicName),
61
+ ].filter((path): path is string => Boolean(path));
62
+ for (const path of new Set(paths)) {
63
+ try {
64
+ unlinkSync(path);
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
67
+ options.logger?.warn(
68
+ { err: error, userId, topicId, path },
69
+ "Failed to clear query state file",
70
+ );
71
+ }
72
+ }
73
+ }
74
+ },
75
+ };
76
+ }
77
+
78
+ const defaultQueryStateStore = createQueryStateStore({
79
+ usersLogDir: USERS_LOG_DIR,
80
+ logger,
81
+ });
82
+
28
83
  export function writeQueryState(
29
84
  userId: QueryStateUserId,
30
85
  topicId: string,
31
86
  topicName: string,
32
87
  task?: string,
33
88
  ) {
34
- const dir = queryStateDirPath(userId);
35
- mkdirSync(dir, { recursive: true });
36
- const state: QueryState = { topicId, topicName, since: new Date().toISOString() };
37
- if (task) state.task = [...task.replace(/\n+/g, " ").trim()].slice(0, 100).join("");
38
- const target = queryStateFile(userId, topicId);
39
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
40
- writeFileSync(tmp, JSON.stringify(state));
41
- renameSync(tmp, target);
89
+ defaultQueryStateStore.write(userId, topicId, topicName, task);
42
90
  }
43
91
 
44
92
  export function clearQueryState(
@@ -46,17 +94,5 @@ export function clearQueryState(
46
94
  topicId: string,
47
95
  legacyTopicName?: string,
48
96
  ) {
49
- const paths = [
50
- queryStateFile(userId, topicId),
51
- legacyQueryStateFile(userId, legacyTopicName),
52
- ].filter((path): path is string => Boolean(path));
53
- for (const path of new Set(paths)) {
54
- try {
55
- unlinkSync(path);
56
- } catch (e) {
57
- if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
58
- logger.warn({ err: e, userId, topicId, path }, "Failed to clear query state file");
59
- }
60
- }
61
- }
97
+ defaultQueryStateStore.clear(userId, topicId, legacyTopicName);
62
98
  }
@@ -80,4 +80,5 @@ export enum AbortReason {
80
80
  None = "none",
81
81
  Internal = "internal", // replaced by a newer query on the same topic
82
82
  External = "external", // /abort command or abort_session via session-inbox
83
+ Infrastructure = "infrastructure", // required runtime dependency exited unexpectedly
83
84
  }
@@ -0,0 +1,59 @@
1
+ import { logger } from "#platform/logger";
2
+ import { onPlaywrightFailure, type PlaywrightFailure } from "#platform/playwright/manager";
3
+ import { AbortReason } from "#query/types";
4
+
5
+ export interface PlaywrightTurnControl {
6
+ topicId: string;
7
+ queryId: string;
8
+ abortController: AbortController;
9
+ abortReason: AbortReason;
10
+ abortError?: string;
11
+ }
12
+
13
+ const turnsByInstance = new Map<string, Set<PlaywrightTurnControl>>();
14
+
15
+ export function trackPlaywrightTurn(instanceKey: string, control: PlaywrightTurnControl): void {
16
+ const turns = turnsByInstance.get(instanceKey) ?? new Set<PlaywrightTurnControl>();
17
+ turns.add(control);
18
+ turnsByInstance.set(instanceKey, turns);
19
+ }
20
+
21
+ export function untrackPlaywrightTurn(instanceKey: string, control: PlaywrightTurnControl): void {
22
+ const turns = turnsByInstance.get(instanceKey);
23
+ if (!turns) return;
24
+ turns.delete(control);
25
+ if (turns.size === 0) turnsByInstance.delete(instanceKey);
26
+ }
27
+
28
+ function failureMessage(failure: PlaywrightFailure): string {
29
+ if (failure.reason === "unhealthy") {
30
+ return "Browser MCP transport became unresponsive and was restarted";
31
+ }
32
+ if (failure.reason === "process-error") {
33
+ return `Browser MCP process failed${failure.error ? `: ${failure.error}` : ""}`;
34
+ }
35
+ return (
36
+ "Browser MCP exited unexpectedly" +
37
+ (failure.code === null || failure.code === undefined ? "" : ` (exitCode=${failure.code})`)
38
+ );
39
+ }
40
+
41
+ export function abortPlaywrightTurns(instanceKey: string, failure: PlaywrightFailure): number {
42
+ const turns = turnsByInstance.get(instanceKey);
43
+ if (!turns) return 0;
44
+
45
+ const error = failureMessage(failure);
46
+ let aborted = 0;
47
+ for (const control of turns) {
48
+ if (control.abortController.signal.aborted) continue;
49
+ control.abortReason = AbortReason.Infrastructure;
50
+ control.abortError = error;
51
+ control.abortController.abort(new Error(error));
52
+ aborted++;
53
+ }
54
+ turnsByInstance.delete(instanceKey);
55
+ logger.warn({ instanceKey, failure, aborted }, "Aborted turns after browser MCP failure");
56
+ return aborted;
57
+ }
58
+
59
+ onPlaywrightFailure(abortPlaywrightTurns);