negotium 0.2.4 → 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 (62) hide show
  1. package/dist/agent-helpers.js +241 -127
  2. package/dist/agent-helpers.js.map +19 -19
  3. package/dist/background-bash.js +1 -3
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +1 -3
  6. package/dist/browser-runtime.js.map +4 -4
  7. package/dist/{chunk-1s9ryz8g.js → chunk-qmbcbhyy.js} +20 -12
  8. package/dist/chunk-qmbcbhyy.js.map +35 -0
  9. package/dist/hosted-agent.js +13 -10
  10. package/dist/hosted-agent.js.map +10 -10
  11. package/dist/main.js +598 -438
  12. package/dist/main.js.map +30 -29
  13. package/dist/mcp-factories.js +238 -123
  14. package/dist/mcp-factories.js.map +18 -18
  15. package/dist/prompts.js +1 -3
  16. package/dist/prompts.js.map +4 -4
  17. package/dist/query-runtime.js +1 -3
  18. package/dist/query-runtime.js.map +5 -5
  19. package/dist/registry.js +3 -3
  20. package/dist/registry.js.map +2 -2
  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-provider.ts +1 -0
  25. package/dist/runtime/src/agents/index.ts +19 -14
  26. package/dist/runtime/src/agents/maestro-provider.ts +1 -1
  27. package/dist/runtime/src/agents/rollout/codex.ts +25 -18
  28. package/dist/runtime/src/agents/self-config-core.ts +2 -4
  29. package/dist/runtime/src/agents/topic-agent-switch.ts +4 -10
  30. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +77 -19
  31. package/dist/runtime/src/application/switch-topic-model.ts +7 -10
  32. package/dist/runtime/src/index.ts +4 -0
  33. package/dist/runtime/src/mcp/wiki-server.ts +19 -12
  34. package/dist/runtime/src/platform/config.ts +0 -2
  35. package/dist/runtime/src/platform/log-rotation.ts +45 -0
  36. package/dist/runtime/src/platform/mcp-config.ts +9 -3
  37. package/dist/runtime/src/query/active-rooms.ts +7 -1
  38. package/dist/runtime/src/runtime/errors.ts +1 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +46 -1
  40. package/dist/runtime/src/runtime/turn-runner.ts +176 -100
  41. package/dist/runtime/src/runtime/user-turn-envelope.ts +19 -0
  42. package/dist/runtime/src/storage/runtime-gateway-submissions.ts +12 -3
  43. package/dist/runtime/src/storage/runtime-turn-requests.ts +68 -7
  44. package/dist/runtime/src/types.ts +2 -0
  45. package/dist/runtime/src/version.ts +1 -1
  46. package/dist/runtime-helpers.js +1 -3
  47. package/dist/runtime-helpers.js.map +4 -4
  48. package/dist/storage.js.map +1 -1
  49. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +1 -1
  50. package/dist/types/packages/core/src/agents/self-config-core.d.ts +1 -1
  51. package/dist/types/packages/core/src/platform/config.d.ts +0 -1
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +3 -0
  53. package/dist/types/packages/core/src/query/active-rooms.d.ts +7 -1
  54. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +42 -1
  55. package/dist/types/packages/core/src/runtime/user-turn-envelope.d.ts +7 -0
  56. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +17 -0
  57. package/dist/types/packages/core/src/types.d.ts +2 -0
  58. package/dist/types/packages/core/src/version.d.ts +1 -1
  59. package/dist/vault.js +1 -3
  60. package/dist/vault.js.map +4 -4
  61. package/package.json +1 -1
  62. package/dist/chunk-1s9ryz8g.js.map +0 -35
