negotium 0.2.2 → 0.2.4

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 (70) hide show
  1. package/dist/agent-helpers.js +3117 -2915
  2. package/dist/agent-helpers.js.map +38 -37
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js +137 -21
  5. package/dist/browser-runtime.js.map +10 -9
  6. package/dist/{chunk-kc5kmr95.js → chunk-1s9ryz8g.js} +1195 -21
  7. package/dist/chunk-1s9ryz8g.js.map +35 -0
  8. package/dist/hosted-agent.js +60 -48
  9. package/dist/hosted-agent.js.map +10 -10
  10. package/dist/main.js +3038 -3357
  11. package/dist/main.js.map +35 -35
  12. package/dist/mcp-factories.js +6227 -6021
  13. package/dist/mcp-factories.js.map +40 -39
  14. package/dist/prompts.js.map +1 -1
  15. package/dist/query-runtime.js +22 -2
  16. package/dist/query-runtime.js.map +4 -4
  17. package/dist/registry.js +169 -213
  18. package/dist/registry.js.map +8 -9
  19. package/dist/rollout.js +1 -1
  20. package/dist/runtime/src/agents/codex-app-server.ts +136 -0
  21. package/dist/runtime/src/agents/codex-native-multi-agent.ts +1 -1
  22. package/dist/runtime/src/agents/codex-provider.ts +21 -11
  23. package/dist/runtime/src/agents/codex-registry.ts +9 -17
  24. package/dist/runtime/src/agents/contracts.ts +2 -0
  25. package/dist/runtime/src/agents/execution-host.ts +6 -0
  26. package/dist/runtime/src/agents/fork.ts +24 -18
  27. package/dist/runtime/src/agents/idle-archiver.ts +7 -2
  28. package/dist/runtime/src/agents/index.ts +2 -1
  29. package/dist/runtime/src/agents/maestro-registry.ts +26 -7
  30. package/dist/runtime/src/agents/memory-archive-policy.ts +20 -0
  31. package/dist/runtime/src/agents/rollout/codex.ts +6 -5
  32. package/dist/runtime/src/agents/rollout/shared.ts +3 -5
  33. package/dist/runtime/src/mcp/factories/session-comm.ts +12 -0
  34. package/dist/runtime/src/mcp/session-comm/default-host.ts +16 -0
  35. package/dist/runtime/src/mcp/session-comm/server.ts +25 -0
  36. package/dist/runtime/src/platform/config.ts +4 -1
  37. package/dist/runtime/src/platform/playwright/manager.ts +57 -1
  38. package/dist/runtime/src/platform/playwright/profile-management.ts +33 -0
  39. package/dist/runtime/src/platform/playwright/public-runtime.ts +5 -0
  40. package/dist/runtime/src/platform/playwright/vault-broker.ts +9 -10
  41. package/dist/runtime/src/storage/browser-profiles.ts +35 -10
  42. package/dist/runtime/src/storage/storage-host.ts +31 -5
  43. package/dist/runtime/src/storage/vault.ts +30 -2
  44. package/dist/runtime/src/topics/derive.ts +38 -2
  45. package/dist/runtime/src/topics/lifecycle.ts +12 -2
  46. package/dist/runtime/src/version.ts +1 -1
  47. package/dist/runtime-helpers.js +22 -2
  48. package/dist/runtime-helpers.js.map +4 -4
  49. package/dist/storage.js +22 -2
  50. package/dist/storage.js.map +4 -4
  51. package/dist/types/packages/core/src/agents/codex-app-server.d.ts +13 -0
  52. package/dist/types/packages/core/src/agents/codex-native-multi-agent.d.ts +1 -0
  53. package/dist/types/packages/core/src/agents/contracts.d.ts +2 -0
  54. package/dist/types/packages/core/src/agents/execution-host.d.ts +2 -0
  55. package/dist/types/packages/core/src/agents/fork.d.ts +8 -7
  56. package/dist/types/packages/core/src/agents/memory-archive-policy.d.ts +10 -0
  57. package/dist/types/packages/core/src/agents/rollout/codex.d.ts +4 -2
  58. package/dist/types/packages/core/src/mcp/factories/session-comm.d.ts +1 -0
  59. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +15 -0
  60. package/dist/types/packages/core/src/platform/playwright/profile-management.d.ts +8 -0
  61. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +2 -1
  62. package/dist/types/packages/core/src/storage/browser-profiles.d.ts +3 -1
  63. package/dist/types/packages/core/src/storage/storage-host.d.ts +1 -0
  64. package/dist/types/packages/core/src/storage/vault.d.ts +8 -0
  65. package/dist/types/packages/core/src/version.d.ts +1 -1
  66. package/dist/vault.js +19 -3
  67. package/dist/vault.js.map +4 -4
  68. package/package.json +2 -2
  69. package/dist/chunk-kc5kmr95.js.map +0 -19
  70. package/dist/runtime/src/migration/single-user.ts +0 -805
