negotium 0.2.3 → 0.2.5

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 (99) hide show
  1. package/dist/agent-helpers.js +3366 -3048
  2. package/dist/agent-helpers.js.map +49 -48
  3. package/dist/background-bash.js +1 -3
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +137 -23
  6. package/dist/browser-runtime.js.map +11 -10
  7. package/dist/chunk-qmbcbhyy.js +2407 -0
  8. package/dist/chunk-qmbcbhyy.js.map +35 -0
  9. package/dist/hosted-agent.js +56 -39
  10. package/dist/hosted-agent.js.map +13 -13
  11. package/dist/main.js +3276 -2808
  12. package/dist/main.js.map +54 -51
  13. package/dist/mcp-factories.js +6471 -6148
  14. package/dist/mcp-factories.js.map +49 -48
  15. package/dist/prompts.js +1 -3
  16. package/dist/prompts.js.map +4 -4
  17. package/dist/query-runtime.js +22 -4
  18. package/dist/query-runtime.js.map +6 -6
  19. package/dist/registry.js +169 -213
  20. package/dist/registry.js.map +8 -9
  21. package/dist/rollout.js +1 -1
  22. package/dist/runtime/src/agents/api-topic-agent-switch.ts +6 -12
  23. package/dist/runtime/src/agents/claude-provider.ts +2 -2
  24. package/dist/runtime/src/agents/codex-app-server.ts +136 -0
  25. package/dist/runtime/src/agents/codex-native-multi-agent.ts +1 -1
  26. package/dist/runtime/src/agents/codex-provider.ts +22 -11
  27. package/dist/runtime/src/agents/codex-registry.ts +9 -17
  28. package/dist/runtime/src/agents/contracts.ts +2 -0
  29. package/dist/runtime/src/agents/execution-host.ts +6 -0
  30. package/dist/runtime/src/agents/fork.ts +24 -18
  31. package/dist/runtime/src/agents/idle-archiver.ts +7 -2
  32. package/dist/runtime/src/agents/index.ts +21 -15
  33. package/dist/runtime/src/agents/maestro-provider.ts +1 -1
  34. package/dist/runtime/src/agents/maestro-registry.ts +26 -7
  35. package/dist/runtime/src/agents/memory-archive-policy.ts +20 -0
  36. package/dist/runtime/src/agents/rollout/codex.ts +31 -23
  37. package/dist/runtime/src/agents/self-config-core.ts +2 -4
  38. package/dist/runtime/src/agents/topic-agent-switch.ts +4 -10
  39. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +77 -19
  40. package/dist/runtime/src/application/switch-topic-model.ts +7 -10
  41. package/dist/runtime/src/index.ts +4 -0
  42. package/dist/runtime/src/mcp/factories/session-comm.ts +12 -0
  43. package/dist/runtime/src/mcp/session-comm/default-host.ts +16 -0
  44. package/dist/runtime/src/mcp/session-comm/server.ts +25 -0
  45. package/dist/runtime/src/mcp/wiki-server.ts +19 -12
  46. package/dist/runtime/src/platform/config.ts +4 -3
  47. package/dist/runtime/src/platform/log-rotation.ts +45 -0
  48. package/dist/runtime/src/platform/mcp-config.ts +9 -3
  49. package/dist/runtime/src/platform/playwright/manager.ts +57 -1
  50. package/dist/runtime/src/platform/playwright/profile-management.ts +33 -0
  51. package/dist/runtime/src/platform/playwright/public-runtime.ts +5 -0
  52. package/dist/runtime/src/platform/playwright/vault-broker.ts +9 -10
  53. package/dist/runtime/src/query/active-rooms.ts +7 -1
  54. package/dist/runtime/src/runtime/errors.ts +1 -0
  55. package/dist/runtime/src/runtime/turn-event-stream.ts +46 -1
  56. package/dist/runtime/src/runtime/turn-runner.ts +176 -100
  57. package/dist/runtime/src/runtime/user-turn-envelope.ts +19 -0
  58. package/dist/runtime/src/storage/browser-profiles.ts +35 -10
  59. package/dist/runtime/src/storage/runtime-gateway-submissions.ts +12 -3
  60. package/dist/runtime/src/storage/runtime-turn-requests.ts +68 -7
  61. package/dist/runtime/src/storage/storage-host.ts +31 -5
  62. package/dist/runtime/src/storage/vault.ts +30 -2
  63. package/dist/runtime/src/topics/derive.ts +38 -2
  64. package/dist/runtime/src/topics/lifecycle.ts +12 -2
  65. package/dist/runtime/src/types.ts +2 -0
  66. package/dist/runtime/src/version.ts +1 -1
  67. package/dist/runtime-helpers.js +22 -4
  68. package/dist/runtime-helpers.js.map +5 -5
  69. package/dist/storage.js +22 -2
  70. package/dist/storage.js.map +5 -5
  71. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +1 -1
  72. package/dist/types/packages/core/src/agents/codex-app-server.d.ts +13 -0
  73. package/dist/types/packages/core/src/agents/codex-native-multi-agent.d.ts +1 -0
  74. package/dist/types/packages/core/src/agents/contracts.d.ts +2 -0
  75. package/dist/types/packages/core/src/agents/execution-host.d.ts +2 -0
  76. package/dist/types/packages/core/src/agents/fork.d.ts +8 -7
  77. package/dist/types/packages/core/src/agents/memory-archive-policy.d.ts +10 -0
  78. package/dist/types/packages/core/src/agents/rollout/codex.d.ts +4 -2
  79. package/dist/types/packages/core/src/agents/self-config-core.d.ts +1 -1
  80. package/dist/types/packages/core/src/mcp/factories/session-comm.d.ts +1 -0
  81. package/dist/types/packages/core/src/platform/config.d.ts +0 -1
  82. package/dist/types/packages/core/src/platform/mcp-config.d.ts +3 -0
  83. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +15 -0
  84. package/dist/types/packages/core/src/platform/playwright/profile-management.d.ts +8 -0
  85. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +2 -1
  86. package/dist/types/packages/core/src/query/active-rooms.d.ts +7 -1
  87. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +42 -1
  88. package/dist/types/packages/core/src/runtime/user-turn-envelope.d.ts +7 -0
  89. package/dist/types/packages/core/src/storage/browser-profiles.d.ts +3 -1
  90. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +17 -0
  91. package/dist/types/packages/core/src/storage/storage-host.d.ts +1 -0
  92. package/dist/types/packages/core/src/storage/vault.d.ts +8 -0
  93. package/dist/types/packages/core/src/types.d.ts +2 -0
  94. package/dist/types/packages/core/src/version.d.ts +1 -1
  95. package/dist/vault.js +19 -5
  96. package/dist/vault.js.map +5 -5
  97. package/package.json +2 -2
  98. package/dist/chunk-hsqgb2hw.js +0 -1223
  99. package/dist/chunk-hsqgb2hw.js.map +0 -19
