negotium 0.2.4 → 0.2.6

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 +607 -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 +91 -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 +32 -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,19 +1,28 @@
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";
5
5
  import { appendRuntimeEvent } from "#storage/runtime-events";
6
6
  import {
7
+ backfillRuntimeGatewaySubmissionPayloadHash,
7
8
  findRuntimeGatewaySubmission,
8
9
  type RuntimeGatewaySubmission,
9
10
  recordRuntimeGatewaySubmission,
10
11
  } from "#storage/runtime-gateway-submissions";
11
- import { enqueueRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
12
+ import { requestRuntimeTurnAbort } from "#storage/runtime-leases";
13
+ import { getRuntimeTopicEpoch } from "#storage/runtime-topic-state";
14
+ import { mergeRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
12
15
  import type { MessageDto, TopicDto } from "#types/api";
13
16
 
14
17
  export interface SubmitRuntimeGatewayTurnParams {
15
18
  topic: TopicDto;
19
+ /** Canonical execution principal, normally `local`. */
16
20
  userId: string;
21
+ /** Authenticated upstream author retained independently from execution. */
22
+ actorUserId?: string;
23
+ actorLabel?: string;
24
+ /** Topic owner's credential namespace. */
25
+ vaultUserId?: string;
17
26
  text: string;
18
27
  clientMessageId: string;
19
28
  requestId?: string;
@@ -25,16 +34,72 @@ export interface SubmitRuntimeGatewayTurnResult extends RuntimeGatewaySubmission
25
34
  deduplicated: boolean;
26
35
  }
27
36
 
28
- function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGatewayTurnResult {
37
+ function duplicateResult(
38
+ submission: RuntimeGatewaySubmission,
39
+ params: SubmitRuntimeGatewayTurnParams,
40
+ requestId: string,
41
+ actorUserId: string,
42
+ payloadHash: string,
43
+ ): SubmitRuntimeGatewayTurnResult {
29
44
  const message = getApiMessage(submission.topicId, submission.messageId);
30
45
  if (!message) throw new Error("gateway submission references a missing canonical message");
46
+ if (submission.payloadHash && submission.payloadHash !== payloadHash) {
47
+ throw new RuntimeGatewayIdempotencyConflictError();
48
+ }
49
+ if (
50
+ // `MessageDto.authorName` is never persisted — `api_messages` has no
51
+ // `author_name` column, so a message fetched back from storage always
52
+ // has `authorName: undefined`. Comparing it here would either always
53
+ // fail (once `actorLabel` is set) or always pass (once it's cleared);
54
+ // it is dropped in favor of the fields that actually round-trip.
55
+ (!submission.payloadHash &&
56
+ (message.authorId !== actorUserId || message.text !== params.text)) ||
57
+ submission.clientMessageId !== params.clientMessageId ||
58
+ submission.requestId !== requestId ||
59
+ submission.topicId !== params.topic.id ||
60
+ submission.userId !== params.userId
61
+ ) {
62
+ throw new RuntimeGatewayIdempotencyConflictError();
63
+ }
64
+ if (!submission.payloadHash) {
65
+ // Pre-0.2.5 rows never recorded `actorLabel`/`vaultUserId`/
66
+ // `allowAutoContinue` anywhere retrievable, so this replay could not
67
+ // actually be checked against them above. Adopt this call's hash as
68
+ // canonical for the key now, so any *later* replay with a different
69
+ // actor label, Vault, or auto-continue flag is caught instead of
70
+ // silently reusing this ACK forever.
71
+ backfillRuntimeGatewaySubmissionPayloadHash(submission.clientMessageId, payloadHash);
72
+ }
31
73
  return {
32
74
  ...submission,
75
+ payloadHash: submission.payloadHash ?? payloadHash,
33
76
  message,
34
77
  deduplicated: true,
35
78
  };
36
79
  }
37
80
 
81
+ function gatewayPayloadHash(
82
+ params: SubmitRuntimeGatewayTurnParams,
83
+ requestId: string,
84
+ actorUserId: string,
85
+ ): string {
86
+ return createHash("sha256")
87
+ .update(
88
+ JSON.stringify([
89
+ params.topic.id,
90
+ params.userId,
91
+ actorUserId,
92
+ params.actorLabel ?? null,
93
+ params.vaultUserId ?? null,
94
+ params.text,
95
+ params.clientMessageId,
96
+ requestId,
97
+ params.allowAutoContinue ?? true,
98
+ ]),
99
+ )
100
+ .digest("hex");
101
+ }
102
+
38
103
  /**
39
104
  * Durable ingress for an authenticated external gateway. It deliberately
40
105
  * reuses the canonical message table and existing durable turn worker rather
@@ -45,8 +110,7 @@ function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGat
45
110
  *
46
111
  * A distinct type rather than a plain `Error` because the control plane maps
47
112
  * 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.
113
+ * which is a malformed request (400).
50
114
  */
51
115
  export class RuntimeGatewayIdempotencyConflictError extends Error {
52
116
  constructor(message = "clientMessageId or requestId is already bound to another turn") {
@@ -59,24 +123,19 @@ export function submitRuntimeGatewayTurn(
59
123
  params: SubmitRuntimeGatewayTurnParams,
60
124
  ): SubmitRuntimeGatewayTurnResult {
61
125
  const requestId = params.requestId ?? params.clientMessageId;
126
+ const actorUserId = params.actorUserId ?? params.userId;
127
+ const payloadHash = gatewayPayloadHash(params, requestId, actorUserId);
62
128
  const existing = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
63
129
  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);
130
+ return duplicateResult(existing, params, requestId, actorUserId, payloadHash);
73
131
  }
74
132
 
75
133
  const createdAt = new Date().toISOString();
76
134
  const message: MessageDto = {
77
135
  id: randomUUID(),
78
136
  topicId: params.topic.id,
79
- authorId: params.userId,
137
+ authorId: actorUserId,
138
+ authorName: params.actorLabel,
80
139
  sourceAdapter: "runtime-gateway",
81
140
  sourceMessageId: params.clientMessageId,
82
141
  text: params.text,
@@ -91,23 +150,31 @@ export function submitRuntimeGatewayTurn(
91
150
  createdAt,
92
151
  ackCursor: 0,
93
152
  messageCursor: 0,
153
+ payloadHash,
94
154
  };
95
155
 
96
156
  try {
97
157
  db.transaction(() => {
98
158
  appendApiMessage(message, { notify: false });
99
- enqueueRuntimeUserTurnRequest({
159
+ mergeRuntimeUserTurnRequest({
100
160
  topicId: params.topic.id,
101
161
  userId: params.userId,
102
- prompt: params.text,
162
+ userMessages: [
163
+ {
164
+ prompt: params.text,
165
+ actorUserId,
166
+ ...(params.actorLabel ? { actorLabel: params.actorLabel } : {}),
167
+ },
168
+ ],
103
169
  allowAutoContinue: params.allowAutoContinue ?? true,
104
170
  requestId,
105
- supersedeExisting: false,
171
+ topicEpoch: getRuntimeTopicEpoch(params.topic.id),
106
172
  execution: {
107
173
  sessionId: getTopicSessionId(params.topic.id),
108
174
  sessionIdSpecified: true,
109
175
  conversationPrompts: [params.text],
110
176
  loggedUserMessageCount: 0,
177
+ vaultUserId: params.vaultUserId,
111
178
  },
112
179
  });
113
180
  const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
@@ -131,9 +198,14 @@ export function submitRuntimeGatewayTurn(
131
198
  })();
132
199
  } catch {
133
200
  const raced = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
134
- if (raced) return duplicateResult(raced);
201
+ if (raced) return duplicateResult(raced, params, requestId, actorUserId, payloadHash);
135
202
  throw new Error("failed to persist gateway turn idempotency record");
136
203
  }
137
204
 
205
+ // A new human message steers the active topic turn. The durable replacement
206
+ // is committed first; the provider observes this abort on its next lease
207
+ // heartbeat and the worker resumes the merged batch after unwind.
208
+ requestRuntimeTurnAbort(params.topic.id, "internal");
209
+
138
210
  return { ...submission, message, deduplicated: false };
139
211
  }
@@ -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
+ }