@@ -13,7 +13,7 @@ import {
13
13
  import type { AgentKind, EffortLevel } from "#types";
14
14
 
15
15
  export type ApiTopicSwitchOutcome =
16
- | { kind: "fresh"; agent: AgentKind; reason: "no-history" | "bridge-failed" }
16
+ | { kind: "fresh"; agent: AgentKind; reason: "no-history" }
17
17
  | { kind: "bridged"; agent: AgentKind; bridgedSessionId: string; rolloutPath: string };
18
18
 
19
19
  export type ApiTopicSwitchResult =
@@ -119,18 +119,12 @@ export function switchApiTopicAgent(opts: SwitchApiTopicAgentOptions): ApiTopicS
119
119
  from: opts.fromAgent,
120
120
  to: opts.agent,
121
121
  },
122
- "api-topic switch: rollout encoding failed — falling back to fresh session",
122
+ "api-topic switch: rollout encoding failed — preserving current agent session",
123
123
  );
124
- try {
125
- commitApiTopicSwitch(opts, null);
126
- } catch (commitErr) {
127
- logger.warn(
128
- { err: commitErr, topicId: opts.topicId, agent: opts.agent },
129
- "api-topic switch fallback commit failed",
130
- );
131
- return { ok: false, error: `failed to set agent for "${opts.topicTitle}"` };
132
- }
133
- return { ok: true, outcome: { kind: "fresh", agent: opts.agent, reason: "bridge-failed" } };
124
+ return {
125
+ ok: false,
126
+ error: `failed to preserve conversation while switching agent for "${opts.topicTitle}"`,
127
+ };
134
128
  }
135
129
 
