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
@@ -18,13 +18,18 @@ import { SUPPORTED_AGENTS } from "#agents/index";
18
18
  import { getRegistry } from "#agents/registry";
19
19
  import { logger } from "#platform/logger";
20
20
  import {
21
- type ConversationEntry,
22
21
  getActiveConversationPath,
23
22
  getConversationPath,
24
23
  readConversation,
25
24
  readRawConversation,
26
25
  } from "#storage/conversations";
27
- import type { AgentKind } from "#types";
26
+ import type { AgentKind, UnifiedEvent } from "#types";
27
+
28
+ export interface TopicConversationEntry {
29
+ ts: string;
30
+ agent: AgentKind;
31
+ event: UnifiedEvent;
32
+ }
28
33
 
29
34
  export interface PurgeSessionRef {
30
35
  agent: AgentKind;
@@ -37,7 +42,7 @@ export interface PurgeSessionRef {
37
42
  * switch back), so we collect into a Set to deduplicate before deletion.
38
43
  */
39
44
  function collectSessionIdsByAgent(
40
- entries: ConversationEntry[],
45
+ entries: TopicConversationEntry[],
41
46
  extraSessions: PurgeSessionRef[] = [],
42
47
  ): Map<AgentKind, Set<string>> {
43
48
  const out = new Map<AgentKind, Set<string>>();
@@ -77,25 +82,49 @@ export interface PurgeTopicLogsOptions {
77
82
  extraSessions?: PurgeSessionRef[];
78
83
  }
79
84
 
85
+ export interface TopicLogMaintenanceHost {
86
+ readonly agents: readonly AgentKind[];
87
+ readonly readActiveConversation: (
88
+ userId: number | string,
89
+ topicName: string,
90
+ ) => TopicConversationEntry[];
91
+ readonly readRawConversation: (
92
+ userId: number | string,
93
+ topicName: string,
94
+ ) => TopicConversationEntry[];
95
+ readonly activeConversationPath: (userId: number | string, topicName: string) => string;
96
+ readonly rawConversationPath: (userId: number | string, topicName: string) => string;
97
+ readonly cleanupRollouts: (agent: AgentKind, cwd: string, sessionIds: string[]) => Promise<void>;
98
+ readonly warn: (context: Record<string, unknown>, message: string) => void;
99
+ }
100
+
101
+ export interface TopicLogMaintenance {
102
+ cleanupTopicRollouts(opts: PurgeTopicLogsOptions): Promise<boolean>;
103
+ cleanupTopicRolloutsFromEntries(
104
+ opts: PurgeTopicLogsOptions,
105
+ entries: TopicConversationEntry[],
106
+ ): Promise<boolean>;
107
+ rotateTopicLogs(opts: RotateTopicLogsOptions): Promise<RotateTopicLogsResult>;
108
+ purgeTopicLogs(opts: PurgeTopicLogsOptions): Promise<boolean>;
109
+ }
110
+
80
111
  async function cleanupSessionRollouts(
112
+ host: TopicLogMaintenanceHost,
81
113
  opts: PurgeTopicLogsOptions,
82
- entries: ConversationEntry[],
114
+ entries: TopicConversationEntry[],
83
115
  ): Promise<boolean> {
84
116
  const { userId, topicName, cwd, extraSessions = [] } = opts;
85
117
  const idsByAgent = collectSessionIdsByAgent(entries, extraSessions);
86
118
  let cleanupFailed = false;
87
119
 
88
120
  await Promise.all(
89
- SUPPORTED_AGENTS.map(async (agent) => {
121
+ host.agents.map(async (agent) => {
90
122
  const ids = idsByAgent.get(agent);
91
123
  if (!ids || ids.size === 0) return;
92
124
  try {
93
- await getRegistry(agent).cleanupRollouts({
94
- cwd,
95
- sessionIds: Array.from(ids),
96
- });
125
+ await host.cleanupRollouts(agent, cwd, Array.from(ids));
97
126
  } catch (e) {
98
- logger.warn(
127
+ host.warn(
99
128
  { err: e, userId, topicName, agent, count: ids.size },
100
129
  "topic logs: agent cleanup failed",
101
130
  );
@@ -107,19 +136,6 @@ async function cleanupSessionRollouts(
107
136
  return !cleanupFailed;
108
137
  }
109
138
 
110
- /** Remove every provider rollout currently manifested by a topic log. */
111
- export async function cleanupTopicRollouts(opts: PurgeTopicLogsOptions): Promise<boolean> {
112
- return cleanupSessionRollouts(opts, readRawConversation(opts.userId, opts.topicName));
113
- }
114
-
115
- /** Remove rollout ids captured from a point-in-time conversation manifest. */
116
- export async function cleanupTopicRolloutsFromEntries(
117
- opts: PurgeTopicLogsOptions,
118
- entries: ConversationEntry[],
119
- ): Promise<boolean> {
120
- return cleanupSessionRollouts(opts, entries);
121
- }
122
-
123
139
  export interface RotateTopicLogsOptions extends PurgeTopicLogsOptions {
124
140
  /** Number of complete user turns carried into the replacement session. */
125
141
  retainTurns: number;
@@ -132,116 +148,138 @@ export interface RotateTopicLogsResult {
132
148
  retainedEntries: number;
133
149
  }
134
150
 
135
- /**
136
- * Replace a topic's native provider sessions while retaining a bounded tail
137
- * of its provider-neutral conversation log. This is used by long-lived Cron
138
- * topics: a fresh native rollout keeps prompt/session state bounded, and the
139
- * retained tail gives the next rollout enough context to continue naturally.
140
- */
141
- export async function rotateTopicLogs(
142
- opts: RotateTopicLogsOptions,
143
- ): Promise<RotateTopicLogsResult> {
144
- const retainTurns = Math.max(0, Math.floor(opts.retainTurns));
145
- const entries = readConversation(opts.userId, opts.topicName);
146
- const userEntryIndexes = entries.flatMap((entry, index) =>
147
- entry.event.type === "user_message" ? [index] : [],
148
- );
149
- const totalTurns = userEntryIndexes.length;
150
- const firstRetainedIndex =
151
- retainTurns === 0
152
- ? entries.length
153
- : (userEntryIndexes[Math.max(0, totalTurns - retainTurns)] ?? 0);
154
- // Native session ids point at rollouts that are removed below. Keeping
155
- // those manifest entries would make later cleanup treat deleted files as
156
- // live context, so only conversational events cross the rotation boundary.
157
- const retained = entries
158
- .slice(firstRetainedIndex)
159
- .filter((entry) => entry.event.type !== "session");
160
-
161
- if (!(await cleanupSessionRollouts(opts, entries))) {
162
- logger.warn(
163
- { userId: opts.userId, topicName: opts.topicName },
164
- "rotateTopicLogs: keeping current context because rollout cleanup failed",
165
- );
166
- return {
167
- rotated: false,
168
- totalTurns,
169
- retainedTurns: Math.min(totalTurns, retainTurns),
170
- retainedEntries: retained.length,
171
- };
172
- }
151
+ export function createTopicLogMaintenance(host: TopicLogMaintenanceHost): TopicLogMaintenance {
152
+ const runtimeHost: TopicLogMaintenanceHost = Object.freeze({
153
+ ...host,
154
+ agents: Object.freeze([...host.agents]),
155
+ });
156
+ const cleanupFromEntries = (
157
+ opts: PurgeTopicLogsOptions,
158
+ entries: TopicConversationEntry[],
159
+ ): Promise<boolean> => cleanupSessionRollouts(runtimeHost, opts, entries);
173
160
 
174
- const path = getActiveConversationPath(opts.userId, opts.topicName);
175
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
176
- try {
177
- mkdirSync(dirname(path), { recursive: true });
178
- writeFileSync(
179
- tempPath,
180
- retained.length > 0 ? `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n` : "",
181
- { flag: "wx" },
182
- );
183
- renameSync(tempPath, path);
184
- } catch (e) {
185
- try {
186
- unlinkSync(tempPath);
187
- } catch {}
188
- logger.warn({ err: e, path }, "rotateTopicLogs: conversation replacement failed");
189
- return {
190
- rotated: false,
191
- totalTurns,
192
- retainedTurns: Math.min(totalTurns, retainTurns),
193
- retainedEntries: retained.length,
194
- };
195
- }
161
+ return Object.freeze({
162
+ cleanupTopicRollouts(opts: PurgeTopicLogsOptions) {
163
+ return cleanupFromEntries(opts, runtimeHost.readRawConversation(opts.userId, opts.topicName));
164
+ },
165
+ cleanupTopicRolloutsFromEntries: cleanupFromEntries,
166
+ async rotateTopicLogs(opts: RotateTopicLogsOptions) {
167
+ const retainTurns = Math.max(0, Math.floor(opts.retainTurns));
168
+ const entries = runtimeHost.readActiveConversation(opts.userId, opts.topicName);
169
+ const userEntryIndexes = entries.flatMap((entry, index) =>
170
+ entry.event.type === "user_message" ? [index] : [],
171
+ );
172
+ const totalTurns = userEntryIndexes.length;
173
+ const firstRetainedIndex =
174
+ retainTurns === 0
175
+ ? entries.length
176
+ : (userEntryIndexes[Math.max(0, totalTurns - retainTurns)] ?? 0);
177
+ const retained = entries
178
+ .slice(firstRetainedIndex)
179
+ .filter((entry) => entry.event.type !== "session");
180
+
181
+ if (!(await cleanupFromEntries(opts, entries))) {
182
+ runtimeHost.warn(
183
+ { userId: opts.userId, topicName: opts.topicName },
184
+ "rotateTopicLogs: keeping current context because rollout cleanup failed",
185
+ );
186
+ return {
187
+ rotated: false,
188
+ totalTurns,
189
+ retainedTurns: Math.min(totalTurns, retainTurns),
190
+ retainedEntries: retained.length,
191
+ };
192
+ }
193
+
194
+ const path = runtimeHost.activeConversationPath(opts.userId, opts.topicName);
195
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
196
+ try {
197
+ mkdirSync(dirname(path), { recursive: true });
198
+ writeFileSync(
199
+ tempPath,
200
+ retained.length > 0
201
+ ? `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n`
202
+ : "",
203
+ { flag: "wx" },
204
+ );
205
+ renameSync(tempPath, path);
206
+ } catch (e) {
207
+ try {
208
+ unlinkSync(tempPath);
209
+ } catch {}
210
+ runtimeHost.warn({ err: e, path }, "rotateTopicLogs: conversation replacement failed");
211
+ return {
212
+ rotated: false,
213
+ totalTurns,
214
+ retainedTurns: Math.min(totalTurns, retainTurns),
215
+ retainedEntries: retained.length,
216
+ };
217
+ }
218
+
219
+ return {
220
+ rotated: true,
221
+ totalTurns,
222
+ retainedTurns: Math.min(totalTurns, retainTurns),
223
+ retainedEntries: retained.length,
224
+ };
225
+ },
226
+ async purgeTopicLogs(opts: PurgeTopicLogsOptions) {
227
+ const { userId, topicName } = opts;
228
+ const entries = runtimeHost.readRawConversation(userId, topicName);
229
+ if (!(await cleanupFromEntries(opts, entries))) {
230
+ runtimeHost.warn(
231
+ { userId, topicName },
232
+ "purgeTopicLogs: keeping unified log because one or more rollout cleanups failed",
233
+ );
234
+ return false;
235
+ }
196
236
 
197
- return {
198
- rotated: true,
199
- totalTurns,
200
- retainedTurns: Math.min(totalTurns, retainTurns),
201
- retainedEntries: retained.length,
202
- };
237
+ let unlinkFailed = false;
238
+ for (const path of [
239
+ runtimeHost.activeConversationPath(userId, topicName),
240
+ runtimeHost.rawConversationPath(userId, topicName),
241
+ ]) {
242
+ try {
243
+ unlinkSync(path);
244
+ } catch (e) {
245
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
246
+ runtimeHost.warn({ err: e, path }, "purgeTopicLogs: conversation log unlink failed");
247
+ unlinkFailed = true;
248
+ }
249
+ }
250
+ }
251
+ return !unlinkFailed;
252
+ },
253
+ });
203
254
  }
204
255
 
256
+ const defaultTopicLogMaintenance = createTopicLogMaintenance({
257
+ agents: SUPPORTED_AGENTS,
258
+ readActiveConversation: readConversation,
259
+ readRawConversation,
260
+ activeConversationPath: getActiveConversationPath,
261
+ rawConversationPath: getConversationPath,
262
+ async cleanupRollouts(agent, cwd, sessionIds) {
263
+ await getRegistry(agent).cleanupRollouts({ cwd, sessionIds });
264
+ },
265
+ warn: logger.warn.bind(logger),
266
+ });
267
+
268
+ /** Remove every provider rollout currently manifested by a topic log. */
269
+ export const cleanupTopicRollouts = defaultTopicLogMaintenance.cleanupTopicRollouts;
270
+
271
+ /** Remove rollout ids captured from a point-in-time conversation manifest. */
272
+ export const cleanupTopicRolloutsFromEntries =
273
+ defaultTopicLogMaintenance.cleanupTopicRolloutsFromEntries;
274
+
205
275
  /**
206
- * Best-effort teardown of every artifact associated with a topic's
207
- * conversation lifecycle. Order matters and is intentional:
208
- *
209
- * 1. Read the unified log first — it's the authoritative manifest of
210
- * every SDK session id the topic has emitted. Merge any DB session id
211
- * passed via `extraSessions` for synthetic rollouts not yet emitted.
212
- * 2. Dispatch per-agent rollout cleanup in parallel (independent
213
- * filesystems, no shared state).
214
- * 3. Unlink the unified log LAST so a partial rollout-cleanup failure
215
- * leaves the manifest in place for a future retry.
216
- *
217
- * Errors at every step are logged and reported through the return value.
218
- * Callers must not commit a reset/delete state transition when this returns
219
- * false, because the retained manifest can otherwise resurrect old context.
276
+ * Replace a topic's native provider sessions while retaining a bounded tail
277
+ * of its provider-neutral conversation log.
220
278
  */
221
- export async function purgeTopicLogs(opts: PurgeTopicLogsOptions): Promise<boolean> {
222
- const { userId, topicName } = opts;
223
- const entries = readRawConversation(userId, topicName);
224
- if (!(await cleanupSessionRollouts(opts, entries))) {
225
- logger.warn(
226
- { userId, topicName },
227
- "purgeTopicLogs: keeping unified log because one or more rollout cleanups failed",
228
- );
229
- return false;
230
- }
279
+ export const rotateTopicLogs = defaultTopicLogMaintenance.rotateTopicLogs;
231
280
 
232
- let unlinkFailed = false;
233
- for (const path of [
234
- getActiveConversationPath(userId, topicName),
235
- getConversationPath(userId, topicName),
236
- ]) {
237
- try {
238
- unlinkSync(path);
239
- } catch (e) {
240
- if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
241
- logger.warn({ err: e, path }, "purgeTopicLogs: conversation log unlink failed");
242
- unlinkFailed = true;
243
- }
244
- }
245
- }
246
- return !unlinkFailed;
247
- }
281
+ /**
282
+ * Best-effort teardown of every artifact associated with a topic's
283
+ * conversation lifecycle.
284
+ */
285
+ export const purgeTopicLogs = defaultTopicLogMaintenance.purgeTopicLogs;
@@ -197,7 +197,10 @@ export {
197
197
  runNodeRequestHandlers,
198
198
  unregisterNodeRequestHandler,
199
199
  } from "#platform/node-plugins";
200
- export { killAllPlaywright } from "#platform/playwright/manager";
200
+ export {
201
+ killAllPlaywright,
202
+ reapPlaywrightOrphans as reapOrphanBrowsers,
203
+ } from "#platform/playwright/manager";
201
204
  // ── Query control ───────────────────────────────────────────────────
202
205
  export {
203
206
  abortAllRooms,
@@ -3,6 +3,15 @@ export {
3
3
  type SessionCommContext,
4
4
  type SessionCommContextDefaults,
5
5
  } from "../session-comm/context";
6
+ export {
7
+ createSessionTargetCatalog,
8
+ type SessionTarget,
9
+ type SessionTargetCatalog,
10
+ type SessionTargetCatalogHost,
11
+ type SessionTopicEntry,
12
+ type SessionTopicRow,
13
+ type ValidateSessionTargetResult,
14
+ } from "../session-comm/topic-catalog";
6
15
  export { executeVaultHttpRequest } from "../vault-http";
7
16
  export { executeVaultRun } from "../vault-run";
8
17
  export {
@@ -0,0 +1,132 @@
1
+ export interface SessionTopicRow {
2
+ id: string;
3
+ title: string;
4
+ kind: string | null;
5
+ agent: string | null;
6
+ sessionId: string | null;
7
+ description: string | null;
8
+ }
9
+
10
+ export interface SessionTopicEntry<TAgent extends string = string> {
11
+ sessionId: string;
12
+ messageThreadId: number;
13
+ name: string;
14
+ kind: "agent" | "channel";
15
+ description?: string;
16
+ topicId?: string;
17
+ agent?: TAgent;
18
+ }
19
+
20
+ export interface SessionTarget<TAgent extends string = string> {
21
+ key: string;
22
+ topic: SessionTopicEntry<TAgent>;
23
+ }
24
+
25
+ export type ValidateSessionTargetResult<TAgent extends string = string> =
26
+ | { ok: true; target: SessionTopicEntry<TAgent> }
27
+ | {
28
+ ok: false;
29
+ error: {
30
+ content: Array<{ type: "text"; text: string }>;
31
+ isError: true;
32
+ };
33
+ };
34
+
35
+ export interface SessionTargetCatalogHost<TAgent extends string = string> {
36
+ readonly listRows: () => SessionTopicRow[];
37
+ readonly currentTopicId?: string;
38
+ readonly currentTopicName?: string;
39
+ readonly isAgent: (value: string | null) => value is TAgent;
40
+ }
41
+
42
+ export interface SessionTargetCatalog<TAgent extends string = string> {
43
+ listTargets(): SessionTarget<TAgent>[];
44
+ getTopics(): Record<string, SessionTopicEntry<TAgent>>;
45
+ validateTarget(to: string): ValidateSessionTargetResult<TAgent>;
46
+ }
47
+
48
+ export function createSessionTargetCatalog<TAgent extends string = string>(
49
+ host: SessionTargetCatalogHost<TAgent>,
50
+ ): SessionTargetCatalog<TAgent> {
51
+ const { currentTopicId, currentTopicName, isAgent, listRows } = host;
52
+
53
+ function listTargets(): SessionTarget<TAgent>[] {
54
+ const eligibleRows = listRows().filter((row) => row.kind !== "manager");
55
+ const titleCounts = new Map<string, number>();
56
+ const qualifiedCounts = new Map<string, number>();
57
+ for (const row of eligibleRows) {
58
+ const normalized = row.title.toLowerCase();
59
+ titleCounts.set(normalized, (titleCounts.get(normalized) ?? 0) + 1);
60
+ const kind = row.kind === "agent" ? "agent" : "channel";
61
+ const qualified = `${kind}:${normalized}`;
62
+ qualifiedCounts.set(qualified, (qualifiedCounts.get(qualified) ?? 0) + 1);
63
+ }
64
+
65
+ const rows = eligibleRows.filter((row) =>
66
+ currentTopicId ? row.id !== currentTopicId : row.title !== currentTopicName,
67
+ );
68
+ return rows.map((row) => {
69
+ const agent = isAgent(row.agent) ? row.agent : undefined;
70
+ const kind = row.kind === "agent" ? "agent" : "channel";
71
+ const topic: SessionTopicEntry<TAgent> = {
72
+ sessionId: row.sessionId ?? "",
73
+ messageThreadId: 0,
74
+ name: row.title,
75
+ kind,
76
+ topicId: row.id,
77
+ ...(agent && { agent }),
78
+ ...(row.description && { description: row.description }),
79
+ };
80
+ const collision = (titleCounts.get(row.title.toLowerCase()) ?? 0) > 1;
81
+ const qualified = `${kind}:${row.title}`;
82
+ const sameKindCollision =
83
+ (qualifiedCounts.get(`${kind}:${row.title.toLowerCase()}`) ?? 0) > 1;
84
+ return {
85
+ key: sameKindCollision ? `${qualified}:${row.id}` : collision ? qualified : row.title,
86
+ topic,
87
+ };
88
+ });
89
+ }
90
+
91
+ function getTopics(): Record<string, SessionTopicEntry<TAgent>> {
92
+ const result: Record<string, SessionTopicEntry<TAgent>> = {};
93
+ const targets = listTargets();
94
+ const qualifiedCounts = new Map<string, number>();
95
+ for (const { topic } of targets) {
96
+ const qualified = `${topic.kind}:${topic.name.toLowerCase()}`;
97
+ qualifiedCounts.set(qualified, (qualifiedCounts.get(qualified) ?? 0) + 1);
98
+ }
99
+ for (const { key, topic } of targets) {
100
+ const qualified = `${topic.kind}:${topic.name}`;
101
+ if ((qualifiedCounts.get(qualified.toLowerCase()) ?? 0) === 1) {
102
+ result[qualified] = topic;
103
+ }
104
+ result[key] = topic;
105
+ }
106
+ return result;
107
+ }
108
+
109
+ function validateTarget(to: string): ValidateSessionTargetResult<TAgent> {
110
+ const topics = getTopics();
111
+ const target = topics[to];
112
+ if (target) return { ok: true, target };
113
+
114
+ const available = listTargets()
115
+ .filter(({ topic }) => Boolean(topic.agent))
116
+ .map(({ key }) => key);
117
+ return {
118
+ ok: false,
119
+ error: {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: `Error: Session "${to}" not found.\nAvailable: ${available.join(", ") || "none"}`,
124
+ },
125
+ ],
126
+ isError: true,
127
+ },
128
+ };
129
+ }
130
+
131
+ return Object.freeze({ listTargets, getTopics, validateTarget });
132
+ }
@@ -22,6 +22,10 @@ import {
22
22
  userId,
23
23
  withDb,
24
24
  } from "./runtime";
25
+ import {
26
+ type SessionTarget as CatalogSessionTarget,
27
+ createSessionTargetCatalog,
28
+ } from "./topic-catalog";
25
29
 
26
30
  export type { QueryState };
27
31
 
@@ -39,10 +43,7 @@ export interface TopicEntry {
39
43
  agent?: AgentKind;
40
44
  }
41
45
 
42
- export interface SessionTarget {
43
- key: string;
44
- topic: TopicEntry;
45
- }
46
+ export type SessionTarget = CatalogSessionTarget<AgentKind>;
46
47
 
47
48
  export interface McpTopicEntry {
48
49
  sessionId: string;
@@ -67,29 +68,7 @@ export type ValidateTargetResult =
67
68
  // --- Queries ---
68
69
 
69
70
  export function validateTarget(to: string): ValidateTargetResult {
70
- const topics = getTopicsForUser();
71
- const target = topics[to];
72
- if (!target) {
73
- const available = Object.entries(topics)
74
- .filter(([name, topic]) => name !== currentTopic && Boolean(topic.agent))
75
- .map(([name]) => name);
76
- return {
77
- ok: false,
78
- error: {
79
- content: [
80
- {
81
- type: "text" as const,
82
- text: `Error: Session "${to}" not found.\nAvailable: ${available.join(", ") || "none"}`,
83
- },
84
- ],
85
- isError: true,
86
- },
87
- };
88
- }
89
- // Topics without an active session (sessionId === "") are still valid
90
- // targets — ask_session / tell_session deliveries will wake them with a
91
- // fresh session via the session-inbox worker. See `session-inbox.ts`.
92
- return { ok: true, target };
71
+ return sessionTargetCatalog.validateTarget(to);
93
72
  }
94
73
 
95
74
  function sessionTargetRows(): Array<{
@@ -130,42 +109,29 @@ function sessionTargetRows(): Array<{
130
109
 
131
110
  /** Canonical, deduplicated targets for display and status inspection. */
132
111
  export function listSessionTargetsForUser(): SessionTarget[] {
133
- const eligibleRows = sessionTargetRows().filter((row) => row.kind !== "manager");
134
- const titleCounts = new Map<string, number>();
135
- for (const row of eligibleRows) {
136
- const normalized = row.title.toLowerCase();
137
- titleCounts.set(normalized, (titleCounts.get(normalized) ?? 0) + 1);
138
- }
139
- const rows = eligibleRows.filter((row) =>
140
- currentTopicId ? row.id !== currentTopicId : row.title !== currentTopic,
141
- );
142
- return rows.map((row) => {
143
- const agent = isAgentKind(row.agent) ? row.agent : undefined;
144
- const kind = row.kind === "agent" ? "agent" : "channel";
145
- const topic: TopicEntry = {
146
- sessionId: row.session_id ?? "",
147
- messageThreadId: 0,
148
- name: row.title,
149
- kind,
150
- topicId: row.id,
151
- ...(agent && { agent }),
152
- ...(row.description && { description: row.description }),
153
- };
154
- const collision = (titleCounts.get(row.title.toLowerCase()) ?? 0) > 1;
155
- return { key: collision ? `${kind}:${row.title}` : row.title, topic };
156
- });
112
+ return sessionTargetCatalog.listTargets();
157
113
  }
158
114
 
159
115
  /** Lookup index with qualified aliases for every target and plain aliases when unambiguous. */
160
116
  export function getTopicsForUser(): { [name: string]: TopicEntry } {
161
- const result: { [name: string]: TopicEntry } = {};
162
- for (const { key, topic } of listSessionTargetsForUser()) {
163
- result[`${topic.kind}:${topic.name}`] = topic;
164
- result[key] = topic;
165
- }
166
- return result;
117
+ return sessionTargetCatalog.getTopics();
167
118
  }
168
119
 
120
+ const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
121
+ currentTopicId,
122
+ currentTopicName: currentTopic,
123
+ isAgent: isAgentKind,
124
+ listRows: () =>
125
+ sessionTargetRows().map((row) => ({
126
+ id: row.id,
127
+ title: row.title,
128
+ kind: row.kind,
129
+ agent: row.agent,
130
+ sessionId: row.session_id,
131
+ description: row.description,
132
+ })),
133
+ });
134
+
169
135
  export function getMcpUserConfig(): McpUserConfig | null {
170
136
  if (!existsSync(SESSIONS_DB)) return null;
171
137
  try {
@@ -1,4 +1,3 @@
1
- import { createHmac } from "node:crypto";
2
1
  import { canonicalMcpBridgeEnv } from "#mcp/canonical-bridge-config";
3
2
  import { buildRuntimeMcpSpec, RUNTIME_MCP_KEY } from "#mcp/runtime-spec";
4
3
  import { peerSessionBridgeIpcEnv } from "#mcp/session-comm/bridge-ipc-config";
@@ -24,6 +23,7 @@ import {
24
23
  commonRuntimeMcpPolicy,
25
24
  type RuntimeMcpScope,
26
25
  } from "#platform/mcp-catalog-policy";
26
+ import { browserOwnerCapability } from "#platform/playwright/capability";
27
27
  import type { AgentKind, AgentQueryOptions, PeerRuntimeBridgeContext } from "#types";
28
28
 
29
29
  export type { RuntimeMcpScope } from "#platform/mcp-catalog-policy";
@@ -216,9 +216,7 @@ export interface RuntimeMcpCatalogEntry {
216
216
 
217
217
  export const CODEX_BROWSER_CAPABILITY_ENV = "NEGOTIUM_BROWSER_CAPABILITY";
218
218
 
219
- export function browserOwnerCapability(capability: string, owner: string): string {
220
- return createHmac("sha256", capability).update(owner).digest("hex");
221
- }
219
+ export { browserOwnerCapability };
222
220
 
223
221
  export function browserOwnerForContext(ctx: {
224
222
  userId?: string;
@@ -145,7 +145,8 @@ export function cleanupZombiePlaywright(): void {
145
145
  */
146
146
  export function killBrowserProcsForUserDataDir(userDataDir: string): void {
147
147
  const target = resolve(userDataDir);
148
- if (!target.startsWith(resolve(BROWSER_PROFILES_DIR))) return;
148
+ const profileRoot = resolve(BROWSER_PROFILES_DIR);
149
+ if (target !== profileRoot && !target.startsWith(`${profileRoot}${sep}`)) return;
149
150
  let pids: string;
150
151
  try {
151
152
  pids = execFileSync("pgrep", ["-f", "--", userDataDir], { stdio: "pipe" }).toString().trim();
@@ -194,7 +195,7 @@ export function selectOrphanBrowserPids(
194
195
  for (const { pid, userDataDir } of procs) {
195
196
  if (pid === selfPid || !userDataDir) continue;
196
197
  const dir = resolve(userDataDir);
197
- if (!dir.startsWith(root)) continue; // never touch Chrome outside our dir
198
+ if (dir !== root && !dir.startsWith(`${root}${sep}`)) continue;
198
199
  if (live.has(dir)) continue; // belongs to a tracked instance
199
200
  out.push(pid);
200
201
  }
@@ -0,0 +1,5 @@
1
+ import { createHmac } from "node:crypto";
2
+
3
+ export function browserOwnerCapability(capability: string, owner: string): string {
4
+ return createHmac("sha256", capability).update(owner).digest("hex");
5
+ }