@inetafrica/open-claudia 2.15.0 → 3.0.1

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 (152) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +81 -3
  12. package/channels/telegram/adapter.js +65 -5
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +155 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +542 -224
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +121 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +125 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +171 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1562 -1286
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/single-instance.js +46 -0
  63. package/core/state.js +1084 -97
  64. package/core/subagent.js +72 -98
  65. package/core/system-prompt.js +462 -279
  66. package/core/tool-guard.js +73 -39
  67. package/core/tools.js +22 -5
  68. package/core/transcripts.js +30 -1
  69. package/core/usage-log.js +19 -4
  70. package/core/utility-agent.js +654 -0
  71. package/core/web-auth.js +29 -13
  72. package/docs/CHANNEL_DESIGN.md +181 -0
  73. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  74. package/docs/PROVIDER_MIGRATION.md +67 -0
  75. package/health.js +50 -39
  76. package/package.json +48 -4
  77. package/setup.js +198 -38
  78. package/soul.md +39 -0
  79. package/test-ability-extraction.js +5 -0
  80. package/test-approval-async.js +63 -4
  81. package/test-cursor-removal.js +337 -0
  82. package/test-enforcer.js +70 -27
  83. package/test-fixtures/current-provider-parity.json +132 -0
  84. package/test-fixtures/fake-agent-cli.js +509 -0
  85. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  86. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  87. package/test-fixtures/migrations/claude-only/state.json +5 -0
  88. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  90. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  92. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  93. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  94. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  95. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  97. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  100. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  101. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  102. package/test-fixtures/migrations/missing-project/state.json +4 -0
  103. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  104. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  105. package/test-fixtures/migrations/multi-user/state.json +6 -0
  106. package/test-fixtures/provider-event-samples.json +17 -0
  107. package/test-learning-e2e.js +5 -0
  108. package/test-memory-mutation-queue.js +191 -0
  109. package/test-provider-boot-matrix.js +399 -0
  110. package/test-provider-bootstrap.js +158 -0
  111. package/test-provider-capabilities.js +232 -0
  112. package/test-provider-claude.js +206 -0
  113. package/test-provider-codex.js +228 -0
  114. package/test-provider-compaction.js +371 -0
  115. package/test-provider-core-prompt.js +264 -0
  116. package/test-provider-coupling-audit.js +90 -0
  117. package/test-provider-dream.js +312 -0
  118. package/test-provider-enforcer.js +252 -0
  119. package/test-provider-env-isolation.js +150 -0
  120. package/test-provider-events.js +171 -0
  121. package/test-provider-fixture.js +332 -0
  122. package/test-provider-gateway.js +508 -0
  123. package/test-provider-language.js +89 -0
  124. package/test-provider-migration-backup.js +424 -0
  125. package/test-provider-pack-review.js +436 -0
  126. package/test-provider-parity-e2e.js +537 -0
  127. package/test-provider-prompt-parity.js +89 -0
  128. package/test-provider-recall.js +271 -0
  129. package/test-provider-registry.js +251 -0
  130. package/test-provider-resume-parity.js +41 -0
  131. package/test-provider-run-lifecycle.js +349 -0
  132. package/test-provider-runner.js +271 -0
  133. package/test-provider-scheduler.js +689 -0
  134. package/test-provider-session-commands.js +185 -0
  135. package/test-provider-session-history.js +205 -0
  136. package/test-provider-side-chat.js +337 -0
  137. package/test-provider-state-migration.js +316 -0
  138. package/test-provider-stream-decoder.js +69 -0
  139. package/test-provider-subagent.js +228 -0
  140. package/test-provider-tool-hooks.js +360 -0
  141. package/test-recall-discoverer.js +1 -0
  142. package/test-recall-engine.js +3 -3
  143. package/test-recall-evolution.js +18 -0
  144. package/test-runner-watchdog-static.js +16 -8
  145. package/test-single-runtime.js +35 -0
  146. package/test-telegram-poll-recovery.js +93 -0
  147. package/test-tool-guard.js +56 -0
  148. package/test-tools.js +19 -0
  149. package/test-unified-identity.js +3 -1
  150. package/test-usage-accounting.js +92 -20
  151. package/test-utility-provider-policy.js +486 -0
  152. package/web.js +130 -35
package/core/runner.js CHANGED
@@ -1,36 +1,43 @@
1
- // Backend runner — Claude Code, Cursor Agent, OpenAI Codex. Owns
1
+ // Provider runner — Claude Code and OpenAI Codex. Owns
2
2
  // argv construction, subprocess lifecycle, stream parsing, and the
3
3
  // progress UI. All IO routes through core/io.js so the same runner
4
4
  // works on every channel.
5
5
 
6
6
  const fs = require("fs");
7
7
  const path = require("path");
8
+ const crypto = require("crypto");
8
9
  const { spawn, execFileSync } = require("child_process");
9
10
  const {
10
- CLAUDE_PATH, DEFAULT_CLAUDE_MODEL, resolvedCursorPath, resolvedCodexPath,
11
- AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT, botSubprocessEnv,
11
+ AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT,
12
12
  CONFIG_DIR, WORKSPACE, config,
13
13
  } = require("./config");
14
- const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage, acquireRunLock } = require("./state");
15
- const { chatContext, currentChannelId, currentAdapter, currentUserId } = require("./context");
16
- const { splitLeadingOriginGroup } = require("./queue-drain");
17
- const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
14
+ const {
15
+ currentState, saveState, recordSession,
16
+ resetSessionUsage, acquireRunLock, getActiveProvider, getProjectKey,
17
+ getProviderSession, setProviderSession, clearProviderSession, getUsageForSession, commitProviderCompaction,
18
+ } = require("./state");
19
+ const { chatContext, currentChannelId, currentAdapter } = require("./context");
20
+ const { buildTurnPrompt } = require("./system-prompt");
21
+ const { getProvider, resolveProviderForRun } = require("./providers");
22
+ const { validateInvocation } = require("./providers/contract");
23
+ const { selectTerminalText } = require("./providers/events");
24
+ const {
25
+ bindRunInputs,
26
+ captureRunContext,
27
+ deepFreeze,
28
+ sameCapturedProject,
29
+ } = require("./run-context");
18
30
  const { redactSensitive } = require("./redact");
19
- const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
31
+ const { createSideChatPromptBuilder, sideChatCoordinator } = require("./side-chat");
32
+ const { send, sendVoice, sendVoiceEnd, splitMessage, editMessage, deleteMessage } = require("./io");
20
33
  const { textToVoice, splitSentences, synthSentenceMp3 } = require("./media");
21
- const { killProcessTree } = require("./process-tree");
34
+ const { killProcessTree, waitForPidsExit } = require("./process-tree");
22
35
  const {
23
36
  appendProjectTranscript, transcriptProjectInfo,
24
37
  stripTranscriptPointerForStorage,
25
38
  } = require("./transcripts");
26
- const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
27
39
  const loopback = require("./loopback");
28
- const skillsLib = require("./skills");
29
- const packsLib = require("./packs");
30
- const entitiesLib = require("./entities");
31
- const toolsLib = require("./tools");
32
40
  const packReview = require("./pack-review");