136
130
  try {
@@ -450,7 +450,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
450
450
  // Resolve Vault placeholders only for explicitly allowlisted
451
451
  // transient execution tools. Messaging and persistence tools
452
452
  // must retain {{KEY}} verbatim.
453
- const userId = opts.userId ?? "";
453
+ const userId = opts.vaultUserId ?? opts.userId ?? "";
454
454
  const withVault = substituteClaudeToolInput(userId, tool_name, tool_input);
455
455
  if (JSON.stringify(withVault) === JSON.stringify(tool_input)) {
456
456
  return { continue: true };
@@ -470,7 +470,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
470
470
  hooks: [
471
471
  async (input: HookInput) => {
472
472
  const { tool_response } = input as PostToolUseHookInput;
473
- const userId = opts.userId ?? "";
473
+ const userId = opts.vaultUserId ?? opts.userId ?? "";
474
474
  const redacted = deepMapStrings(tool_response, (value) =>
475
475
  redactHostedSecrets(userId, value),
476
476
  );
@@ -774,6 +774,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
774
774
  } catch (err) {
775
775
  if (err instanceof Error && err.name === "AbortError") throw err;
776
776
  if (!currentSessionId || !isMissingCodexRolloutError(err)) throw err;
777
+ if (opts.userId && opts.session) throw err;
777
778
  logger.warn(
778
779
  { staleSessionId: currentSessionId, err: errMsg(err) },
779
780
  "codexProvider: stale/missing rollout, restarting fresh thread",
@@ -129,18 +129,17 @@ export async function resolveSessionFileMissing(
129
129
 
130
130
  /**
131
131
  * Rebuild a missing SDK session file from the unified conversation log, or
132
- * fall back to a fresh session. Returns true when the session file is confirmed
133
- * to exist (either pre-existing or successfully rebuilt), false when the agent
134
- * 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.
135
134
  */
136
135
  async function maybeRebuildSession(
137
136
  opts: AgentQueryOptions & { userId: string; session: string },
138
- ): Promise<boolean> {
139
- 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
140
139
 
141
140
  try {
142
141
  const missing = await resolveSessionFileMissing(opts.agent, opts.sessionId, opts.cwd);
143
- if (!missing) return true; // file still on disk
142
+ if (!missing) return "ready"; // file still on disk
144
143
 
145
144
  logger.info(
146
145
  { agent: opts.agent, sessionId: opts.sessionId, topic: opts.session },
@@ -153,7 +152,7 @@ async function maybeRebuildSession(
153
152
  { agent: opts.agent, sessionId: opts.sessionId },
154
153
  "no unified log entries to rebuild — starting fresh session",
155
154
  );
156
- return false;
155
+ return "fresh";
157
156
  }
158
157
 
159
158
  const operations = getRegistryOperations(opts.agent);
@@ -168,13 +167,13 @@ async function maybeRebuildSession(
168
167
  { agent: opts.agent, sessionId: result.sessionId, rolloutPath: result.rolloutPath },
169
168
  "session rebuilt from unified log",
170
169
  );
171
- return true;
170
+ return "ready";
172
171
  } catch (err) {
173
172
  logger.warn(
174
173
  { err, agent: opts.agent, sessionId: opts.sessionId },
175
- "session rebuild failed — starting fresh session",
174
+ "session rebuild failed — preserving resume key",
176
175
  );
177
- return false;
176
+ return "failed";
178
177
  }
179
178
  }
180
179
 
@@ -183,11 +182,17 @@ export async function* runAgent(opts: AgentQueryOptions): AsyncGenerator<Unified
183
182
  const dispatchOpts: AgentQueryOptions = { ...opts };
184
183
 
185
184
  if (hasSessionRepairContext(dispatchOpts)) {
186
- const ok = await maybeRebuildSession(dispatchOpts);
187
- if (!ok) {
188
- // Rebuild failed / no entries → clear sessionId so the agent starts
189
- // 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.
190
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;
191
196
  }
192
197
  }
193
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.
@@ -157,6 +157,19 @@ export interface CodexPatchPreview {
157
157
  changes: CodexPatchChangePreview[];
158
158
  }
159
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
+
160
173
  function canonicalFilePath(path: string): string {
161
174
  const absolute = resolve(path);
162
175
  try {
@@ -564,22 +577,17 @@ export function writeCodexRollout(opts: CodexRolloutOptions): CodexRolloutResult
564
577
  payload: { type: "task_complete" },
565
578
  });
566
579
 
567
- const yyyy = now.getUTCFullYear();
568
- const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
569
- const dd = String(now.getUTCDate()).padStart(2, "0");
570
- const tsStr = tsIso.replace(/[:.]/g, "-").slice(0, 19);
571
- const dir = join(codexSessionsDir(), String(yyyy), mm, dd);
572
- 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);
573
585
 
574
586
  // Round-trip cleanup: when the caller reused a threadId (claude→codex→...
575
587
  // or maestro→codex→... cycles via `findLastSessionIdForAgent`), the SDK's
576
- // original rollout for that threadId still sits at its ORIGINAL-date
577
- // directory (`~/.codex/sessions/<orig-yyyy>/<orig-mm>/<orig-dd>/...`).
578
- // Writing today's synthetic rollout next to it leaves two jsonls sharing
579
- // the same threadId — `codex resume <tid>` globs `~/.codex/sessions/**`
580
- // and the SDK has no spec for which match it picks, so behavior becomes
581
- // environment-/codex-version-dependent. Sweep every prior file for this
582
- // 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.
583
591
  //
584
592
  // Synchronous on purpose: writeCodexRollout's signature is sync (called
585
593
  // inside set_agent's DB transaction), and an async sweep would race with
@@ -672,11 +680,10 @@ function sweepPriorRolloutsFullTree(threadId: string, sessionsDir: string): void
672
680
  }
673
681
 
674
682
  /**
675
- * UUIDv7 ids encode their creation time in the first 48 bits. `writeCodexRollout`
676
- * buckets new files at `<yyyy>/<mm>/<dd>/` keyed off the WRITE time, so a
677
- * thread's rollouts can only live between (threadId's birth date) and
678
- * (today's date) — typically a 0-2 day span. We enumerate every day in that
679
- * 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.
680
687
  *
681
688
  * Returns null when:
682
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,
@@ -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 });
@@ -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,