@@ -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(
@@ -439,23 +439,28 @@ function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
439
439
 
440
440
  const name = slugify(topicNameFrom(rawTopic));
441
441
 
442
- // Try per-topic archive dir first, then flat file
442
+ // Try per-topic archive dir first, then flat file. This is a read path —
443
+ // it must never create directories on lookup (that previously left an
444
+ // empty `wiki/archive/<topic>/` behind for every topic name ever queried,
445
+ // even when nothing was ever archived under the per-topic layout).
443
446
  const archiveDir = resolve(runtime().archiveDir, name);
444
- ensureDir(archiveDir);
445
447
 
446
448
  try {
447
449
  // List archive files sorted by name (which should be date-sorted)
448
- let files = readdirSync(archiveDir)
449
- .filter((f) => f.endsWith(".jsonl"))
450
- .sort()
451
- .reverse();
450
+ let files = existsSync(archiveDir)
451
+ ? readdirSync(archiveDir)
452
+ .filter((f) => f.endsWith(".jsonl"))
453
+ .sort()
454
+ .reverse()
455
+ : [];
452
456
  if (files.length === 0) {
453
457
  // Try flat archive
454
- ensureDir(runtime().archiveDir);
455
- files = readdirSync(runtime().archiveDir)
456
- .filter((f) => f.startsWith(name) && f.endsWith(".jsonl"))
457
- .sort()
458
- .reverse();
458
+ files = existsSync(runtime().archiveDir)
459
+ ? readdirSync(runtime().archiveDir)
460
+ .filter((f) => f.startsWith(name) && f.endsWith(".jsonl"))
461
+ .sort()
462
+ .reverse()
463
+ : [];
459
464
  }
460
465
  if (files.length === 0) {
461
466
  return {
@@ -467,7 +472,9 @@ function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
467
472
  const actualPath = files[0].includes("/")
468
473
  ? files[0]
469
474
  : resolve(
470
- readdirSync(archiveDir).includes(files[0]) ? archiveDir : runtime().archiveDir,
475
+ existsSync(archiveDir) && readdirSync(archiveDir).includes(files[0])
476
+ ? archiveDir
477
+ : runtime().archiveDir,
471
478
  files[0],
472
479
  );
473
480
 
@@ -75,7 +75,6 @@ export const BROWSER_PROFILES_DIR = resolve(BROWSER_DIR, "profiles");
75
75
  export const BINARIES_DIR = resolve(STATE_DIR, "binaries");
76
76
  export const SECRETS_DIR = resolve(STATE_DIR, "secrets");
77
77
  // Legacy names remain public, but their durable contents now live under data.
78
- export const CONTEXTS_DIR = resolve(STATE_DIR, "data", "contexts");
79
78
  export const DM_WORKSPACE_DIR = resolve(STATE_DIR, "data", "dm");
80
79
  export const SESSION_WORKSPACE_DIR = resolve(STATE_DIR, "data", "sessions");
81
80
  // The Claude Agent SDK ships a platform-matched Claude Code binary. Keep that
@@ -364,7 +363,6 @@ mkdirSync(WORKSPACE_DIR, { recursive: true });
364
363
  mkdirSync(TOPIC_WORKSPACE_DIR, { recursive: true });
365
364
  mkdirSync(SHARED_WIKI_DIR, { recursive: true });
366
365
  mkdirSync(CRON_WORKSPACE_DIR, { recursive: true });
367
- mkdirSync(CONTEXTS_DIR, { recursive: true });
368
366
  mkdirSync(DM_WORKSPACE_DIR, { recursive: true });
369
367
  mkdirSync(SESSION_WORKSPACE_DIR, { recursive: true });
370
368
  mkdirSync(BROWSER_DIR, { recursive: true });
@@ -453,7 +451,10 @@ export const MAX_TELL_DEPTH =
453
451
  /** Codex CLI auth file. 호출 시점에 env를 읽는다 — 테스트가 런타임에
454
452
  * NEGOTIUM_CODEX_AUTH_FILE을 바꾸므로 모듈 로드 상수로 만들면 안 된다. */
455
453
  export function codexAuthFilePath(): string {
456
- return process.env.NEGOTIUM_CODEX_AUTH_FILE || join(homedir(), ".codex", "auth.json");
454
+ return (
455
+ process.env.NEGOTIUM_CODEX_AUTH_FILE ||
456
+ join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json")
457
+ );
457
458
  }
458
459
 
459
460
  // System defaults moved to per-agent registries
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Size-based rotation for daemon stderr logs.
3
+ *
4
+ * Negotium adapters (`negotium-terminal`, `negotium-telegram`, `negotium-otium`)
5
+ * each spawn the node daemon with its stderr redirected straight to
6
+ * `<LOG_DIR>/node-daemon.log`. That file is opened once at spawn time and
7
+ * accumulates for the daemon's whole lifetime with no cap, so long-running
8
+ * hosts eventually carry an unbounded log file.
9
+ *
10
+ * True in-place rotation (SIGHUP-style reopen) would require the daemon to
11
+ * cooperate by reopening its own stderr fd, which Bun does not expose. The
12
+ * practical fix instead rotates *before* spawn: every time a CLI is about to
13
+ * start the daemon, check the previous log's size and, if it crossed the
14
+ * threshold, rename it aside with a timestamp so the new spawn starts a
15
+ * fresh, empty file.
16
+ */
17
+
18
+ import { existsSync, renameSync, statSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ /** Rotate once the log exceeds this size (bytes) at the next daemon start. */
22
+ export const DEFAULT_DAEMON_LOG_ROTATE_BYTES = 20 * 1024 * 1024; // 20MB
23
+
24
+ /**
25
+ * If `logPath` exists and is at least `maxBytes`, rename it aside with an
26
+ * ISO-ish timestamp suffix so a fresh file can be created at `logPath`.
27
+ * No-op (and never throws) when the file is missing, under the threshold, or
28
+ * the rename fails for any reason — log rotation must never block startup.
29
+ */
30
+ export function rotateOversizedLog(
31
+ logPath: string,
32
+ maxBytes: number = DEFAULT_DAEMON_LOG_ROTATE_BYTES,
33
+ ): void {
34
+ try {
35
+ if (!existsSync(logPath)) return;
36
+ const { size } = statSync(logPath);
37
+ if (size < maxBytes) return;
38
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
39
+ const dir = join(logPath, "..");
40
+ const base = logPath.slice(dir.length + 1);
41
+ renameSync(logPath, join(dir, `${base}.${stamp}`));
42
+ } catch {
43
+ // Best-effort: a failed rotation should never prevent the daemon from starting.
44
+ }
45
+ }
@@ -207,6 +207,8 @@ export function consumePlaywrightUnavailable(userId: string, topic: string | und
207
207
 
208
208
  export interface RuntimeMcpBuildContext {
209
209
  userId: string;
210
+ /** Vault namespace when credentials belong to a different principal. */
211
+ vaultUserId?: string;
210
212
  /** "dm" for DM scope, topic/session name for forum/fork. */
211
213
  session: string;
212
214
  /** REST/WS topic id when known. Prefer for authorization/scope checks. */
@@ -527,13 +529,13 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
527
529
  vault: {
528
530
  ...commonRuntimeMcpPolicy("vault"),
529
531
  build(ctx) {
530
- const { userId, agent } = ctx;
532
+ const { userId, vaultUserId, agent } = ctx;
531
533
  // Normal turns use {{KEY}} directly in tool inputs. Keep the Vault MCP
532
534
  // surface focused on key discovery; broker tools remain available from
533
535
  // the public factory for compatibility but are no longer the default UX.
534
- const args = [`--user-id=${userId}`];
536
+ const args = [`--user-id=${vaultUserId ?? userId}`];
535
537
  if (agent !== "codex") args.push("--list-only=true");
536
- return buildBuiltinMcpServer("vault", ctx, () =>
538
+ return buildBuiltinMcpServer("vault", { ...ctx, userId: vaultUserId ?? userId }, () =>
537
539
  buildStdioMcpServer(agent, VAULT_SERVER, args),
538
540
  );
539
541
  },
@@ -767,6 +769,7 @@ export function getManagerMcpServers(opts: {
767
769
  */
768
770
  export function getForumMcpServers(opts: {
769
771
  userId: string;
772
+ vaultUserId?: string;
770
773
  session: string;
771
774
  topicId?: string;
772
775
  subagentParentTopicId?: string;
@@ -790,6 +793,7 @@ export function getForumMcpServers(opts: {
790
793
  }) {
791
794
  const {
792
795
  userId,
796
+ vaultUserId,
793
797
  session,
794
798
  topicId,
795
799
  subagentParentTopicId,
@@ -824,6 +828,7 @@ export function getForumMcpServers(opts: {
824
828
  "forum",
825
829
  {
826
830
  userId,
831
+ vaultUserId,
827
832
  session,
828
833
  topicId,
829
834
  subagentParentTopicId,
@@ -945,6 +950,7 @@ export function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, u
945
950
  }
946
951
  return getForumMcpServers({
947
952
  userId: opts.userId || "local",
953
+ vaultUserId: opts.vaultUserId,
948
954
  session: opts.session || "default",
949
955
  topicId: opts.topicId,
950
956
  subagentParentTopicId: opts.subagentParentTopicId,
@@ -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() });
@@ -42,6 +42,8 @@ export interface DeferredInject {
42
42
  /** Topic execution epoch captured when this work was accepted. */
43
43
  runtimeEpoch?: number;
44
44
  userId: string;
45
+ /** Credential namespace when the executor and Vault owner differ. */
46
+ vaultUserId?: string;
45
47
  prompt: string;
46
48
  /** Inject source — the topic name/id this inject came from (never "user"). */
47
49
  origin: string;
@@ -120,8 +122,12 @@ export interface RoomQueryControl {
120
122
  durableRequestIds?: string[];
121
123
  /** Attachment ids currently being processed. User-turn preemption carries them forward. */
122
124
  attachments?: string[];
123
- /** Provider session id this turn resumed from. Superseding user turns restart from this base. */
125
+ /** Latest provider session id observed for this turn. */
124
126
  sessionId?: string | null;
127
+ /** True once the provider has published the native session backing this turn. */
128
+ providerSessionObserved?: boolean;
129
+ /** True once provider output proves that this turn's prompt reached the native session. */
130
+ providerTurnContentObserved?: boolean;
125
131
  abortController: AbortController;
126
132
  abortReason: AbortReason;
127
133
  /** Specific failure surfaced when infrastructure aborts the turn. */
@@ -64,6 +64,7 @@ export function isSessionExpiredError(message: string): boolean {
64
64
  return (
65
65
  message.includes(SESSION_EXPIRED_MSG) ||
66
66
  /session was recorded with model .+ but is resuming with/i.test(message) ||
67
+ /failed to resolve rollout path .+file does not exist/i.test(message) ||
67
68
  /session.*not found|session.*expired|unknown conversation/i.test(message)
68
69
  );
69
70
  }
@@ -64,6 +64,10 @@ import {
64
64
  updateApiMessageUsage,
65
65
  } from "#storage/api-messages";
66
66
  import { getTopic, setTopicSessionId } from "#storage/api-topics";
67
+ import {
68
+ getRuntimeUserTurnRequest,
69
+ markRuntimeUserTurnProviderSessionObserved,
70
+ } from "#storage/runtime-turn-requests";
67
71
  import { recordUsage } from "#storage/token-stats";
68
72
  import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext, UnifiedEvent } from "#types";
69
73
  import type { MessageDto } from "#types/api";
@@ -267,6 +271,30 @@ export async function runTurnEventStream(
267
271
  for await (const event of events) {
268
272
  if (abortController.signal.aborted) break;
269
273
 
274
+ // A provider may publish its session id before it appends the user
275
+ // prompt to the native rollout. Only semantic provider output proves
276
+ // that the prompt made it into that session. This distinction matters
277
+ // when a second user message preempts the turn in that short window:
278
+ // the replacement must replay both messages instead of dropping the
279
+ // first one merely because a session event was seen.
280
+ if (
281
+ !control.providerTurnContentObserved &&
282
+ (event.type === "text_delta" ||
283
+ event.type === "text" ||
284
+ event.type === "reasoning" ||
285
+ event.type === "tool_use" ||
286
+ event.type === "tool_result" ||
287
+ event.type === "file" ||
288
+ event.type === "result")
289
+ ) {
290
+ control.providerTurnContentObserved = true;
291
+ if (control.sessionId) {
292
+ for (const requestId of control.durableRequestIds ?? []) {
293
+ markRuntimeUserTurnProviderSessionObserved(topicId, requestId, control.sessionId);
294
+ }
295
+ }
296
+ }
297
+
270
298
  switch (event.type) {
271
299
  case "text_delta": {
272
300
  pendingSawDelta = true;
@@ -672,6 +700,16 @@ export async function runTurnEventStream(
672
700
  }
673
701
  break;
674
702
  case "session":
703
+ // Keep the control synchronized with the provider, not merely the
704
+ // topic row. A superseding user message can then resume the partial
705
+ // native rollout after this process has fully unwound.
706
+ control.sessionId = event.sessionId;
707
+ control.providerSessionObserved = true;
708
+ if (control.providerTurnContentObserved) {
709
+ for (const requestId of control.durableRequestIds ?? []) {
710
+ markRuntimeUserTurnProviderSessionObserved(topicId, requestId, event.sessionId);
711
+ }
712
+ }
675
713
  // Persist visible topic turns only. Silent ask_session forks use
676
714
  // temporary session ids and must not clobber the topic's durable
677
715
  // session; visible tell/reply injects do belong to the main topic
@@ -788,7 +826,14 @@ export async function runTurnEventStream(
788
826
  // Drain one deferred session-inject if the room is now idle. If a
789
827
  // replacement turn already occupies the slot, leave it queued — that
790
828
  // turn's finally will drain it when it completes.
791
- if (roomId === topicId && outcome.kind !== "session-expired" && !getRoomQuery(topicId)) {
829
+ const queuedUserTurn = roomId === topicId ? getRuntimeUserTurnRequest(topicId) : null;
830
+ const hasReplacementUserTurn = queuedUserTurn !== null && queuedUserTurn.requestId !== queryId;
831
+ if (
832
+ roomId === topicId &&
833
+ outcome.kind !== "session-expired" &&
834
+ !getRoomQuery(topicId) &&
835
+ !hasReplacementUserTurn
836
+ ) {
792
837
  const next = takeDeferredInject(topicId);
793
838
  if (next) redispatchInject(next);
794
839
  }