negotium 0.22.3 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bin/negotium.mjs +0 -0
  2. package/dist/agent-helpers.js +601 -42
  3. package/dist/agent-helpers.js.map +8 -6
  4. package/dist/{chunk-6c637pwe.js → chunk-0jtan6fd.js} +3 -3
  5. package/dist/{chunk-6c637pwe.js.map → chunk-0jtan6fd.js.map} +2 -2
  6. package/dist/{chunk-kn8y5scr.js → chunk-rwj7x5pg.js} +4 -1
  7. package/dist/{chunk-kn8y5scr.js.map → chunk-rwj7x5pg.js.map} +3 -3
  8. package/dist/{chunk-bn1tt2qw.js → chunk-y4tyc603.js} +1 -1
  9. package/dist/hosted-agent.js +5 -2
  10. package/dist/hosted-agent.js.map +4 -4
  11. package/dist/main.js +1138 -252
  12. package/dist/main.js.map +21 -19
  13. package/dist/mcp-factories.js +620 -61
  14. package/dist/mcp-factories.js.map +8 -6
  15. package/dist/registry.js +3 -3
  16. package/dist/rollout.js +2 -2
  17. package/dist/runtime/src/agents/mcp-tools/request-vault.ts +616 -0
  18. package/dist/runtime/src/application/topic-service.ts +7 -0
  19. package/dist/runtime/src/index.ts +9 -0
  20. package/dist/runtime/src/mcp-runtime-host.ts +1 -0
  21. package/dist/runtime/src/node-host.ts +5 -0
  22. package/dist/runtime/src/runtime/turn-event-stream.ts +2 -0
  23. package/dist/runtime/src/storage/api-messages.ts +8 -2
  24. package/dist/runtime/src/storage/vault-request-gates.ts +359 -0
  25. package/dist/runtime/src/types/api.ts +23 -1
  26. package/dist/runtime/src/version.ts +1 -1
  27. package/dist/runtime-helpers.js +4 -1
  28. package/dist/runtime-helpers.js.map +3 -3
  29. package/dist/storage.js +8 -4
  30. package/dist/storage.js.map +3 -3
  31. package/dist/types/packages/core/src/agents/mcp-tools/request-vault.d.ts +120 -0
  32. package/dist/types/packages/core/src/storage/api-messages.d.ts +1 -0
  33. package/dist/types/packages/core/src/storage/vault-request-gates.d.ts +69 -0
  34. package/dist/types/packages/core/src/types/api.d.ts +15 -1
  35. package/dist/types/packages/core/src/version.d.ts +1 -1
  36. package/package.json +1 -1
  37. /package/dist/{chunk-bn1tt2qw.js.map → chunk-y4tyc603.js.map} +0 -0
package/dist/registry.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  encodeClaudeCwd,
4
4
  writeClaudeRollout
5
- } from "./chunk-bn1tt2qw.js";
5
+ } from "./chunk-y4tyc603.js";
6
6
  import {
7
7
  CLAUDE_EFFORT_VALUES,
8
8
  CODEX_EFFORT_VALUES,
@@ -17,7 +17,7 @@ import {
17
17
  hostedCodexHomePath,
18
18
  logger,
19
19
  writeCodexRollout
20
- } from "./chunk-kn8y5scr.js";
20
+ } from "./chunk-rwj7x5pg.js";
21
21
 
22
22
  // ../../packages/core/src/agents/claude-registry.ts
23
23
  import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "fs";
@@ -132,7 +132,7 @@ var codexRegistryOperations = {
132
132
  return { sessionId: threadId, rolloutPath };
133
133
  },
134
134
  async forkSession({ parentSessionId }) {
135
- const { forkCodexSession } = await import("./chunk-6c637pwe.js");
135
+ const { forkCodexSession } = await import("./chunk-0jtan6fd.js");
136
136
  return await forkCodexSession(parentSessionId);
137
137
  },
