@inetafrica/open-claudia 3.5.14 → 3.5.17

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 (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +3 -1
  3. package/README.md +1 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +19 -0
  7. package/channels/kazee/adapter.js +304 -53
  8. package/channels/kazee/bot-handoffs.js +230 -0
  9. package/channels/telegram/adapter.js +4 -2
  10. package/core/actions.js +8 -0
  11. package/core/browser-view.js +200 -0
  12. package/core/config.js +19 -1
  13. package/core/cost-report.js +3 -0
  14. package/core/cron-schedule.js +59 -0
  15. package/core/dream.js +141 -34
  16. package/core/guardrail.js +5 -0
  17. package/core/handlers.js +24 -12
  18. package/core/lessons.js +11 -5
  19. package/core/loopback.js +29 -0
  20. package/core/passive-replies.js +4 -2
  21. package/core/providers/claude-events.js +9 -0
  22. package/core/providers/codex-hook.js +5 -1
  23. package/core/providers/codex.js +48 -0
  24. package/core/relationship.js +1 -1
  25. package/core/router.js +6 -1
  26. package/core/runner.js +264 -8
  27. package/core/safe-filename.js +47 -0
  28. package/core/scheduler.js +137 -36
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +506 -0
  32. package/core/utility-agent.js +17 -1
  33. package/package.json +4 -4
  34. package/test-fixtures/fake-agent-cli.js +17 -1
  35. package/test-guardrail.js +19 -0
  36. package/test-kazee-context-media.js +1 -1
  37. package/test-provider-dream.js +3 -1
  38. package/test-provider-events.js +21 -0
  39. package/test-provider-language.js +20 -1
  40. package/test-provider-pack-review.js +3 -1
  41. package/test-provider-scheduler.js +3 -1
  42. package/test-provider-tool-hooks.js +17 -0
  43. package/test-thread-lanes.js +18 -4
  44. package/web.js +148 -50
@@ -0,0 +1,506 @@
1
+ // Turn journal: interrupted turns resume after a restart.
2
+ //
3
+ // The project transcript writes a turn's lines when the turn ENDS, messages
4
+ // queued behind a running turn live in memory, and Claude Code only resumes a
5
+ // cut session when the next message happens to arrive. So a restart (planned
6
+ // or a crash) silently lost the turn in flight and everything queued behind
7
+ // it. Now:
8
+ // - "lock": when a foreground chat turn starts, <config>/turns/<conversation>.json
9
+ // records what is needed to run it again (conversation, provider, session,
10
+ // prompt, media, message id), refreshed by a 60 s heartbeat; messages
11
+ // queued behind it are listed in queued[] until they start. "Unlock" is
12
+ // deleting the turn on a clean end, a /stop cancel or a reported failure.
13
+ // While the bot shuts down nothing is unlocked: the kill that ends a turn
14
+ // then is not the turn's failure.
15
+ // - boot (after the adapters connect, never in safe mode, RESUME_INTERRUPTED
16
+ // on by default): a turn cut within RESUME_MAX_AGE_MIN (120) is resumed
17
+ // once in the conversation's session with a "you were cut off" prompt
18
+ // (also when the session is lost: the work may be half done); an
19
+ // older one is offered back with Continue / Drop buttons (kept 24 h); a
20
+ // turn already resumed twice is not run again (it may be what kills the
21
+ // bot) and the owner is told. Queued messages are restored in order.
22
+ // - planned restarts drain: new turns are not admitted (they are journaled
23
+ // as queued and run after the restart), running turns get DRAIN_TIMEOUT_S
24
+ // (60) to finish on /restart, /upgrade, /downgrade, and DRAIN_SIGTERM_S
25
+ // (6) on SIGTERM, whose sender kills soon after; whatever is still
26
+ // running stays journaled.
27
+ // - /stop offers each dropped queued message back with a "Run it" button.
28
+ // Scheduled runs (wake-ups, crons, watches) are not journaled: jobs.json keeps
29
+ // their own retry, so they are never resumed twice.
30
+
31
+ const fs = require("fs");
32
+ const path = require("path");
33
+ const crypto = require("crypto");
34
+ const CONFIG_DIR = require("../config-dir");
35
+
36
+ const DIR = () => process.env.OC_TURNS_DIR || path.join(CONFIG_DIR, "turns");
37
+ const HEARTBEAT_MS = 60000;
38
+ const ASK_KEEP_MS = 24 * 3600000;
39
+ const DROPPED_KEEP_MS = 24 * 3600000;
40
+ const MAX_ATTEMPTS = 2;
41
+
42
+ function resumeEnabled() {
43
+ return !/^(off|false|0|no)$/i.test(String(process.env.RESUME_INTERRUPTED ?? require("./config").config.RESUME_INTERRUPTED ?? "on").trim());
44
+ }
45
+ function maxAgeMs() {
46
+ const n = Number(process.env.RESUME_MAX_AGE_MIN ?? require("./config").config.RESUME_MAX_AGE_MIN);
47
+ return (Number.isFinite(n) && n > 0 ? n : 120) * 60000;
48
+ }
49
+ function drainTimeoutMs() {
50
+ const n = Number(process.env.DRAIN_TIMEOUT_S ?? require("./config").config.DRAIN_TIMEOUT_S);
51
+ return (Number.isFinite(n) && n >= 0 ? n : 60) * 1000;
52
+ }
53
+ // SIGTERM comes from a supervisor that kills soon after (Agent Space bot pods:
54
+ // terminationGracePeriodSeconds 15; launchd: 20 s). This drain plus the 5 s
55
+ // memory drain after it must fit inside that window.
56
+ function sigtermDrainTimeoutMs() {
57
+ const n = Number(process.env.DRAIN_SIGTERM_S ?? require("./config").config.DRAIN_SIGTERM_S);
58
+ return (Number.isFinite(n) && n >= 0 ? n : 6) * 1000;
59
+ }
60
+
61
+ function idFor(laneKey) {
62
+ return crypto.createHash("sha1").update(String(laneKey)).digest("hex").slice(0, 12);
63
+ }
64
+ function fileFor(laneKey) {
65
+ const safe = String(laneKey).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
66
+ return path.join(DIR(), `${safe}-${idFor(laneKey)}.json`);
67
+ }
68
+ function read(file) {
69
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (_) { return null; }
70
+ }
71
+ function write(file, record) {
72
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
73
+ const tmp = `${file}.${process.pid}.tmp`;
74
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
75
+ fs.renameSync(tmp, file);
76
+ }
77
+ function remove(file) { try { fs.unlinkSync(file); } catch (_) {} }
78
+
79
+ // Persist the record, or drop the file when nothing is left in it.
80
+ function store(file, record) {
81
+ if (!record.turn && !(record.queued || []).length) return remove(file);
82
+ write(file, record);
83
+ }
84
+
85
+ // A test run that did not point the config dir somewhere else must never
86
+ // leave a turn in the live journal for the real bot to resume at boot.
87
+ function journalOn() {
88
+ return !(process.env.OPEN_CLAUDIA_TEST === "1" && !process.env.OPEN_CLAUDIA_CONFIG_DIR && !process.env.OC_TURNS_DIR);
89
+ }
90
+
91
+ // Only a foreground chat turn with a chat to answer in.
92
+ function journalable(runContext) {
93
+ return journalOn()
94
+ && !!runContext
95
+ && runContext.purpose === "foreground"
96
+ && !runContext.liveCall
97
+ && !!runContext.origin?.adapterId
98
+ && !!runContext.channelId;
99
+ }
100
+
101
+ function conversationOf(runContext) {
102
+ const origin = runContext.origin || {};
103
+ return {
104
+ laneKey: runContext.laneKey,
105
+ adapterId: origin.adapterId || null,
106
+ transport: origin.transport || null,
107
+ channelId: String(runContext.channelId || origin.channelId || ""),
108
+ threadId: runContext.threadId || null,
109
+ userId: origin.userId || null,
110
+ canonicalUserId: runContext.canonicalUserId || origin.canonicalUserId || null,
111
+ };
112
+ }
113
+
114
+ function inboundOf(runContext) {
115
+ return {
116
+ runId: runContext.runId,
117
+ prompt: String(runContext.userPrompt || ""),
118
+ imagePaths: runContext.imagePaths || [],
119
+ attachments: runContext.attachments || [],
120
+ replyToMsgId: runContext.replyToMsgId || null,
121
+ };
122
+ }
123
+
124
+ let shuttingDown = false;
125
+ let draining = false;
126
+ const active = new Map(); // laneKey -> file of a journaled running turn
127
+ let heartbeat = null;
128
+
129
+ function ensureHeartbeat(now = Date.now) {
130
+ if (heartbeat) return;
131
+ heartbeat = setInterval(() => beat(now()), HEARTBEAT_MS);
132
+ heartbeat.unref?.();
133
+ }
134
+
135
+ function beat(at = Date.now()) {
136
+ for (const file of active.values()) {
137
+ const record = read(file);
138
+ if (!record?.turn) continue;
139
+ record.turn.heartbeatAt = at;
140
+ try { write(file, record); } catch (_) {}
141
+ }
142
+ }
143
+
144
+ // "Lock": a journalable turn starts. A turn re-started by the boot resume
145
+ // keeps its attempt count and its original message.
146
+ function begin(runContext, opts = {}) {
147
+ if (!journalable(runContext)) return false;
148
+ const at = Number.isFinite(opts.now) ? opts.now : Date.now();
149
+ const file = fileFor(runContext.laneKey);
150
+ const existing = read(file) || {};
151
+ const carried = existing.turn && existing.turn.resuming ? existing.turn : null;
152
+ const record = {
153
+ v: 1,
154
+ conversation: conversationOf(runContext),
155
+ turn: {
156
+ ...inboundOf(runContext),
157
+ provider: runContext.provider,
158
+ sessionId: runContext.sessionId || null,
159
+ project: runContext.project ? { name: runContext.project.name || null, dir: runContext.project.dir || null } : null,
160
+ originalPrompt: carried ? (carried.originalPrompt || carried.prompt) : String(runContext.userPrompt || ""),
161
+ startedAt: carried ? carried.startedAt : at,
162
+ heartbeatAt: at,
163
+ attempts: carried ? (carried.attempts || 0) : 0,
164
+ },
165
+ queued: (existing.queued || []).filter((q) => q.runId !== runContext.runId),
166
+ };
167
+ try { write(file, record); } catch (error) { console.warn(`[turn-journal] could not journal ${runContext.laneKey}: ${error.message}`); return false; }
168
+ active.set(runContext.laneKey, file);
169
+ ensureHeartbeat();
170
+ return true;
171
+ }
172
+
173
+ // "Unlock": the turn ended (cleanly, cancelled or failed). Never while the
174
+ // bot shuts down: that kill is not the turn's end.
175
+ function end(runContext) {
176
+ if (!journalable(runContext) || shuttingDown) return false;
177
+ const file = fileFor(runContext.laneKey);
178
+ const record = read(file);
179
+ if (!record?.turn || record.turn.runId !== runContext.runId) return false;
180
+ active.delete(runContext.laneKey);
181
+ record.turn = null;
182
+ store(file, record);
183
+ return true;
184
+ }
185
+
186
+ // A message waiting behind the running turn (or for a slot).
187
+ function enqueue(runContext, opts = {}) {
188
+ if (!journalable(runContext)) return false;
189
+ const file = fileFor(runContext.laneKey);
190
+ const record = read(file) || { v: 1, conversation: conversationOf(runContext), turn: null, queued: [] };
191
+ record.conversation = record.conversation || conversationOf(runContext);
192
+ record.queued = (record.queued || []).filter((q) => q.runId !== runContext.runId);
193
+ record.queued.push({ ...inboundOf(runContext), provider: runContext.provider, queuedAt: Number.isFinite(opts.now) ? opts.now : Date.now() });
194
+ try { write(file, record); } catch (_) { return false; }
195
+ return true;
196
+ }
197
+
198
+ // Queued messages that will not start (/stop, /new, a project change).
199
+ function dropQueued(laneKey, runIds) {
200
+ const file = fileFor(laneKey);
201
+ const record = read(file);
202
+ if (!record) return;
203
+ const ids = new Set(runIds);
204
+ record.queued = (record.queued || []).filter((q) => !ids.has(q.runId));
205
+ store(file, record);
206
+ }
207
+
208
+ function list() {
209
+ if (!journalOn()) return [];
210
+ let names = [];
211
+ try { names = fs.readdirSync(DIR()); } catch (_) { return []; }
212
+ return names.filter((n) => n.endsWith(".json") && n !== "dropped.json")
213
+ .map((n) => ({ file: path.join(DIR(), n), record: read(path.join(DIR(), n)) }))
214
+ .filter((x) => x.record && x.record.conversation);
215
+ }
216
+
217
+ // ── boot resume ─────────────────────────────────────────────────────────
218
+
219
+ const RESUME_PROMPT = (at, original, { sessionLost = false } = {}) => `You were cut off by a restart at ${at} while working on: ${original}\n\n${sessionLost ? "Your earlier session was lost, so you have no memory of that work: some of it may already be done. " : ""}Check what you already did (your history, git, files, tool state, the chat) before acting; never repeat a send, push, deploy or write that already happened; then finish.`;
220
+
221
+ function hhmm(ms) {
222
+ return new Date(ms).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
223
+ }
224
+ function clip(text, n = 80) {
225
+ const s = String(text || "").replace(/\s+/g, " ").trim();
226
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
227
+ }
228
+
229
+ function defaultDeps() {
230
+ const stateApi = require("./state");
231
+ const { canonicalForChannel, conversationKeyFor } = require("./identity");
232
+ return {
233
+ now: Date.now,
234
+ isSafeMode: () => { try { return require("./safe-mode").isSafeMode(); } catch (_) { return false; } },
235
+ runInChat: (ctx, fn) => require("./context").runInChat(ctx, fn),
236
+ runAgent: (...args) => require("./runner").runAgent(...args),
237
+ send: (...args) => require("./io").send(...args),
238
+ getUserState: (id) => stateApi.getUserState(id),
239
+ getProviderSession: (state, provider, project) => stateApi.getProviderSession(state, provider, project),
240
+ ownerTarget: () => require("./relationship").ownerTarget(),
241
+ canonicalForChannel, conversationKeyFor,
242
+ };
243
+ }
244
+
245
+ function chatScope(conversation, adapter, deps) {
246
+ const canonical = conversation.canonicalUserId || deps.canonicalForChannel(adapter.type, conversation.channelId);
247
+ return {
248
+ adapter,
249
+ channelId: String(conversation.channelId),
250
+ canonicalUserId: canonical,
251
+ userId: String(conversation.userId || (adapter.type === "telegram" ? conversation.channelId : canonical || "")),
252
+ transport: adapter.type,
253
+ raw: null,
254
+ conversationKey: conversation.laneKey,
255
+ threadId: conversation.threadId || null,
256
+ roomKey: deps.conversationKeyFor(adapter.type, conversation.channelId),
257
+ };
258
+ }
259
+
260
+ function findAdapter(adapters, conversation) {
261
+ return adapters.find((a) => a.id === conversation.adapterId)
262
+ || adapters.find((a) => a.type === conversation.transport) || null;
263
+ }
264
+
265
+ function runQueued(record, adapter, deps) {
266
+ const queued = record.queued || [];
267
+ if (!queued.length) return [];
268
+ const scope = chatScope(record.conversation, adapter, deps);
269
+ return deps.runInChat(scope, () => queued.map((q) => {
270
+ const dir = record.turn?.project?.dir || null;
271
+ return Promise.resolve(deps.runAgent(q.prompt, dir, q.replyToMsgId, {
272
+ purpose: "foreground", provider: q.provider || undefined,
273
+ imagePaths: q.imagePaths || [], attachments: q.attachments || [],
274
+ })).catch((error) => console.warn(`[turn-journal] queued message failed: ${error?.message || error}`));
275
+ }));
276
+ }
277
+
278
+ // Run the cut turn again, always with the cut-off prompt: the work may be half
279
+ // done whatever the session, so it is never replayed as a plain message.
280
+ async function runResume(file, record, adapter, deps, { cutAt }) {
281
+ const turn = record.turn;
282
+ const scope = chatScope(record.conversation, adapter, deps);
283
+ turn.attempts = (turn.attempts || 0) + 1;
284
+ turn.resuming = true;
285
+ delete turn.askedAt;
286
+ delete turn.askId;
287
+ const queued = record.queued || [];
288
+ record.queued = [];
289
+ write(file, record);
290
+ return deps.runInChat(scope, async () => {
291
+ let state = null;
292
+ try { state = deps.getUserState(scope.canonicalUserId); } catch (_) { state = null; }
293
+ const current = state ? deps.getProviderSession(state, turn.provider, turn.project?.name || record.conversation.laneKey) : null;
294
+ // The conversation's session now holds the cut turn's progress: the one it
295
+ // resumed, or the one it started (a first turn or a thread fork journals
296
+ // no id; the provider reports it mid-turn) or moved to (compaction).
297
+ const sessionId = current || turn.sessionId || null;
298
+ const sessionLost = !sessionId;
299
+ const original = turn.originalPrompt || turn.prompt;
300
+ await Promise.resolve(deps.send(sessionLost
301
+ ? `Picking up where I was cut off (restart at ${hhmm(cutAt)}). The earlier session is gone, so I'm checking what I already did before carrying on.`
302
+ : `Picking up where I was cut off (restart at ${hhmm(cutAt)}).`)).catch(() => {});
303
+ const prompt = RESUME_PROMPT(hhmm(cutAt), original, { sessionLost });
304
+ const opts = {
305
+ purpose: "foreground", provider: turn.provider,
306
+ imagePaths: turn.imagePaths || [], attachments: turn.attachments || [],
307
+ ...(sessionId ? { resumeSessionId: sessionId } : {}),
308
+ };
309
+ const running = Promise.resolve(deps.runAgent(prompt, turn.project?.dir || null, turn.replyToMsgId, opts));
310
+ // Queued messages follow the resumed turn, in order, in the same lane.
311
+ runQueued({ ...record, queued }, adapter, deps);
312
+ return running;
313
+ });
314
+ }
315
+
316
+ async function tellOwner(text, adapters, deps) {
317
+ try {
318
+ const target = await deps.ownerTarget();
319
+ const adapter = target && (adapters.find((a) => a.id === target.adapter) || adapters.find((a) => a.type === target.adapter));
320
+ if (adapter) { await adapter.send(target.channelId, text); return true; }
321
+ } catch (_) { /* logged below */ }
322
+ console.warn(`[turn-journal] ${text}`);
323
+ return false;
324
+ }
325
+
326
+ async function resumeInterrupted(adapters, overrides = {}) {
327
+ const deps = { ...defaultDeps(), ...overrides };
328
+ const out = { resumed: [], asked: [], refused: [], restored: 0 };
329
+ if (!resumeEnabled()) { console.log("[turn-journal] resume off (RESUME_INTERRUPTED=off)"); return out; }
330
+ if (deps.isSafeMode()) { console.log("[turn-journal] safe mode: interrupted turns left for later"); return out; }
331
+ const now = deps.now();
332
+ const runs = [];
333
+ for (const { file, record } of list()) {
334
+ const adapter = findAdapter(adapters || [], record.conversation);
335
+ if (!adapter) { console.warn(`[turn-journal] ${record.conversation.laneKey}: its channel is not loaded; left for later`); continue; }
336
+ const turn = record.turn;
337
+ if (!turn) {
338
+ const queued = record.queued || [];
339
+ record.queued = [];
340
+ remove(file);
341
+ runQueued({ ...record, queued }, adapter, deps);
342
+ out.restored += queued.length;
343
+ continue;
344
+ }
345
+ const cutAt = turn.heartbeatAt || turn.startedAt || now;
346
+ if (turn.askedAt) {
347
+ // Offered already: the buttons work for 24 h, then it is dropped.
348
+ if (now - turn.askedAt > ASK_KEEP_MS) { record.turn = null; store(file, record); }
349
+ continue;
350
+ }
351
+ if ((turn.attempts || 0) >= MAX_ATTEMPTS) {
352
+ const where = record.conversation.threadId ? `a thread of ${record.conversation.transport}:${record.conversation.channelId}` : `${record.conversation.transport}:${record.conversation.channelId}`;
353
+ await tellOwner(`⚠️ I did not resume a turn in ${where} that was cut off ${turn.attempts} times (it may be what stops me): "${clip(turn.originalPrompt || turn.prompt)}". Send it again if you still want it.`, adapters, deps);
354
+ const queued = record.queued || [];
355
+ record.turn = null;
356
+ record.queued = [];
357
+ remove(file);
358
+ runQueued({ ...record, queued }, adapter, deps);
359
+ out.refused.push(record.conversation.laneKey);
360
+ out.restored += queued.length;
361
+ continue;
362
+ }
363
+ if (now - cutAt > maxAgeMs()) {
364
+ const askId = idFor(`${record.conversation.laneKey}:${turn.runId}`);
365
+ turn.askedAt = now;
366
+ turn.askId = askId;
367
+ const queued = record.queued || [];
368
+ record.queued = [];
369
+ write(file, record);
370
+ const scope = chatScope(record.conversation, adapter, deps);
371
+ await deps.runInChat(scope, () => Promise.resolve(deps.send(
372
+ `I was cut off at ${hhmm(cutAt)} doing: ${clip(turn.originalPrompt || turn.prompt)}. Continue?`,
373
+ { keyboard: { inline_keyboard: [[{ text: "Continue", callback_data: `rsm:go:${askId}` }, { text: "Drop", callback_data: `rsm:drop:${askId}` }]] } },
374
+ )).catch(() => {}));
375
+ runQueued({ ...record, queued }, adapter, deps);
376
+ out.asked.push(record.conversation.laneKey);
377
+ out.restored += queued.length;
378
+ continue;
379
+ }
380
+ out.resumed.push(record.conversation.laneKey);
381
+ out.restored += (record.queued || []).length;
382
+ runs.push(runResume(file, record, adapter, deps, { cutAt }));
383
+ }
384
+ out.runs = runs;
385
+ return out;
386
+ }
387
+
388
+ // A button is pressed in the chat it was posted in, by the person whose
389
+ // message it was or by the owner.
390
+ function mayPress(envelope, conversation, deps) {
391
+ if (String(envelope?.channelId || "") !== String(conversation.channelId)) return false;
392
+ if (deps.isOwner === true) return true;
393
+ return !!conversation.canonicalUserId && String(envelope?.canonicalUserId || "") === String(conversation.canonicalUserId);
394
+ }
395
+
396
+ // Continue / Drop on an offered turn (callback "rsm:go:<id>" / "rsm:drop:<id>").
397
+ async function handleResumeAction(data, envelope, overrides = {}) {
398
+ const deps = { ...defaultDeps(), ...overrides };
399
+ const [, verb, askId] = String(data).split(":");
400
+ const hit = list().find((x) => x.record.turn?.askId === askId);
401
+ if (!hit) return deps.send("That cut-off turn is no longer waiting.");
402
+ const { file, record } = hit;
403
+ if (!mayPress(envelope, record.conversation, deps)) return deps.send("That button belongs to another chat or person.");
404
+ if (verb === "drop") {
405
+ record.turn = null;
406
+ store(file, record);
407
+ return deps.send("Dropped.");
408
+ }
409
+ const adapter = envelope?.adapter || null;
410
+ if (!adapter) return null;
411
+ return runResume(file, record, adapter, deps, { cutAt: record.turn.heartbeatAt || record.turn.startedAt || deps.now() });
412
+ }
413
+
414
+ // ── /stop: dropped queued messages can be run again ────────────────────
415
+
416
+ function droppedFile() { return path.join(DIR(), "dropped.json"); }
417
+
418
+ // Keep each dropped message (24 h) and return its button id.
419
+ function rememberDropped(items, opts = {}) {
420
+ const at = Number.isFinite(opts.now) ? opts.now : Date.now();
421
+ const all = read(droppedFile()) || {};
422
+ for (const [id, item] of Object.entries(all)) if (at - (item.at || 0) > DROPPED_KEEP_MS) delete all[id];
423
+ const ids = [];
424
+ for (const item of items) {
425
+ const runContext = item?.runContext;
426
+ if (!runContext || !journalable(runContext)) { ids.push(null); continue; }
427
+ const id = idFor(`${runContext.runId}:${at}`).slice(0, 10);
428
+ all[id] = { ...inboundOf(runContext), provider: runContext.provider, conversation: conversationOf(runContext), projectDir: runContext.project?.dir || null, at };
429
+ ids.push(id);
430
+ }
431
+ try { write(droppedFile(), all); } catch (_) { return items.map(() => null); }
432
+ return ids;
433
+ }
434
+
435
+ // The buttons for a /stop notice: one "Run it" per dropped message (max 5).
436
+ function droppedKeyboard(items, opts = {}) {
437
+ const ids = rememberDropped(items, opts);
438
+ const rows = [];
439
+ items.forEach((item, i) => {
440
+ if (!ids[i] || rows.length >= 5) return;
441
+ const words = clip(item.text || item.runContext?.userPrompt || "", 28);
442
+ rows.push([{ text: `▶️ Run it: "${words}"`, callback_data: `rq:${ids[i]}` }]);
443
+ });
444
+ return rows.length ? { inline_keyboard: rows } : null;
445
+ }
446
+
447
+ async function handleRunDropped(data, envelope, overrides = {}) {
448
+ const deps = { ...defaultDeps(), ...overrides };
449
+ const id = String(data).slice(3);
450
+ const all = read(droppedFile()) || {};
451
+ const item = all[id];
452
+ if (!item) return deps.send("That message is no longer kept (24 h).");
453
+ if (!mayPress(envelope, item.conversation, deps)) return deps.send("That button belongs to another chat or person.");
454
+ delete all[id];
455
+ try { write(droppedFile(), all); } catch (_) {}
456
+ const scope = chatScope(item.conversation, envelope.adapter, deps);
457
+ return deps.runInChat(scope, () => deps.runAgent(item.prompt, item.projectDir, item.replyToMsgId, {
458
+ purpose: "foreground", provider: item.provider || undefined,
459
+ imagePaths: item.imagePaths || [], attachments: item.attachments || [],
460
+ }));
461
+ }
462
+
463
+ // ── planned restarts ───────────────────────────────────────────────────
464
+
465
+ function isDraining() { return draining; }
466
+
467
+ // Stop admitting new turns, give running ones up to DRAIN_TIMEOUT_S, then mark
468
+ // shutdown so the turns still running stay journaled when they are killed.
469
+ async function drain(opts = {}) {
470
+ draining = true;
471
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : drainTimeoutMs();
472
+ const busy = opts.anyTurnActive || (() => require("./state").anyTurnActive());
473
+ const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
474
+ const now = opts.now || Date.now;
475
+ const deadline = now() + timeoutMs;
476
+ while (busy() && now() < deadline) await sleep(250);
477
+ const finished = !busy();
478
+ shuttingDown = true;
479
+ if (!finished) console.log(`[turn-journal] drain: turns still running after ${Math.round(timeoutMs / 1000)} s stay journaled and resume after the restart`);
480
+ return { finished };
481
+ }
482
+
483
+ // A planned exit (/restart, /upgrade, /downgrade): drain first.
484
+ async function exitAfterDrain(code = 0, opts = {}) {
485
+ try { require("./scheduler").stopAll(); } catch (_) {}
486
+ try { await drain(opts); } catch (_) { shuttingDown = true; }
487
+ (opts.exit || process.exit)(code);
488
+ }
489
+
490
+ function markShuttingDown() { shuttingDown = true; }
491
+
492
+ function _resetForTests() {
493
+ shuttingDown = false;
494
+ draining = false;
495
+ active.clear();
496
+ if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
497
+ }
498
+
499
+ module.exports = {
500
+ journalable, begin, end, enqueue, dropQueued, beat, list, fileFor,
501
+ resumeInterrupted, handleResumeAction, RESUME_PROMPT,
502
+ rememberDropped, droppedKeyboard, handleRunDropped,
503
+ drain, exitAfterDrain, isDraining, markShuttingDown,
504
+ resumeEnabled, maxAgeMs, drainTimeoutMs, sigtermDrainTimeoutMs, MAX_ATTEMPTS, HEARTBEAT_MS,
505
+ _resetForTests,
506
+ };
@@ -514,6 +514,7 @@ function createUtilityAgentService(options = {}) {
514
514
  activeControllers.add(controller);
515
515
  request = { ...request, signal: controller.signal };
516
516
  let schemaResource = null;
517
+ const providerSchemaResources = [];
517
518
  try {
518
519
  const candidates = policy.candidates({ ...request, purpose });
519
520
  if (request.outputSchema) schemaResource = prepareSchemaFile(request.outputSchema);
@@ -522,6 +523,20 @@ function createUtilityAgentService(options = {}) {
522
523
  const selection = candidates[index];
523
524
  const provider = selection.provider;
524
525
  const effort = providerEffort(provider, request.effort);
526
+ // A provider may need its own dialect of the caller's schema, or none at
527
+ // all (Codex: OpenAI strict mode, 6 Oct 2026). The reply is still checked
528
+ // against the caller's schema after the run.
529
+ let providerSchema = request.outputSchema || null;
530
+ let providerSchemaPath = schemaResource ? schemaResource.file : null;
531
+ if (providerSchema && typeof provider.outputSchemaFor === "function") {
532
+ providerSchema = provider.outputSchemaFor(request.outputSchema) || null;
533
+ if (!providerSchema) providerSchemaPath = null;
534
+ else if (JSON.stringify(providerSchema) !== JSON.stringify(request.outputSchema)) {
535
+ const resource = prepareSchemaFile(providerSchema);
536
+ providerSchemaResources.push(resource);
537
+ providerSchemaPath = resource.file;
538
+ }
539
+ }
525
540
  const systemPrompt = typeof request.systemPrompt === "string" && request.systemPrompt.trim()
526
541
  ? request.systemPrompt
527
542
  : DEFAULT_SYSTEM_PROMPT;
@@ -541,7 +556,7 @@ function createUtilityAgentService(options = {}) {
541
556
  : {}),
542
557
  ...(request.allowedTools !== undefined ? { allowedTools: request.allowedTools } : {}),
543
558
  ...(request.disallowedTools !== undefined ? { disallowedTools: request.disallowedTools } : {}),
544
- ...(request.outputSchema ? { outputSchema: request.outputSchema, outputSchemaPath: schemaResource.file } : {}),
559
+ ...(providerSchema ? { outputSchema: providerSchema, outputSchemaPath: providerSchemaPath } : {}),
545
560
  ...(effort ? { effort } : {}),
546
561
  };
547
562
 
@@ -615,6 +630,7 @@ function createUtilityAgentService(options = {}) {
615
630
  activeControllers.delete(controller);
616
631
  if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
617
632
  if (schemaResource) schemaResource.cleanup();
633
+ for (const resource of providerSchemaResources) resource.cleanup();
618
634
  release(purpose);
619
635
  }
620
636
  }