33
- const { recallNodeFromPath } = require("./recall/read-signal");
34
41
  const {
35
42
  appendUsageRecord,
36
43
  loadUsageHistory,
@@ -56,7 +63,10 @@ function usageNumber(usage, ...keys) {
56
63
  return 0;
57
64
  }
58
65
 
59
- function usageParts(usage, backend = "claude") {
66
+ function usageParts(usage, backend) {
67
+ if (!new Set(["claude", "codex"]).has(backend)) {
68
+ throw Object.assign(new TypeError("usage accounting requires an explicit provider"), { code: "INVALID_PROVIDER" });
69
+ }
60
70
  const input = usageNumber(usage, "input_tokens");
61
71
  const cacheRead = backend === "codex"
62
72
  ? usageNumber(usage, "cached_input_tokens", "cache_read_input_tokens")
@@ -85,36 +95,35 @@ function clampUsageDelta(current, previous) {
85
95
  };
86
96
  }
87
97
 
88
- function snapshotCodexUsage(state, sessionId, usage) {
98
+ function snapshotCodexUsage(state, sessionId, usage, options = {}) {
89
99
  if (!state || !sessionId || !usage) return null;
90
- const snapshots = state.codexUsageSnapshots && typeof state.codexUsageSnapshots === "object"
91
- ? state.codexUsageSnapshots
92
- : {};
93
- const previous = snapshots[sessionId] || null;
94
- snapshots[sessionId] = {
100
+ const project = options.project || getProjectKey(state);
101
+ if (!project) return null;
102
+ const bucket = getUsageForSession(state, project, "codex", sessionId);
103
+ const previous = bucket.providerUsageSnapshot || null;
104
+ bucket.providerUsageSnapshot = {
95
105
  input_tokens: usageNumber(usage, "input_tokens"),
96
106
  cached_input_tokens: usageNumber(usage, "cached_input_tokens"),
97
107
  output_tokens: usageNumber(usage, "output_tokens"),
98
108
  };
99
- const keys = Object.keys(snapshots);
100
- if (keys.length > 20) {
101
- for (const key of keys.slice(0, keys.length - 20)) delete snapshots[key];
102
- }
103
- state.codexUsageSnapshots = snapshots;
104
109
  return previous;
105
110
  }
106
111
 
107
- function normalizeCodexUsage(state, sessionId, usage) {
108
- const previous = snapshotCodexUsage(state, sessionId, usage);
112
+ function normalizeCodexUsage(state, sessionId, usage, options = {}) {
113
+ const previous = snapshotCodexUsage(state, sessionId, usage, options);
109
114
  if (!previous) return { usage, usageScope: "session_total", rawUsage: usage };
110
115
  return { usage: clampUsageDelta(usage, previous), usageScope: "turn_delta", rawUsage: usage };
111
116
  }
112
117
 
113
118
  function applyUsageToState(state, usage, costUsd, opts = {}) {
114
119
  if (!usage) return null;
115
- const backend = state.settings?.backend || "claude";
120
+ const backend = opts.backend || getActiveProvider(state);
116
121
  const parts = usageParts(usage, backend);
117
- const u = state.sessionUsage;
122
+ const project = opts.project || null;
123
+ const sessionId = opts.sessionId || null;
124
+ const u = project
125
+ ? getUsageForSession(state, project, backend, sessionId)
126
+ : state.sessionUsage;
118
127
  u.turns += 1;
119
128
  u.inputTokens += parts.input;
120
129
  u.outputTokens += parts.output;
@@ -130,7 +139,7 @@ function applyUsageToState(state, usage, costUsd, opts = {}) {
130
139
 
131
140
  function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
132
141
  if (!usage) return null;
133
- const backend = state.settings?.backend || "claude";
142
+ const backend = opts.backend || getActiveProvider(state);
134
143
  const parts = usageParts(usage, backend);
135
144
  const usageScope = opts.usageScope || "turn_delta";
136
145
  // `parts.context` is the per-turn SUM of every API call's prefix, inflated
@@ -142,9 +151,16 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
142
151
  ts: new Date().toISOString(),
143
152
  version: PKG_VERSION,
144
153
  backend,
145
- model: state.settings?.model || (backend === "claude" ? DEFAULT_CLAUDE_MODEL : null),
146
- userId: state.userId || null,
147
- sessionId: state[getActiveSessionKey(state)] || null,
154
+ provider: backend,
155
+ model: Object.prototype.hasOwnProperty.call(opts, "model")
156
+ ? (opts.model || null)
157
+ : (state.providerSettings?.[backend]?.model || getProvider(backend).defaultModel()),
158
+ userId: opts.userId || state.userId || null,
159
+ sessionId: Object.prototype.hasOwnProperty.call(opts, "sessionId")
160
+ ? (opts.sessionId || null)
161
+ : getProviderSession(state, backend, getProjectKey(state)),
162
+ runId: opts.runId || null,
163
+ project: opts.project || null,
148
164
  usageScope,
149
165
  inputTokens: parts.input,
150
166
  outputTokens: parts.output,
@@ -162,7 +178,7 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
162
178
  record.rawContextTokens = raw.context;
163
179
  }
164
180
  const previousRecords = loadUsageHistory();
165
- appendUsageRecord(record);
181
+ appendUsageRecord(record, { strict: !!opts.strict });
166
182
 
167
183
  const policy = usageAlertPolicy(config);
168
184
  const lastAlertAt = state.usageAlertLastAt || 0;
@@ -194,69 +210,689 @@ function telegramHtmlOpts(extra = {}) {
194
210
  return adapter?.type === "telegram" ? { ...extra, parseMode: "HTML" } : extra;
195
211
  }
196
212
 
197
- function chatEnvOverlay() {
198
- const adapter = currentAdapter();
199
- const channelId = currentChannelId();
200
- const lb = loopback.info();
201
- const overlay = {};
202
- if (adapter && adapter.id) overlay.OC_CHANNEL_ADAPTER = adapter.id;
203
- if (channelId) overlay.OC_CHANNEL_ID = String(channelId);
204
- // Raw per-user id (distinct from channelId on Kazee) so subprocess egress
205
- // guards classify the speaker by person, not room — matching in-process
206
- // relationship.speakerFor. On Telegram this equals channelId.
207
- const uid = currentUserId();
208
- if (uid) overlay.OC_CHANNEL_USER_ID = String(uid);
209
- if (lb) {
210
- overlay.OC_SEND_URL = lb.url;
211
- overlay.OC_SEND_TOKEN = lb.token;
212
- }
213
- const tinfo = transcriptProjectInfo();
214
- if (tinfo && tinfo.transcriptPath) overlay.OC_TRANSCRIPT_PATH = tinfo.transcriptPath;
213
+ function stableRunError(message, code = "PROVIDER_RUN_FAILED", details = {}) {
214
+ const error = new Error(redactSensitive(String(message || "Provider run failed")));
215
+ error.name = "ProviderRunError";
216
+ error.code = code;
217
+ error.details = details;
218
+ return error;
219
+ }
220
+
221
+ function createPersistenceOutcome() {
222
+ return {
223
+ transcript: { ok: true, skipped: true },
224
+ usage: { ok: true, skipped: true },
225
+ session: { ok: true, skipped: true },
226
+ };
227
+ }
228
+
229
+ // Provider-neutral child lifecycle. It owns framing/normalization and resolves
230
+ // only after EOF, child close, cleanup, required persistence, and delivery.
231
+ // Expected provider/process failures are values, not rejected promises.
232
+ async function executeProviderInvocation(options = {}) {
233
+ const {
234
+ runContext,
235
+ provider,
236
+ cwd,
237
+ spawnProcess = spawn,
238
+ timeoutMs = MAX_PROCESS_TIMEOUT,
239
+ killTree = killProcessTree,
240
+ killGraceMs = 5000,
241
+ cleanup = async () => {},
242
+ persistTranscript = null,
243
+ persistUsage = null,
244
+ persistSession = null,
245
+ deliver = null,
246
+ onEvent = null,
247
+ onSpawn = null,
248
+ onStderr = null,
249
+ idleTimeoutMs = 0,
250
+ } = options;
251
+ const startedAt = Date.now();
252
+ const invocation = validateInvocation(options.invocation);
253
+ const persistence = createPersistenceOutcome();
254
+ const delivery = { ok: true, skipped: !deliver };
255
+ const cleanupOutcome = { ok: true, skipped: false };
256
+ const rawErrors = [];
257
+ const usageEvents = [];
258
+ const tools = [];
259
+ const textDeltas = [];
260
+ const textFinals = [];
261
+ // Text blocks grouped by the tool runs between them. The last segment is
262
+ // the provider's actual answer; everything before it is interim narration
263
+ // ("Let me check X…" written before a tool call). Kept separate so chat
264
+ // delivery can split them while result.text keeps the full transcript.
265
+ const textSegments = [[]];
266
+ let resultText = "";
267
+ let sessionId = runContext?.sessionId || null;
268
+ let terminalSeen = false;
269
+ let stdoutEnded = false;
270
+ let timedOut = false;
271
+ let watchdogTripped = false;
272
+ let spawnError = null;
273
+ let stderr = "";
274
+ let proc = null;
275
+ let closeInfo = null;
276
+ let lastActivity = Date.now();
277
+ let timeoutTimer = null;
278
+ let killTimer = null;
279
+ let idleTimer = null;
280
+ let terminationStarted = false;
281
+ let terminationForced = false;
282
+ let resolveTermination;
283
+ let terminationPromise = Promise.resolve();
284
+ const terminationDescendants = new Set();
285
+ let decoderEnded = false;
286
+ let eventChain = Promise.resolve();
287
+ const decoder = provider.createEventDecoder();
288
+
289
+ const rememberDescendants = (value) => {
290
+ if (!Array.isArray(value)) return;
291
+ for (const pid of value) {
292
+ const number = Number(pid);
293
+ if (Number.isSafeInteger(number) && number > 0 && number !== proc?.pid) terminationDescendants.add(number);
294
+ }
295
+ };
296
+
297
+ const forceTermination = async () => {
298
+ if (!terminationStarted || terminationForced) return;
299
+ terminationForced = true;
300
+ if (killTimer) clearTimeout(killTimer);
301
+ try {
302
+ if (!closeInfo) {
303
+ try { rememberDescendants(killTree(proc?.pid, "SIGKILL")); } catch (_) {}
304
+ }
305
+ for (const pid of [...terminationDescendants]) {
306
+ try { rememberDescendants(killTree(pid, "SIGKILL")); } catch (_) {}
307
+ }
308
+ const survivors = await waitForPidsExit(
309
+ [...terminationDescendants],
310
+ Math.max(100, Math.min(2000, Number(killGraceMs) || 100)),
311
+ );
312
+ if (survivors.length) {
313
+ cleanupOutcome.ok = false;
314
+ cleanupOutcome.error = stableRunError("Provider descendants survived forced termination", "RUN_CLEANUP_FAILED", { pids: survivors });
315
+ }
316
+ } finally {
317
+ resolveTermination?.();
318
+ }
319
+ };
320
+
321
+ const beginTermination = (idle = false) => {
322
+ if (terminationStarted) return;
323
+ terminationStarted = true;
324
+ timedOut = true;
325
+ watchdogTripped = idle;
326
+ terminationPromise = new Promise((resolve) => { resolveTermination = resolve; });
327
+ killTimer = setTimeout(() => { void forceTermination(); }, Math.max(0, killGraceMs));
328
+ try { rememberDescendants(killTree(proc?.pid, "SIGTERM")); } catch (_) {}
329
+ };
330
+
331
+ const handleNormalizedEvent = (event) => {
332
+ if (!event || typeof event !== "object") return;
333
+ if (event.type === "session" && event.sessionId) sessionId = event.sessionId;
334
+ else if (event.type === "text_delta" && typeof event.text === "string") textDeltas.push(event.text);
335
+ else if (event.type === "text_final" && typeof event.text === "string") {
336
+ textFinals.push(event.text);
337
+ textSegments[textSegments.length - 1].push(event.text);
338
+ } else if (event.type === "tool_start" || event.type === "tool_end") {
339
+ if (event.type === "tool_start" && textSegments[textSegments.length - 1].length) textSegments.push([]);
340
+ tools.push(event);
341
+ }
342
+ else if (event.type === "usage") usageEvents.push(event);
343
+ else if (event.type === "result") {
344
+ terminalSeen = true;
345
+ if (event.sessionId) sessionId = event.sessionId;
346
+ if (typeof event.text === "string" && event.text) resultText = event.text;
347
+ } else if (event.type === "error") {
348
+ terminalSeen = true;
349
+ rawErrors.push(event);
350
+ }
351
+ if (onEvent) {
352
+ eventChain = eventChain.then(() => onEvent(event)).catch((error) => {
353
+ rawErrors.push({
354
+ type: "error",
355
+ message: `run event handler failed: ${error.message}`,
356
+ authError: false,
357
+ usageLimit: false,
358
+ });
359
+ });
360
+ }
361
+ };
362
+
363
+ const handleRawEvents = (rawEvents) => {
364
+ for (const rawEvent of rawEvents || []) {
365
+ let normalized;
366
+ try { normalized = provider.normalizeEvent(rawEvent, invocation.parserState); }
367
+ catch (error) {
368
+ normalized = [{ type: "error", message: `provider event normalization failed: ${error.message}`, authError: false, usageLimit: false }];
369
+ }
370
+ const list = Array.isArray(normalized) ? normalized : (normalized ? [normalized] : []);
371
+ for (const event of list) handleNormalizedEvent(event);
372
+ }
373
+ };
374
+
375
+ const finishDecoder = () => {
376
+ if (decoderEnded) return;
377
+ decoderEnded = true;
378
+ stdoutEnded = true;
379
+ try { handleRawEvents(decoder.end()); }
380
+ catch (error) {
381
+ handleNormalizedEvent({ type: "error", message: `provider stream finalization failed: ${error.message}`, authError: false, usageLimit: false });
382
+ }
383
+ };
384
+
385
+ let resolveStdout;
386
+ const stdoutPromise = new Promise((resolve) => { resolveStdout = resolve; });
387
+ let resolveClose;
388
+ const closePromise = new Promise((resolve) => { resolveClose = resolve; });
389
+
215
390
  try {
216
- const state = currentState();
217
- if (state) {
218
- if (state.userId) overlay.OC_CANONICAL_USER_ID = String(state.userId);
219
- const key = getActiveSessionKey(state);
220
- const sid = state[key];
221
- if (sid) {
222
- overlay.OC_LAST_SESSION_ID = String(sid);
223
- overlay.OC_LAST_SESSION_KEY = key;
391
+ proc = spawnProcess(invocation.binary, invocation.args, {
392
+ cwd,
393
+ env: invocation.env,
394
+ stdio: [invocation.stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
395
+ detached: process.platform !== "win32",
396
+ });
397
+ } catch (error) {
398
+ spawnError = error;
399
+ finishDecoder();
400
+ resolveStdout();
401
+ resolveClose({ code: null, signal: null });
402
+ }
403
+
404
+ if (proc) {
405
+ try { if (onSpawn) onSpawn(proc); }
406
+ catch (error) { rawErrors.push({ type: "error", message: `spawn observer failed: ${error.message}`, authError: false, usageLimit: false }); }
407
+
408
+ proc.stdout.on("data", (data) => {
409
+ lastActivity = Date.now();
410
+ try { handleRawEvents(decoder.push(data)); }
411
+ catch (error) {
412
+ handleNormalizedEvent({ type: "error", message: `provider stream decoding failed: ${error.message}`, authError: false, usageLimit: false });
224
413
  }
414
+ });
415
+ proc.stdout.once("end", () => {
416
+ finishDecoder();
417
+ resolveStdout();
418
+ });
419
+ proc.stdout.once("close", () => {
420
+ if (timedOut || spawnError) {
421
+ finishDecoder();
422
+ resolveStdout();
423
+ }
424
+ });
425
+ proc.stdout.once("error", (error) => {
426
+ handleNormalizedEvent({ type: "error", message: `provider stdout failed: ${error.message}`, authError: false, usageLimit: false });
427
+ finishDecoder();
428
+ resolveStdout();
429
+ });
430
+ proc.stderr.on("data", (data) => {
431
+ lastActivity = Date.now();
432
+ const chunk = data.toString("utf8");
433
+ stderr = (stderr + chunk).slice(-64 * 1024);
434
+ if (onStderr) onStderr(chunk);
435
+ });
436
+ proc.stderr.once("error", (error) => {
437
+ rawErrors.push({ type: "error", message: `provider stderr failed: ${error.message}`, authError: false, usageLimit: false });
438
+ });
439
+ proc.once("error", (error) => {
440
+ spawnError = error;
441
+ resolveClose({ code: null, signal: null });
442
+ if (!stdoutEnded) {
443
+ try { proc.stdout.destroy(); } catch (_) {}
444
+ finishDecoder();
445
+ resolveStdout();
446
+ }
447
+ });
448
+ proc.once("close", (code, signal) => {
449
+ closeInfo = { code, signal };
450
+ resolveClose(closeInfo);
451
+ if (terminationStarted) void forceTermination();
452
+ if ((timedOut || spawnError) && !stdoutEnded) {
453
+ try { proc.stdout.destroy(); } catch (_) {}
454
+ finishDecoder();
455
+ resolveStdout();
456
+ }
457
+ });
458
+
459
+ if (invocation.stdin !== null && proc.stdin) {
460
+ // EPIPE here (child died before the pipe flushed) must not become an
461
+ // uncaughtException; the close handler settles the run via exit code.
462
+ proc.stdin.once("error", (error) => {
463
+ stderr = (stderr + `\n[stdin] ${error.message}`).slice(-64 * 1024);
464
+ });
465
+ proc.stdin.end(invocation.stdin);
466
+ }
467
+
468
+ const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : MAX_PROCESS_TIMEOUT;
469
+ timeoutTimer = setTimeout(() => beginTermination(false), effectiveTimeout);
470
+
471
+ if (Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0) {
472
+ idleTimer = setInterval(() => {
473
+ if (watchdogTripped) return;
474
+ if (Date.now() - lastActivity <= idleTimeoutMs) return;
475
+ beginTermination(true);
476
+ }, Math.min(1000, Math.max(10, Math.floor(idleTimeoutMs / 4))));
225
477
  }
226
- } catch (e) { /* no chat context (e.g. startup) — skip */ }
227
- return overlay;
478
+ }
479
+
480
+ await Promise.all([stdoutPromise, closePromise]);
481
+ closeInfo = closeInfo || { code: null, signal: null };
482
+ if (timeoutTimer) clearTimeout(timeoutTimer);
483
+ if (idleTimer) clearInterval(idleTimer);
484
+ if (terminationStarted) await terminationPromise;
485
+ else if (killTimer) clearTimeout(killTimer);
486
+ await eventChain;
487
+
488
+ try { await cleanup(proc, { timedOut, watchdogTripped, closeInfo }); }
489
+ catch (error) {
490
+ cleanupOutcome.ok = false;
491
+ cleanupOutcome.error = stableRunError(error.message, "RUN_CLEANUP_FAILED");
492
+ }
493
+
494
+ const finalUsage = usageEvents.length ? usageEvents[usageEvents.length - 1] : null;
495
+ const text = selectTerminalText({
496
+ resultText,
497
+ finalText: textFinals.join(""),
498
+ deltaText: textDeltas.join(""),
499
+ });
500
+ const segmentTexts = textSegments.map((blocks) => blocks.join("")).filter((t) => t.trim());
501
+ const finalAnswerText = segmentTexts.length ? segmentTexts[segmentTexts.length - 1] : "";
502
+ const narrationText = segmentTexts.slice(0, -1).join("\n\n");
503
+ let failure = null;
504
+ if (spawnError) failure = stableRunError(spawnError.message, "PROVIDER_SPAWN_FAILED");
505
+ else if (timedOut) failure = stableRunError(
506
+ watchdogTripped ? "Provider run stopped after becoming idle" : "Provider run timed out",
507
+ watchdogTripped ? "PROVIDER_IDLE_TIMEOUT" : "PROVIDER_TIMEOUT",
508
+ );
509
+ else if (closeInfo.signal) failure = stableRunError(`Provider process exited on signal ${closeInfo.signal}`, "PROVIDER_SIGNAL_EXIT", { signal: closeInfo.signal });
510
+ else if (rawErrors.length) {
511
+ const event = rawErrors[0];
512
+ failure = stableRunError(event.message || "Provider reported an error", event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR"), event);
513
+ } else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
514
+ else if (!terminalSeen) failure = stableRunError("Provider stream ended without a terminal event", "PROVIDER_MISSING_TERMINAL");
515
+ else if (!cleanupOutcome.ok) failure = cleanupOutcome.error;
516
+
517
+ const result = {
518
+ ok: !failure,
519
+ status: failure ? "failed" : "succeeded",
520
+ runId: runContext?.runId || null,
521
+ provider: runContext?.provider || provider.id,
522
+ project: runContext?.project || null,
523
+ admittedSessionId: runContext?.sessionId || null,
524
+ sessionId,
525
+ purpose: runContext?.purpose || "foreground",
526
+ text: redactSensitive(String(text || "").trim()),
527
+ finalAnswerText: redactSensitive(String(finalAnswerText || "").trim()),
528
+ narrationText: redactSensitive(String(narrationText || "")),
529
+ usage: finalUsage?.usage || null,
530
+ usageScope: finalUsage?.scope || null,
531
+ usageEvents,
532
+ costUsd: Number.isFinite(invocation.parserState.totalCostUsd) ? invocation.parserState.totalCostUsd : 0,
533
+ tools,
534
+ exitCode: closeInfo.code,
535
+ signal: closeInfo.signal,
536
+ timedOut,
537
+ watchdogTripped,
538
+ terminalSeen,
539
+ stdoutEnded,
540
+ stderr: redactSensitive(stderr),
541
+ error: failure,
542
+ diagnostic: failure ? failure.message : null,
543
+ cleanup: cleanupOutcome,
544
+ persistence,
545
+ delivery,
546
+ timings: { startedAt, finishedAt: null, durationMs: null },
547
+ };
548
+
549
+ const runStage = async (name, hook) => {
550
+ if (!hook) return;
551
+ persistence[name] = { ok: true, skipped: false };
552
+ try { await hook(result, runContext); }
553
+ catch (error) {
554
+ persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
555
+ if (!result.error) result.error = persistence[name].error;
556
+ result.ok = false;
557
+ result.status = "failed";
558
+ result.diagnostic = result.error.message;
559
+ }
560
+ };
561
+
562
+ await runStage("transcript", persistTranscript);
563
+ await runStage("usage", persistUsage);
564
+ await runStage("session", persistSession);
565
+
566
+ if (deliver) {
567
+ delivery.skipped = false;
568
+ try {
569
+ const outcome = await deliver(result, runContext);
570
+ if (outcome && outcome.ok === false) throw new Error(outcome.error?.message || "required delivery failed");
571
+ delivery.ok = true;
572
+ if (outcome !== undefined) delivery.result = outcome;
573
+ } catch (error) {
574
+ delivery.ok = false;
575
+ delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
576
+ if (!result.error) result.error = delivery.error;
577
+ result.ok = false;
578
+ result.status = "failed";
579
+ result.diagnostic = result.error.message;
580
+ }
581
+ }
582
+
583
+ result.timings.finishedAt = Date.now();
584
+ result.timings.durationMs = result.timings.finishedAt - startedAt;
585
+ return result;
228
586
  }
229
587
 
230
- function subprocessEnv() {
231
- return { ...claudeSubprocessEnv(), ...chatEnvOverlay() };
588
+ function capabilitySupported(provider, name) {
589
+ return provider.capabilities?.[name]?.support === "native"
590
+ || provider.capabilities?.[name]?.support === "emulated";
591
+ }
592
+
593
+ function invocationContextForRun(runContext, bundle, provider) {
594
+ const providerSettings = { ...(runContext.providerSettings || {}) };
595
+ if (!capabilitySupported(provider, "budget")) providerSettings.budget = null;
596
+ if (!capabilitySupported(provider, "worktree")) providerSettings.worktree = false;
597
+ const context = {
598
+ ...runContext,
599
+ ...bundle,
600
+ providerSettings,
601
+ sessionId: runContext.sessionId,
602
+ resumeSessionId: runContext.resumeSessionId,
603
+ continueSession: runContext.continueSession,
604
+ fresh: runContext.fresh,
605
+ maxTurns: capabilitySupported(provider, "maxTurns") ? runContext.maxTurns : null,
606
+ imagePaths: capabilitySupported(provider, "imageFlag") ? runContext.imagePaths : [],
607
+ voiceStream: capabilitySupported(provider, "partialStreaming") && runContext.voiceStream,
608
+ transcriptPaths: bundle.transcriptPaths || null,
609
+ toolHookSettings: capabilitySupported(provider, "preToolHook") ? runContext.toolHookSettings : null,
610
+ };
611
+ return Object.freeze(context);
232
612
  }
233
613
 
234
- function parseStreamEvents(data) {
235
- const events = [];
236
- for (const line of data.split("\n").filter((l) => l.trim())) {
237
- try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
614
+ function preflightFailureResult(runContext, provider, failure) {
615
+ const error = stableRunError(
616
+ failure?.message || `${provider.label} is unavailable`,
617
+ failure?.code || "PROVIDER_PREFLIGHT_FAILED",
618
+ { providerId: provider.id },
619
+ );
620
+ return {
621
+ ok: false,
622
+ status: "failed",
623
+ runId: runContext.runId,
624
+ provider: runContext.provider,
625
+ project: runContext.project,
626
+ admittedSessionId: runContext.sessionId,
627
+ sessionId: runContext.sessionId,
628
+ purpose: runContext.purpose,
629
+ text: "",
630
+ usage: null,
631
+ usageScope: null,
632
+ usageEvents: [],
633
+ tools: [],
634
+ exitCode: null,
635
+ signal: null,
636
+ timedOut: false,
637
+ terminalSeen: false,
638
+ stdoutEnded: false,
639
+ stderr: "",
640
+ error,
641
+ diagnostic: error.message,
642
+ cleanup: { ok: true, skipped: true },
643
+ persistence: createPersistenceOutcome(),
644
+ delivery: { ok: true, skipped: true },
645
+ timings: { startedAt: Date.now(), finishedAt: Date.now(), durationMs: 0 },
646
+ };
647
+ }
648
+
649
+ function cancelledRunResult(runContext, provider) {
650
+ const result = preflightFailureResult(runContext, provider, {
651
+ code: "RUN_CANCELLED",
652
+ message: "Cancelled by /stop before the provider started",
653
+ });
654
+ result.status = "cancelled";
655
+ return result;
656
+ }
657
+
658
+ async function settleImmediateRunResult(result, runContext, dependencies, capture) {
659
+ for (const [name, hook] of [
660
+ ["transcript", dependencies.persistTranscript],
661
+ ["usage", dependencies.persistUsage],
662
+ ["session", dependencies.persistSession],
663
+ ]) {
664
+ if (!hook) continue;
665
+ result.persistence[name] = { ok: true, skipped: false };
666
+ try { await hook(result, runContext); }
667
+ catch (error) {
668
+ result.persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
669
+ if (!result.error) result.error = result.persistence[name].error;
670
+ result.ok = false;
671
+ result.status = "failed";
672
+ }
673
+ }
674
+ if (!capture && dependencies.deliver) {
675
+ result.delivery.skipped = false;
676
+ try {
677
+ const outcome = await dependencies.deliver(result, runContext);
678
+ if (outcome && outcome.ok === false) throw new Error(outcome.error?.message || "required delivery failed");
679
+ result.delivery.ok = true;
680
+ result.delivery.result = outcome;
681
+ } catch (error) {
682
+ result.delivery.ok = false;
683
+ result.delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
684
+ if (!result.error) result.error = result.delivery.error;
685
+ result.ok = false;
686
+ result.status = "failed";
687
+ }
688
+ }
689
+ result.diagnostic = result.error?.message || result.diagnostic;
690
+ result.timings.finishedAt = Date.now();
691
+ result.timings.durationMs = result.timings.finishedAt - result.timings.startedAt;
692
+ return result;
693
+ }
694
+
695
+ function createRunnerService(dependencies = {}) {
696
+ const getState = dependencies.getState || currentState;
697
+ const getStore = dependencies.getStore || (() => chatContext.getStore());
698
+ const resolveProvider = dependencies.resolveProvider || getProvider;
699
+ const promptBuilder = dependencies.buildTurnPrompt || buildTurnPrompt;
700
+ const invocationBuilder = dependencies.buildInvocation
701
+ || ((provider, context) => provider.buildMainInvocation(context));
702
+
703
+ function admit(prompt, cwd, replyToMsgId, opts = {}) {
704
+ const state = getState();
705
+ const store = getStore() || null;
706
+ return opts.runContext || captureRunContext({
707
+ state,
708
+ store,
709
+ prompt,
710
+ cwd,
711
+ replyToMsgId,
712
+ opts,
713
+ channelId: opts.channelId || store?.channelId || currentChannelId(),
714
+ canonicalUserId: opts.canonicalUserId || state.userId,
715
+ runId: opts.runId || null,
716
+ });
717
+ }
718
+
719
+ async function executeCaptured(prompt, runContext, { capture = false } = {}) {
720
+ const immediateFailure = (provider, failure) => settleImmediateRunResult(
721
+ preflightFailureResult(runContext, provider, failure),
722
+ runContext,
723
+ dependencies,
724
+ capture,
725
+ );
726
+ let provider;
727
+ try {
728
+ provider = resolveProvider(runContext.provider);
729
+ } catch (error) {
730
+ return immediateFailure({ id: runContext.provider, label: runContext.provider }, error);
731
+ }
732
+ if (!provider) {
733
+ return immediateFailure({ id: runContext.provider, label: runContext.provider }, {
734
+ code: "UNKNOWN_PROVIDER",
735
+ message: `Unknown provider: ${runContext.provider}`,
736
+ });
737
+ }
738
+ try {
739
+ const authFailure = typeof provider.preflightAuth === "function" ? provider.preflightAuth() : null;
740
+ if (authFailure) return immediateFailure(provider, authFailure);
741
+ } catch (error) {
742
+ return immediateFailure(provider, error);
743
+ }
744
+
745
+ let bundle;
746
+ let invocation;
747
+ try {
748
+ bundle = await promptBuilder(prompt, { runContext, provider });
749
+ invocation = invocationBuilder(provider, invocationContextForRun(runContext, bundle, provider));
750
+ if (dependencies.decorateInvocation) {
751
+ invocation = dependencies.decorateInvocation(invocation, runContext, bundle, provider);
752
+ }
753
+ invocation = validateInvocation(invocation);
754
+ for (const warning of invocation.warnings || []) {
755
+ if (dependencies.onInvocationWarning) {
756
+ await dependencies.onInvocationWarning(warning, runContext, provider);
757
+ } else {
758
+ console.warn(`[provider-warning] ${warning.code}: ${warning.message}`);
759
+ }
760
+ }
761
+ } catch (error) {
762
+ return immediateFailure(provider, error);
763
+ }
764
+
765
+ // Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
766
+ // flag instead of killing a process (there is none yet). Honour it here,
767
+ // after the slow prompt build and before anything spawns. Persistence
768
+ // still settles (the transcript records the cancelled turn); delivery is
769
+ // suppressed downstream via the RUN_CANCELLED code.
770
+ if (dependencies.shouldAbort && dependencies.shouldAbort(runContext, { capture })) {
771
+ return settleImmediateRunResult(cancelledRunResult(runContext, provider), runContext, dependencies, capture);
772
+ }
773
+
774
+ const result = await executeProviderInvocation({
775
+ runContext,
776
+ provider,
777
+ invocation,
778
+ cwd: runContext.cwd || runContext.project?.dir || process.cwd(),
779
+ spawnProcess: dependencies.spawnProcess || spawn,
780
+ timeoutMs: runContext.timeoutMs || dependencies.timeoutMs || MAX_PROCESS_TIMEOUT,
781
+ killTree: dependencies.killTree || killProcessTree,
782
+ killGraceMs: dependencies.killGraceMs,
783
+ cleanup: dependencies.cleanup
784
+ ? (proc, details) => dependencies.cleanup(proc, details, runContext)
785
+ : (async () => {}),
786
+ persistTranscript: dependencies.persistTranscript || null,
787
+ persistUsage: dependencies.persistUsage || null,
788
+ persistSession: dependencies.persistSession || null,
789
+ deliver: capture ? null : (dependencies.deliver || null),
790
+ onEvent: dependencies.onEvent ? (event) => dependencies.onEvent(event, runContext) : null,
791
+ onSpawn: dependencies.onSpawn ? (proc) => dependencies.onSpawn(proc, runContext) : null,
792
+ onStderr: dependencies.onStderr ? (chunk) => dependencies.onStderr(chunk, runContext) : null,
793
+ idleTimeoutMs: dependencies.idleTimeoutMs || 0,
794
+ });
795
+ result.recallMetadata = bundle.recallMetadata || null;
796
+ result.transcriptPaths = bundle.transcriptPaths || null;
797
+ return result;
798
+ }
799
+
800
+ // These wrappers are intentionally not async: admission and deep-freezing
801
+ // happen synchronously before prompt construction can yield.
802
+ function runAgentCapture(prompt, cwd, opts = {}) {
803
+ const admitted = admit(prompt, cwd, null, { ...opts, requiredDelivery: false });
804
+ const runContext = bindRunInputs(admitted, {
805
+ userPrompt: prompt,
806
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
807
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
808
+ });
809
+ return executeCaptured(prompt, runContext, { capture: true });
810
+ }
811
+
812
+ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
813
+ const admitted = admit(prompt, cwd, replyToMsgId, opts);
814
+ const runContext = bindRunInputs(admitted, {
815
+ userPrompt: prompt,
816
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
817
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
818
+ });
819
+ return executeCaptured(prompt, runContext, { capture: false });
820
+ }
821
+
822
+ function runAgentSilent(prompt, cwd, label, opts = {}) {
823
+ return runAgent(prompt, cwd, null, { ...opts, label, purpose: opts.purpose || "silent" });
238
824
  }
239
- return events;
825
+
826
+ return {
827
+ admit,
828
+ executeCaptured,
829
+ runAgent,
830
+ runAgentCapture,
831
+ runAgentSilent,
832
+ };
240
833
  }
241
834
 
242
- function getActiveBinary() {
243
- const { settings } = currentState();
244
- if (settings.backend === "cursor") return resolvedCursorPath;
245
- if (settings.backend === "codex") return resolvedCodexPath;
246
- return CLAUDE_PATH;
835
+ function createSideChatService(dependencies = {}) {
836
+ const promptBuilder = createSideChatPromptBuilder(dependencies.buildCoreInstructions);
837
+ const service = createRunnerService({
838
+ resolveProvider: dependencies.resolveProvider || resolveForegroundProvider,
839
+ buildTurnPrompt: dependencies.buildTurnPrompt || promptBuilder,
840
+ buildInvocation(provider, context) {
841
+ const isolated = Object.freeze({
842
+ ...context,
843
+ fresh: true,
844
+ sessionId: null,
845
+ admittedSessionId: null,
846
+ previousSessionId: null,
847
+ resumeSessionId: null,
848
+ continueSession: false,
849
+ readOnly: true,
850
+ permissionMode: "plan",
851
+ toolHookSettings: null,
852
+ imagePaths: [],
853
+ });
854
+ if (typeof provider.buildUtilityInvocation !== "function") {
855
+ throw stableRunError(`${provider.label || provider.id} does not support isolated side responses`, "UNSUPPORTED_PROVIDER_CAPABILITY");
856
+ }
857
+ return provider.buildUtilityInvocation(isolated);
858
+ },
859
+ decorateInvocation(invocation, runContext) {
860
+ return {
861
+ ...invocation,
862
+ env: {
863
+ ...invocation.env,
864
+ OC_PROVIDER: runContext.provider,
865
+ OC_RUN_ID: runContext.runId,
866
+ },
867
+ };
868
+ },
869
+ spawnProcess: dependencies.spawnProcess,
870
+ timeoutMs: dependencies.timeoutMs,
871
+ killTree: dependencies.killTree,
872
+ killGraceMs: dependencies.killGraceMs,
873
+ onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc)),
874
+ onStderr: dependencies.onStderr || ((chunk) => console.error("SIDE PROVIDER STDERR:", redactSensitive(chunk))),
875
+ cleanup: dependencies.cleanup || (async (proc, details, runContext) => {
876
+ sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc);
877
+ }),
878
+ });
879
+ return {
880
+ run(request) {
881
+ if (!request?.runContext || request.runContext.purpose !== "side-chat") {
882
+ throw new TypeError("an isolated side-chat request is required");
883
+ }
884
+ return service.executeCaptured(request.runContext.userPrompt, request.runContext, { capture: true });
885
+ },
886
+ };
247
887
  }
248
888
 
249
- function getActiveSessionId() {
250
- const state = currentState();
251
- if (state.settings.backend === "cursor") return state.cursorSessionId;
252
- if (state.settings.backend === "codex") return state.codexSessionId;
253
- return state.lastSessionId;
889
+ function resolveForegroundProvider(providerId) {
890
+ return resolveProviderForRun(providerId);
254
891
  }
255
892
 
256
- function getActiveSessionKey(state = currentState()) {
257
- if (state.settings.backend === "cursor") return "cursorSessionId";
258
- if (state.settings.backend === "codex") return "codexSessionId";
259
- return "lastSessionId";
893
+ function getActiveSessionId() {
894
+ const state = currentState();
895
+ return getProviderSession(state, getActiveProvider(state), getProjectKey(state));
260
896
  }
261
897
 
262
898
  function resolveRunCwd(cwd) {
@@ -267,10 +903,9 @@ function resolveRunCwd(cwd) {
267
903
  const fallback = WORKSPACE && fs.existsSync(WORKSPACE) ? WORKSPACE : process.cwd();
268
904
  if (state.currentSession && state.currentSession.dir === requested) {
269
905
  console.warn(`[session-guard] repairing missing session dir ${requested} -> ${fallback}`);
906
+ const project = getProjectKey(state);
270
907
  state.currentSession = { ...state.currentSession, dir: fallback };
271
- state.lastSessionId = null;
272
- state.cursorSessionId = null;
273
- state.codexSessionId = null;
908
+ for (const provider of ["claude", "codex"]) clearProviderSession(state, provider, project);
274
909
  resetSessionUsage(state);
275
910
  saveState();
276
911
  } else {
@@ -288,147 +923,21 @@ function effectiveCompactThreshold(state = currentState()) {
288
923
 
289
924
  function shouldAutoCompact(state = currentState(), opts = {}) {
290
925
  if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
291
- if (!state[getActiveSessionKey(state)]) return false;
926
+ if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
292
927
  const threshold = effectiveCompactThreshold(state);
293
928
  if (threshold === 0) return false;
294
- const contextTokens = state.sessionUsage?.liveContextTokens || state.sessionUsage?.lastInputTokens || 0;
929
+ const activeUsage = state.sessionUsage;
930
+ const contextTokens = activeUsage?.liveContextTokens || activeUsage?.lastInputTokens || 0;
295
931
  if (contextTokens < threshold) return false;
296
932
  const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
297
933
  const farOverThreshold = contextTokens >= threshold * 2;
298
- if (!farOverThreshold && state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
934
+ const checkpointTime = Date.parse(activeUsage?.compactionCheckpoint?.at || "");
935
+ const scopedLastCompactedAt = Number(activeUsage?.lastCompactedAt)
936
+ || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
937
+ if (!farOverThreshold && scopedLastCompactedAt && (Date.now() - scopedLastCompactedAt) < minInterval) return false;
299
938
  return true;
300
939
  }
301
940
 
302
- async function buildClaudeArgs(prompt, opts = {}) {
303
- const state = currentState();
304
- const { settings } = state;
305
- if (settings.backend === "cursor") return buildCursorArgs(prompt, opts);
306
- if (settings.backend === "codex") return buildCodexArgs(prompt, opts);
307
- const args = ["-p", "--verbose", "--output-format", "stream-json",
308
- "--append-system-prompt", buildSystemPrompt()];
309
- // Tool-first deny-gate (5b): PreToolUse hook on Bash via --settings. A
310
- // failure to write the settings file just means no gate this turn (fail-open).
311
- try { args.push("--settings", require("./tool-guard").ensureHookSettings()); } catch (e) {}
312
- const transcriptInfo = transcriptProjectInfo(state);
313
- if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
314
- if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
315
- else if (opts.continueSession) args.push("--continue");
316
- else if (state.lastSessionId && !opts.fresh) {
317
- if (userOwnsClaudeSession(state.userId, state.lastSessionId)) {
318
- args.push("--resume", state.lastSessionId);
319
- } else {
320
- console.warn(`[session-guard] dropping stale lastSessionId=${state.lastSessionId.slice(0, 8)} for ${state.userId}; not present in sessions.json — starting fresh`);
321
- state.lastSessionId = null;
322
- }
323
- }
324
- args.push("--model", settings.model || DEFAULT_CLAUDE_MODEL);
325
- if (opts.maxTurns) args.push("--max-turns", String(opts.maxTurns));
326
- if (settings.effort) args.push("--effort", settings.effort);
327
- if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
328
- if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
329
- else args.push("--dangerously-skip-permissions");
330
- if (settings.worktree) args.push("--worktree");
331
- // Voice turns stream partial text so the spoken reply can start mid-generation
332
- // (see the streaming-out handler in the runner). Strictly gated to the voice
333
- // channel — zero behaviour change for Telegram/Kazee.
334
- if (state.lastInputWasVoice) {
335
- try {
336
- const { currentTransport } = require("./context");
337
- if (currentTransport() === "voice") args.push("--include-partial-messages");
338
- } catch { /* context not ready — skip streaming flag */ }
339
- }
340
- // Dynamic state rides in the user prompt so the appended system prompt
341
- // stays byte-stable across turns and the prompt-cache prefix survives.
342
- args.push(await promptWithDynamicContext(prompt));
343
- return args;
344
- }
345
-
346
- function buildCursorArgs(prompt, opts = {}) {
347
- const state = currentState();
348
- const { settings } = state;
349
- const args = ["--print", "--trust", "--output-format", "stream-json"];
350
- if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
351
- else if (opts.continueSession) args.push("--continue");
352
- else if (state.cursorSessionId && !opts.fresh) args.push("--resume", state.cursorSessionId);
353
- if (settings.model) args.push("--model", settings.model);
354
- if (settings.permissionMode === "plan") args.push("--mode", "plan");
355
- else if (settings.permissionMode === "ask") args.push("--mode", "ask");
356
- if (settings.worktree) args.push("--worktree");
357
- args.push(prompt);
358
- return args;
359
- }
360
-
361
- async function buildCodexArgs(prompt, opts = {}) {
362
- const state = currentState();
363
- const { settings, codexSessionId } = state;
364
- const args = [];
365
- const resumeId = opts.resumeSessionId || ((!opts.fresh && !opts.continueSession) ? codexSessionId : null);
366
- if (opts.continueSession && codexSessionId) args.push("exec", "resume", codexSessionId);
367
- else if (resumeId) args.push("exec", "resume", resumeId);
368
- else args.push("exec");
369
- args.push("--json", "--skip-git-repo-check");
370
- if (settings.permissionMode === "plan") args.push("--sandbox", "read-only");
371
- else args.push("--dangerously-bypass-approvals-and-sandbox");
372
- if (settings.model) args.push("--model", settings.model);
373
- args.push(await promptWithDynamicContext(prompt, { includeTranscriptPointer: true }));
374
- return args;
375
- }
376
-
377
- function preflightClaudeAuthMessage() {
378
- const state = currentState();
379
- if (state.settings.backend !== "claude") return null;
380
- if (getClaudeOAuthToken().value) return null;
381
- try {
382
- const { execSync } = require("child_process");
383
- const output = execSync(`"${CLAUDE_PATH}" auth status`, {
384
- cwd: process.env.HOME || require("os").homedir(),
385
- env: subprocessEnv(),
386
- encoding: "utf8",
387
- timeout: 10000,
388
- stdio: ["ignore", "pipe", "pipe"],
389
- });
390
- if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
391
- return null;
392
- } catch (e) {
393
- const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
394
- if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
395
- return null;
396
- }
397
- }
398
-
399
- function claudeEmptyFailureMessage(code, stderrText = "") {
400
- const backend = currentState().settings.backend || "claude";
401
- const stderr = redactSensitive(String(stderrText || "").trim());
402
- if (backend === "claude") {
403
- if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
404
- if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
405
- const authStatus = runClaudeAuthStatusDiagnostic();
406
- if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
407
- if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
408
- return [
409
- `Claude exited with code ${code} but produced no assistant output.`,
410
- stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
411
- authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
412
- "",
413
- "Useful next steps:",
414
- "• /auth_status — verify Claude auth",
415
- "• /model sonnet — switch away from Opus if usage-limited",
416
- "• /setup_token — create a launchd-safe OAuth token if Keychain is the issue",
417
- ].filter(Boolean).join("\n");
418
- }
419
- const label = backend === "codex" ? "Codex" : "Cursor";
420
- const nextSteps = backend === "codex"
421
- ? ["• /codex_auth_status — verify Codex auth", "• /codex_login — device-code login", "• /codex_setup_token — paste an OpenAI API key"]
422
- : ["• /backend — confirm Cursor is selected", "• Run `agent login` on the host machine", "• /claude — fall back to Claude if Cursor stays broken"];
423
- return [
424
- `${label} exited with code ${code} but produced no assistant output.`,
425
- stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
426
- "",
427
- "Useful next steps:",
428
- ...nextSteps,
429
- ].filter(Boolean).join("\n");
430
- }
431
-
432
941
  function compactSummaryPrompt() {
433
942
  return [
434
943
  "Summarize this conversation for a fresh compacted continuation.",
@@ -624,1141 +1133,911 @@ function collectRecentTurns(state = currentState(), maxTurns = 6, maxCharsPerTur
624
1133
  }
625
1134
  }
626
1135
 
627
- function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
628
- const mins = Math.floor(elapsed / 60);
629
- const secs = elapsed % 60;
630
- const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
631
- const parts = [];
632
- let status = currentTool ? `Working: ${currentTool}` : (tools.length > 0 ? "Processing..." : "Thinking...");
633
- if (currentTool && toolDetail) status += ` ${toolDetail}`;
634
- parts.push(`${status} (${time})`);
635
- if (tools.length > 1) parts.push(`Steps: ${[...new Set(tools)].join(" > ")}`);
636
- if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
637
- return parts.join("\n\n");
1136
+ function ensureCompactionAckSchemaPath() {
1137
+ const directory = path.join(CONFIG_DIR, "provider-contracts");
1138
+ const filePath = path.join(directory, "compaction-ack.schema.json");
1139
+ const schema = {
1140
+ type: "object",
1141
+ properties: {
1142
+ acknowledgement: { type: "string", minLength: 1, maxLength: 240 },
1143
+ },
1144
+ required: ["acknowledgement"],
1145
+ additionalProperties: false,
1146
+ };
1147
+ const expected = `${JSON.stringify(schema, null, 2)}\n`;
1148
+ try {
1149
+ if (fs.readFileSync(filePath, "utf8") === expected) return filePath;
1150
+ } catch (_) {}
1151
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
1152
+ require("./fsutil").atomicWriteFileSync(filePath, expected);
1153
+ try { fs.chmodSync(filePath, 0o600); } catch (_) {}
1154
+ return filePath;
1155
+ }
1156
+
1157
+ function deriveCompactionRunContext(base, overrides = {}) {
1158
+ const providerSettings = {
1159
+ ...(base.providerSettings || {}),
1160
+ budget: null,
1161
+ worktree: false,
1162
+ permissionMode: "plan",
1163
+ };
1164
+ return deepFreeze({
1165
+ ...base,
1166
+ runId: overrides.runId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${base.runId}-${Date.now()}`),
1167
+ admittedAt: Date.now(),
1168
+ userPrompt: String(overrides.userPrompt || ""),
1169
+ purpose: overrides.purpose,
1170
+ label: overrides.label,
1171
+ providerSettings,
1172
+ sessionId: overrides.sessionId ?? null,
1173
+ admittedSessionId: overrides.sessionId ?? null,
1174
+ previousSessionId: overrides.previousSessionId ?? base.sessionId ?? null,
1175
+ resumeSessionId: overrides.resumeSessionId ?? null,
1176
+ continueSession: false,
1177
+ fresh: !!overrides.fresh,
1178
+ skipAutoCompact: true,
1179
+ requiredDelivery: false,
1180
+ readOnly: true,
1181
+ permissionMode: "plan",
1182
+ maxTurns: base.provider === "claude" ? 1 : null,
1183
+ outputSchemaPath: overrides.outputSchemaPath || null,
1184
+ imagePaths: [],
1185
+ attachments: [],
1186
+ voiceStream: false,
1187
+ lastInputWasVoice: false,
1188
+ });
638
1189
  }
639
1190
 
640
- async function runClaudeCapture(prompt, cwd, opts = {}) {
641
- const state = currentState();
642
- const store = chatContext.getStore();
643
- if (state.runningProcess) throw new Error("Another task is already running.");
644
- const authPreflight = preflightClaudeAuthMessage();
645
- if (authPreflight) throw new Error(authPreflight);
646
- cwd = resolveRunCwd(cwd);
647
-
648
- const args = await buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
649
- return new Promise((resolve, reject) => {
650
- let assistantText = "";
651
- let stderrBuffer = "";
652
- let streamBuffer = "";
653
- let sessionId = null;
654
- appendProjectTranscript(opts.transcriptRole || "system-note", stripTranscriptPointerForStorage(prompt), {
655
- capture: true,
656
- label: opts.label || null,
657
- fresh: !!opts.fresh,
658
- continueSession: !!opts.continueSession,
659
- resumeSessionId: opts.resumeSessionId || null,
660
- }, state, getActiveSessionId);
661
- const proc = spawn(getActiveBinary(), args, {
662
- cwd,
663
- env: subprocessEnv(),
664
- stdio: ["ignore", "pipe", "pipe"],
665
- detached: process.platform !== "win32",
1191
+ function successfulTerminalCapture(result, { requireSession = false } = {}) {
1192
+ return !!result?.ok && result.status === "succeeded" && result.terminalSeen === true
1193
+ && (!requireSession || (typeof result.sessionId === "string" && !!result.sessionId));
1194
+ }
1195
+
1196
+ function createCompactionService(dependencies = {}) {
1197
+ const getState = dependencies.getState || currentState;
1198
+ const captureContext = dependencies.captureRunContext || admitRunContext;
1199
+ const runCapture = dependencies.runCapture || runAgentCapture;
1200
+ const commitCompaction = dependencies.commitCompaction || commitProviderCompaction;
1201
+ const archiveBrief = dependencies.archiveBrief || archiveCompactionBrief;
1202
+ const appendDaySeed = dependencies.appendDaySeed || ((entry) => require("./day-seeds").appendDaySeed(entry));
1203
+ const collectRepoFacts = dependencies.collectRepoFacts || collectRepoStateFacts;
1204
+ const collectRecent = dependencies.collectRecentTurns || collectRecentTurns;
1205
+ const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
1206
+ const drainQueue = dependencies.drainQueue || drainQueuedMessages;
1207
+
1208
+ async function compact(cwd, opts = {}) {
1209
+ const state = getState();
1210
+ if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
1211
+ if (state.runningProcess || (state.preparingRun && !opts.admissionHeld)) {
1212
+ return { compacted: false, reason: "Finish or stop the active run before compacting." };
1213
+ }
1214
+ const provider = getActiveProvider(state);
1215
+ const project = getProjectKey(state);
1216
+ const oldSessionId = getProviderSession(state, provider, project);
1217
+ if (!project || !oldSessionId) return { compacted: false, reason: "No conversation." };
1218
+ const summaryPrompt = compactSummaryPrompt();
1219
+ const admitted = captureContext(summaryPrompt, cwd, null, {
1220
+ provider,
1221
+ resumeSessionId: oldSessionId,
1222
+ skipAutoCompact: true,
1223
+ label: "compact-summary",
1224
+ purpose: "compact-summary",
1225
+ requiredDelivery: false,
1226
+ timeoutMs: COMPACT_SUMMARY_TIMEOUT,
1227
+ });
1228
+ const summaryContext = deriveCompactionRunContext(admitted, {
1229
+ userPrompt: summaryPrompt,
1230
+ purpose: "compact-summary",
1231
+ label: "compact-summary",
1232
+ sessionId: oldSessionId,
1233
+ resumeSessionId: oldSessionId,
1234
+ previousSessionId: oldSessionId,
1235
+ fresh: false,
666
1236
  });
667
- state.runningProcess = proc;
668
- const timeout = setTimeout(() => {
669
- if (state.runningProcess === proc) {
670
- killProcessTree(proc.pid, "SIGTERM");
671
- setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
1237
+ const operationId = opts.operationId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `compact-${Date.now()}`);
1238
+ const recentTurns = collectRecent(state);
1239
+ state.isCompacting = true;
1240
+ try {
1241
+ if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1242
+ const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1243
+ runContext: summaryContext,
1244
+ persist: false,
1245
+ });
1246
+ if (!successfulTerminalCapture(summaryRun)) {
1247
+ return { compacted: false, reason: summaryRun?.diagnostic || "Compaction summary did not finish successfully." };
672
1248
  }
673
- }, opts.timeoutMs || MAX_PROCESS_TIMEOUT);
674
-
675
- proc.stdout.on("data", (data) => {
676
- streamBuffer += data.toString();
677
- const events = parseStreamEvents(streamBuffer);
678
- const lastNewline = streamBuffer.lastIndexOf("\n");
679
- streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
680
- for (const evt of events) {
681
- if (evt.type === "assistant" && evt.message?.content) {
682
- for (const block of evt.message.content) {
683
- if (block.type === "text") assistantText += block.text;
684
- }
685
- }
686
- if (evt.type === "item.completed" && evt.item?.type === "agent_message" && typeof evt.item.text === "string") {
687
- assistantText += (assistantText ? "\n" : "") + evt.item.text;
688
- }
689
- if (evt.type === "thread.started" && evt.thread_id) {
690
- state.codexSessionId = evt.thread_id;
691
- sessionId = evt.thread_id;
692
- saveState();
693
- }
694
- if (evt.type === "result" && evt.session_id) {
695
- if (state.settings.backend === "cursor") state.cursorSessionId = evt.session_id;
696
- else state.lastSessionId = evt.session_id;
697
- sessionId = evt.session_id;
698
- if (evt.usage) {
699
- applyUsageToState(state, evt.usage, 0, { usageScope: "turn_delta" });
700
- }
701
- if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
702
- saveState();
703
- }
704
- // evt.result only carries the FINAL text segment of the turn. Using it
705
- // as anything but a fallback would clobber text streamed before tool
706
- // calls (the "long reply, then tools, then short closing line" shape).
707
- if (evt.type === "result" && evt.result && !assistantText.trim()) assistantText = evt.result;
1249
+ const summary = summaryRun.text || "No prior context was returned by the summarizer.";
1250
+ const { fullBrief, condensed } = splitCompactionBrief(summary);
1251
+ const seedSummary = condensed ? `${fullBrief}\n\n${condensed}` : fullBrief;
1252
+ const repoFacts = collectRepoFacts(summaryContext.cwd || cwd);
1253
+ let seedPrompt = compactSeedPrompt(seedSummary, {
1254
+ briefPath: null,
1255
+ condensed: false,
1256
+ repoFacts,
1257
+ recentTurns,
1258
+ });
1259
+ let outputSchemaPath = null;
1260
+ if (provider === "codex") {
1261
+ outputSchemaPath = ackSchemaPath();
1262
+ seedPrompt += "\n\nReturn JSON matching the supplied output schema. Put the one short acknowledgement in the acknowledgement field.";
708
1263
  }
709
- });
710
- proc.stderr.on("data", (d) => { stderrBuffer += d.toString(); });
711
- proc.on("close", (code) => chatContext.run(store, () => {
712
- if (state.runningProcess === proc) state.runningProcess = null;
713
- clearTimeout(timeout);
714
- if (code !== 0 && code !== null) {
715
- const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
716
- appendProjectTranscript("system-note", failureText, { capture: true, label: opts.label || null, exitCode: code, kind: "backend-error" }, state, getActiveSessionId);
717
- reject(new Error(failureText));
718
- return;
1264
+ const seedContext = deriveCompactionRunContext(summaryContext, {
1265
+ userPrompt: seedPrompt,
1266
+ purpose: "compact-seed",
1267
+ label: "compact-seed",
1268
+ sessionId: null,
1269
+ resumeSessionId: null,
1270
+ previousSessionId: oldSessionId,
1271
+ fresh: true,
1272
+ outputSchemaPath,
1273
+ });
1274
+ const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1275
+ runContext: seedContext,
1276
+ persist: false,
1277
+ });
1278
+ if (!successfulTerminalCapture(seedRun, { requireSession: true })) {
1279
+ return { compacted: false, reason: seedRun?.diagnostic || "Compaction seed did not finish successfully." };
719
1280
  }
720
- const cleanText = redactSensitive(assistantText.trim());
721
- appendProjectTranscript("assistant", cleanText, { capture: true, label: opts.label || null, exitCode: code }, state, getActiveSessionId);
722
- resolve({ text: cleanText, sessionId });
723
- }));
724
- proc.on("error", (err) => chatContext.run(store, () => {
725
- if (state.runningProcess === proc) state.runningProcess = null;
726
- clearTimeout(timeout);
727
- reject(err);
728
- }));
729
- });
1281
+ if (seedRun.sessionId === oldSessionId) {
1282
+ return { compacted: false, reason: "Compaction seed did not create a fresh provider session." };
1283
+ }
1284
+ let commit;
1285
+ try {
1286
+ commit = await commitCompaction(state, {
1287
+ operationId,
1288
+ canonicalUserId: summaryContext.canonicalUserId,
1289
+ project: summaryContext.project.name,
1290
+ provider: summaryContext.provider,
1291
+ previousSessionId: oldSessionId,
1292
+ newSessionId: seedRun.sessionId,
1293
+ title: `Compacted ${new Date().toLocaleDateString()}`,
1294
+ seedUsage: seedRun.usage || null,
1295
+ });
1296
+ } catch (error) {
1297
+ return { compacted: false, reason: `Compaction commit failed: ${redactSensitive(error.message)}` };
1298
+ }
1299
+ let briefPath = null;
1300
+ try { briefPath = archiveBrief(fullBrief, capturedStateView(state, summaryContext)); } catch (_) {}
1301
+ try {
1302
+ appendDaySeed({
1303
+ summary: condensed || fullBrief,
1304
+ project: `${summaryContext.project.name} (${summaryContext.project.dir})`,
1305
+ channel: summaryContext.channelId,
1306
+ briefPath,
1307
+ });
1308
+ } catch (_) {}
1309
+ return {
1310
+ compacted: true,
1311
+ operationId,
1312
+ oldSessionId,
1313
+ newSessionId: seedRun.sessionId,
1314
+ provider: summaryContext.provider,
1315
+ project: summaryContext.project.name,
1316
+ summary,
1317
+ commit,
1318
+ };
1319
+ } finally {
1320
+ state.isCompacting = false;
1321
+ try { await drainQueue(state); }
1322
+ catch (error) { console.warn(`[compact] queue drain failed: ${redactSensitive(error.message)}`); }
1323
+ }
1324
+ }
1325
+
1326
+ return { compact };
730
1327
  }
731
1328
 
732
1329
  async function compactActiveSession(cwd, opts = {}) {
733
- const state = currentState();
734
- const sessionKey = getActiveSessionKey(state);
735
- const oldSessionId = state[sessionKey];
736
- if (!oldSessionId) return { compacted: false, reason: "No conversation." };
737
- if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
738
-
739
- state.isCompacting = true;
740
- let summary;
741
- const recentTurns = collectRecentTurns(state);
742
- try {
743
- if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
744
- const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true, label: "compact-summary", timeoutMs: COMPACT_SUMMARY_TIMEOUT });
745
- summary = summaryRun.text || "No prior context was returned by the summarizer.";
746
- } finally {
747
- state.isCompacting = false;
748
- saveState();
749
- }
1330
+ return createCompactionService().compact(cwd, opts);
1331
+ }
750
1332
 
751
- const { fullBrief, condensed } = splitCompactionBrief(summary);
752
- const briefPath = archiveCompactionBrief(fullBrief, state);
753
- // Append the condensed digest to today's per-day seed file so the nightly
754
- // dream can review what was worked on across the day. Best-effort.
755
- try {
756
- require("./day-seeds").appendDaySeed({
757
- summary: condensed || fullBrief,
758
- project: state.currentSession ? `${state.currentSession.name} (${state.currentSession.dir})` : null,
759
- channel: currentChannelId(),
760
- briefPath,
761
- });
762
- } catch (e) {}
763
- // Only seed with the condensed version when the full text actually made it to disk.
764
- const seedSummary = (condensed && briefPath) ? condensed : (condensed ? `${fullBrief}\n\n${condensed}` : fullBrief);
765
- const repoFacts = collectRepoStateFacts(cwd);
766
- // The old session id stays in place until the seed succeeds: if the seed
767
- // dies (timeout, /stop, crash) the conversation keeps resuming the
768
- // pre-compaction session instead of being orphaned. maxTurns: 1 hard-stops
769
- // a seed that ignores the no-work instruction and reaches for tools.
770
- const seedRun = await runClaudeCapture(compactSeedPrompt(seedSummary, { briefPath, condensed: !!(condensed && briefPath), repoFacts, recentTurns }), cwd, { fresh: true, skipAutoCompact: true, label: "compact-seed", timeoutMs: COMPACT_SUMMARY_TIMEOUT, maxTurns: 1 });
771
- const newSessionId = seedRun.sessionId || null;
772
- state[sessionKey] = newSessionId;
773
- state.isFirstMessage = false;
774
- state.lastCompactedAt = Date.now();
775
- state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0, liveContextTokens: 0, lastUsageScope: "turn_delta" };
776
- state.codexUsageSnapshots = {};
777
- saveState();
778
-
779
- if (newSessionId && state.currentSession) {
780
- const title = `Compacted ${new Date().toLocaleDateString()}`;
781
- recordSession(state.userId, state.currentSession.name, newSessionId, title);
782
- }
783
- return { compacted: true, oldSessionId, newSessionId, summary };
1333
+ function capturedStateView(state, runContext) {
1334
+ return {
1335
+ ...state,
1336
+ userId: runContext.canonicalUserId || state.userId,
1337
+ currentSession: runContext.project
1338
+ ? { name: runContext.project.name, dir: runContext.project.dir }
1339
+ : null,
1340
+ settings: {
1341
+ ...(runContext.providerSettings || {}),
1342
+ backend: runContext.provider,
1343
+ },
1344
+ };
784
1345
  }
785
1346
 
786
- async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
787
- const state = currentState();
788
- const store = chatContext.getStore();
789
- const channelId = currentChannelId();
790
- const adapter = currentAdapter();
791
- const { settings } = state;
792
- // A streaming status/preview message id is only editable on the channel it
793
- // was minted on. Under unified identity the owner's Telegram + Kazee turns
794
- // share one per-canonical state object, so a message id minted on one channel
795
- // must never be used as an edit target under the other adapter (Telegram int
796
- // vs Kazee ObjectId → PUT /message/<id> 500s). Also rejects the non-string
797
- // "sent, no id" sentinel a socket adapter returns, so we edit only real ids.
798
- const canEditStatus = () => typeof state.statusMessageId === "string" && state.statusMessageChannel === channelId;
799
- // Owner-vs-external, resolved once per turn. All internal-activity chatter
800
- // (recall banners, tool/skill traces, usage alerts) is owner-only so nothing
801
- // about how the assistant works leaks to an external speaker.
802
- let externalSpeaker = false;
803
- try { externalSpeaker = require("./relationship").isCurrentSpeakerGuarded(); } catch (e) {}
804
- cwd = resolveRunCwd(cwd);
1347
+ function capturedTranscriptMetadata(runContext, extra = {}) {
1348
+ return {
1349
+ provider: runContext.provider,
1350
+ project: runContext.project?.name || null,
1351
+ projectPath: runContext.project?.dir || null,
1352
+ runId: runContext.runId,
1353
+ purpose: runContext.purpose,
1354
+ ...(runContext.occurrenceId ? { occurrenceId: runContext.occurrenceId } : {}),
1355
+ ...(runContext.scheduledJobId ? { scheduledJobId: runContext.scheduledJobId } : {}),
1356
+ ...(runContext.originRunId ? { originRunId: runContext.originRunId } : {}),
1357
+ ...extra,
1358
+ };
1359
+ }
805
1360
 
806
- if (!acquireRunLock(state)) {
807
- // Capture the origin channel so the drained reply routes back to where the
808
- // message came from. Under unified identity the owner's channels share one
809
- // run-lock and queue, so a Kazee message queued during a Telegram run must
810
- // still be answered on Kazee not wherever the finishing turn happens to be.
811
- state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now(), origin: store ? { ...store } : null });
812
- await send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId });
813
- return;
1361
+ function providerRunDiagnostic(result, runContext) {
1362
+ if (result.ok) return null;
1363
+ let provider;
1364
+ try { provider = getProvider(runContext.provider); }
1365
+ catch (_) { provider = { label: runContext.provider, authHelp: () => "" }; }
1366
+ const label = provider.label || runContext.provider;
1367
+ const code = result.error?.code || "PROVIDER_RUN_FAILED";
1368
+ if (code === "PROVIDER_UNAUTHENTICATED") {
1369
+ const help = typeof provider.authHelp === "function" ? provider.authHelp() : "";
1370
+ return `${label} authentication failed.${help ? `\n\n${help}` : ""}`;
814
1371
  }
815
-
816
- const authPreflight = preflightClaudeAuthMessage();
817
- if (authPreflight) {
818
- // Lock was armed synchronously above; release it before bailing so the
819
- // next turn isn't wedged behind a run that never spawned.
820
- state.preparingRun = false;
821
- await send(authPreflight, { replyTo: replyToMsgId });
822
- return;
1372
+ if (code === "PROVIDER_USAGE_LIMIT") {
1373
+ return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
823
1374
  }
1375
+ const detail = result.stderr ? `\n\nStderr:\n${result.stderr.slice(-1200)}` : "";
1376
+ return `${label} run failed: ${result.error?.message || "unknown provider failure"}${detail}`;
1377
+ }
824
1378
 
825
- if (state.currentSession && shouldAutoCompact(state, opts)) {
826
- try {
827
- await compactActiveSession(state.currentSession.dir, { notify: true, message: "Context is getting large, compacting before I handle this…" });
828
- } catch (e) {
829
- console.warn(`[preflight-compact] failed: ${redactSensitive(e.message)}`);
1379
+ async function persistForegroundTranscript(result, runContext, state = currentState()) {
1380
+ const view = capturedStateView(state, runContext);
1381
+ const session = () => result.sessionId || runContext.sessionId || null;
1382
+ appendProjectTranscript(
1383
+ runContext.transcriptRole || "user",
1384
+ stripTranscriptPointerForStorage(runContext.userPrompt),
1385
+ capturedTranscriptMetadata(runContext, {
1386
+ sourceMessageId: runContext.replyToMsgId,
1387
+ fresh: runContext.fresh,
1388
+ continueSession: runContext.continueSession,
1389
+ resumeSessionId: runContext.resumeSessionId,
1390
+ label: runContext.label,
1391
+ }),
1392
+ view,
1393
+ session,
1394
+ { strict: true },
1395
+ );
1396
+ const role = result.ok ? "assistant" : "system-note";
1397
+ const text = result.ok
1398
+ ? (result.text || "(no output)")
1399
+ : (providerRunDiagnostic(result, runContext) || result.diagnostic || "Provider run failed");
1400
+ appendProjectTranscript(
1401
+ role,
1402
+ text,
1403
+ capturedTranscriptMetadata(runContext, {
1404
+ exitCode: result.exitCode,
1405
+ signal: result.signal,
1406
+ kind: result.ok ? undefined : "provider-error",
1407
+ label: runContext.label,
1408
+ }),
1409
+ view,
1410
+ session,
1411
+ { strict: true },
1412
+ );
1413
+ }
1414
+
1415
+ async function persistForegroundUsage(result, runContext, state = currentState(), store = chatContext.getStore()) {
1416
+ if (!result.usage) return;
1417
+ const previousUsageBySession = structuredClone(state.usageBySession || {});
1418
+ const previousAlertAt = state.usageAlertLastAt || 0;
1419
+ try {
1420
+ let usage = result.usage;
1421
+ let usageScope = result.usageScope || "turn_delta";
1422
+ let rawUsage = usage;
1423
+ let liveContextTokens = usageParts(usage, runContext.provider).context;
1424
+ const usageSessionId = result.sessionId || runContext.sessionId;
1425
+ const usageProject = runContext.project?.name || null;
1426
+ if (runContext.provider === "codex") {
1427
+ const normalized = normalizeCodexUsage(state, usageSessionId, usage, { project: usageProject });
1428
+ usage = normalized.usage;
1429
+ usageScope = normalized.usageScope;
1430
+ rawUsage = normalized.rawUsage;
1431
+ liveContextTokens = usageParts(rawUsage, "codex").context;
830
1432
  }
1433
+ result.usage = usage;
1434
+ result.usageScope = usageScope;
1435
+ applyUsageToState(state, usage, result.costUsd || 0, {
1436
+ backend: runContext.provider,
1437
+ usageScope,
1438
+ liveContextTokens,
1439
+ project: usageProject,
1440
+ sessionId: usageSessionId,
1441
+ });
1442
+ logTurnUsage(
1443
+ state,
1444
+ usage,
1445
+ result.costUsd || 0,
1446
+ (text) => chatContext.run(store, () => send(text)),
1447
+ {
1448
+ backend: runContext.provider,
1449
+ model: runContext.providerSettings?.model || null,
1450
+ userId: runContext.canonicalUserId,
1451
+ sessionId: usageSessionId,
1452
+ runId: runContext.runId,
1453
+ project: usageProject,
1454
+ usageScope,
1455
+ liveContextTokens,
1456
+ rawUsage,
1457
+ strict: true,
1458
+ },
1459
+ );
1460
+ saveState({ strict: true });
1461
+ } catch (error) {
1462
+ state.usageBySession = previousUsageBySession;
1463
+ state.usageAlertLastAt = previousAlertAt;
1464
+ throw error;
831
1465
  }
1466
+ }
832
1467
 
833
- appendProjectTranscript("user", prompt, {
834
- sourceMessageId: replyToMsgId || null,
835
- fresh: !!opts.fresh,
836
- continueSession: !!opts.continueSession,
837
- resumeSessionId: opts.resumeSessionId || null,
838
- }, state, getActiveSessionId);
839
-
840
- // Typing heartbeat: keep the indicator alive continuously from message
841
- // receipt through the recall/discoverer phase and into streaming, not just
842
- // for the ~5s a single typing action lasts. Stored on state so /stop can
843
- // clear it instantly. Cleared on close/error/cancel.
844
- // While true, the bot is reasoning before its first output token, so the
845
- // typing heartbeat advertises the richer "thinking" state. Flipped to false
846
- // the moment the first assistant text / tool_use / text_delta arrives.
847
- state.thinkingPhase = true;
848
- const startTyping = () => {
849
- if (!adapter || !channelId) return;
850
- adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
851
- if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {}), 4000);
852
- };
853
- // Called on the first real output token to leave the thinking phase and, if
854
- // the turn is still live, immediately advertise plain "typing" so the client
855
- // flips without waiting for the next 4s heartbeat.
856
- const endThinking = () => {
857
- if (!state.thinkingPhase) return;
858
- state.thinkingPhase = false;
859
- if (adapter && channelId && state.typingHeartbeat) adapter.typing(channelId).catch(() => {});
860
- };
861
- const stopTyping = () => {
862
- state.thinkingPhase = false;
863
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
864
- // Telegram's chat action auto-expires, but socket-based channels (Kazee)
865
- // keep showing "typing…" until told to stop. Optional on the adapter.
866
- if (adapter && channelId) { try { adapter.typingStop?.(channelId); } catch (e) {} }
867
- };
868
- // Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
869
- // it sets state.cancelRequested and we bail at the checkpoint before spawning.
870
- // preparingRun was armed synchronously by acquireRunLock() at the top of this
871
- // function so the lock already spans the auth/compact awaits above no re-arm.
872
- state.cancelRequested = false;
873
- startTyping();
1468
+ async function persistForegroundSession(result, runContext, state = currentState()) {
1469
+ if (!result.ok || !result.sessionId) return;
1470
+ const liveProvider = state.settings?.backend || null;
1471
+ const project = runContext.project?.name || null;
1472
+ const pointerUnchanged = getProviderSession(state, runContext.provider, project) === (runContext.previousSessionId || null);
1473
+ const attach = runContext.attachSession !== false && !!project && pointerUnchanged;
1474
+ if (attach) {
1475
+ setProviderSession(state, runContext.provider, project, result.sessionId);
1476
+ if (liveProvider === runContext.provider && sameCapturedProject(state, runContext)) state.isFirstMessage = false;
1477
+ saveState({ strict: true });
1478
+ }
1479
+ if (runContext.project?.name) {
1480
+ const title = runContext.fresh
1481
+ ? (runContext.userPrompt.length > 60 ? `${runContext.userPrompt.slice(0, 57)}...` : runContext.userPrompt)
1482
+ : null;
1483
+ recordSession(
1484
+ runContext.canonicalUserId,
1485
+ runContext.project.name,
1486
+ runContext.provider,
1487
+ result.sessionId,
1488
+ title,
1489
+ { strict: true },
1490
+ );
1491
+ }
1492
+ // A wakeup can be created inside a fresh provider turn before that turn's
1493
+ // native session ID exists. The loopback stores the immutable origin run ID;
1494
+ // once the terminal result reveals the session, bind every matching job to
1495
+ // it before reporting session persistence complete.
1496
+ require("./jobs").bindOriginRunSession(
1497
+ runContext.runId,
1498
+ runContext.provider,
1499
+ runContext.project?.name,
1500
+ result.sessionId,
1501
+ );
1502
+ result.sessionAttached = attach;
1503
+ }
1504
+
1505
+ // Settle the streamed preview message before the final answer goes out.
1506
+ // Success with narration the preview becomes the narration record (footer
1507
+ // dropped). Success without narration → the preview only ever showed the
1508
+ // streamed answer itself; delete it so the final message isn't a duplicate.
1509
+ // Failure/cancel → keep the partial text but drop the lying "working" footer.
1510
+ async function finalizeStreamPreview(state, result) {
1511
+ if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1512
+ const id = state.statusMessageId;
1513
+ const channel = state.statusMessageChannel;
1514
+ const buffer = String(state.streamBuffer || "").trim();
874
1515
  state.statusMessageId = null;
875
1516
  state.statusMessageChannel = null;
876
1517
  state.streamBuffer = "";
877
- let assistantText = "";
878
-
879
- // Phase 3 relationship guardrail: is this turn's speaker an EXTERNAL (a
880
- // non-owner)? Computed once per turn. For the owner (and any no-context run)
881
- // this is false, so every guardrail branch below — the streaming-preview
882
- // suppression, the disabled unvetted voice-out, and the final-reply vet — is a
883
- // strict no-op and the owner path stays byte-for-byte unchanged (R9/R14).
884
- let guardedTurn = false;
885
- try { guardedTurn = require("./relationship").isCurrentSpeakerGuarded(); } catch (e) { guardedTurn = false; }
886
-
887
- // Voice streaming-out: on voice turns we speak each finished sentence as it is
888
- // generated (off the partial text_delta events) so the first audio plays while
889
- // the rest of the reply is still being written — far lower time-to-first-sound
890
- // than synthesizing one pass over the whole reply at the end. Reads the delta
891
- // stream only; the text/transcript channel still reads whole-message events, so
892
- // chat transports are completely unaffected. Disabled for guarded turns: a
893
- // streamed clip would speak unvetted text aloud before the guard sees it.
894
- let voiceStreaming = false;
1518
+ if (typeof id !== "string") return;
1519
+ // A message id is only valid on the channel that minted it; a foreign-
1520
+ // channel preview is left as an orphan rather than edited cross-transport.
1521
+ if (!channel || String(channel) !== String(currentChannelId())) return;
895
1522
  try {
896
- const { currentTransport } = require("./context");
897
- voiceStreaming = !guardedTurn && !!state.lastInputWasVoice && currentTransport() === "voice";
898
- } catch { voiceStreaming = false; }
899
- let spokenBuf = ""; // text_delta accumulator awaiting a sentence boundary
900
- let ttsChain = Promise.resolve(); // ordered send queue so clips play in order
901
- let spokeAnyStreamed = false;
902
- const SPOKEN_MIN_CHARS = 40; // don't fire TTS on tiny fragments ("Hi.")
903
- // Voice latency probe (measurement only) timestamps filled in as the turn runs.
904
- // Spawn time is `startTime` (captured just after spawn below).
905
- let vlFirstSysAt = null, vlFirstTokenAt = null, vlFirstAudioAt = null, vlResultAt = null;
906
- function dispatchSpoken(text) {
907
- const clean = redactSensitive(text);
908
- if (!clean.trim()) return;
909
- spokeAnyStreamed = true;
910
- const synthP = synthSentenceMp3(clean); // start synth now (parallel)
911
- ttsChain = ttsChain.then(async () => { // but send strictly in order
912
- try {
913
- const clip = await synthP;
914
- if (clip) { await sendVoice(clip); if (vlFirstAudioAt == null) vlFirstAudioAt = Date.now(); }
915
- } catch (e) { console.error("voice stream clip failed:", e.message); }
916
- });
1523
+ if (!result || !result.ok) {
1524
+ if (buffer) await editMessage(id, buffer.slice(-4000), telegramHtmlOpts({ streaming: true }));
1525
+ return;
1526
+ }
1527
+ const narration = String(result.narrationText || "").trim();
1528
+ if (narration) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
1529
+ else await deleteMessage(id);
1530
+ } catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
1531
+ }
1532
+
1533
+ async function deliverForegroundResult(result, runContext, state = currentState()) {
1534
+ if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
1535
+ await finalizeStreamPreview(state, result);
1536
+ // /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
1537
+ // is not a failure worth announcing /stop already replied "Cancelled.".
1538
+ // The flag is consumed here (and re-cleared on the run's unwind) so it can
1539
+ // never suppress a later run's genuine failure.
1540
+ if (!result.ok && (state.cancelRequested || result.error?.code === "RUN_CANCELLED")) {
1541
+ state.cancelRequested = false;
1542
+ return { ok: true, suppressed: "cancelled" };
917
1543
  }
918
- function pumpSpoken(flush) {
919
- // Cut the smallest prefix that ends in a sentence terminator and is at least
920
- // SPOKEN_MIN_CHARS long, dispatch it, repeat. On flush, send whatever remains.
921
- while (true) {
922
- const re = /[.!?]+(?=\s|$)/g;
923
- let idx = -1, m;
924
- while ((m = re.exec(spokenBuf)) !== null) {
925
- const end = m.index + m[0].length;
926
- if (end >= SPOKEN_MIN_CHARS) { idx = end; break; }
1544
+ const diagnostic = providerRunDiagnostic(result, runContext);
1545
+ if (diagnostic) result.diagnostic = diagnostic;
1546
+ const cleanAnswer = String(result.finalAnswerText || "").trim();
1547
+ let finalText = result.ok ? (cleanAnswer || result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
1548
+
1549
+ if (result.ok) {
1550
+ let guarded = false;
1551
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (error) {
1552
+ throw stableRunError(`Unable to classify reply recipient: ${error.message}`, "RUN_DELIVERY_GUARD_FAILED");
1553
+ }
1554
+ if (guarded) {
1555
+ const guard = await require("./enforcer").guardOutboundReply(finalText);
1556
+ if (!guard.allow) {
1557
+ if (!guard.escalated) {
1558
+ const held = await send("I need to check with the owner before I can answer that — I'll follow up.");
1559
+ if (!held.ok) throw new Error("failed to deliver guarded-reply notice");
1560
+ }
1561
+ return { ok: true, held: true };
927
1562
  }
928
- if (idx === -1) break;
929
- const sentence = spokenBuf.slice(0, idx).trim();
930
- spokenBuf = spokenBuf.slice(idx).replace(/^\s+/, "");
931
- if (sentence) dispatchSpoken(sentence);
932
1563
  }
933
- if (flush) { const tail = spokenBuf.trim(); spokenBuf = ""; if (tail) dispatchSpoken(tail); }
934
1564
  }
935
1565
 
936
- let toolUses = [];
937
- let currentTool = null;
938
- let currentToolDetail = "";
939
- // True context-window occupancy is the largest single API call's prefix
940
- // (input + cache_read + cache_creation), NOT the per-turn sum the result
941
- // event reports — that sum re-counts the cached prefix once per tool
942
- // round-trip and balloons to millions. Track the peak per-call prefix.
943
- let peakContextTokens = 0;
944
-
945
- // Hermes-style skill announcements: one chat line when a learned skill
946
- // is invoked or its SKILL.md is written/patched. Deduped per turn.
947
- const skillNotified = new Set();
948
- const notifySkill = (key, text) => {
949
- if (externalSpeaker) return; // never leak internal activity to a non-owner
950
- if (skillNotified.has(key)) return;
951
- skillNotified.add(key);
952
- chatContext.run(store, () => send(text).catch(() => {}));
953
- };
954
- // Tool-activity visibility (surfaced / ran / created-updated). Off by default,
955
- // toggled per-channel with /tooltrace — mirrors /recall's showRecall. Dedup +
956
- // delivery reuse notifySkill so a tool touched twice a turn announces once.
957
- const notifyToolTrace = (key, text) => {
958
- if (!settings.showToolTrace) return;
959
- notifySkill(key, text);
960
- };
961
- const noteSkillToolUse = (toolName, input) => {
962
- try {
963
- if (toolName === "Skill" && input?.skill) {
964
- notifySkill(`use:${input.skill}`, `Using skill: ${input.skill}`);
965
- return;
966
- }
967
- const filePath = input?.file_path || input?.filePath;
968
- if ((toolName === "Write" || toolName === "Edit") && filePath) {
969
- const packDir = packsLib.packNameFromPath(filePath);
970
- if (packDir) {
971
- if (packsLib.readPack(packDir)) notifySkill(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
972
- else notifySkill(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
973
- try {
974
- packsLib.recordForegroundWrite(packDir, {
975
- tool: toolName,
976
- oldString: input?.old_string ?? input?.oldString,
977
- content: input?.content,
978
- });
979
- } catch (e) {}
980
- return;
981
- }
982
- const entSlug = entitiesLib.entityNameFromPath(filePath);
983
- if (entSlug) {
984
- if (entitiesLib.readEntity(entSlug)) notifySkill(`entity:${entSlug}`, `👤 Updating what I know about ${entSlug}…`);
985
- else notifySkill(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
986
- return;
987
- }
988
- const toolName2 = toolsLib.toolNameFromPath(filePath);
989
- if (toolName2) {
990
- if (toolsLib.findTool(toolName2)) notifyToolTrace(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
991
- else notifyToolTrace(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
992
- return;
993
- }
994
- const dir = skillsLib.skillNameFromPath(filePath);
995
- if (!dir) return;
996
- // The tool_use event precedes the actual write, so existence now
997
- // distinguishes a brand-new skill from a patch.
998
- if (skillsLib.skillExists(dir)) notifySkill(`write:${dir}`, `Updating skill: ${dir}`);
999
- else notifySkill(`write:${dir}`, `Learning new skill: ${dir} — /skills show ${dir} to inspect, /skills remove ${dir} to drop it.`);
1000
- }
1001
- } catch (e) { /* announcements are best-effort */ }
1002
- };
1566
+ const chunks = splitMessage(finalText);
1567
+ for (let index = 0; index < chunks.length; index += 1) {
1568
+ let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId } : {}));
1569
+ if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts());
1570
+ if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1571
+ }
1003
1572
 
1004
- // Read-side recall: fire "📖 Recalled my notes on …" only when the agent
1005
- // actually pulls a full pack/entity via the CLI (it decided it was worth
1006
- // reading), not when a headline was auto-injected. Deduped per turn through
1007
- // notifySkill's Set so repeated `pack show` calls don't spam the chat.
1008
- // Nodes the agent actually OPENED this turn (📖). This is the co-use signal
1009
- // the recall graph reinforces on — actually-read, not merely surfaced.
1010
- const openedThisTurn = new Set();
1011
- // Reusable tools the agent RAN this turn, in order, as { name, shape }. Feeds
1012
- // two graphs (core/tool-graph.js): names → directed follows-edges ("you ran X
1013
- // — you usually run Y next"); shapes → pack↔tool edges ("in this topic, tool X
1014
- // is used as `…`"). Order matters here (unlike openedThisTurn, a Set), because
1015
- // a tool chain is a pipeline.
1016
- const toolRunsThisTurn = [];
1017
- const noteRecallFromShell = (command) => {
1018
- try {
1019
- const cmd = String(command || "");
1020
- // 5c raw-op audit: every Bash command in the stream passes here, so
1021
- // operational work done raw — heredoc scripts, DB clients, mutating
1022
- // kubectl — gets stamped to the guard log for the dream's KPI pass.
1023
- // Observation only; blocking is the deny-gate hook's job, not this.
1024
- try { require("./tool-guard").noteRawOp(cmd); } catch (e) { /* best-effort */ }
1025
- if (!cmd.includes("open-claudia")) return;
1026
- let m;
1027
- const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
1028
- while ((m = packRe.exec(cmd))) {
1029
- const dir = m[1];
1030
- const pack = packsLib.readPack(dir);
1031
- const name = (pack && (pack.name || pack.dir)) || dir;
1032
- openedThisTurn.add(`pack:${dir}`);
1033
- notifySkill(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
1034
- }
1035
- const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
1036
- while ((m = entRe.exec(cmd))) {
1037
- const slug = m[1];
1038
- const ent = entitiesLib.readEntity(slug);
1039
- const name = (ent && (ent.name || ent.slug)) || slug;
1040
- openedThisTurn.add(`entity:${slug}`);
1041
- notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
1573
+ if (result.ok && runContext.lastInputWasVoice) {
1574
+ const { currentTransport } = require("./context");
1575
+ if (currentTransport() === "voice") {
1576
+ const sentences = splitSentences(finalText);
1577
+ let spoke = false;
1578
+ for (const sentence of sentences) {
1579
+ const clip = await synthSentenceMp3(sentence);
1580
+ if (clip) { spoke = true; await sendVoice(clip); }
1042
1581
  }
1043
- // `open-claudia tool run|exec <name> <args>` — record each run as
1044
- // { name, shape }. The name feeds the directed follows-graph (what runs
1045
- // next); the sanitized command shape feeds pack↔tool capture (how a tool
1046
- // is used in a topic). Parsing lives in tool-graph so it stays testable.
1047
- try {
1048
- for (const run of require("./tool-graph").parseToolRuns(cmd)) {
1049
- toolRunsThisTurn.push(run);
1050
- // Visibility (gated by /tooltrace): one 🔧 line per distinct
1051
- // tool+verb this turn — sanitized command shape plus the verb's own
1052
- // doc sentence (verbDoc: parsed mechanically from the tool header,
1053
- // zero model cost). Telemetry (recordRun) fires at end-of-turn.
1054
- const verb = (String(run.shape || "").split(/\s+/)[1] || "");
1055
- let doc = "";
1056
- try { doc = toolsLib.verbDoc(run.name, verb); } catch (e) {}
1057
- notifyToolTrace(`ran:${run.name}:${verb}`, `🔧 ${run.shape || run.name}${doc ? ` — ${doc}` : ""}`);
1058
- }
1059
- } catch (e) { /* graph optional (old node) */ }
1060
- // `open-claudia tool add <src> [--name X]` — a tool being saved/updated.
1061
- // The Write/Edit path above only catches direct edits to TOOLS_DIR; `tool
1062
- // add` copies an external file in, so detect it here. Gated by /tooltrace.
1063
- const addRe = /\btool\s+add\s+(\S+)([^\n;|&]*)/gi;
1064
- while ((m = addRe.exec(cmd))) {
1065
- let name = (m[2].match(/--name\s+["']?([^\s"']+)/i) || [])[1];
1066
- if (!name) name = (String(m[1]).split(/[\\/]/).pop() || "").replace(/\.[^.]+$/, "");
1067
- name = toolsLib.sanitizeName(name);
1068
- if (name) notifyToolTrace(`added:${name}`, `🛠️ Saved tool: ${name} — open-claudia tool show ${name} to inspect.`);
1582
+ if (!spoke) {
1583
+ const voicePath = await textToVoice(finalText);
1584
+ if (voicePath) await sendVoice(voicePath);
1069
1585
  }
1070
- } catch (e) { /* announcements are best-effort */ }
1071
- };
1586
+ await sendVoiceEnd();
1587
+ }
1588
+ }
1589
+ return { ok: true, chunks: chunks.length };
1590
+ }
1072
1591
 
1073
- // The same 📖 signal when the agent opens a note with the Read tool instead
1074
- // of the CLI: noteRecallFromShell only sees shell commands, so a raw-file
1075
- // read of a pack/entity would otherwise be invisible to both the banner and
1076
- // the co-use graph. Deduped against the CLI path via the shared notifySkill
1077
- // key, so reading the same node both ways still announces only once.
1078
- const noteRecallFromReadPath = (filePath) => {
1079
- try {
1080
- const node = recallNodeFromPath(filePath);
1081
- if (!node) return;
1082
- openedThisTurn.add(`${node.kind}:${node.id}`);
1083
- notifySkill(`recall:${node.kind}:${node.id}`, `📖 Recalled my notes on: ${node.name}`);
1084
- } catch (e) { /* announcements are best-effort */ }
1592
+ function decorateForegroundInvocation(invocation, runContext, bundle) {
1593
+ const env = { ...invocation.env };
1594
+ const lb = loopback.info();
1595
+ const compactionStage = runContext.purpose === "compact-summary" || runContext.purpose === "compact-seed";
1596
+ if (!compactionStage) {
1597
+ if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
1598
+ if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
1599
+ if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
1600
+ if (runContext.canonicalUserId) env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
1601
+ if (runContext.sessionId) env.OC_LAST_SESSION_ID = String(runContext.sessionId);
1602
+ if (lb?.url) env.OC_SEND_URL = lb.url;
1603
+ }
1604
+ env.OC_PROVIDER = runContext.provider;
1605
+ env.OC_RUN_ID = runContext.runId;
1606
+ if (runContext.project?.name) env.OC_PROJECT = String(runContext.project.name);
1607
+ if (runContext.project?.dir) env.OC_PROJECT_DIR = String(runContext.project.dir);
1608
+ if (runContext.sessionId) env.OC_SESSION_ID = String(runContext.sessionId);
1609
+ if (runContext.previousSessionId) env.OC_PREVIOUS_SESSION_ID = String(runContext.previousSessionId);
1610
+ if (runContext.providerSettings) env.OC_PROVIDER_SETTINGS = JSON.stringify(runContext.providerSettings);
1611
+ if (runContext.occurrenceId) env.OC_OCCURRENCE_ID = String(runContext.occurrenceId);
1612
+ if (runContext.scheduledJobId) env.OC_SCHEDULED_JOB_ID = String(runContext.scheduledJobId);
1613
+ if (runContext.originRunId) env.OC_ORIGIN_RUN_ID = String(runContext.originRunId);
1614
+ if (bundle.transcriptPaths?.transcriptPath) env.OC_TRANSCRIPT_PATH = bundle.transcriptPaths.transcriptPath;
1615
+ return { ...invocation, env };
1616
+ }
1617
+
1618
+ function startTypingHeartbeat(state, store) {
1619
+ const adapter = store?.adapter || currentAdapter();
1620
+ const channelId = store?.channelId || currentChannelId();
1621
+ if (!adapter || !channelId) return () => {};
1622
+ const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
1623
+ state.thinkingPhase = true;
1624
+ tick();
1625
+ state.typingHeartbeat = setInterval(tick, 4000);
1626
+ return () => {
1627
+ state.thinkingPhase = false;
1628
+ if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1629
+ state.typingHeartbeat = null;
1630
+ try { adapter.typingStop?.(channelId); } catch (_) {}
1085
1631
  };
1632
+ }
1086
1633
 
1087
- let args;
1088
- try {
1089
- args = await buildClaudeArgs(prompt, opts);
1090
- } catch (e) {
1091
- state.preparingRun = false;
1092
- stopTyping();
1093
- throw e;
1094
- }
1095
- // Recall announcements are now fired at READ time, not injection time:
1096
- // matched packs/entities enter context only as small headlines (see
1097
- // system-prompt.js recallHeadline). The "📖 Recalled my notes on …" line is
1098
- // emitted by noteRecallFromShell below when the agent actually runs
1099
- // `open-claudia pack show <dir>` / `entity show <slug>` so the banner
1100
- // reflects what was read, not what was pushed. (consumeLastInjected is
1101
- // drained here to keep the per-turn buffer from leaking into the next turn.)
1102
- let turnRecallTier = "";
1103
- // Pack dirs surfaced (recall-matched) for this turn — joined with
1104
- // openedThisTurn at end-of-turn to tell the post-turn reviewer which packs
1105
- // this turn's work belongs to (attribution + safe State rewrites).
1106
- let surfacedPackDirs = [];
1107
- try {
1108
- const injected = require("./system-prompt").consumeLastInjected();
1109
- if (injected && Array.isArray(injected.packDirs)) surfacedPackDirs = injected.packDirs;
1110
- if (injected && injected.recall) {
1111
- const r = injected.recall;
1112
- turnRecallTier = String(r.tier || "");
1113
- const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1114
- const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
1115
- // Debug traces are owner-only: recall/tool/engine internals must never
1116
- // leak to an external speaker even when a debug toggle is left on.
1117
- const traceOwnerOnly = !externalSpeaker;
1118
- // 🧠 packs/entities recall — gated by /recall.
1119
- if (traceOwnerOnly && settings.showRecall) {
1120
- const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
1121
- if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1122
- else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
1123
- }
1124
- // 🔧 tools surfaced this turn (first-class discoverer nodes) — gated by /tooltrace.
1125
- if (traceOwnerOnly && settings.showToolTrace) {
1126
- const toolLines = fmt(r.tools || [], "🔧");
1127
- if (toolLines.length) send(`🔧 <b>Tools surfaced this turn</b>\n${toolLines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1634
+ function settleImmediateEffects(result, runContext, { state, store, capture }) {
1635
+ const stages = [
1636
+ ["transcript", persistForegroundTranscript],
1637
+ ["usage", persistForegroundUsage],
1638
+ ["session", persistForegroundSession],
1639
+ ];
1640
+ return (async () => {
1641
+ for (const [name, hook] of stages) {
1642
+ if (!result.persistence[name].skipped) continue;
1643
+ result.persistence[name] = { ok: true, skipped: false };
1644
+ try { await hook(result, runContext, state, store); }
1645
+ catch (error) {
1646
+ result.persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
1647
+ if (!result.error) result.error = result.persistence[name].error;
1648
+ result.ok = false;
1649
+ result.status = "failed";
1128
1650
  }
1129
1651
  }
1130
- } catch (e) { /* best-effort */ }
1131
- // /stop landed during the pre-spawn window (recall/compaction): bail before
1132
- // spawning. The /stop handler already acknowledged in chat, so stay silent.
1133
- if (state.cancelRequested) {
1134
- state.cancelRequested = false;
1135
- state.preparingRun = false;
1136
- stopTyping();
1137
- return;
1138
- }
1139
- const binaryPath = getActiveBinary();
1140
- const proc = spawn(binaryPath, args, {
1141
- cwd,
1142
- env: subprocessEnv(),
1143
- stdio: ["ignore", "pipe", "pipe"],
1144
- detached: process.platform !== "win32",
1145
- });
1146
-
1147
- state.runningProcess = proc;
1148
- state.preparingRun = false;
1149
- const startTime = Date.now();
1150
- let longRunningNotified = false;
1151
-
1152
- // Inactivity watchdog. The live bot runs through this modular runner
1153
- // (`bot.js` → `core/runner.js`), so the guard must live here, not only in
1154
- // the legacy monolith. `state.runningProcess` is single-flight and normally
1155
- // clears only on child close/error; a silent wedged child can otherwise pin it
1156
- // forever and leave every later message queued. Treat child stdout/stderr as
1157
- // liveness and stop the process group if it goes quiet past the idle limit.
1158
- const idleLimitMs = Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000);
1159
- let lastActivity = Date.now();
1160
- let watchdogTripped = false;
1161
- let watchdogNotice = null;
1162
-
1163
- const processTimeout = setTimeout(() => {
1164
- if (state.runningProcess === proc) {
1165
- killProcessTree(proc.pid, "SIGTERM");
1166
- setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
1167
- chatContext.run(store, () => send(`Task timed out after ${MAX_PROCESS_TIMEOUT / 60000} minutes. Stopped.`));
1652
+ if (!capture && result.delivery.skipped) {
1653
+ result.delivery.skipped = false;
1654
+ try {
1655
+ result.delivery.result = await deliverForegroundResult(result, runContext, state);
1656
+ result.delivery.ok = true;
1657
+ } catch (error) {
1658
+ result.delivery.ok = false;
1659
+ result.delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
1660
+ if (!result.error) result.error = result.delivery.error;
1661
+ result.ok = false;
1662
+ result.status = "failed";
1663
+ }
1168
1664
  }
1169
- }, MAX_PROCESS_TIMEOUT);
1665
+ result.diagnostic = result.ok ? null : (providerRunDiagnostic(result, runContext) || result.error?.message || "Provider run failed");
1666
+ return result;
1667
+ })();
1668
+ }
1170
1669
 
1171
- let lastUpdate = "";
1172
- const scheduleUpdate = () => {
1173
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
1174
- const interval = elapsed > 120 ? 5000 : 2000;
1175
- state.streamInterval = setTimeout(() => chatContext.run(store, updateProgress), interval);
1670
+ const STREAM_PREVIEW_THROTTLE_MS = 2500;
1671
+ const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
1672
+
1673
+ // Edited-in-place interim preview: each completed text block the provider
1674
+ // emits mid-run is appended to one throttled status message, so the user sees
1675
+ // the narration as it happens instead of silence. finalizeStreamPreview
1676
+ // settles the message when the run ends.
1677
+ function createStreamPreview({ state, store }) {
1678
+ let enabled = !!store && (store?.adapter?.type || "") !== "voice";
1679
+ if (enabled) {
1680
+ // Interim text is NOT vetted by guardOutboundReply (only the final answer
1681
+ // is), so it must never reach a guarded external speaker. Fail closed if
1682
+ // classification is unavailable.
1683
+ try { enabled = chatContext.run(store, () => !require("./relationship").isCurrentSpeakerGuarded()); }
1684
+ catch (_) { enabled = false; }
1685
+ }
1686
+ const channelId = store?.channelId || null;
1687
+
1688
+ const flush = () => {
1689
+ state.streamInterval = null;
1690
+ if (!enabled || state.cancelRequested) return;
1691
+ const body = String(state.streamBuffer || "").trim();
1692
+ if (!body) return;
1693
+ const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
1694
+ // Event callbacks arrive off the provider's stdout stream, outside the
1695
+ // chat AsyncLocalStorage context — rebind before any send/edit.
1696
+ chatContext.run(store, async () => {
1697
+ try {
1698
+ if (typeof state.statusMessageId === "string" && String(state.statusMessageChannel) === String(channelId)) {
1699
+ await editMessage(state.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
1700
+ return;
1701
+ }
1702
+ if (state.statusMessageId === true) return; // sent but not editable — leave the single notice alone
1703
+ const sent = await send(display, telegramHtmlOpts());
1704
+ state.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
1705
+ state.statusMessageChannel = sent.ok ? channelId : null;
1706
+ } catch (_) { /* preview is best-effort */ }
1707
+ }).catch(() => {});
1176
1708
  };
1177
1709
 
1178
- const drainQueuedMessages = async () => {
1179
- if (state.messageQueue.length === 0 || !state.currentSession) return;
1180
- const drained = state.messageQueue.splice(0);
1181
- // Handle one same-origin run at a time; requeue the remainder so the
1182
- // finishing turn's close→drain tail-recursion picks up the next origin
1183
- // group. Each group's reply is scoped to the channel it came from, so a
1184
- // message queued from one of the owner's channels during a run on another
1185
- // is still answered on its own channel (unified identity, one run-lock).
1186
- const { group, rest } = splitLeadingOriginGroup(drained);
1187
- if (rest.length) state.messageQueue.unshift(...rest);
1188
-
1189
- const runGroup = async () => {
1190
- if (group.length === 1) {
1191
- const only = group[0];
1192
- await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1193
- } else {
1194
- const fmtTime = (ts) => {
1195
- const d = new Date(ts);
1196
- const hh = String(d.getHours()).padStart(2, "0");
1197
- const mm = String(d.getMinutes()).padStart(2, "0");
1198
- const ss = String(d.getSeconds()).padStart(2, "0");
1199
- return `${hh}:${mm}:${ss}`;
1200
- };
1201
- const numbered = group.map((m, idx) => `[${idx + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1202
- const batched =
1203
- `While you were working the user sent these ${group.length} follow-up messages (oldest first):\n\n` +
1204
- `${numbered}\n\n` +
1205
- `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1206
- `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1207
- const last = group[group.length - 1];
1208
- await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
1209
- }
1210
- };
1211
-
1212
- const origin = group[0].origin;
1213
- if (origin) await chatContext.run(origin, runGroup);
1214
- else await runGroup();
1710
+ const appendChunk = (chunk, runContext) => {
1711
+ if (!enabled || runContext?.purpose !== "foreground" || state.cancelRequested) return;
1712
+ state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
1713
+ if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
1714
+ if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
1215
1715
  };
1216
1716
 
1217
- const updateProgress = async () => {
1218
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
1219
- if (!watchdogTripped && state.runningProcess === proc && Date.now() - lastActivity > idleLimitMs) {
1220
- watchdogTripped = true;
1221
- const idleMin = Math.max(1, Math.floor((Date.now() - lastActivity) / 60000));
1222
- watchdogNotice = `Task stalled no activity for ${idleMin}min, so I stopped it and I'm responsive again. Send /continue to resume, or just re-ask.`;
1223
- console.error(`[watchdog] child pid ${proc.pid} idle ${idleMin}min (limit ${Math.floor(idleLimitMs / 60000)}min) — killing`);
1224
- try { killProcessTree(proc.pid, "SIGTERM"); }
1225
- catch (e) { try { proc.kill("SIGTERM"); } catch (e2) {} }
1226
- setTimeout(() => { try { killProcessTree(proc.pid, "SIGKILL"); } catch (e) {} }, 3000);
1227
- setTimeout(() => chatContext.run(store, async () => {
1228
- if (state.runningProcess === proc) {
1229
- state.runningProcess = null;
1230
- state.preparingRun = false;
1231
- stopTyping();
1232
- clearTimeout(state.streamInterval); state.streamInterval = null;
1233
- clearTimeout(processTimeout);
1234
- state.statusMessageId = null;
1235
- if (settings.budget) settings.budget = null;
1236
- saveState();
1237
- await drainQueuedMessages();
1238
- }
1239
- }), 10000);
1240
- try { await send(watchdogNotice, { replyTo: replyToMsgId }); } catch (e) {}
1241
- state.statusMessageId = null;
1242
- return;
1243
- }
1244
- try {
1245
- if (adapter && channelId) adapter.typing(channelId).catch(() => {});
1246
- // For guarded (external) turns the live preview must not echo unvetted
1247
- // assistant text — show tool/elapsed status only until the final reply is
1248
- // vetted and delivered.
1249
- const display = formatProgress(guardedTurn ? "" : assistantText, toolUses, currentTool, elapsed, currentToolDetail);
1250
- if (display && display !== lastUpdate) {
1251
- // A statusMessageId minted on another channel (shared per-canonical
1252
- // state under unified identity) can't be edited here — drop it so a
1253
- // fresh preview is created on this channel instead of 500-ing.
1254
- if (state.statusMessageId && state.statusMessageChannel !== channelId) {
1255
- state.statusMessageId = null;
1256
- state.statusMessageChannel = null;
1257
- }
1258
- if (!state.statusMessageId && assistantText) {
1259
- const previewResult = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
1260
- // Keep the real string id when the preview is editable (Telegram +
1261
- // Kazee both normalize to a string now); otherwise a truthy sentinel
1262
- // so we don't re-post a fresh preview each tick, while canEditStatus()
1263
- // (typeof string) still declines to edit an unaddressable message.
1264
- state.statusMessageId = previewResult.editable ? previewResult.messageId : (previewResult.ok ? true : null);
1265
- state.statusMessageChannel = channelId;
1266
- } else if (canEditStatus()) {
1267
- await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
1268
- }
1269
- lastUpdate = display;
1270
- }
1271
- if (elapsed > 300 && !longRunningNotified) {
1272
- longRunningNotified = true;
1273
- await send(`Still working (${Math.floor(elapsed / 60)}min)... Send /stop to cancel.`);
1274
- }
1275
- } catch (e) {
1276
- console.error("Progress update error:", e.message);
1277
- }
1278
- if (state.runningProcess) scheduleUpdate();
1717
+ return {
1718
+ append(text, runContext) {
1719
+ appendChunk(text, runContext);
1720
+ },
1721
+ // Thinking/reasoning is interim-only display: it rides the preview buffer
1722
+ // under /verbose but never enters narrationText or the final answer, so
1723
+ // a successful run's settled messages carry no reasoning residue.
1724
+ appendThinking(text, runContext) {
1725
+ if (!state.settings?.showThinking) return;
1726
+ const summary = String(text).trim();
1727
+ if (!summary) return;
1728
+ const clipped = summary.length > 900 ? `${summary.slice(0, 900)}…` : summary;
1729
+ appendChunk(`<i>🧠 ${clipped}</i>`, runContext);
1730
+ },
1279
1731
  };
1280
- scheduleUpdate();
1281
-
1282
- proc.stdout.on("data", (data) => {
1283
- lastActivity = Date.now();
1284
- state.streamBuffer += data.toString();
1285
- const events = parseStreamEvents(state.streamBuffer);
1286
- const lastNewline = state.streamBuffer.lastIndexOf("\n");
1287
- state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
1288
- for (const evt of events) {
1289
- // Leave the "thinking" phase the moment the model produces any real
1290
- // output — streamed text, a finished assistant block, a tool call, or a
1291
- // terminal result. Idempotent (guards on state.thinkingPhase internally).
1292
- if (state.thinkingPhase && (
1293
- (evt.type === "stream_event" && evt.event?.type === "content_block_delta" && evt.event.delta?.type === "text_delta") ||
1294
- (evt.type === "assistant" && evt.message?.content) ||
1295
- (evt.type === "tool_call" && evt.subtype === "started") ||
1296
- evt.type === "result"
1297
- )) {
1298
- endThinking();
1299
- }
1300
- // Voice latency probe: first "system" event = CLI ready (cold-start done);
1301
- // "result" = generation finished (before the TTS tail drains).
1302
- if (voiceStreaming && vlFirstSysAt == null && evt.type === "system") vlFirstSysAt = Date.now();
1303
- if (voiceStreaming && vlResultAt == null && evt.type === "result") vlResultAt = Date.now();
1304
- // Voice streaming-out: speak finished sentences as the model writes them.
1305
- // Only text_delta is spoken; thinking_delta and tool events are ignored.
1306
- if (voiceStreaming && evt.type === "stream_event"
1307
- && evt.event?.type === "content_block_delta"
1308
- && evt.event.delta?.type === "text_delta"
1309
- && typeof evt.event.delta.text === "string") {
1310
- if (vlFirstTokenAt == null) vlFirstTokenAt = Date.now();
1311
- spokenBuf += evt.event.delta.text;
1312
- pumpSpoken(false);
1313
- }
1314
- if (evt.type === "assistant" && evt.message?.usage) {
1315
- const callPrefix = usageParts(evt.message.usage, settings.backend || "claude").context;
1316
- if (callPrefix > peakContextTokens) peakContextTokens = callPrefix;
1317
- }
1318
- if (evt.type === "assistant" && evt.message?.content) {
1319
- for (const block of evt.message.content) {
1320
- if (block.type === "text") assistantText += block.text;
1321
- else if (block.type === "tool_use") {
1322
- currentTool = block.name;
1323
- toolUses.push(block.name);
1324
- const input = block.input || {};
1325
- if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
1326
- else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1327
- else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1328
- else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1329
- else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
1330
- else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
1331
- else if (block.name === "Skill" && input.skill) currentToolDetail = input.skill;
1332
- else currentToolDetail = "";
1333
- noteSkillToolUse(block.name, input);
1334
- if (block.name === "Bash" && input.command) noteRecallFromShell(input.command);
1335
- else if (block.name === "Read") noteRecallFromReadPath(input.file_path || input.filePath);
1336
- }
1337
- }
1338
- }
1339
- if (evt.type === "tool_call" && evt.subtype === "started" && evt.tool_call) {
1340
- const tc = evt.tool_call;
1341
- if (tc.shellToolCall) {
1342
- const a = tc.shellToolCall.args || {};
1343
- currentTool = "Shell"; toolUses.push("Shell");
1344
- currentToolDetail = (a.description || a.command || "").slice(0, 80);
1345
- if (a.command) noteRecallFromShell(a.command);
1346
- } else if (tc.readToolCall) {
1347
- currentTool = "Read"; toolUses.push("Read");
1348
- const readPath = tc.readToolCall.args?.path;
1349
- currentToolDetail = (readPath || "").split("/").slice(-2).join("/");
1350
- noteRecallFromReadPath(readPath);
1351
- } else if (tc.editToolCall) {
1352
- currentTool = "Edit"; toolUses.push("Edit");
1353
- currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
1354
- noteSkillToolUse("Edit", tc.editToolCall.args);
1355
- } else if (tc.writeToolCall) {
1356
- currentTool = "Write"; toolUses.push("Write");
1357
- currentToolDetail = (tc.writeToolCall.args?.filePath || "").split("/").slice(-2).join("/");
1358
- noteSkillToolUse("Write", tc.writeToolCall.args);
1359
- } else if (tc.grepToolCall) {
1360
- currentTool = "Grep"; toolUses.push("Grep");
1361
- currentToolDetail = (tc.grepToolCall.args?.pattern || "").slice(0, 40);
1362
- } else if (tc.globToolCall) {
1363
- currentTool = "Glob"; toolUses.push("Glob");
1364
- currentToolDetail = (tc.globToolCall.args?.globPattern || "").slice(0, 40);
1365
- } else if (tc.createPlanToolCall) {
1366
- currentTool = "Plan"; toolUses.push("Plan");
1367
- const plan = tc.createPlanToolCall.args || {};
1368
- let planText = "";
1369
- if (plan.name) planText += `**${plan.name}**\n\n`;
1370
- if (plan.plan) planText += plan.plan + "\n";
1371
- if (plan.todos && plan.todos.length) {
1372
- planText += "\n**Tasks:**\n";
1373
- for (const todo of plan.todos) {
1374
- planText += `• ${todo.content || todo.id}\n`;
1375
- }
1376
- }
1377
- if (planText) assistantText = planText;
1378
- currentToolDetail = plan.name || "creating plan";
1379
- } else {
1380
- const toolKey = Object.keys(tc)[0] || "unknown";
1381
- currentTool = toolKey.replace("ToolCall", ""); toolUses.push(currentTool);
1382
- currentToolDetail = "";
1383
- }
1732
+ }
1733
+
1734
+ function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1735
+ const persistEffects = !capture || persistCapture;
1736
+ const preview = capture ? null : createStreamPreview({ state, store });
1737
+ return createRunnerService({
1738
+ getState: () => state,
1739
+ getStore: () => store,
1740
+ resolveProvider: resolveForegroundProvider,
1741
+ buildTurnPrompt,
1742
+ buildInvocation(provider, context) {
1743
+ if (context.purpose === "compact-seed" && typeof provider.buildCompactionInvocation === "function") {
1744
+ return provider.buildCompactionInvocation(context);
1384
1745
  }
1385
- if (evt.type === "thread.started" && evt.thread_id) {
1386
- state.codexSessionId = evt.thread_id;
1387
- saveState();
1746
+ return provider.buildMainInvocation(context);
1747
+ },
1748
+ decorateInvocation: decorateForegroundInvocation,
1749
+ persistTranscript: persistEffects ? ((result, runContext) => persistForegroundTranscript(result, runContext, state)) : null,
1750
+ persistUsage: persistEffects ? ((result, runContext) => persistForegroundUsage(result, runContext, state, store)) : null,
1751
+ persistSession: persistEffects ? ((result, runContext) => persistForegroundSession(result, runContext, state)) : null,
1752
+ deliver: capture ? null : (result, runContext) => deliverForegroundResult(result, runContext, state),
1753
+ onSpawn(proc) {
1754
+ state.runningProcess = proc;
1755
+ if (ownsAdmissionLock) state.preparingRun = false;
1756
+ },
1757
+ shouldAbort(runContext, { capture: isCapture } = {}) {
1758
+ if (isCapture || !ownsAdmissionLock) return false;
1759
+ if (!state.cancelRequested) return false;
1760
+ state.cancelRequested = false;
1761
+ return true;
1762
+ },
1763
+ onEvent(event, runContext) {
1764
+ if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1765
+ if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1766
+ const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1767
+ if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
1388
1768
  }
1389
- if (evt.type === "item.started" && evt.item) {
1390
- const it = evt.item;
1391
- if (it.type === "command_execution" && it.command) {
1392
- currentTool = "Shell"; toolUses.push("Shell");
1393
- currentToolDetail = String(it.command).slice(0, 80);
1394
- noteRecallFromShell(it.command);
1395
- } else if (it.type === "file_change" && (it.path || it.file_path)) {
1396
- currentTool = "Edit"; toolUses.push("Edit");
1397
- const p = it.path || it.file_path;
1398
- currentToolDetail = String(p).split("/").slice(-2).join("/");
1399
- } else if (it.type === "web_search" && it.query) {
1400
- currentTool = "Web"; toolUses.push("Web");
1401
- currentToolDetail = String(it.query).slice(0, 60);
1402
- } else if (it.type) {
1403
- currentTool = it.type; toolUses.push(it.type);
1404
- currentToolDetail = "";
1405
- }
1769
+ if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
1770
+ preview.append(event.text, runContext);
1406
1771
  }
1407
- if (evt.type === "item.completed" && evt.item) {
1408
- const it = evt.item;
1409
- if (it.type === "agent_message" && typeof it.text === "string") {
1410
- assistantText += (assistantText ? "\n" : "") + it.text;
1411
- }
1772
+ if (preview && event.type === "thinking" && typeof event.text === "string" && event.text) {
1773
+ preview.appendThinking(event.text, runContext);
1412
1774
  }
1413
- if (evt.type === "turn.completed" && evt.usage && settings.backend === "codex") {
1414
- const session = state.codexSessionId || evt.thread_id || getActiveSessionId();
1415
- const normalized = normalizeCodexUsage(state, session, evt.usage);
1416
- const rawParts = usageParts(evt.usage, "codex");
1417
- applyUsageToState(state, normalized.usage, 0, {
1418
- usageScope: normalized.usageScope,
1419
- liveContextTokens: rawParts.context,
1420
- });
1421
- logTurnUsage(state, normalized.usage, 0, (text) => chatContext.run(store, () => send(text)), normalized);
1422
- saveState();
1423
- }
1424
- if (evt.type === "result" && evt.session_id) {
1425
- if (settings.backend === "cursor") { state.cursorSessionId = evt.session_id; }
1426
- else { state.lastSessionId = evt.session_id; }
1427
- // Codex already reports usage at turn.completed. A result usage block,
1428
- // if present, is the same cumulative total and must not be counted again.
1429
- if (evt.usage && settings.backend !== "codex") {
1430
- const peakOpts = { usageScope: "turn_delta" };
1431
- if (peakContextTokens > 0) peakOpts.liveContextTokens = peakContextTokens;
1432
- applyUsageToState(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, peakOpts);
1433
- logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)), peakOpts);
1434
- }
1435
- if (typeof evt.total_cost_usd === "number" && settings.backend === "codex") state.sessionUsage.costUsd += evt.total_cost_usd;
1436
- saveState();
1437
- }
1438
- // Fallback only: evt.result is just the final text segment, and assigning
1439
- // it unconditionally wiped everything the model said before tool calls.
1440
- if (evt.type === "result" && evt.result && !assistantText.trim()) assistantText = evt.result;
1441
- }
1775
+ },
1776
+ onInvocationWarning(warning) {
1777
+ return send(`⚠️ ${warning.message}`);
1778
+ },
1779
+ onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
1780
+ cleanup: async (proc) => {
1781
+ if (state.runningProcess === proc) state.runningProcess = null;
1782
+ // Keep the single-flight lock armed while transcript/usage/session
1783
+ // persistence and required delivery settle after child close.
1784
+ if (ownsAdmissionLock) state.preparingRun = true;
1785
+ stopTyping();
1786
+ },
1787
+ idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
1442
1788
  });
1789
+ }
1443
1790
 
1444
- let stderrBuffer = "";
1445
- proc.stderr.on("data", (d) => {
1446
- lastActivity = Date.now();
1447
- const chunk = d.toString();
1448
- stderrBuffer += chunk;
1449
- console.error("STDERR:", redactSensitive(chunk));
1791
+ async function handoffMemoryReview(result, runContext, store) {
1792
+ if (!result.ok || !result.text || runContext.purpose !== "foreground") return;
1793
+ const recall = result.recallMetadata || {};
1794
+ packReview.reviewTurn({
1795
+ userText: runContext.userPrompt,
1796
+ assistantText: result.text,
1797
+ channelId: runContext.channelId,
1798
+ provider: runContext.provider,
1799
+ runId: runContext.runId,
1800
+ activePacks: recall.packDirs || [],
1801
+ signals: { tier: recall.tier || "", wrote: result.tools.some((event) => event.type === "tool_start" && ["Edit", "Write", "Bash", "Shell"].includes(event.name)) },
1802
+ source: (() => {
1803
+ try {
1804
+ const speaker = require("./people").findByHandle(store?.adapter?.type, String(store?.userId || store?.channelId || ""));
1805
+ return speaker ? { name: speaker.name, isOwner: !!speaker.isOwner } : null;
1806
+ } catch (_) { return null; }
1807
+ })(),
1808
+ announce: (text) => chatContext.run(store, () => send(text)),
1450
1809
  });
1810
+ }
1451
1811
 
1452
- proc.on("close", (code) => chatContext.run(store, async () => {
1453
- state.runningProcess = null;
1454
- state.preparingRun = false;
1455
- stopTyping();
1456
- clearTimeout(state.streamInterval); state.streamInterval = null;
1457
- clearTimeout(processTimeout);
1458
-
1459
- if (watchdogTripped) {
1460
- const note = watchdogNotice || "Task stalled; watchdog stopped the child process.";
1461
- appendProjectTranscript("system-note", note, { exitCode: code, kind: "watchdog" }, state, getActiveSessionId);
1462
- if (settings.budget) settings.budget = null;
1463
- state.statusMessageId = null;
1464
- await drainQueuedMessages();
1465
- return;
1466
- }
1467
-
1468
- if (settings.backend === "claude" && isClaudeAuthErrorText(stderrBuffer)) {
1469
- const authText = claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800));
1470
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1471
- await send(authText, { replyTo: replyToMsgId });
1472
- return;
1473
- }
1474
- if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1475
- const authText = "Cursor authentication error. Run `agent login` on this machine, then retry.";
1476
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1477
- await send(authText, { replyTo: replyToMsgId });
1478
- return;
1479
- }
1480
- if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
1481
- const authText = "Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.";
1482
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1483
- await send(authText, { replyTo: replyToMsgId });
1484
- return;
1485
- }
1486
-
1487
- // Phase 3: set when an external reply is held by the guardrail — suppresses
1488
- // delivery AND the post-turn reviewer (a blocked turn must not accrete into
1489
- // the owner's memory). Declared out here so it's visible at reviewTurn below.
1490
- let suppressDelivery = false;
1491
- try {
1492
- if (code !== 0 && code !== null && !assistantText.trim()) {
1493
- const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
1494
- appendProjectTranscript("system-note", failureText, { exitCode: code, kind: "backend-error" }, state, getActiveSessionId);
1495
- await send(failureText, { replyTo: replyToMsgId });
1496
- return;
1497
- }
1812
+ function deferredRunResult() {
1813
+ let resolve;
1814
+ let reject;
1815
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
1816
+ return { promise, resolve, reject };
1817
+ }
1498
1818
 
1499
- const finalText = redactSensitive(assistantText || "(no output)");
1500
- appendProjectTranscript("assistant", finalText, {
1501
- exitCode: code,
1502
- sourceMessageId: typeof state.statusMessageId === "string" ? state.statusMessageId : null,
1503
- }, state, getActiveSessionId);
1504
-
1505
- // Phase 3 (3.3): vet the outbound reply for external speakers. Owner/no-
1506
- // context → allow instantly (guardOutboundReply returns allow without a
1507
- // model call). On block/escalate the guard has already told the external
1508
- // "let me check with <owner>" and raised an owner approval, so we suppress
1509
- // delivery here; the vetted text is delivered later by the apr: handler on
1510
- // approval. The blocked text is still recorded to the transcript above so
1511
- // the owner can see what was held.
1512
- if (guardedTurn) {
1513
- let g = { allow: true };
1514
- try { g = await require("./enforcer").guardOutboundReply(finalText); }
1515
- catch (e) { g = { allow: false, escalated: false, reason: e.message }; }
1516
- if (!g.allow) {
1517
- suppressDelivery = true;
1518
- if (!g.escalated) {
1519
- try { await send("I need to check with the owner before I can answer that — I'll follow up."); } catch (e) {}
1520
- }
1521
- }
1522
- }
1819
+ function drainQueuedMessages(state) {
1820
+ if (!Array.isArray(state.messageQueue) || !state.messageQueue.length || state.runningProcess || state.preparingRun) return Promise.resolve();
1821
+ const item = state.messageQueue.shift();
1822
+ state.preparingRun = true;
1823
+ setImmediate(() => {
1824
+ const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
1825
+ const promise = item.store ? chatContext.run(item.store, execute) : execute();
1826
+ promise.then(item.deferred.resolve, item.deferred.reject);
1827
+ });
1828
+ return Promise.resolve();
1829
+ }
1523
1830
 
1524
- const chunks = splitMessage(finalText);
1525
- const firstChunk = chunks[0];
1526
-
1527
- if (suppressDelivery) {
1528
- // The reply was held for the owner: nothing is spoken or sent to the
1529
- // external now. Clear the voice-input flag so it can't leak into the
1530
- // next turn (no TTS flush runs on this path).
1531
- state.lastInputWasVoice = false;
1532
- if (canEditStatus()) { try { await editMessage(state.statusMessageId, "Checking with the owner…", telegramHtmlOpts()); } catch (e) {} }
1533
- } else if (canEditStatus() && chunks.length === 1) {
1534
- // Edit the streaming preview into the final reply — but only if that
1535
- // preview was minted on THIS channel. A foreign-channel id falls through
1536
- // to a fresh send below (never edited cross-channel, which would 500).
1537
- await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
1538
- } else {
1539
- const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
1540
- if (!sent.ok) await send(firstChunk);
1541
- for (let i = 1; i < chunks.length; i++) {
1542
- await send(chunks[i], telegramHtmlOpts());
1543
- }
1544
- }
1545
- if (!suppressDelivery && code !== 0 && code !== null) await send(`Exit code: ${code}`);
1546
-
1547
- if (!suppressDelivery && state.lastInputWasVoice) {
1548
- state.lastInputWasVoice = false;
1549
- if (voiceStreaming) {
1550
- // Sentences were already being spoken as the model wrote them. Flush
1551
- // the trailing partial sentence, wait for the ordered send queue to
1552
- // drain, then close the turn so the client re-arms the mic.
1553
- pumpSpoken(true);
1554
- await ttsChain;
1555
- if (!spokeAnyStreamed) {
1556
- // Tool-only / empty turn produced no spoken text — say the final
1557
- // text once so the user still hears a reply.
1558
- const voicePath = await textToVoice(finalText);
1559
- if (voicePath) await sendVoice(voicePath);
1560
- }
1561
- await sendVoiceEnd();
1562
- // Per-stage latency breakdown (measurement only — temporary debug footer).
1563
- try {
1564
- const vl = state.voiceLat || {};
1565
- const endAt = Date.now();
1566
- const sec = (n) => n != null ? (n / 1000).toFixed(1) + "s" : "?";
1567
- const diff = (a, b) => (a != null && b != null) ? sec(b - a) : "?";
1568
- const fromStart = (t) => diff(vl.startAt, t);
1569
- const line = `⏱ dl ${sec(vl.downloadMs)} · stt ${sec(vl.sttMs)}`
1570
- + ` · cli ${diff(startTime, vlFirstSysAt)} · ttft ${diff(vlFirstSysAt, vlFirstTokenAt)}`
1571
- + ` · gen ${diff(vlFirstTokenAt, vlResultAt)} · 1st-audio ${fromStart(vlFirstAudioAt)}`
1572
- + ` · total ${fromStart(endAt)}`;
1573
- console.log(`[VOICE-LAT] ${line}`);
1574
- await send(line);
1575
- } catch (e) { /* metrics best-effort */ }
1576
- state.voiceLat = null;
1577
- } else {
1578
- // Non-streamed fallback. Spoken replies belong to the hands-free voice
1579
- // channel; on chat transports (Telegram/Kazee) an auto voice note on
1580
- // every voice input is unwanted noise, so gate it to the voice channel.
1581
- const { currentTransport } = require("./context");
1582
- if (currentTransport() === "voice") {
1583
- const sentences = splitSentences(finalText);
1584
- let spokeAny = false;
1585
- for (const sentence of sentences) {
1586
- const clip = await synthSentenceMp3(sentence);
1587
- if (clip) { spokeAny = true; await sendVoice(clip); }
1588
- }
1589
- if (!spokeAny) {
1590
- const voicePath = await textToVoice(finalText);
1591
- if (voicePath) await sendVoice(voicePath);
1592
- }
1593
- await sendVoiceEnd();
1594
- }
1595
- }
1596
- }
1597
- } catch (e) {
1598
- console.error("Final message delivery failed:", e.message);
1599
- await send("Task completed but failed to deliver the response. Send /continue to see the result.");
1831
+ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
1832
+ const tracksMainRun = ownsAdmissionLock && !capture;
1833
+ if (tracksMainRun) state.activeRunContext = runContext;
1834
+ const stopTyping = startTypingHeartbeat(state, store);
1835
+ const persistCapture = executionOptions.persistCapture !== false;
1836
+ const internalCapture = capture && !persistCapture;
1837
+ const service = createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture });
1838
+ let result;
1839
+ try {
1840
+ result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
1841
+ if ((result.persistence.transcript.skipped || (!capture && result.delivery.skipped)) && !internalCapture) {
1842
+ result = await settleImmediateEffects(result, runContext, { state, store, capture });
1600
1843
  }
1601
- if (settings.budget) settings.budget = null;
1602
- state.statusMessageId = null;
1603
-
1604
- // Outcome signal: only learn from turns that actually completed. A turn that
1605
- // errored out is not evidence that a recalled pattern "helped" — reinforcing
1606
- // or recording reuse on it would teach the wrong lesson (gap: reinforcement
1607
- // must track "helped", not merely "opened").
1608
- const turnSucceeded = (code === 0 || code === null);
1609
-
1610
- // Hebbian co-use: nodes the agent actually opened together this turn get
1611
- // their `related` edges reinforced, so future spreading activation pulls
1612
- // the cluster together. Reinforce on co-USE (📖), never co-recall.
1613
- if (turnSucceeded && openedThisTurn.size > 0) {
1614
- try {
1615
- const recallGraph = require("./recall/graph");
1616
- if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
1617
- require("./recall/metrics").logUse([...openedThisTurn]);
1618
- } catch (e) { /* best-effort */ }
1844
+ } catch (error) {
1845
+ result = preflightFailureResult(runContext, { id: runContext.provider, label: runContext.provider }, error);
1846
+ if (!internalCapture) {
1847
+ result = await settleImmediateEffects(result, runContext, { state, store, capture });
1619
1848
  }
1849
+ } finally {
1850
+ if (tracksMainRun && state.runningProcess) state.runningProcess = null;
1851
+ if (ownsAdmissionLock) state.preparingRun = false;
1852
+ // A cancel flag that survived this run (e.g. /stop raced a run that
1853
+ // finished successfully anyway) must not leak into the next turn, where
1854
+ // it would falsely abort a queued run or mute a genuine failure.
1855
+ if (ownsAdmissionLock) state.cancelRequested = false;
1856
+ if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
1857
+ stopTyping();
1858
+ }
1620
1859
 
1621
- // Directed tool-graph: a chain of reusable tools run in succession this turn
1622
- // (auth → list → download) becomes follows-edges, so next time the first
1623
- // runs we can surface "usually followed by …". Kept in a tool-specific graph
1624
- // so it never bleeds into recall's spreading activation. Reinforce only on a
1625
- // successful turn, and only when ≥2 ran (a single run has no succession).
1626
- if (turnSucceeded && toolRunsThisTurn.length > 1) {
1627
- try { require("./tool-graph").reinforceSequence(toolRunsThisTurn.map((t) => t.name)); }
1628
- catch (e) { /* best-effort */ }
1860
+ if (tracksMainRun) {
1861
+ sideChatCoordinator.noteMainResult(runContext, result);
1862
+ for (const item of state.messageQueue) {
1863
+ finalizeSideChatQueueItem(item, runContext, result);
1629
1864
  }
1630
-
1631
- // Tool usage telemetry: a JSON sidecar (runCount + lastUsed per tool) that
1632
- // works on every node version (unlike the SQLite graphs above). matchTools
1633
- // breaks score ties by runCount so well-worn tools surface first, and dream
1634
- // hygiene flags tools created-but-never-run. Fires for any successful run.
1635
- if (turnSucceeded && toolRunsThisTurn.length) {
1636
- try { for (const t of toolRunsThisTurn) toolsLib.recordRun(t.name); }
1637
- catch (e) { /* best-effort */ }
1865
+ }
1866
+ if (!internalCapture) await handoffMemoryReview(result, runContext, store);
1867
+ if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1868
+ state.settings.budget = null;
1869
+ try { saveState({ strict: true }); }
1870
+ catch (error) {
1871
+ result.ok = false;
1872
+ result.status = "failed";
1873
+ result.error = result.error || stableRunError(error.message, "RUN_SETTINGS_PERSIST_FAILED");
1874
+ result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
1638
1875
  }
1876
+ }
1877
+ if (!capture && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
1878
+ try { await compactActiveSession(runContext.project.dir, { notify: false }); }
1879
+ catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
1880
+ }
1881
+ if (!internalCapture) await drainQueuedMessages(state);
1882
+ return result;
1883
+ }
1639
1884
 
1640
- // Pack↔tool contextual edges: a reusable tool run while a context pack was
1641
- // open this turn records "this tool was used in this topic, as `<shape>`",
1642
- // so next time the pack is active the discoverer can surface the concrete
1643
- // invocation instead of leaving the agent to re-derive it. Fires even for a
1644
- // single run (one tool-in-a-pack is still a usage) — unlike the follows-graph
1645
- // above which needs a succession. Best-effort; never blocks the turn.
1646
- if (turnSucceeded && toolRunsThisTurn.length && openedThisTurn.size) {
1647
- try {
1648
- const tg = require("./tool-graph");
1649
- const packDirs = [...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5));
1650
- for (const dir of packDirs) {
1651
- for (const run of toolRunsThisTurn) tg.recordPackTool(dir, run.name, { command: run.shape, bump: 1 });
1652
- }
1653
- } catch (e) { /* best-effort */ }
1654
- }
1885
+ function admissionOptions(state, opts = {}) {
1886
+ const provider = opts.provider || state.settings?.backend || null;
1887
+ return { ...opts, provider };
1888
+ }
1655
1889
 
1656
- // Close the learning loop: when an ABILITY pack is opened in the same turn
1657
- // as a project (context) pack, the ability was demonstrably applied while
1658
- // working on that project. recordCoUse grows the ability's applied_on, which
1659
- // forms the project→ability governed-by edge on the next structural sync —
1660
- // so reuse transfers AUTOMATICALLY from actual use, without waiting on the
1661
- // reviewer to infer it. This runs before the (async) reviewer, so it wins
1662
- // the race and the reviewer sees applied_on already set (no double-announce).
1663
- if (turnSucceeded && openedThisTurn.size > 1) {
1664
- try {
1665
- for (const t of packsLib.recordCoUse([...openedThisTurn])) {
1666
- notifySkill(`applied:${t.ability}:${t.project}`,
1667
- `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} — it transfers there now too.`);
1668
- }
1669
- } catch (e) { /* best-effort */ }
1670
- }
1890
+ function admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
1891
+ const state = currentState();
1892
+ const store = chatContext.getStore() || null;
1893
+ const resolvedCwd = resolveRunCwd(cwd);
1894
+ return captureRunContext({
1895
+ state,
1896
+ store,
1897
+ prompt,
1898
+ cwd: resolvedCwd,
1899
+ replyToMsgId,
1900
+ opts: admissionOptions(state, opts),
1901
+ channelId: currentChannelId(),
1902
+ canonicalUserId: state.userId,
1903
+ runId: opts.runId || null,
1904
+ });
1905
+ }
1671
1906
 
1672
- // Post-turn pack review: fire-and-forget on a cheap model; never
1673
- // blocks queue drain or the next turn. Skipped when the reply was held by
1674
- // the external guardrail a blocked turn must not accrete into memory.
1675
- if ((code === 0 || code === null) && assistantText.trim() && !suppressDelivery) {
1676
- const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "Bash", "Shell", "Skill", "Task", "Agent"]);
1677
- packReview.reviewTurn({
1678
- userText: prompt,
1679
- assistantText,
1680
- channelId,
1681
- // Packs this turn demonstrably worked with: surfaced by recall for the
1682
- // incoming message, or explicitly opened by the agent. The reviewer
1683
- // sees their full current sections and must attribute updates here —
1684
- // prevents cross-project misattribution and blind State rewrites.
1685
- activePacks: [...new Set([
1686
- ...surfacedPackDirs,
1687
- ...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
1688
- ])],
1689
- signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
1690
- // Provenance (2.5): who contributed this turn, so accreted Log/Journal
1691
- // lines from a non-owner get stamped "(via <name>)". Resolved from the
1692
- // live store here because the reviewer applies in an async continuation.
1693
- source: (() => {
1694
- try {
1695
- const sp = require("./people").findByHandle(store.adapter?.type, store.channelId);
1696
- if (sp) return { name: sp.name, isOwner: !!sp.isOwner };
1697
- } catch (e) {}
1698
- return null;
1699
- })(),
1700
- announce: (text) => chatContext.run(store, () => send(text)),
1907
+ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
1908
+ const state = currentState();
1909
+ const store = chatContext.getStore() || null;
1910
+ const admitted = opts.runContext || admitRunContext(prompt, cwd, replyToMsgId, opts);
1911
+ const runContext = bindRunInputs(admitted, {
1912
+ userPrompt: prompt,
1913
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
1914
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
1915
+ lastInputWasVoice: opts.lastInputWasVoice !== undefined ? opts.lastInputWasVoice : admitted.lastInputWasVoice,
1916
+ voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
1917
+ });
1918
+
1919
+ if (!acquireRunLock(state)) {
1920
+ if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
1921
+ return sideChatCoordinator.respond({
1922
+ runContext,
1923
+ activeRunContext: state.activeRunContext,
1924
+ store,
1701
1925
  });
1702
1926
  }
1927
+ const deferred = deferredRunResult();
1928
+ state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
1929
+ send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId }).catch(() => {});
1930
+ return deferred.promise;
1931
+ }
1932
+ return executeAdmittedRun(runContext, state, store, false, true);
1933
+ }
1703
1934
 
1704
- if (state.lastSessionId && state.currentSession) {
1705
- const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
1706
- recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
1707
- state.isFirstMessage = false;
1708
- }
1935
+ function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
1936
+ return !!state?.runningProcess
1937
+ && opts.sideChat !== false
1938
+ && runContext?.purpose === "foreground"
1939
+ && coordinator.enabled();
1940
+ }
1709
1941
 
1710
- if (state.messageQueue.length === 0 && state.currentSession && shouldAutoCompact(state)) {
1711
- try {
1712
- await compactActiveSession(state.currentSession.dir, { notify: false });
1713
- } catch (e) {
1714
- console.warn(`[proactive-compact] failed: ${redactSensitive(e.message)}`);
1715
- }
1716
- }
1942
+ function runSideChatRequest(request, dependencies = {}) {
1943
+ return createSideChatService(dependencies).run(request);
1944
+ }
1717
1945
 
1718
- await drainQueuedMessages();
1719
- }));
1946
+ function withSideChatMainSession(runContext, sessionId) {
1947
+ if (!sessionId) return runContext;
1948
+ return deepFreeze({
1949
+ ...runContext,
1950
+ sessionId,
1951
+ admittedSessionId: sessionId,
1952
+ previousSessionId: sessionId,
1953
+ resumeSessionId: sessionId,
1954
+ continueSession: false,
1955
+ fresh: false,
1956
+ });
1957
+ }
1720
1958
 
1721
- proc.on("error", (err) => chatContext.run(store, async () => {
1722
- state.runningProcess = null;
1723
- state.preparingRun = false;
1724
- stopTyping();
1725
- clearTimeout(state.streamInterval);
1726
- clearTimeout(processTimeout);
1727
- await send(`Error: ${err.message}`);
1728
- state.statusMessageId = null;
1729
- }));
1730
- }
1731
-
1732
- async function runClaudeSilent(prompt, cwd, label) {
1733
- const dynamicPrompt = await promptWithDynamicContext(prompt);
1734
- return new Promise((resolve) => {
1735
- const args = ["-p", "--output-format", "text", "--verbose",
1736
- "--append-system-prompt", buildSystemPrompt(),
1737
- "--dangerously-skip-permissions", dynamicPrompt];
1738
- const proc = spawn(CLAUDE_PATH, args, {
1739
- cwd, env: claudeSubprocessEnv(),
1740
- stdio: ["ignore", "pipe", "pipe"],
1741
- });
1742
- let output = "";
1743
- proc.stdout.on("data", (d) => { output += d.toString(); });
1744
- proc.on("close", async () => {
1745
- const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
1746
- for (const c of chunks) await send(c);
1747
- resolve();
1748
- });
1749
- proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
1959
+ function createSideChatQueueItem(request, store, lineage, state, deferred) {
1960
+ const queuedContext = lineage.mainSessionId
1961
+ ? withSideChatMainSession(request.originalRunContext, lineage.mainSessionId)
1962
+ : request.originalRunContext;
1963
+ const awaitsActiveMain = !lineage.mainSessionId
1964
+ && lineage.mainRunId
1965
+ && state.activeRunContext?.runId === lineage.mainRunId;
1966
+ return {
1967
+ runContext: awaitsActiveMain ? null : queuedContext,
1968
+ store,
1969
+ deferred,
1970
+ queuedAt: Date.now(),
1971
+ followMainRunId: awaitsActiveMain ? lineage.mainRunId : null,
1972
+ pendingSideChat: awaitsActiveMain ? { originalRunContext: request.originalRunContext } : null,
1973
+ };
1974
+ }
1975
+
1976
+ function finalizeSideChatQueueItem(item, mainRunContext, result) {
1977
+ if (!item?.pendingSideChat || item.followMainRunId !== mainRunContext?.runId) return false;
1978
+ item.runContext = result?.ok && result.sessionId
1979
+ ? withSideChatMainSession(item.pendingSideChat.originalRunContext, result.sessionId)
1980
+ : item.pendingSideChat.originalRunContext;
1981
+ item.followMainRunId = null;
1982
+ item.pendingSideChat = null;
1983
+ return true;
1984
+ }
1985
+
1986
+ function enqueueSideChatRequest(request, store = null, lineage = {}) {
1987
+ if (!request?.originalRunContext || request.originalRunContext.purpose !== "foreground") {
1988
+ throw new TypeError("side-chat enqueue requires the original foreground run context");
1989
+ }
1990
+ const state = currentState();
1991
+ if (String(state.userId) !== String(request.canonicalUserId)) {
1992
+ const error = new Error("side-chat action belongs to another user");
1993
+ error.code = "SIDE_CHAT_FORBIDDEN";
1994
+ throw error;
1995
+ }
1996
+ const deferred = deferredRunResult();
1997
+ state.messageQueue.push(createSideChatQueueItem(request, store, lineage, state, deferred));
1998
+ deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
1999
+ if (!state.runningProcess && !state.preparingRun) drainQueuedMessages(state);
2000
+ return { queued: true, position: state.messageQueue.length || 1 };
2001
+ }
2002
+
2003
+ function runAgentCapture(prompt, cwd, opts = {}) {
2004
+ const state = currentState();
2005
+ const store = chatContext.getStore() || null;
2006
+ const admitted = opts.runContext || admitRunContext(prompt, cwd, null, { ...opts, requiredDelivery: false });
2007
+ const runContext = bindRunInputs(admitted, {
2008
+ userPrompt: prompt,
2009
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
2010
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
2011
+ lastInputWasVoice: opts.lastInputWasVoice !== undefined ? opts.lastInputWasVoice : admitted.lastInputWasVoice,
2012
+ voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
1750
2013
  });
2014
+ return executeAdmittedRun(runContext, state, store, true, false, { persistCapture: opts.persist !== false });
2015
+ }
2016
+
2017
+ function runAgentSilent(prompt, cwd, label, opts = {}) {
2018
+ return runAgent(prompt, cwd, null, { ...opts, label, purpose: opts.purpose || "silent" });
1751
2019
  }
1752
2020
 
1753
2021
  module.exports = {
1754
- runClaude,
1755
- runClaudeCapture,
1756
- runClaudeSilent,
2022
+ admitRunContext,
2023
+ capturedTranscriptMetadata,
2024
+ decorateForegroundInvocation,
2025
+ createRunnerService,
2026
+ createSideChatService,
2027
+ createSideChatQueueItem,
2028
+ finalizeSideChatQueueItem,
2029
+ executeProviderInvocation,
2030
+ invocationContextForRun,
2031
+ runAgent,
2032
+ runAgentCapture,
2033
+ runAgentSilent,
2034
+ shouldStartSideChat,
2035
+ runSideChatRequest,
2036
+ enqueueSideChatRequest,
1757
2037
  compactActiveSession,
1758
- buildSystemPrompt,
2038
+ createCompactionService,
2039
+ deriveCompactionRunContext,
1759
2040
  getActiveSessionId,
1760
- getActiveBinary,
1761
- getActiveSessionKey,
1762
2041
  shouldAutoCompact,
1763
2042
  effectiveCompactThreshold,
1764
2043
  usageParts,
@@ -1770,7 +2049,4 @@ module.exports = {
1770
2049
  archiveCompactionBrief,
1771
2050
  collectRepoStateFacts,
1772
2051
  collectRecentTurns,
1773
- formatProgress,
1774
- preflightClaudeAuthMessage,
1775
- claudeEmptyFailureMessage,
1776
2052
  };