138
138
  async cleanupRollouts({ sessionIds }) {
package/dist/rollout.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  encodeClaudeCwd,
4
4
  repairPoisonedRollout,
5
5
  writeClaudeRollout
6
- } from "./chunk-bn1tt2qw.js";
6
+ } from "./chunk-y4tyc603.js";
7
7
  import {
8
8
  configureRolloutHost,
9
9
  decodeUuidV7Timestamp,
@@ -16,7 +16,7 @@ import {
16
16
  readLatestCodexContextUsage,
17
17
  readLatestCodexPatchPreview,
18
18
  writeCodexRollout
19
- } from "./chunk-kn8y5scr.js";
19
+ } from "./chunk-rwj7x5pg.js";
20
20
 
21
21
  // src/rollout.ts
22
22
  import { fileURLToPath } from "url";
@@ -0,0 +1,616 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { errorResult, type SharedMcpTool, textResult } from "#agents/mcp-tools/common";
4
+ import { WsHub } from "#bus";
5
+ import { appendApiMessage, getApiMessage } from "#storage/api-messages";
6
+ import { getApiTopicConfig } from "#storage/api-topic-config";
7
+ import { getTopic } from "#storage/api-topics";
8
+ import {
9
+ acquireRuntimeProcessLease,
10
+ isRuntimeProcessLeaseAlive,
11
+ listRuntimeProcessLeases,
12
+ type RuntimeProcessLease,
13
+ type RuntimeProcessLeaseHandle,
14
+ removeDeadRuntimeProcessLeases,
15
+ } from "#storage/runtime-process-leases";
16
+ import {
17
+ normalizeVaultKey,
18
+ VAULT_DESCRIPTION_MAX_LENGTH,
19
+ VAULT_KEY_PATTERN,
20
+ VAULT_VALUE_MAX_BYTES,
21
+ VAULT_VALUE_MIN_BYTES,
22
+ vaultHasKey,
23
+ vaultSet,
24
+ } from "#storage/vault";
25
+ import {
26
+ type ClaimVaultRequestGateResult,
27
+ cancelVaultRequestGate,
28
+ claimVaultRequestGateAndAnswer,
29
+ type PrepareVaultRequestGateResult,
30
+ prepareVaultRequestGate,
31
+ quarantineForeignVaultRequestGates,
32
+ quarantineVaultRequestGate,
33
+ type VaultRequestGateCardUpdate,
34
+ } from "#storage/vault-request-gates";
35
+ import type { AgentKind } from "#types";
36
+ import type { MessageDto, TopicDto, VaultSecretRequestDto } from "#types/api";
37
+
38
+ const MAX_DESCRIPTION_CHARS = VAULT_DESCRIPTION_MAX_LENGTH;
39
+ const MAX_IDEMPOTENCY_KEY_CHARS = 200;
40
+ const VAULT_GATE_OWNER_ID = `request-vault-${process.pid}-${randomUUID()}`;
41
+ const VAULT_GATE_LEASE_ROLE_PREFIX = "vault-request-gate:";
42
+
43
+ export interface RequestVaultToolContext {
44
+ userId: string;
45
+ topicId: string;
46
+ queryId?: string;
47
+ agent: AgentKind;
48
+ model?: string;
49
+ threadRootId?: string;
50
+ /**
51
+ * Set when this turn's surface cannot mask text input (Telegram has no
52
+ * native password field, so anything a person types becomes a plaintext
53
+ * message that lingers in chat history). When true the tool refuses
54
+ * immediately and never creates a card, instead of asking for a secret on
55
+ * a surface that cannot protect it.
56
+ */
57
+ unsupportedSurface?: boolean;
58
+ }
59
+
60
+ export type AnswerVaultSecretRequestResult =
61
+ | { ok: true; queryId?: string; outcome: "fulfilled" | "declined"; answerMessage: MessageDto }
62
+ | { ok: false; error: string };
63
+
64
+ export interface RequestVaultRuntimeHost {
65
+ messaging: {
66
+ persistence: {
67
+ getMessage(topicId: string, messageId: string): MessageDto | null | undefined;
68
+ appendMessage(message: MessageDto): void;
69
+ };
70
+ publication: {
71
+ publishMessage(topicId: string, message: MessageDto): void;
72
+ publishCardUpdate(update: VaultRequestGateCardUpdate): void;
73
+ };
74
+ };
75
+ topics: {
76
+ getTopic(topicId: string): Pick<TopicDto, "participants"> | null | undefined;
77
+ getConfig(topicId: string): { model?: string } | undefined;
78
+ };
79
+ vault: {
80
+ hasKey(userId: string, key: string): boolean;
81
+ /** Writes the secret. Called before the gate is claimed, so a storage
82
+ * failure never leaves a card marked "fulfilled" without a stored value. */
83
+ set(userId: string, key: string, value: string, description: string): void;
84
+ };
85
+ durability: {
86
+ gates: {
87
+ prepare(args: {
88
+ gateId: string;
89
+ topicId: string;
90
+ queryId?: string;
91
+ idempotencyKey: string;
92
+ bodyHash: string;
93
+ messageId: string;
94
+ ownerId: string;
95
+ now: string;
96
+ }): PrepareVaultRequestGateResult;
97
+ claimAndAnswer(args: {
98
+ topicId: string;
99
+ messageId: string;
100
+ outcome: "fulfilled" | "declined";
101
+ userId: string;
102
+ ownerId: string;
103
+ source: string;
104
+ now: string;
105
+ }): ClaimVaultRequestGateResult;
106
+ cancel(
107
+ topicId: string,
108
+ messageId: string,
109
+ ownerId: string,
110
+ now: string,
111
+ ): VaultRequestGateCardUpdate | null;
112
+ quarantine(gateId: string, ownerId: string, now: string): VaultRequestGateCardUpdate | null;
113
+ quarantineForeign(
114
+ liveOwnerIds: ReadonlySet<string>,
115
+ now: string,
116
+ ): VaultRequestGateCardUpdate[];
117
+ };
118
+ processLeases: {
119
+ acquire(role: string, ownerId: string): RuntimeProcessLeaseHandle | null;
120
+ list(rolePrefix: string): RuntimeProcessLease[];
121
+ isAlive(lease: RuntimeProcessLease): boolean;
122
+ removeDead(rolePrefix: string): void;
123
+ };
124
+ };
125
+ runtime: {
126
+ ownerId: string;
127
+ createId(): string;
128
+ now(): string;
129
+ };
130
+ }
131
+
132
+ export interface RequestVaultRuntime {
133
+ startGateOwner(): void;
134
+ stopGateOwner(): void;
135
+ reconcilePendingGates(): number;
136
+ answerPendingRequest(
137
+ topicId: string,
138
+ messageId: string,
139
+ userId: string,
140
+ value: string | null,
141
+ ): AnswerVaultSecretRequestResult;
142
+ cancelPendingRequests(topicId: string, queryId: string): void;
143
+ createToolDefinition(ctx: RequestVaultToolContext): SharedMcpTool;
144
+ }
145
+
146
+ type PendingVaultRequest = {
147
+ topicId: string;
148
+ queryId?: string;
149
+ messageId: string;
150
+ key: string;
151
+ description: string;
152
+ promise: Promise<{ outcome: "fulfilled" | "declined" | "no_answer"; userId: string }>;
153
+ resolve: (answer: { outcome: "fulfilled" | "declined" | "no_answer"; userId: string }) => void;
154
+ };
155
+
156
+ function requestBodyHash(key: string, description: string): string {
157
+ return createHash("sha256").update(JSON.stringify({ key, description })).digest("hex");
158
+ }
159
+
160
+ function answerToolResult(key: string, outcome: "fulfilled" | "declined" | "no_answer") {
161
+ if (outcome === "no_answer") {
162
+ return errorResult("The AI turn ended before the user answered.");
163
+ }
164
+ if (outcome === "declined") {
165
+ return errorResult(`User declined to provide a value for ${key}.`);
166
+ }
167
+ return textResult(
168
+ `User stored a value for ${key} in the vault. Reference it as {{${key}}} directly in supported transient tool inputs. Never ask the user to repeat the raw value in chat.`,
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Create an isolated request-vault-secret lifecycle over host-provided
174
+ * messaging, topic, vault, durability, and runtime services. Mirrors
175
+ * `createAskUserRuntime`'s durable-gate shape, but the thing being
176
+ * exchanged is "did the user supply a value", never the value itself.
177
+ */
178
+ export function createRequestVaultRuntime(host: RequestVaultRuntimeHost): RequestVaultRuntime {
179
+ const pendingRequests = new Map<string, PendingVaultRequest>();
180
+ const { ownerId } = host.runtime;
181
+ const createId = () => host.runtime.createId();
182
+ const now = () => host.runtime.now();
183
+ const isProcessLeaseAlive = (lease: RuntimeProcessLease) =>
184
+ host.durability.processLeases.isAlive(lease);
185
+ let gateOwnerLease: RuntimeProcessLeaseHandle | null = null;
186
+
187
+ function broadcastGateUpdates(updates: VaultRequestGateCardUpdate[]): void {
188
+ for (const update of updates) host.messaging.publication.publishCardUpdate(update);
189
+ }
190
+
191
+ function startGateOwner(): void {
192
+ if (gateOwnerLease) return;
193
+ gateOwnerLease = host.durability.processLeases.acquire(
194
+ `${VAULT_GATE_LEASE_ROLE_PREFIX}${ownerId}`,
195
+ ownerId,
196
+ );
197
+ if (!gateOwnerLease) throw new Error("failed to acquire the vault-request gate owner lease");
198
+ }
199
+
200
+ function stopGateOwner(): void {
201
+ gateOwnerLease?.stop();
202
+ gateOwnerLease = null;
203
+ }
204
+
205
+ function reconcilePendingGates(): number {
206
+ startGateOwner();
207
+ host.durability.processLeases.removeDead(VAULT_GATE_LEASE_ROLE_PREFIX);
208
+ const liveOwners = new Set(
209
+ host.durability.processLeases
210
+ .list(VAULT_GATE_LEASE_ROLE_PREFIX)
211
+ .filter(isProcessLeaseAlive)
212
+ .map((lease) => lease.ownerId),
213
+ );
214
+ const updates = host.durability.gates.quarantineForeign(liveOwners, now());
215
+ broadcastGateUpdates(updates);
216
+ return updates.length;
217
+ }
218
+
219
+ function answerPendingRequest(
220
+ topicId: string,
221
+ messageId: string,
222
+ userId: string,
223
+ value: string | null,
224
+ ): AnswerVaultSecretRequestResult {
225
+ reconcilePendingGates();
226
+ const topic = host.topics.getTopic(topicId);
227
+ if (!topic?.participants.some((participant) => participant.userId === userId)) {
228
+ return { ok: false, error: "User is not a member of this topic." };
229
+ }
230
+ const existing = host.messaging.persistence.getMessage(topicId, messageId);
231
+ const request =
232
+ existing?.kind === "vault_secret_request" ? existing.vaultSecretRequest : undefined;
233
+ if (!existing || existing.deleted || !request) {
234
+ return { ok: false, error: "Vault request card not found." };
235
+ }
236
+ if (request.status !== "pending") {
237
+ return { ok: false, error: "This request is no longer awaiting an answer." };
238
+ }
239
+ const pending = pendingRequests.get(messageId);
240
+ if (!pending || pending.topicId !== topicId) {
241
+ return { ok: false, error: "This request is no longer awaiting an answer." };
242
+ }
243
+
244
+ const declining = value === null;
245
+ if (!declining) {
246
+ const bytes = Buffer.byteLength(value, "utf8");
247
+ if (bytes < VAULT_VALUE_MIN_BYTES || bytes > VAULT_VALUE_MAX_BYTES) {
248
+ return {
249
+ ok: false,
250
+ error: `Value must be between ${VAULT_VALUE_MIN_BYTES} and ${VAULT_VALUE_MAX_BYTES} bytes.`,
251
+ };
252
+ }
253
+ }
254
+
255
+ // Write the secret (if any) before the gate is claimed: a storage fault
256
+ // here must leave the card still pending, not falsely "fulfilled".
257
+ if (!declining) {
258
+ try {
259
+ host.vault.set(userId, pending.key, value, pending.description);
260
+ } catch (error) {
261
+ return {
262
+ ok: false,
263
+ error: `Failed to store the value: ${error instanceof Error ? error.message : String(error)}`,
264
+ };
265
+ }
266
+ }
267
+
268
+ const outcome: "fulfilled" | "declined" = declining ? "declined" : "fulfilled";
269
+ let claim: ClaimVaultRequestGateResult;
270
+ try {
271
+ claim = host.durability.gates.claimAndAnswer({
272
+ topicId,
273
+ messageId,
274
+ outcome,
275
+ userId,
276
+ ownerId,
277
+ source: "host-control",
278
+ now: now(),
279
+ });
280
+ } catch {
281
+ return { ok: false, error: "This request could not be claimed safely. Retry once." };
282
+ }
283
+ if (claim.outcome !== "claimed") {
284
+ return { ok: false, error: "This request is no longer awaiting an answer." };
285
+ }
286
+ pendingRequests.delete(messageId);
287
+
288
+ const answerMessage: MessageDto = {
289
+ id: createId(),
290
+ topicId,
291
+ authorId: userId,
292
+ text:
293
+ outcome === "fulfilled"
294
+ ? `Provided a value for ${pending.key}.`
295
+ : `Declined to provide a value for ${pending.key}.`,
296
+ parentId: messageId,
297
+ ...(existing.threadRootId ? { threadRootId: existing.threadRootId } : {}),
298
+ createdAt: now(),
299
+ };
300
+ try {
301
+ host.messaging.publication.publishCardUpdate({
302
+ topicId,
303
+ messageId,
304
+ vaultSecretRequest: claim.vaultSecretRequest,
305
+ editedAt: claim.editedAt,
306
+ });
307
+ host.messaging.persistence.appendMessage(answerMessage);
308
+ host.messaging.publication.publishMessage(topicId, answerMessage);
309
+ } catch {
310
+ // The durable answer is authoritative; publication and the convenience
311
+ // child message are best-effort after it.
312
+ }
313
+ pending.resolve({ outcome, userId });
314
+ return { ok: true, queryId: pending.queryId, outcome, answerMessage };
315
+ }
316
+
317
+ function cancelPendingRequests(topicId: string, queryId: string): void {
318
+ for (const [messageId, pending] of pendingRequests) {
319
+ if (pending.topicId === topicId && pending.queryId === queryId) {
320
+ pendingRequests.delete(messageId);
321
+ const update = host.durability.gates.cancel(topicId, messageId, ownerId, now());
322
+ if (update) broadcastGateUpdates([update]);
323
+ pending.resolve({ outcome: "no_answer", userId: "" });
324
+ }
325
+ }
326
+ }
327
+
328
+ function appendRequestMessage(
329
+ ctx: RequestVaultToolContext,
330
+ key: string,
331
+ description: string,
332
+ messageId: string,
333
+ ): MessageDto {
334
+ const cfg = host.topics.getConfig(ctx.topicId);
335
+ const vaultSecretRequest: VaultSecretRequestDto = { key, description, status: "pending" };
336
+ const message: MessageDto = {
337
+ id: messageId,
338
+ topicId: ctx.topicId,
339
+ authorId: "ai",
340
+ text: `Vault secret requested: ${key}`,
341
+ queryId: ctx.queryId,
342
+ agentType: ctx.agent,
343
+ model: ctx.model ?? cfg?.model ?? "unknown",
344
+ kind: "vault_secret_request",
345
+ vaultSecretRequest,
346
+ ...(ctx.threadRootId ? { threadRootId: ctx.threadRootId } : {}),
347
+ createdAt: now(),
348
+ };
349
+ host.messaging.persistence.appendMessage(message);
350
+ return message;
351
+ }
352
+
353
+ async function requestVaultSecret(
354
+ ctx: RequestVaultToolContext,
355
+ input: { key?: unknown; description?: unknown; idempotency_key?: unknown },
356
+ ) {
357
+ if (ctx.unsupportedSurface) {
358
+ return errorResult(
359
+ "Error: this surface cannot mask secret input safely (there is no password field here, so a typed value would land in plaintext chat history). Ask the user to provide this value from the Terminal app or Otium instead.",
360
+ );
361
+ }
362
+ reconcilePendingGates();
363
+ const topic = host.topics.getTopic(ctx.topicId);
364
+ if (!topic) return errorResult(`Error: topic '${ctx.topicId}' not found.`);
365
+ if (!topic.participants.some((participant) => participant.userId === ctx.userId)) {
366
+ return errorResult("Error: user is not a member of this topic.");
367
+ }
368
+
369
+ if (typeof input.key !== "string" || !input.key.trim()) {
370
+ return errorResult("Error: key is required.");
371
+ }
372
+ const key = normalizeVaultKey(input.key);
373
+ if (!VAULT_KEY_PATTERN.test(key)) {
374
+ return errorResult(
375
+ "Error: key must start with a letter and contain only uppercase letters, digits, and underscores (max 128 chars).",
376
+ );
377
+ }
378
+ if (typeof input.description !== "string" || !input.description.trim()) {
379
+ return errorResult("Error: description is required so the user knows what to type.");
380
+ }
381
+ const description = input.description.trim().slice(0, MAX_DESCRIPTION_CHARS);
382
+ if (
383
+ input.idempotency_key !== undefined &&
384
+ (typeof input.idempotency_key !== "string" || !input.idempotency_key.trim())
385
+ ) {
386
+ return errorResult("Error: idempotency_key must be a non-empty string.");
387
+ }
388
+ const rawIdempotencyKey =
389
+ typeof input.idempotency_key === "string" ? input.idempotency_key.trim() : createId();
390
+ if (rawIdempotencyKey.length > MAX_IDEMPOTENCY_KEY_CHARS) {
391
+ return errorResult(
392
+ `Error: idempotency_key must be at most ${MAX_IDEMPOTENCY_KEY_CHARS} characters.`,
393
+ );
394
+ }
395
+
396
+ const bodyHash = requestBodyHash(key, description);
397
+ const createGate = () => {
398
+ const suffix = createId();
399
+ return host.durability.gates.prepare({
400
+ gateId: `vault-request-gate-${suffix}`,
401
+ topicId: ctx.topicId,
402
+ queryId: ctx.queryId,
403
+ idempotencyKey: rawIdempotencyKey,
404
+ bodyHash,
405
+ messageId: `vault-request-${ctx.queryId ?? "runtime"}-${suffix}`,
406
+ ownerId,
407
+ now: now(),
408
+ });
409
+ };
410
+
411
+ let prepared = createGate();
412
+ if (prepared.outcome === "conflict") {
413
+ return errorResult(
414
+ "Error: idempotency_conflict. The same idempotency_key was already used with a different key or description.",
415
+ );
416
+ }
417
+ if (prepared.outcome === "replay") {
418
+ if (!prepared.gate.outcome || !prepared.gate.answeredBy) {
419
+ return errorResult("Error: the stored request_vault_secret replay is incomplete.");
420
+ }
421
+ return answerToolResult(key, prepared.gate.outcome);
422
+ }
423
+ if (prepared.outcome === "pending") {
424
+ if (prepared.gate.ownerId !== ownerId) {
425
+ return errorResult(
426
+ "Error: this request_vault_secret is pending in another healthy runtime process.",
427
+ );
428
+ }
429
+ const existingPending = pendingRequests.get(prepared.gate.messageId);
430
+ if (existingPending) return answerToolResult(key, (await existingPending.promise).outcome);
431
+ if (prepared.gate.state === "claimed") {
432
+ return errorResult("Error: this request_vault_secret is already being finalized.");
433
+ }
434
+ const update = host.durability.gates.quarantine(prepared.gate.gateId, ownerId, now());
435
+ if (update) broadcastGateUpdates([update]);
436
+ prepared = createGate();
437
+ }
438
+ if (prepared.outcome !== "created") {
439
+ return errorResult("Error: failed to create a durable request_vault_secret gate.");
440
+ }
441
+
442
+ // Checked here, not up front: an idempotent retry of an already-fulfilled
443
+ // request must still replay above, even though the key now exists
444
+ // precisely because that earlier call succeeded.
445
+ if (host.vault.hasKey(ctx.userId, key)) {
446
+ host.durability.gates.quarantine(prepared.gate.gateId, ownerId, now());
447
+ return errorResult(
448
+ `Error: vault key '${key}' already exists. Pick a different key, or ask the user to update it from their Vault manager.`,
449
+ );
450
+ }
451
+
452
+ let message: MessageDto;
453
+ try {
454
+ message = appendRequestMessage(ctx, key, description, prepared.gate.messageId);
455
+ } catch (error) {
456
+ host.durability.gates.quarantine(prepared.gate.gateId, ownerId, now());
457
+ return errorResult(
458
+ `Error: failed to persist request_vault_secret: ${error instanceof Error ? error.message : String(error)}`,
459
+ );
460
+ }
461
+ let resolveAnswer!: (answer: {
462
+ outcome: "fulfilled" | "declined" | "no_answer";
463
+ userId: string;
464
+ }) => void;
465
+ const promise = new Promise<{
466
+ outcome: "fulfilled" | "declined" | "no_answer";
467
+ userId: string;
468
+ }>((resolve) => {
469
+ resolveAnswer = resolve;
470
+ });
471
+ pendingRequests.set(message.id, {
472
+ topicId: ctx.topicId,
473
+ queryId: ctx.queryId,
474
+ messageId: message.id,
475
+ key,
476
+ description,
477
+ promise,
478
+ resolve: resolveAnswer,
479
+ });
480
+ try {
481
+ host.messaging.publication.publishMessage(ctx.topicId, message);
482
+ } catch (error) {
483
+ pendingRequests.delete(message.id);
484
+ try {
485
+ host.durability.gates.quarantine(prepared.gate.gateId, ownerId, now());
486
+ } catch {
487
+ // A retry can recover the owner-local gate after the storage fault clears.
488
+ }
489
+ return errorResult(
490
+ `Error: failed to publish request_vault_secret: ${error instanceof Error ? error.message : String(error)}`,
491
+ );
492
+ }
493
+ return answerToolResult(key, (await promise).outcome);
494
+ }
495
+
496
+ function createToolDefinition(ctx: RequestVaultToolContext): SharedMcpTool {
497
+ return {
498
+ name: "request_vault_secret",
499
+ description:
500
+ "Ask the user to store a brand-new secret value in their Vault. You choose the key name and a short description of what to paste; the user types only the value on a surface that can mask it, and you never see the raw value. Use this instead of asking the user to paste a secret directly into chat. After it succeeds, reference the value as {{KEY}} in supported transient tool inputs.",
501
+ schema: {
502
+ key: z
503
+ .string()
504
+ .describe(
505
+ "New Vault key name, e.g. STRIPE_API_KEY. Letters/digits/underscores, must start with a letter.",
506
+ ),
507
+ description: z
508
+ .string()
509
+ .max(MAX_DESCRIPTION_CHARS)
510
+ .describe("One sentence telling the user what value to paste and where it's from."),
511
+ idempotency_key: z
512
+ .string()
513
+ .max(MAX_IDEMPOTENCY_KEY_CHARS)
514
+ .optional()
515
+ .describe(
516
+ "Optional retry key. Reusing it with the same key/description replays the existing result; reusing it with different content fails.",
517
+ ),
518
+ },
519
+ async handler(input) {
520
+ return requestVaultSecret(ctx, input);
521
+ },
522
+ };
523
+ }
524
+
525
+ return {
526
+ startGateOwner,
527
+ stopGateOwner,
528
+ reconcilePendingGates,
529
+ answerPendingRequest,
530
+ cancelPendingRequests,
531
+ createToolDefinition,
532
+ };
533
+ }
534
+
535
+ export const defaultRequestVaultDurabilityHost: RequestVaultRuntimeHost["durability"] = {
536
+ gates: {
537
+ prepare: prepareVaultRequestGate,
538
+ claimAndAnswer: claimVaultRequestGateAndAnswer,
539
+ cancel: cancelVaultRequestGate,
540
+ quarantine: quarantineVaultRequestGate,
541
+ quarantineForeign: quarantineForeignVaultRequestGates,
542
+ },
543
+ processLeases: {
544
+ acquire: (role, ownerId) => acquireRuntimeProcessLease(role, { ownerId }),
545
+ list: listRuntimeProcessLeases,
546
+ isAlive: isRuntimeProcessLeaseAlive,
547
+ removeDead: removeDeadRuntimeProcessLeases,
548
+ },
549
+ };
550
+
551
+ const defaultRequestVaultRuntime = createRequestVaultRuntime({
552
+ messaging: {
553
+ persistence: {
554
+ getMessage: getApiMessage,
555
+ appendMessage: appendApiMessage,
556
+ },
557
+ publication: {
558
+ publishMessage: (topicId, message) => WsHub.get().broadcastMessage(topicId, message),
559
+ publishCardUpdate: (update) =>
560
+ WsHub.get().broadcastMessageUpdated(update.topicId, update.messageId, {
561
+ vaultSecretRequest: update.vaultSecretRequest,
562
+ editedAt: update.editedAt,
563
+ }),
564
+ },
565
+ },
566
+ topics: {
567
+ getTopic,
568
+ getConfig: getApiTopicConfig,
569
+ },
570
+ vault: {
571
+ hasKey: vaultHasKey,
572
+ set: vaultSet,
573
+ },
574
+ durability: defaultRequestVaultDurabilityHost,
575
+ runtime: {
576
+ ownerId: VAULT_GATE_OWNER_ID,
577
+ createId: randomUUID,
578
+ now: () => new Date().toISOString(),
579
+ },
580
+ });
581
+
582
+ export function startRequestVaultGateOwner(): void {
583
+ defaultRequestVaultRuntime.startGateOwner();
584
+ }
585
+
586
+ export function stopRequestVaultGateOwner(): void {
587
+ defaultRequestVaultRuntime.stopGateOwner();
588
+ }
589
+
590
+ export function reconcilePendingRequestVaultGates(): number {
591
+ return defaultRequestVaultRuntime.reconcilePendingGates();
592
+ }
593
+
594
+ /**
595
+ * Host-neutral answer path for pending vault-request cards. Terminal and
596
+ * Otium should call this rather than reimplementing pending-map and
597
+ * persistence ordering. `value: null` means the user declined; any other
598
+ * string is the secret, stored via `vaultSet` before the gate is claimed,
599
+ * and never echoed back in the result.
600
+ */
601
+ export function answerPendingVaultSecretRequest(
602
+ topicId: string,
603
+ messageId: string,
604
+ userId: string,
605
+ value: string | null,
606
+ ): AnswerVaultSecretRequestResult {
607
+ return defaultRequestVaultRuntime.answerPendingRequest(topicId, messageId, userId, value);
608
+ }
609
+
610
+ export function cancelPendingVaultSecretRequests(topicId: string, queryId: string): void {
611
+ defaultRequestVaultRuntime.cancelPendingRequests(topicId, queryId);
612
+ }
613
+
614
+ export function createRequestVaultToolDefinition(ctx: RequestVaultToolContext): SharedMcpTool {
615
+ return defaultRequestVaultRuntime.createToolDefinition(ctx);
616
+ }
@@ -1,4 +1,5 @@
1
1
  import { answerPendingAskUserQuestion } from "#agents/mcp-tools/ask-user";
2
+ import { answerPendingVaultSecretRequest } from "#agents/mcp-tools/request-vault";
2
3
  import { WsHub } from "#bus";
3
4
  import { abortRoom, getRoomQuery } from "#query/active-rooms";
4
5
  import { getTopic } from "#storage/api-topics";
@@ -128,6 +129,12 @@ export const topicService = {
128
129
  return answerPendingAskUserQuestion(topicId, messageId, label, userId);
129
130
  },
130
131
 
132
+ /** `value: null` means the user declined; the plaintext never round-trips through this call's result. */
133
+ answerVaultRequest(topicId: string, messageId: string, userId: string, value: string | null) {
134
+ participantTopic(topicId, userId);
135
+ return answerPendingVaultSecretRequest(topicId, messageId, userId, value);
136
+ },
137
+
131
138
  abortTurn(topicId: string, userId: string): boolean {
132
139
  participantTopic(topicId, userId);
133
140
  const maintenance = beginRuntimeTopicMaintenance(topicId);
@@ -30,6 +30,15 @@ export {
30
30
  } from "#agents/mcp-tools/ask-user";
31
31
  export type { McpToolResult, SharedMcpTool } from "#agents/mcp-tools/common";
32
32
  export { errorResult, textResult } from "#agents/mcp-tools/common";
33
+ export type { AnswerVaultSecretRequestResult } from "#agents/mcp-tools/request-vault";
34
+ export {
35
+ answerPendingVaultSecretRequest,
36
+ cancelPendingVaultSecretRequests,
37
+ createRequestVaultToolDefinition,
38
+ reconcilePendingRequestVaultGates,
39
+ startRequestVaultGateOwner,
40
+ stopRequestVaultGateOwner,
41
+ } from "#agents/mcp-tools/request-vault";
33
42
  export type { SelfConfigContext } from "#agents/mcp-tools/self-config";
34
43
  export { createSelfConfigToolDefinitions } from "#agents/mcp-tools/self-config";
35
44
  export {