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
@@ -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
@@ -128,18 +129,17 @@ export async function resolveSessionFileMissing(
128
129
 
129
130
  /**
130
131
  * Rebuild a missing SDK session file from the unified conversation log, or
131
- * fall back to a fresh session. Returns true when the session file is confirmed
132
- * to exist (either pre-existing or successfully rebuilt), false when the agent
133
- * should start a fresh session (no entries, rebuild failed, etc.).
132
+ * report whether the session is ready, genuinely has no history, or could not
133
+ * be repaired. Repair failures must not silently discard conversation context.
134
134
  */
135
135
  async function maybeRebuildSession(
136
136
  opts: AgentQueryOptions & { userId: string; session: string },
137
- ): Promise<boolean> {
138
- if (!opts.sessionId) return true; // fresh session, nothing to rebuild
137
+ ): Promise<"ready" | "fresh" | "failed"> {
138
+ if (!opts.sessionId) return "ready"; // fresh session, nothing to rebuild
139
139
 
140
140
  try {
141
141
  const missing = await resolveSessionFileMissing(opts.agent, opts.sessionId, opts.cwd);
142
- if (!missing) return true; // file still on disk
142
+ if (!missing) return "ready"; // file still on disk
143
143
 
144
144
  logger.info(
145
145
  { agent: opts.agent, sessionId: opts.sessionId, topic: opts.session },
@@ -152,7 +152,7 @@ async function maybeRebuildSession(
152
152
  { agent: opts.agent, sessionId: opts.sessionId },
153
153
  "no unified log entries to rebuild — starting fresh session",
154
154
  );
155
- return false;
155
+ return "fresh";
156
156
  }
157
157
 
158
158
  const operations = getRegistryOperations(opts.agent);
@@ -167,13 +167,13 @@ async function maybeRebuildSession(
167
167
  { agent: opts.agent, sessionId: result.sessionId, rolloutPath: result.rolloutPath },
168
168
  "session rebuilt from unified log",
169
169
  );
170
- return true;
170
+ return "ready";
171
171
  } catch (err) {
172
172
  logger.warn(
173
173
  { err, agent: opts.agent, sessionId: opts.sessionId },
174
- "session rebuild failed — starting fresh session",
174
+ "session rebuild failed — preserving resume key",
175
175
  );
176
- return false;
176
+ return "failed";
177
177
  }
178
178
  }
179
179
 
@@ -182,11 +182,17 @@ export async function* runAgent(opts: AgentQueryOptions): AsyncGenerator<Unified
182
182
  const dispatchOpts: AgentQueryOptions = { ...opts };
183
183
 
184
184
  if (hasSessionRepairContext(dispatchOpts)) {
185
- const ok = await maybeRebuildSession(dispatchOpts);
186
- if (!ok) {
187
- // Rebuild failed / no entries → clear sessionId so the agent starts
188
- // a fresh session instead of trying to resume a non-existent one.
185
+ const repair = await maybeRebuildSession(dispatchOpts);
186
+ if (repair === "fresh") {
187
+ // No unified history exists, so a new provider session is intentional.
189
188
  dispatchOpts.sessionId = undefined;
189
+ } else if (repair === "failed") {
190
+ yield {
191
+ type: "error",
192
+ content:
193
+ "Provider session could not be rebuilt from conversation history; the existing session was preserved for retry.",
194
+ };
195
+ return;
190
196
  }
191
197
  }
192
198
 
@@ -242,7 +242,7 @@ export function maestroProvider(opts: AgentQueryOptions): AsyncGenerator<Unified
242
242
  if (opts.agent !== undefined && opts.agent !== "maestro") {
243
243
  throw new Error(`maestroProvider: unexpected agent "${opts.agent}", expected "maestro"`);
244
244
  }
245
- const userId = opts.userId ?? "";
245
+ const userId = opts.vaultUserId ?? opts.userId ?? "";
246
246
  // The SDK deliberately ships with an empty MCP resolver. Keep this wiring in
247
247
  // the provider adapter rather than every host bootstrap so a new Negotium
248
248
  // channel cannot accidentally run Maestro with zero runtime/wiki/task MCPs.
@@ -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;
@@ -156,6 +157,19 @@ export interface CodexPatchPreview {
156
157
  changes: CodexPatchChangePreview[];
157
158
  }
158
159
 
160
+ function codexRolloutPath(threadId: string, fallback: Date): string {
161
+ const threadCreatedAt = decodeUuidV7Timestamp(threadId);
162
+ const createdAt = threadCreatedAt === null ? fallback : new Date(threadCreatedAt);
163
+ const yyyy = String(createdAt.getFullYear());
164
+ const mm = String(createdAt.getMonth() + 1).padStart(2, "0");
165
+ const dd = String(createdAt.getDate()).padStart(2, "0");
166
+ const hh = String(createdAt.getHours()).padStart(2, "0");
167
+ const min = String(createdAt.getMinutes()).padStart(2, "0");
168
+ const ss = String(createdAt.getSeconds()).padStart(2, "0");
169
+ const dir = join(codexSessionsDir(), yyyy, mm, dd);
170
+ return join(dir, `rollout-${yyyy}-${mm}-${dd}T${hh}-${min}-${ss}-${threadId}.jsonl`);
171
+ }
172
+
159
173
  function canonicalFilePath(path: string): string {
160
174
  const absolute = resolve(path);
161
175
  try {
@@ -420,7 +434,7 @@ export function migrateCodexRolloutNativeMultiAgentMetadata(threadId: string): b
420
434
  }
421
435
  }
422
436
 
423
- function latestCodexRolloutPath(threadId: string): string | undefined {
437
+ export function latestCodexRolloutPath(threadId: string): string | undefined {
424
438
  const sessionsDir = codexSessionsDir();
425
439
  const candidates: string[] = [];
426
440
  const buckets = candidateDateBuckets(threadId);
@@ -563,22 +577,17 @@ export function writeCodexRollout(opts: CodexRolloutOptions): CodexRolloutResult
563
577
  payload: { type: "task_complete" },
564
578
  });
565
579
 
566
- const yyyy = now.getUTCFullYear();
567
- const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
568
- const dd = String(now.getUTCDate()).padStart(2, "0");
569
- const tsStr = tsIso.replace(/[:.]/g, "-").slice(0, 19);
570
- const dir = join(codexSessionsDir(), String(yyyy), mm, dd);
571
- const path = join(dir, `rollout-${tsStr}-${threadId}.jsonl`);
580
+ // Codex indexes a thread by the local wall-clock timestamp encoded in its
581
+ // UUIDv7 creation time. A cross-agent round-trip may rewrite the rollout
582
+ // much later, but the path must remain the original canonical path or
583
+ // `thread/resume` rejects it as missing and the provider starts fresh.
584
+ const path = codexRolloutPath(threadId, now);
572
585
 
573
586
  // Round-trip cleanup: when the caller reused a threadId (claude→codex→...
574
587
  // or maestro→codex→... cycles via `findLastSessionIdForAgent`), the SDK's
575
- // original rollout for that threadId still sits at its ORIGINAL-date
576
- // directory (`~/.codex/sessions/<orig-yyyy>/<orig-mm>/<orig-dd>/...`).
577
- // Writing today's synthetic rollout next to it leaves two jsonls sharing
578
- // the same threadId — `codex resume <tid>` globs `~/.codex/sessions/**`
579
- // and the SDK has no spec for which match it picks, so behavior becomes
580
- // environment-/codex-version-dependent. Sweep every prior file for this
581
- // threadId BEFORE writing the new one so resume always reads our synth.
588
+ // original rollout for that threadId still sits at its canonical path.
589
+ // Sweep every prior file for this threadId BEFORE replacing that path so
590
+ // legacy writers cannot leave ambiguous duplicates behind.
582
591
  //
583
592
  // Synchronous on purpose: writeCodexRollout's signature is sync (called
584
593
  // inside set_agent's DB transaction), and an async sweep would race with
@@ -671,11 +680,10 @@ function sweepPriorRolloutsFullTree(threadId: string, sessionsDir: string): void
671
680
  }
672
681
 
673
682
  /**
674
- * UUIDv7 ids encode their creation time in the first 48 bits. `writeCodexRollout`
675
- * buckets new files at `<yyyy>/<mm>/<dd>/` keyed off the WRITE time, so a
676
- * thread's rollouts can only live between (threadId's birth date) and
677
- * (today's date) — typically a 0-2 day span. We enumerate every day in that
678
- * inclusive range so a Mon-bridge / Tue-rebridge picks up both.
683
+ * UUIDv7 ids encode their creation time in the first 48 bits. Current writers
684
+ * always use that canonical birth-date bucket, but older writers used the
685
+ * rewrite date during cross-agent bridges. Enumerate the inclusive range from
686
+ * birth date to today so cleanup also removes those legacy misplaced files.
679
687
  *
680
688
  * Returns null when:
681
689
  * - threadId isn't a parseable UUIDv7 (atypical fixture / future format)
@@ -89,7 +89,7 @@ export type SelfConfigAgentSwitchResult =
89
89
  | {
90
90
  ok: true;
91
91
  outcome:
92
- | { kind: "fresh"; agent: AgentKind; reason: "no-history" | "bridge-failed" }
92
+ | { kind: "fresh"; agent: AgentKind; reason: "no-history" }
93
93
  | {
94
94
  kind: "bridged";
95
95
  agent: AgentKind;
@@ -386,9 +386,7 @@ export function createSelfConfigCore(
386
386
  const sessionNote =
387
387
  switched.outcome.kind === "bridged"
388
388
  ? ` Conversation history was bridged into a ${agent} session (${switched.outcome.bridgedSessionId}).`
389
- : switched.outcome.reason === "bridge-failed"
390
- ? " Conversation history bridge failed; the next turn starts fresh."
391
- : " No prior conversation history was found; the next turn starts fresh.";
389
+ : " No prior conversation history was found; the next turn starts fresh.";
392
390
  return ok(
393
391
  `Agent for this topic set to '${agent}'. Model/effort reset to '${agent}' defaults.${sessionNote}`,
394
392
  );
@@ -39,7 +39,7 @@ export type SwitchAgentOutcome =
39
39
  | {
40
40
  kind: "fresh";
41
41
  agent: AgentKind;
42
- reason: "no-history" | "bridge-failed";
42
+ reason: "no-history";
43
43
  }
44
44
  | {
45
45
  kind: "bridged";
@@ -128,17 +128,11 @@ export function switchTopicAgent(
128
128
  } catch (err) {
129
129
  logger.warn(
130
130
  { err, userId, topicName, from: oldAgent, to: agent },
131
- "switchTopicAgent: rollout encoding failed — falling back to fresh session",
131
+ "switchTopicAgent: rollout encoding failed — preserving current agent session",
132
132
  );
133
- const changed = setTopicAgentAndClearSession({
134
- userId,
135
- topicName,
136
- agent,
137
- });
138
- if (!changed) return { ok: false, error: `failed to set agent for "${topicName}"` };
139
133
  return {
140
- ok: true,
141
- outcome: { kind: "fresh", agent, reason: "bridge-failed" },
134
+ ok: false,
135
+ error: `failed to preserve conversation while switching agent for "${topicName}"`,
142
136
  };
143
137
  }
144
138
 
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { appendApiMessage, getApiMessage } from "#storage/api-messages";
3
3
  import { getTopicSessionId } from "#storage/api-topics";
4
4
  import { db } from "#storage/forum-db";
@@ -8,12 +8,20 @@ import {
8
8
  type RuntimeGatewaySubmission,
9
9
  recordRuntimeGatewaySubmission,
10
10
  } from "#storage/runtime-gateway-submissions";
11
- import { enqueueRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
11
+ import { requestRuntimeTurnAbort } from "#storage/runtime-leases";
12
+ import { getRuntimeTopicEpoch } from "#storage/runtime-topic-state";
13
+ import { mergeRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
12
14
  import type { MessageDto, TopicDto } from "#types/api";
13
15
 
14
16
  export interface SubmitRuntimeGatewayTurnParams {
15
17
  topic: TopicDto;
18
+ /** Canonical execution principal, normally `local`. */
16
19
  userId: string;
20
+ /** Authenticated upstream author retained independently from execution. */
21
+ actorUserId?: string;
22
+ actorLabel?: string;
23
+ /** Topic owner's credential namespace. */
24
+ vaultUserId?: string;
17
25
  text: string;
18
26
  clientMessageId: string;
19
27
  requestId?: string;
@@ -25,9 +33,30 @@ export interface SubmitRuntimeGatewayTurnResult extends RuntimeGatewaySubmission
25
33
  deduplicated: boolean;
26
34
  }