@@ -1,4 +1,5 @@
1
1
  import { existsSync, unlinkSync } from "node:fs";
2
+ import type { ForkRegistryResult } from "#agents/contracts";
2
3
  import { getRegistryOperations } from "#agents/registry";
3
4
  import { logger } from "#platform/logger";
4
5
  import type { AgentKind, EffortLevel } from "#types";
@@ -7,9 +8,10 @@ import type { AgentKind, EffortLevel } from "#types";
7
8
  * Agent-aware fork primitives.
8
9
  *
9
10
  * Strategy lives in each agent's registry (`*-registry.ts:forkSession`).
10
- * Claude uses the SDK's native `forkSession` (byte-equivalent rollout copy);
11
- * Codex synthesizes a rollout from the provider-agnostic conversation log
12
- * because the SDK has no fork API. Both surfaces converge on `ForkHandle`.
11
+ * Claude uses the SDK's native `forkSession`, Codex uses App Server's native
12
+ * `thread/fork`, and Maestro uses the SDK's `forkSessionAt`. Providers without
13
+ * a native branch primitive synthesize from the provider-agnostic conversation
14
+ * log. All surfaces converge on ForkHandle.
13
15
  */
14
16
 
15
17
  export interface ForkAgentSessionOptions {
@@ -35,13 +37,15 @@ export interface ForkHandle {
35
37
  forkId: string;
36
38
  /** Absolute path of the rollout file to remove on cleanup. */
37
39
  rolloutPath: string;
40
+ /** Additional provider-owned files removed with the primary rollout. */
41
+ cleanupPaths?: string[];
38
42
  }
39
43
 
40
44
  export interface AgentForkHost {
41
45
  forkSession(
42
46
  agent: AgentKind,
43
47
  options: Omit<ForkAgentSessionOptions, "agent">,
44
- ): Promise<{ forkId: string; rolloutPath: string }>;
48
+ ): Promise<ForkRegistryResult>;
45
49
  exists(path: string): boolean;
46
50
  unlink(path: string): void;
47
51
  warn(details: Record<string, unknown>, message: string): void;
@@ -56,22 +60,24 @@ export function createAgentForkHelpers(host: AgentForkHost): AgentForkHelpers {
56
60
  return {
57
61
  async forkAgentSession(options) {
58
62
  const { agent, ...forkOptions } = options;
59
- const { forkId, rolloutPath } = await host.forkSession(agent, forkOptions);
60
- return { agent, forkId, rolloutPath };
63
+ const { forkId, rolloutPath, cleanupPaths } = await host.forkSession(agent, forkOptions);
64
+ return { agent, forkId, rolloutPath, ...(cleanupPaths?.length ? { cleanupPaths } : {}) };
61
65
  },
62
66
  cleanupAgentFork(handle) {
63
- try {
64
- if (host.exists(handle.rolloutPath)) host.unlink(handle.rolloutPath);
65
- } catch (error) {
66
- host.warn(
67
- {
68
- error,
69
- agent: handle.agent,
70
- forkId: handle.forkId,
71
- rolloutPath: handle.rolloutPath,
72
- },
73
- "cleanupAgentFork: failed to remove rollout",
74
- );
67
+ for (const path of [handle.rolloutPath, ...(handle.cleanupPaths ?? [])]) {
68
+ try {
69
+ if (host.exists(path)) host.unlink(path);
70
+ } catch (error) {
71
+ host.warn(
72
+ {
73
+ error,
74
+ agent: handle.agent,
75
+ forkId: handle.forkId,
76
+ path,
77
+ },
78
+ "cleanupAgentFork: failed to remove rollout",
79
+ );
80
+ }
75
81
  }
76
82
  },
77
83
  };
@@ -1,5 +1,8 @@
1
1
  import { runArchiverTurn } from "#agents/archiver";
2
- import { countMemoryArchiveExchanges } from "#agents/memory-archive-policy";
2
+ import {
3
+ countMemoryArchiveExchanges,
4
+ meetsMemoryArchiveExchangeThreshold,
5
+ } from "#agents/memory-archive-policy";
3
6
  import { logger } from "#platform/logger";
4
7
  import { getRoomQuery } from "#query/active-rooms";
5
8
  import { getMessagesForTopicAfterRowid } from "#storage/api-messages";
@@ -141,7 +144,9 @@ export function archiveActiveTopicForMemory(
141
144
  return null;
142
145
  }
143
146
  const exchangeCount = countMemoryArchiveExchanges(pending);
144
- skipMemoryTurn = options.minExchanges !== undefined && exchangeCount < options.minExchanges;
147
+ skipMemoryTurn =
148
+ options.minExchanges !== undefined &&
149
+ !meetsMemoryArchiveExchangeThreshold(exchangeCount, options.minExchanges, topic);
145
150
  const archived = (options.archiveMessages ?? archiveTopicMessages)(topicId, topic.title, {
146
151
  afterRowid,
147
152
  reason: options.reason,
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { hostedCodexHomePath } from "#agents/execution-host";
4
5
  import { getRegistryOperations } from "#agents/registry";
5
6
  import { encodeClaudeCwd } from "#agents/rollout/claude";
6
7
  import { resolveTaskEventScope, withTaskSnapshots } from "#agents/task-events";
@@ -108,7 +109,7 @@ export async function resolveSessionFileMissing(
108
109
  return !existsSync(path);
109
110
  }
110
111
  case "codex": {
111
- const sessionsDir = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
112
+ const sessionsDir = join(hostedCodexHomePath(), "sessions");
112
113
  const glob = new Bun.Glob(`**/rollout-*-${sessionId}.jsonl`);
113
114
  for await (const _rel of glob.scan({ cwd: sessionsDir, onlyFiles: true })) {
114
115
  return false; // found at least one match → session exists
@@ -5,7 +5,6 @@ import { join, resolve } from "node:path";
5
5
  import type { AgentRegistry, AgentRegistryOperations } from "#agents/contracts";
6
6
  import { assertUuidLike, ensureCwdExists, extractChatPairs } from "#agents/rollout/shared";
7
7
  import { logger } from "#platform/logger";
8
- import { readConversation } from "#storage/conversations";
9
8
  import { MAESTRO_EFFORT_VALUES } from "#types";
10
9
 
11
10
  const ALIAS_MAP: Record<string, string> = {
@@ -80,7 +79,7 @@ function writeRollout(options: Parameters<AgentRegistryOperations["writeRollout"
80
79
  version: 1,
81
80
  cwd: options.cwd,
82
81
  createdAt: existingCreatedAt(path) ?? new Date().toISOString(),
83
- sdkVersion: "0.1.54",
82
+ sdkVersion: "0.2.0",
84
83
  },
85
84
  },
86
85
  ...messages,
@@ -96,13 +95,33 @@ export const maestroRegistryOperations: AgentRegistryOperations = {
96
95
  return writeRollout(options);
97
96
  },
98
97
  async forkSession(options) {
99
- const rollout = writeRollout({
98
+ // Fork the full raw history while preserving the compacted working view.
99
+ // A compacted parent that cannot copy its active projection is not a usable
100
+ // cache-preserving fork, so let the caller fall back to bounded synthesis.
101
+ const { deleteMaestroSession, forkSessionAt, loadRawMaestroSession } = await import(
102
+ "maestro-agent-sdk"
103
+ );
104
+ const parentMessages = loadRawMaestroSession(options.parentSessionId);
105
+ if (!parentMessages) {
106
+ throw new Error(`Maestro parent session not found: ${options.parentSessionId}`);
107
+ }
108
+ const parentHasActiveProjection = existsSync(maestroActiveSessionPath(options.parentSessionId));
109
+ const fork = forkSessionAt({
110
+ parentSessionId: options.parentSessionId,
111
+ messageIndex: parentMessages.length,
100
112
  cwd: options.cwd,
101
- entries: readConversation(options.userId, options.topicName),
102
- ...(options.model ? { model: options.model } : {}),
103
- ...(options.effort ? { effort: options.effort } : {}),
113
+ userId: String(options.userId),
104
114
  });
105
- return { forkId: rollout.sessionId, rolloutPath: rollout.rolloutPath };
115
+ if (parentHasActiveProjection && !fork.activeProjectionForked) {
116
+ deleteMaestroSession(fork.sessionId);
117
+ throw new Error(`Maestro active projection could not be forked: ${options.parentSessionId}`);
118
+ }
119
+ const activePath = maestroActiveSessionPath(fork.sessionId);
120
+ return {
121
+ forkId: fork.sessionId,
122
+ rolloutPath: fork.rolloutPath,
123
+ ...(existsSync(activePath) ? { cleanupPaths: [activePath] } : {}),
124
+ };
106
125
  },
107
126
  async cleanupRollouts(options) {
108
127
  // Keep the SDK off the daemon startup path, but use its canonical cleanup
@@ -12,6 +12,26 @@ export interface MemoryArchiveMessage {
12
12
  */
13
13
  export const MIN_MEMORY_ARCHIVE_EXCHANGES = 6;
14
14
 
15
+ export interface MemoryArchiveTopicContext {
16
+ isSubagent?: boolean;
17
+ memoryTopicId?: string;
18
+ }
19
+
20
+ /**
21
+ * Explicit-memory subagents are usually short, focused turns. Persist one
22
+ * completed exchange back to their selected memory topic, while retaining the
23
+ * normal threshold for regular topics and incomplete/failed turns.
24
+ */
25
+ export function meetsMemoryArchiveExchangeThreshold(
26
+ exchangeCount: number,
27
+ minExchanges: number,
28
+ topic: MemoryArchiveTopicContext,
29
+ ): boolean {
30
+ if (exchangeCount <= 0) return false;
31
+ if (topic.isSubagent && topic.memoryTopicId) return true;
32
+ return exchangeCount >= minExchanges;
33
+ }
34
+
15
35
  /** Count completed conversational exchanges while ignoring system/tool/card noise. */
16
36
  export function countMemoryArchiveExchanges(rows: readonly MemoryArchiveMessage[]): number {
17
37
  let waitingForAssistant = false;
@@ -10,14 +10,15 @@
10
10
  * synthesized `response_item`/`event_msg` pairs per turn.
11
11
  *
12
12
  * Tool history is folded into assistant text as `[Tool: ...]` annotations
13
- * (see shared.ts:extractChatPairs) the SDK has no fork API and synthetic
14
- * tool_use IDs across SDKs are too fragile to reconstruct structurally.
13
+ * (see shared.ts:extractChatPairs). Native same-agent forks use App Server's
14
+ * `thread/fork`; this encoder remains the fallback and cross-agent bridge,
15
+ * where synthetic tool_use IDs are too fragile to reconstruct structurally.
15
16
  */
16
17
 
17
18
  import { randomBytes } from "node:crypto";
18
19
  import { existsSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs";
19
- import { homedir } from "node:os";
20
20
  import { basename, dirname, join, resolve } from "node:path";
21
+ import { hostedCodexHomePath } from "#agents/execution-host";
21
22
  import {
22
23
  assertUuidLike,
23
24
  type ChatPair,
@@ -40,7 +41,7 @@ interface CodexShell {
40
41
  }
41
42
 
42
43
  function codexSessionsDir(): string {
43
- return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
44
+ return join(hostedCodexHomePath(), "sessions");
44
45
  }
45
46
 
46
47
  let _shellCache: CodexShell | null = null;
@@ -420,7 +421,7 @@ export function migrateCodexRolloutNativeMultiAgentMetadata(threadId: string): b
420
421
  }
421
422
  }
422
423
 
423
- function latestCodexRolloutPath(threadId: string): string | undefined {
424
+ export function latestCodexRolloutPath(threadId: string): string | undefined {
424
425
  const sessionsDir = codexSessionsDir();
425
426
  const candidates: string[] = [];
426
427
  const buckets = candidateDateBuckets(threadId);
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import { mkdirSync } from "node:fs";
11
- import { dirname, join, resolve } from "node:path";
12
- import { fileURLToPath } from "node:url";
11
+ import { resolve } from "node:path";
13
12
  import {
14
13
  DM_WORKSPACE_DIR,
14
+ PROJECT_ROOT,
15
15
  SESSION_WORKSPACE_DIR,
16
16
  TOPIC_WORKSPACE_DIR,
17
17
  WORKSPACE_DIR,
@@ -20,8 +20,6 @@ import { logger } from "#platform/logger";
20
20
  import { renderUserPromptBatch } from "#runtime/user-turn-envelope";
21
21
  import type { ConversationEntry } from "#storage/conversations";
22
22
 
23
- const __dirname = dirname(fileURLToPath(import.meta.url));
24
-
25
23
  export interface RolloutHostOptions {
26
24
  /** Absolute workspace roots under which synthetic provider sessions may be written. */
27
25
  workspaceRoots?: readonly string[];
@@ -69,7 +67,7 @@ export function configureRolloutHost(options: RolloutHostOptions): () => void {
69
67
  * non-conversational entries from the resulting rollout file, and update
70
68
  * `src/agents/fixtures/{codex-shell,claude-attachments}.jsonl`.
71
69
  */
72
- export let FIXTURES_DIR = join(__dirname, "..", "fixtures");
70
+ export let FIXTURES_DIR = resolve(PROJECT_ROOT, "src", "agents", "fixtures");
73
71
 
74
72
  /** Deep-clone to avoid mutating cached fixture objects. */
75
73
  export function clone<T>(obj: T): T {
@@ -22,6 +22,10 @@ export interface SessionCommMcpHost {
22
22
  context: SessionCommContext,
23
23
  profile: string,
24
24
  ): MaybePromise<SessionCommMcpResult>;
25
+ deleteBrowserProfile?(
26
+ context: SessionCommContext,
27
+ profile: string,
28
+ ): MaybePromise<SessionCommMcpResult>;
25
29
  peekSession(context: SessionCommContext): MaybePromise<SessionCommMcpResult>;
26
30
  setDescription(
27
31
  context: SessionCommContext,
@@ -90,6 +94,14 @@ export function createSessionCommMcpServer(
90
94
  async ({ profile }) => host.setBrowserProfile!(context, profile),
91
95
  );
92
96
  }
97
+ if (host.deleteBrowserProfile) {
98
+ server.tool(
99
+ "delete_browser_profile",
100
+ "Delete an unused named browser profile and its on-disk data. The default or an assigned profile cannot be deleted.",
101
+ { profile: z.string() },
102
+ async ({ profile }) => host.deleteBrowserProfile!(context, profile),
103
+ );
104
+ }
93
105
  server.tool(
94
106
  "peek_session",
95
107
  "Inspect running and idle sessions, including pending ask_session calls.",
@@ -11,6 +11,7 @@ import { ACTIVE_QUERY_STALE_MS, MAX_TELL_DEPTH, USERS_LOG_DIR } from "#platform/
11
11
  import { appendJsonlEntry, readJsonFile } from "#platform/jsonl";
12
12
  import { OPTIONAL_FORUM_MCP_SERVERS, REQUIRED_FORUM_MCP_SERVERS } from "#platform/mcp-config";
13
13
  import { closeBrowserOwnerTabs } from "#platform/playwright/manager";
14
+ import { deleteManagedBrowserProfile } from "#platform/playwright/profile-management";
14
15
  import { sessionInboxPath } from "#query/session-inbox-path";
15
16
  import { sanitizeId } from "#security/sanitize";
16
17
  import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
@@ -199,6 +200,21 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
199
200
  }
200
201
  },
201
202
 
203
+ async deleteBrowserProfile(context, profile) {
204
+ const topic = currentTopic(context);
205
+ if (!topic) return error("Error: No current API topic.");
206
+ if (!isTopicBrowserProfileOwner(topic.id, context.userId)) {
207
+ return error("Error: Only the topic owner can delete its browser profiles.");
208
+ }
209
+ try {
210
+ const ownerId = getBrowserProfileOwner(topic.id, context.userId);
211
+ const result = await deleteManagedBrowserProfile(ownerId, profile);
212
+ return ok(JSON.stringify(result, null, 2));
213
+ } catch (err) {
214
+ return error(`Error: ${err instanceof Error ? err.message : String(err)}`);
215
+ }
216
+ },
217
+
202
218
  peekSession(context) {
203
219
  const targets = targetCatalog(context).listTargets();
204
220
  const running: string[] = [];
@@ -12,6 +12,7 @@ import {
12
12
  REQUIRED_FORUM_MCP_SERVERS,
13
13
  } from "#platform/mcp-config";
14
14
  import { closeBrowserOwnerTabs } from "#platform/playwright/manager";
15
+ import { deleteManagedBrowserProfile } from "#platform/playwright/profile-management";
15
16
  import { sanitizeId } from "#security/sanitize";
16
17
  import { getTopic } from "#storage/api-topics";
17
18
  import {
@@ -318,6 +319,30 @@ server.tool(
318
319
  },
319
320
  );
320
321
 
322
+ server.tool(
323
+ "delete_browser_profile",
324
+ "Delete an unused named browser profile and its on-disk data. The default or an assigned profile cannot be deleted.",
325
+ {
326
+ profile: z
327
+ .string()
328
+ .describe("Unused named profile to delete; the default profile cannot be deleted"),
329
+ },
330
+ async ({ profile }) => {
331
+ const topicId = currentApiTopicId();
332
+ if (!topicId || !userId) return mcpError("Error: No current API topic.");
333
+ if (!isTopicBrowserProfileOwner(topicId, userId)) {
334
+ return mcpError("Error: Only the topic owner can delete its browser profiles.");
335
+ }
336
+ try {
337
+ const ownerId = getBrowserProfileOwner(topicId, userId);
338
+ const result = await deleteManagedBrowserProfile(ownerId, profile);
339
+ return mcpOk(JSON.stringify(result, null, 2));
340
+ } catch (err) {
341
+ return mcpError(`Error: ${errMsg(err)}`);
342
+ }
343
+ },
344
+ );
345
+
321
346
  // --- always-available session inspection / self-config ---
322
347
 
323
348
  server.tool(
@@ -453,7 +453,10 @@ export const MAX_TELL_DEPTH =
453
453
  /** Codex CLI auth file. 호출 시점에 env를 읽는다 — 테스트가 런타임에
454
454
  * NEGOTIUM_CODEX_AUTH_FILE을 바꾸므로 모듈 로드 상수로 만들면 안 된다. */
455
455
  export function codexAuthFilePath(): string {
456
- return process.env.NEGOTIUM_CODEX_AUTH_FILE || join(homedir(), ".codex", "auth.json");
456
+ return (
457
+ process.env.NEGOTIUM_CODEX_AUTH_FILE ||
458
+ join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json")
459
+ );
457
460
  }
458
461
 
459
462
  // System defaults moved to per-agent registries
@@ -84,10 +84,23 @@ export interface PlaywrightManagerHost {
84
84
  ) => NodeJS.ProcessEnv;
85
85
  /** Reap an orphan that owns this exact host-managed data directory. */
86
86
  readonly cleanupBrowserProcessesForDataDir: (userDataDir: string) => void;
87
+ /** Remove an exact host-managed profile directory, rejecting paths outside the host's root. */
88
+ readonly removeProfileDataDir?: (userDataDir: string) => boolean;
87
89
  /** Reap host-managed browsers that are not represented by the supplied live directories. */
88
90
  readonly reapOrphanBrowsers: (liveUserDataDirs: Iterable<string>) => void;
89
91
  }
90
92
 
93
+ function removeDefaultProfileDataDir(userDataDir: string): boolean {
94
+ const root = resolve(BROWSER_PROFILES_DIR);
95
+ const target = resolve(userDataDir);
96
+ if (dirname(target) !== root) {
97
+ throw new Error(`Refusing to delete browser profile outside managed root: ${target}`);
98
+ }
99
+ const existed = existsSync(target);
100
+ rmSync(target, { recursive: true, force: true });
101
+ return existed;
102
+ }
103
+
91
104
  export {
92
105
  cleanupZombiePlaywright,
93
106
  isBrowserJanitorOwner,
@@ -210,6 +223,7 @@ const defaultManagerHost: Readonly<PlaywrightManagerHost> = Object.freeze({
210
223
  },
211
224
  createChildEnvironment: defaultChildEnvironment,
212
225
  cleanupBrowserProcessesForDataDir: killBrowserProcsForUserDataDir,
226
+ removeProfileDataDir: removeDefaultProfileDataDir,
213
227
  reapOrphanBrowsers,
214
228
  });
215
229
 
@@ -239,10 +253,12 @@ export function configurePlaywrightManagerHost(
239
253
  next.resolveInstanceDataDir !== defaultManagerHost.resolveInstanceDataDir &&
240
254
  (next.cleanupBrowserProcessesForDataDir ===
241
255
  defaultManagerHost.cleanupBrowserProcessesForDataDir ||
256
+ !next.removeProfileDataDir ||
257
+ next.removeProfileDataDir === defaultManagerHost.removeProfileDataDir ||
242
258
  next.reapOrphanBrowsers === defaultManagerHost.reapOrphanBrowsers)
243
259
  ) {
244
260
  throw new Error(
245
- "custom Playwright profile paths require host crash cleanup and orphan sweep hooks",
261
+ "custom Playwright profile paths require host crash cleanup, profile removal, and orphan sweep hooks",
246
262
  );
247
263
  }
248
264
  managerHost = Object.freeze(next);
@@ -293,6 +309,7 @@ function deletePortFile(instanceKey: string) {
293
309
  try {
294
310
  unlinkSync(join(managerHost.portsDir, portFileName(instanceKey)));
295
311
  } catch (e) {
312
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
296
313
  logger.warn({ err: e, instanceKey }, "Failed to delete playwright port file");
297
314
  }
298
315
  }
@@ -433,6 +450,45 @@ export function stopPlaywrightProfile(ownerId: string, rawProfile: string): Prom
433
450
  );
434
451
  }
435
452
 
453
+ export interface RemovePlaywrightProfileDataResult {
454
+ dataDir: string;
455
+ processStopped: boolean;
456
+ dataRemoved: boolean;
457
+ }
458
+
459
+ export interface RemovePlaywrightProfileDataOptions {
460
+ /** Revalidate external ownership immediately before the synchronous removal. */
461
+ beforeRemove?: () => void;
462
+ /** Commit external metadata before releasing the profile lifecycle barrier. */
463
+ afterRemove?: () => void;
464
+ }
465
+
466
+ /** Stop all exact-profile processes and remove its data through the configured host boundary. */
467
+ export function removePlaywrightProfileData(
468
+ ownerId: string,
469
+ rawProfile: string,
470
+ options: RemovePlaywrightProfileDataOptions = {},
471
+ ): Promise<RemovePlaywrightProfileDataResult> {
472
+ return withPlaywrightProfileMaintenance(
473
+ ownerId,
474
+ rawProfile,
475
+ async (binding, { stopInstance }) => {
476
+ const dataDir = resolveUserDataDir(binding.instanceKey);
477
+ const processStopped = await stopInstance(binding.instanceKey);
478
+ managerHost.cleanupBrowserProcessesForDataDir(dataDir);
479
+ deletePortFile(binding.instanceKey);
480
+ options.beforeRemove?.();
481
+ const removeProfileDataDir = managerHost.removeProfileDataDir;
482
+ if (!removeProfileDataDir) {
483
+ throw new Error("Browser profile deletion is not configured for this host.");
484
+ }
485
+ const dataRemoved = removeProfileDataDir(dataDir);
486
+ options.afterRemove?.();
487
+ return { dataDir, processStopped, dataRemoved };
488
+ },
489
+ );
490
+ }
491
+
436
492
  // A shared profile may have several concurrent turns. Reference counts keep an
437
493
  // idle sweep from evicting the process until every borrower has finished.
438
494
  const pinnedInstances = new Map<string, number>();
@@ -0,0 +1,33 @@
1
+ import { removePlaywrightProfileData } from "#platform/playwright/manager";
2
+ import { assertBrowserProfileDeletable, deleteBrowserProfile } from "#storage/browser-profiles";
3
+
4
+ export interface DeleteManagedBrowserProfileResult {
5
+ profile: string;
6
+ metadataRemoved: boolean;
7
+ dataRemoved: boolean;
8
+ processStopped: boolean;
9
+ }
10
+
11
+ /** Delete an unused named profile while serialized against browser startup. */
12
+ export async function deleteManagedBrowserProfile(
13
+ ownerId: string,
14
+ rawProfile: string,
15
+ ): Promise<DeleteManagedBrowserProfileResult> {
16
+ const profile = assertBrowserProfileDeletable(ownerId, rawProfile);
17
+ let metadataRemoved = false;
18
+ const removed = await removePlaywrightProfileData(ownerId, profile, {
19
+ beforeRemove: () => {
20
+ assertBrowserProfileDeletable(ownerId, profile);
21
+ },
22
+ afterRemove: () => {
23
+ metadataRemoved = deleteBrowserProfile(ownerId, profile);
24
+ },
25
+ });
26
+
27
+ return {
28
+ profile,
29
+ metadataRemoved,
30
+ dataRemoved: removed.dataRemoved,
31
+ processStopped: removed.processStopped,
32
+ };
33
+ }
@@ -20,6 +20,7 @@ export {
20
20
  type PlaywrightProfileBinding,
21
21
  pinPlaywrightInstance,
22
22
  reapPlaywrightOrphans,
23
+ removePlaywrightProfileData,
23
24
  resetPlaywrightManagerHost,
24
25
  resolvePlaywrightCapabilityOwner,
25
26
  resolvePlaywrightProfileBinding,
@@ -39,6 +40,10 @@ export {
39
40
  waitForChildProcessExit,
40
41
  waitForChildProcessSpawnError,
41
42
  } from "./manager-utils";
43
+ export {
44
+ type DeleteManagedBrowserProfileResult,
45
+ deleteManagedBrowserProfile,
46
+ } from "./profile-management";
42
47
  export {
43
48
  probeMcpTransport,
44
49
  probePlaywrightMcpTransports,
@@ -3,7 +3,7 @@ import { chmodSync } from "node:fs";
3
3
  import { createServer, type Server, type Socket } from "node:net";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { normalizeVaultKey, redactVaultSecrets, vaultListWithValues } from "#storage/vault-public";
6
+ import { redactVaultSecrets, vaultGetValue, vaultSubstituteDetailed } from "#storage/vault-public";
7
7
 
8
8
  const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
9
9
  const DEFAULT_OUTPUT_LIMIT = 100_000;
@@ -158,16 +158,15 @@ export async function createBrowserVaultBroker(userId: string): Promise<BrowserV
158
158
  if (leases.size >= MAX_ACTIVE_LEASES) {
159
159
  return { id, ok: false, error: "too many active leases" };
160
160
  }
161
- const entries = new Map(vaultListWithValues(userId).map((entry) => [entry.key, entry.value]));
162
- const substituted = deepMapStrings(request.value, (text) =>
163
- text.replace(/\{\{([^}]+)\}\}/g, (match, rawKey: string) => {
164
- const key = normalizeVaultKey(rawKey);
165
- const secret = entries.get(key);
166
- if (secret === undefined) return match;
161
+ const substituted = deepMapStrings(request.value, (text) => {
162
+ const result = vaultSubstituteDetailed(userId, text);
163
+ for (const key of result.usedKeys) {
164
+ const secret = vaultGetValue(userId, key);
165
+ if (secret === undefined) continue;
167
166
  for (const form of encodedSecretForms(secret)) retainedForms.set(form, key);
168
- return secret;
169
- }),
170
- );
167
+ }
168
+ return result.text;
169
+ });
171
170
  const prepared = prepareInput(request.tool, substituted);
172
171
  const lease = randomBytes(24).toString("hex");
173
172
  leases.set(lease, { boundary: prepared.boundary, createdAt: Date.now() });
@@ -6,6 +6,7 @@ export interface BrowserProfileRecord {
6
6
  name: string;
7
7
  createdAt: string | null;
8
8
  topics: Array<{ id: string; title: string }>;
9
+ deletable: boolean;
9
10
  }
10
11
 
11
12
  export function normalizeBrowserProfileName(name: string): string {
@@ -103,14 +104,26 @@ export function listBrowserProfiles(ownerId: string): BrowserProfileRecord[] {
103
104
  name: DEFAULT_BROWSER_PROFILE,
104
105
  createdAt: null,
105
106
  topics: [],
107
+ deletable: false,
106
108
  });
107
109
  for (const row of records) {
108
- byName.set(row.name, { name: row.name, createdAt: row.created_at, topics: [] });
110
+ byName.set(row.name, {
111
+ name: row.name,
112
+ createdAt: row.created_at,
113
+ topics: [],
114
+ deletable: true,
115
+ });
109
116
  }
110
117
  for (const topic of topics) {
111
118
  const name = topic.browser_profile || DEFAULT_BROWSER_PROFILE;
112
- const record = byName.get(name) ?? { name, createdAt: null, topics: [] };
119
+ const record = byName.get(name) ?? {
120
+ name,
121
+ createdAt: null,
122
+ topics: [],
123
+ deletable: name !== DEFAULT_BROWSER_PROFILE,
124
+ };
113
125
  record.topics.push({ id: topic.id, title: topic.title });
126
+ record.deletable = false;
114
127
  byName.set(name, record);
115
128
  }
116
129
  return [...byName.values()].sort((a, b) => {
@@ -120,16 +133,28 @@ export function listBrowserProfiles(ownerId: string): BrowserProfileRecord[] {
120
133
  });
121
134
  }
122
135
 
123
- export function deleteBrowserProfile(ownerId: string, rawName: string): void {
136
+ export function assertBrowserProfileDeletable(ownerId: string, rawName: string): string {
124
137
  void ownerId;
125
138
  const name = normalizeBrowserProfileName(rawName);
126
139
  if (name === DEFAULT_BROWSER_PROFILE) throw new Error("The default profile cannot be deleted.");
127
- const used = db
128
- .query<{ count: number }, string>(
129
- `SELECT COUNT(*) AS count FROM api_topics t
130
- WHERE t.browser_profile = ?`,
140
+ const topics = db
141
+ .query<{ id: string; title: string }, string>(
142
+ `SELECT t.id, t.title FROM api_topics t
143
+ WHERE t.browser_profile = ?
144
+ ORDER BY t.title`,
131
145
  )
132
- .get(name)?.count;
133
- if (used) throw new Error(`Profile "${name}" is assigned to ${used} topic(s).`);
134
- db.query("DELETE FROM browser_profiles WHERE owner_id = ? AND name = ?").run("local", name);
146
+ .all(name);
147
+ if (topics.length > 0) {
148
+ const assigned = topics.map((topic) => `${topic.title} (${topic.id})`).join(", ");
149
+ throw new Error(`Profile "${name}" is assigned to ${topics.length} topic(s): ${assigned}.`);
150
+ }
151
+ return name;
152
+ }
153
+
154
+ export function deleteBrowserProfile(ownerId: string, rawName: string): boolean {
155
+ const name = assertBrowserProfileDeletable(ownerId, rawName);
156
+ const result = db
157
+ .query("DELETE FROM browser_profiles WHERE owner_id = ? AND name = ?")
158
+ .run("local", name);
159
+ return Number(result.changes ?? 0) > 0;
135
160
  }