27
35
 
28
- function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGatewayTurnResult {
36
+ function duplicateResult(
37
+ submission: RuntimeGatewaySubmission,
38
+ params: SubmitRuntimeGatewayTurnParams,
39
+ requestId: string,
40
+ actorUserId: string,
41
+ payloadHash: string,
42
+ ): SubmitRuntimeGatewayTurnResult {
29
43
  const message = getApiMessage(submission.topicId, submission.messageId);
30
44
  if (!message) throw new Error("gateway submission references a missing canonical message");
45
+ if (submission.payloadHash && submission.payloadHash !== payloadHash) {
46
+ throw new RuntimeGatewayIdempotencyConflictError();
47
+ }
48
+ if (
49
+ (!submission.payloadHash &&
50
+ (message.authorName !== params.actorLabel ||
51
+ message.authorId !== actorUserId ||
52
+ message.text !== params.text)) ||
53
+ submission.clientMessageId !== params.clientMessageId ||
54
+ submission.requestId !== requestId ||
55
+ submission.topicId !== params.topic.id ||
56
+ submission.userId !== params.userId
57
+ ) {
58
+ throw new RuntimeGatewayIdempotencyConflictError();
59
+ }
31
60
  return {
32
61
  ...submission,
33
62
  message,
@@ -35,6 +64,28 @@ function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGat
35
64
  };
36
65
  }
37
66
 
67
+ function gatewayPayloadHash(
68
+ params: SubmitRuntimeGatewayTurnParams,
69
+ requestId: string,
70
+ actorUserId: string,
71
+ ): string {
72
+ return createHash("sha256")
73
+ .update(
74
+ JSON.stringify([
75
+ params.topic.id,
76
+ params.userId,
77
+ actorUserId,
78
+ params.actorLabel ?? null,
79
+ params.vaultUserId ?? null,
80
+ params.text,
81
+ params.clientMessageId,
82
+ requestId,
83
+ params.allowAutoContinue ?? true,
84
+ ]),
85
+ )
86
+ .digest("hex");
87
+ }
88
+
38
89
  /**
39
90
  * Durable ingress for an authenticated external gateway. It deliberately
40
91
  * reuses the canonical message table and existing durable turn worker rather
@@ -45,8 +96,7 @@ function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGat
45
96
  *
46
97
  * A distinct type rather than a plain `Error` because the control plane maps
47
98
  * this to 409, and it must not be confused with a *missing* `clientMessageId`,
48
- * which is a malformed request (400). Matching on message text conflated the
49
- * two.
99
+ * which is a malformed request (400).
50
100
  */
51
101
  export class RuntimeGatewayIdempotencyConflictError extends Error {
52
102
  constructor(message = "clientMessageId or requestId is already bound to another turn") {
@@ -59,24 +109,19 @@ export function submitRuntimeGatewayTurn(
59
109
  params: SubmitRuntimeGatewayTurnParams,
60
110
  ): SubmitRuntimeGatewayTurnResult {
61
111
  const requestId = params.requestId ?? params.clientMessageId;
112
+ const actorUserId = params.actorUserId ?? params.userId;
113
+ const payloadHash = gatewayPayloadHash(params, requestId, actorUserId);
62
114
  const existing = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
63
115
  if (existing) {
64
- if (
65
- existing.clientMessageId !== params.clientMessageId ||
66
- existing.requestId !== requestId ||
67
- existing.topicId !== params.topic.id ||
68
- existing.userId !== params.userId
69
- ) {
70
- throw new RuntimeGatewayIdempotencyConflictError();
71
- }
72
- return duplicateResult(existing);
116
+ return duplicateResult(existing, params, requestId, actorUserId, payloadHash);
73
117
  }
74
118
 
75
119
  const createdAt = new Date().toISOString();
76
120
  const message: MessageDto = {
77
121
  id: randomUUID(),
78
122
  topicId: params.topic.id,
79
- authorId: params.userId,
123
+ authorId: actorUserId,
124
+ authorName: params.actorLabel,
80
125
  sourceAdapter: "runtime-gateway",
81
126
  sourceMessageId: params.clientMessageId,
82
127
  text: params.text,
@@ -91,23 +136,31 @@ export function submitRuntimeGatewayTurn(
91
136
  createdAt,
92
137
  ackCursor: 0,
93
138
  messageCursor: 0,
139
+ payloadHash,
94
140
  };
95
141
 
96
142
  try {
97
143
  db.transaction(() => {
98
144
  appendApiMessage(message, { notify: false });
99
- enqueueRuntimeUserTurnRequest({
145
+ mergeRuntimeUserTurnRequest({
100
146
  topicId: params.topic.id,
101
147
  userId: params.userId,
102
- prompt: params.text,
148
+ userMessages: [
149
+ {
150
+ prompt: params.text,
151
+ actorUserId,
152
+ ...(params.actorLabel ? { actorLabel: params.actorLabel } : {}),
153
+ },
154
+ ],
103
155
  allowAutoContinue: params.allowAutoContinue ?? true,
104
156
  requestId,
105
- supersedeExisting: false,
157
+ topicEpoch: getRuntimeTopicEpoch(params.topic.id),
106
158
  execution: {
107
159
  sessionId: getTopicSessionId(params.topic.id),
108
160
  sessionIdSpecified: true,
109
161
  conversationPrompts: [params.text],
110
162
  loggedUserMessageCount: 0,
163
+ vaultUserId: params.vaultUserId,
111
164
  },
112
165
  });
113
166
  const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
@@ -131,9 +184,14 @@ export function submitRuntimeGatewayTurn(
131
184
  })();
132
185
  } catch {
133
186
  const raced = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
134
- if (raced) return duplicateResult(raced);
187
+ if (raced) return duplicateResult(raced, params, requestId, actorUserId, payloadHash);
135
188
  throw new Error("failed to persist gateway turn idempotency record");
136
189
  }
137
190
 
191
+ // A new human message steers the active topic turn. The durable replacement
192
+ // is committed first; the provider observes this abort on its next lease
193
+ // heartbeat and the worker resumes the merged batch after unwind.
194
+ requestRuntimeTurnAbort(params.topic.id, "internal");
195
+
138
196
  return { ...submission, message, deduplicated: false };
139
197
  }
@@ -1,11 +1,10 @@
1
1
  import { switchApiTopicAgent } from "#agents/api-topic-agent-switch";
2
2
  import { checkAgentModelAuth } from "#agents/auth-check";
3
- import { resolveModelForAgent, selectableModel } from "#agents/model-catalog";
4
- import { getRegistry } from "#agents/registry";
3
+ import { selectableModel } from "#agents/model-catalog";
5
4
  import { WsHub } from "#bus";
6
5
  import { resolveTopicWorkspaceDir } from "#platform/config";
7
6
  import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
8
- import { clearTopicSessionId, getTopic } from "#storage/api-topics";
7
+ import { getTopic } from "#storage/api-topics";
9
8
 
10
9
  export type SwitchTopicModelResult =
11
10
  | { ok: true; model: string; text: string }
@@ -63,14 +62,12 @@ export function switchTopicModel(params: SwitchTopicModelParams): SwitchTopicMod
63
62
  });
64
63
  if (!switched.ok) return { ok: false, error: switched.error };
65
64
  } else {
66
- const registry = getRegistry(currentAgent);
67
- const previousModel = resolveModelForAgent(
68
- currentAgent,
69
- currentConfig.model ?? topic.defaultModel,
70
- registry,
71
- );
72
65
  setApiTopicConfig(topic.id, nextConfig);
73
- if (previousModel !== target.model) clearTopicSessionId(topic.id, "user-model-switch");
66
+ // A provider session is not owned by one model. Codex, Claude, and Maestro
67
+ // all accept the selected model when resuming, so keep the durable resume
68
+ // key and let the next turn continue the same rollout with the new model.
69
+ // Clearing it here silently discarded native tool/history structure and
70
+ // made an ordinary model change look like `/new`.
74
71
  }
75
72
 
76
73
  WsHub.get().broadcastTopicUpdated(topic.id);
@@ -176,6 +176,10 @@ export { FROM_AUTO_CONTINUE } from "#platform/constants";
176
176
  export { errMsg } from "#platform/error";
177
177
  export { appendJsonlEntry } from "#platform/jsonl";
178
178
  export { onShutdown, runShutdown } from "#platform/lifecycle";
179
+ export {
180
+ DEFAULT_DAEMON_LOG_ROTATE_BYTES,
181
+ rotateOversizedLog,
182
+ } from "#platform/log-rotation";
179
183
  export { logger } from "#platform/logger";
180
184
  export type {
181
185
  NodeMcpEntry,