@inetafrica/open-claudia 2.15.0 → 3.0.0

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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -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 +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  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 +149 -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 +503 -217
  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 +117 -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 +121 -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 +170 -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 +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. 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 } = 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,656 @@ 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
+ let resultText = "";
262
+ let sessionId = runContext?.sessionId || null;
263
+ let terminalSeen = false;
264
+ let stdoutEnded = false;
265
+ let timedOut = false;
266
+ let watchdogTripped = false;
267
+ let spawnError = null;
268
+ let stderr = "";
269
+ let proc = null;
270
+ let closeInfo = null;
271
+ let lastActivity = Date.now();
272
+ let timeoutTimer = null;
273
+ let killTimer = null;
274
+ let idleTimer = null;
275
+ let terminationStarted = false;
276
+ let terminationForced = false;
277
+ let resolveTermination;
278
+ let terminationPromise = Promise.resolve();
279
+ const terminationDescendants = new Set();
280
+ let decoderEnded = false;
281
+ let eventChain = Promise.resolve();
282
+ const decoder = provider.createEventDecoder();
283
+
284
+ const rememberDescendants = (value) => {
285
+ if (!Array.isArray(value)) return;
286
+ for (const pid of value) {
287
+ const number = Number(pid);
288
+ if (Number.isSafeInteger(number) && number > 0 && number !== proc?.pid) terminationDescendants.add(number);
289
+ }
290
+ };
291
+
292
+ const forceTermination = async () => {
293
+ if (!terminationStarted || terminationForced) return;
294
+ terminationForced = true;
295
+ if (killTimer) clearTimeout(killTimer);
296
+ try {
297
+ if (!closeInfo) {
298
+ try { rememberDescendants(killTree(proc?.pid, "SIGKILL")); } catch (_) {}
299
+ }
300
+ for (const pid of [...terminationDescendants]) {
301
+ try { rememberDescendants(killTree(pid, "SIGKILL")); } catch (_) {}
302
+ }
303
+ const survivors = await waitForPidsExit(
304
+ [...terminationDescendants],
305
+ Math.max(100, Math.min(2000, Number(killGraceMs) || 100)),
306
+ );
307
+ if (survivors.length) {
308
+ cleanupOutcome.ok = false;
309
+ cleanupOutcome.error = stableRunError("Provider descendants survived forced termination", "RUN_CLEANUP_FAILED", { pids: survivors });
310
+ }
311
+ } finally {
312
+ resolveTermination?.();
313
+ }
314
+ };
315
+
316
+ const beginTermination = (idle = false) => {
317
+ if (terminationStarted) return;
318
+ terminationStarted = true;
319
+ timedOut = true;
320
+ watchdogTripped = idle;
321
+ terminationPromise = new Promise((resolve) => { resolveTermination = resolve; });
322
+ killTimer = setTimeout(() => { void forceTermination(); }, Math.max(0, killGraceMs));
323
+ try { rememberDescendants(killTree(proc?.pid, "SIGTERM")); } catch (_) {}
324
+ };
325
+
326
+ const handleNormalizedEvent = (event) => {
327
+ if (!event || typeof event !== "object") return;
328
+ if (event.type === "session" && event.sessionId) sessionId = event.sessionId;
329
+ else if (event.type === "text_delta" && typeof event.text === "string") textDeltas.push(event.text);
330
+ else if (event.type === "text_final" && typeof event.text === "string") textFinals.push(event.text);
331
+ else if (event.type === "tool_start" || event.type === "tool_end") tools.push(event);
332
+ else if (event.type === "usage") usageEvents.push(event);
333
+ else if (event.type === "result") {
334
+ terminalSeen = true;
335
+ if (event.sessionId) sessionId = event.sessionId;
336
+ if (typeof event.text === "string" && event.text) resultText = event.text;
337
+ } else if (event.type === "error") {
338
+ terminalSeen = true;
339
+ rawErrors.push(event);
340
+ }
341
+ if (onEvent) {
342
+ eventChain = eventChain.then(() => onEvent(event)).catch((error) => {
343
+ rawErrors.push({
344
+ type: "error",
345
+ message: `run event handler failed: ${error.message}`,
346
+ authError: false,
347
+ usageLimit: false,
348
+ });
349
+ });
350
+ }
351
+ };
352
+
353
+ const handleRawEvents = (rawEvents) => {
354
+ for (const rawEvent of rawEvents || []) {
355
+ let normalized;
356
+ try { normalized = provider.normalizeEvent(rawEvent, invocation.parserState); }
357
+ catch (error) {
358
+ normalized = [{ type: "error", message: `provider event normalization failed: ${error.message}`, authError: false, usageLimit: false }];
359
+ }
360
+ const list = Array.isArray(normalized) ? normalized : (normalized ? [normalized] : []);
361
+ for (const event of list) handleNormalizedEvent(event);
362
+ }
363
+ };
364
+
365
+ const finishDecoder = () => {
366
+ if (decoderEnded) return;
367
+ decoderEnded = true;
368
+ stdoutEnded = true;
369
+ try { handleRawEvents(decoder.end()); }
370
+ catch (error) {
371
+ handleNormalizedEvent({ type: "error", message: `provider stream finalization failed: ${error.message}`, authError: false, usageLimit: false });
372
+ }
373
+ };
374
+
375
+ let resolveStdout;
376
+ const stdoutPromise = new Promise((resolve) => { resolveStdout = resolve; });
377
+ let resolveClose;
378
+ const closePromise = new Promise((resolve) => { resolveClose = resolve; });
379
+
215
380
  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;
381
+ proc = spawnProcess(invocation.binary, invocation.args, {
382
+ cwd,
383
+ env: invocation.env,
384
+ stdio: [invocation.stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
385
+ detached: process.platform !== "win32",
386
+ });
387
+ } catch (error) {
388
+ spawnError = error;
389
+ finishDecoder();
390
+ resolveStdout();
391
+ resolveClose({ code: null, signal: null });
392
+ }
393
+
394
+ if (proc) {
395
+ try { if (onSpawn) onSpawn(proc); }
396
+ catch (error) { rawErrors.push({ type: "error", message: `spawn observer failed: ${error.message}`, authError: false, usageLimit: false }); }
397
+
398
+ proc.stdout.on("data", (data) => {
399
+ lastActivity = Date.now();
400
+ try { handleRawEvents(decoder.push(data)); }
401
+ catch (error) {
402
+ handleNormalizedEvent({ type: "error", message: `provider stream decoding failed: ${error.message}`, authError: false, usageLimit: false });
403
+ }
404
+ });
405
+ proc.stdout.once("end", () => {
406
+ finishDecoder();
407
+ resolveStdout();
408
+ });
409
+ proc.stdout.once("close", () => {
410
+ if (timedOut || spawnError) {
411
+ finishDecoder();
412
+ resolveStdout();
413
+ }
414
+ });
415
+ proc.stdout.once("error", (error) => {
416
+ handleNormalizedEvent({ type: "error", message: `provider stdout failed: ${error.message}`, authError: false, usageLimit: false });
417
+ finishDecoder();
418
+ resolveStdout();
419
+ });
420
+ proc.stderr.on("data", (data) => {
421
+ lastActivity = Date.now();
422
+ const chunk = data.toString("utf8");
423
+ stderr = (stderr + chunk).slice(-64 * 1024);
424
+ if (onStderr) onStderr(chunk);
425
+ });
426
+ proc.stderr.once("error", (error) => {
427
+ rawErrors.push({ type: "error", message: `provider stderr failed: ${error.message}`, authError: false, usageLimit: false });
428
+ });
429
+ proc.once("error", (error) => {
430
+ spawnError = error;
431
+ resolveClose({ code: null, signal: null });
432
+ if (!stdoutEnded) {
433
+ try { proc.stdout.destroy(); } catch (_) {}
434
+ finishDecoder();
435
+ resolveStdout();
224
436
  }
437
+ });
438
+ proc.once("close", (code, signal) => {
439
+ closeInfo = { code, signal };
440
+ resolveClose(closeInfo);
441
+ if (terminationStarted) void forceTermination();
442
+ if ((timedOut || spawnError) && !stdoutEnded) {
443
+ try { proc.stdout.destroy(); } catch (_) {}
444
+ finishDecoder();
445
+ resolveStdout();
446
+ }
447
+ });
448
+
449
+ if (invocation.stdin !== null && proc.stdin) {
450
+ // EPIPE here (child died before the pipe flushed) must not become an
451
+ // uncaughtException; the close handler settles the run via exit code.
452
+ proc.stdin.once("error", (error) => {
453
+ stderr = (stderr + `\n[stdin] ${error.message}`).slice(-64 * 1024);
454
+ });
455
+ proc.stdin.end(invocation.stdin);
456
+ }
457
+
458
+ const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : MAX_PROCESS_TIMEOUT;
459
+ timeoutTimer = setTimeout(() => beginTermination(false), effectiveTimeout);
460
+
461
+ if (Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0) {
462
+ idleTimer = setInterval(() => {
463
+ if (watchdogTripped) return;
464
+ if (Date.now() - lastActivity <= idleTimeoutMs) return;
465
+ beginTermination(true);
466
+ }, Math.min(1000, Math.max(10, Math.floor(idleTimeoutMs / 4))));
467
+ }
468
+ }
469
+
470
+ await Promise.all([stdoutPromise, closePromise]);
471
+ closeInfo = closeInfo || { code: null, signal: null };
472
+ if (timeoutTimer) clearTimeout(timeoutTimer);
473
+ if (idleTimer) clearInterval(idleTimer);
474
+ if (terminationStarted) await terminationPromise;
475
+ else if (killTimer) clearTimeout(killTimer);
476
+ await eventChain;
477
+
478
+ try { await cleanup(proc, { timedOut, watchdogTripped, closeInfo }); }
479
+ catch (error) {
480
+ cleanupOutcome.ok = false;
481
+ cleanupOutcome.error = stableRunError(error.message, "RUN_CLEANUP_FAILED");
482
+ }
483
+
484
+ const finalUsage = usageEvents.length ? usageEvents[usageEvents.length - 1] : null;
485
+ const text = selectTerminalText({
486
+ resultText,
487
+ finalText: textFinals.join(""),
488
+ deltaText: textDeltas.join(""),
489
+ });
490
+ let failure = null;
491
+ if (spawnError) failure = stableRunError(spawnError.message, "PROVIDER_SPAWN_FAILED");
492
+ else if (timedOut) failure = stableRunError(
493
+ watchdogTripped ? "Provider run stopped after becoming idle" : "Provider run timed out",
494
+ watchdogTripped ? "PROVIDER_IDLE_TIMEOUT" : "PROVIDER_TIMEOUT",
495
+ );
496
+ else if (closeInfo.signal) failure = stableRunError(`Provider process exited on signal ${closeInfo.signal}`, "PROVIDER_SIGNAL_EXIT", { signal: closeInfo.signal });
497
+ else if (rawErrors.length) {
498
+ const event = rawErrors[0];
499
+ failure = stableRunError(event.message || "Provider reported an error", event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR"), event);
500
+ } else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
501
+ else if (!terminalSeen) failure = stableRunError("Provider stream ended without a terminal event", "PROVIDER_MISSING_TERMINAL");
502
+ else if (!cleanupOutcome.ok) failure = cleanupOutcome.error;
503
+
504
+ const result = {
505
+ ok: !failure,
506
+ status: failure ? "failed" : "succeeded",
507
+ runId: runContext?.runId || null,
508
+ provider: runContext?.provider || provider.id,
509
+ project: runContext?.project || null,
510
+ admittedSessionId: runContext?.sessionId || null,
511
+ sessionId,
512
+ purpose: runContext?.purpose || "foreground",
513
+ text: redactSensitive(String(text || "").trim()),
514
+ usage: finalUsage?.usage || null,
515
+ usageScope: finalUsage?.scope || null,
516
+ usageEvents,
517
+ costUsd: Number.isFinite(invocation.parserState.totalCostUsd) ? invocation.parserState.totalCostUsd : 0,
518
+ tools,
519
+ exitCode: closeInfo.code,
520
+ signal: closeInfo.signal,
521
+ timedOut,
522
+ watchdogTripped,
523
+ terminalSeen,
524
+ stdoutEnded,
525
+ stderr: redactSensitive(stderr),
526
+ error: failure,
527
+ diagnostic: failure ? failure.message : null,
528
+ cleanup: cleanupOutcome,
529
+ persistence,
530
+ delivery,
531
+ timings: { startedAt, finishedAt: null, durationMs: null },
532
+ };
533
+
534
+ const runStage = async (name, hook) => {
535
+ if (!hook) return;
536
+ persistence[name] = { ok: true, skipped: false };
537
+ try { await hook(result, runContext); }
538
+ catch (error) {
539
+ persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
540
+ if (!result.error) result.error = persistence[name].error;
541
+ result.ok = false;
542
+ result.status = "failed";
543
+ result.diagnostic = result.error.message;
544
+ }
545
+ };
546
+
547
+ await runStage("transcript", persistTranscript);
548
+ await runStage("usage", persistUsage);
549
+ await runStage("session", persistSession);
550
+
551
+ if (deliver) {
552
+ delivery.skipped = false;
553
+ try {
554
+ const outcome = await deliver(result, runContext);
555
+ if (outcome && outcome.ok === false) throw new Error(outcome.error?.message || "required delivery failed");
556
+ delivery.ok = true;
557
+ if (outcome !== undefined) delivery.result = outcome;
558
+ } catch (error) {
559
+ delivery.ok = false;
560
+ delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
561
+ if (!result.error) result.error = delivery.error;
562
+ result.ok = false;
563
+ result.status = "failed";
564
+ result.diagnostic = result.error.message;
225
565
  }
226
- } catch (e) { /* no chat context (e.g. startup) — skip */ }
227
- return overlay;
566
+ }
567
+
568
+ result.timings.finishedAt = Date.now();
569
+ result.timings.durationMs = result.timings.finishedAt - startedAt;
570
+ return result;
571
+ }
572
+
573
+ function capabilitySupported(provider, name) {
574
+ return provider.capabilities?.[name]?.support === "native"
575
+ || provider.capabilities?.[name]?.support === "emulated";
576
+ }
577
+
578
+ function invocationContextForRun(runContext, bundle, provider) {
579
+ const providerSettings = { ...(runContext.providerSettings || {}) };
580
+ if (!capabilitySupported(provider, "budget")) providerSettings.budget = null;
581
+ if (!capabilitySupported(provider, "worktree")) providerSettings.worktree = false;
582
+ const context = {
583
+ ...runContext,
584
+ ...bundle,
585
+ providerSettings,
586
+ sessionId: runContext.sessionId,
587
+ resumeSessionId: runContext.resumeSessionId,
588
+ continueSession: runContext.continueSession,
589
+ fresh: runContext.fresh,
590
+ maxTurns: capabilitySupported(provider, "maxTurns") ? runContext.maxTurns : null,
591
+ imagePaths: capabilitySupported(provider, "imageFlag") ? runContext.imagePaths : [],
592
+ voiceStream: capabilitySupported(provider, "partialStreaming") && runContext.voiceStream,
593
+ transcriptPaths: bundle.transcriptPaths || null,
594
+ toolHookSettings: capabilitySupported(provider, "preToolHook") ? runContext.toolHookSettings : null,
595
+ };
596
+ return Object.freeze(context);
228
597
  }
229
598
 
230
- function subprocessEnv() {
231
- return { ...claudeSubprocessEnv(), ...chatEnvOverlay() };
599
+ function preflightFailureResult(runContext, provider, failure) {
600
+ const error = stableRunError(
601
+ failure?.message || `${provider.label} is unavailable`,
602
+ failure?.code || "PROVIDER_PREFLIGHT_FAILED",
603
+ { providerId: provider.id },
604
+ );
605
+ return {
606
+ ok: false,
607
+ status: "failed",
608
+ runId: runContext.runId,
609
+ provider: runContext.provider,
610
+ project: runContext.project,
611
+ admittedSessionId: runContext.sessionId,
612
+ sessionId: runContext.sessionId,
613
+ purpose: runContext.purpose,
614
+ text: "",
615
+ usage: null,
616
+ usageScope: null,
617
+ usageEvents: [],
618
+ tools: [],
619
+ exitCode: null,
620
+ signal: null,
621
+ timedOut: false,
622
+ terminalSeen: false,
623
+ stdoutEnded: false,
624
+ stderr: "",
625
+ error,
626
+ diagnostic: error.message,
627
+ cleanup: { ok: true, skipped: true },
628
+ persistence: createPersistenceOutcome(),
629
+ delivery: { ok: true, skipped: true },
630
+ timings: { startedAt: Date.now(), finishedAt: Date.now(), durationMs: 0 },
631
+ };
232
632
  }
233
633
 
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 */ }
634
+ async function settleImmediateRunResult(result, runContext, dependencies, capture) {
635
+ for (const [name, hook] of [
636
+ ["transcript", dependencies.persistTranscript],
637
+ ["usage", dependencies.persistUsage],
638
+ ["session", dependencies.persistSession],
639
+ ]) {
640
+ if (!hook) continue;
641
+ result.persistence[name] = { ok: true, skipped: false };
642
+ try { await hook(result, runContext); }
643
+ catch (error) {
644
+ result.persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
645
+ if (!result.error) result.error = result.persistence[name].error;
646
+ result.ok = false;
647
+ result.status = "failed";
648
+ }
649
+ }
650
+ if (!capture && dependencies.deliver) {
651
+ result.delivery.skipped = false;
652
+ try {
653
+ const outcome = await dependencies.deliver(result, runContext);
654
+ if (outcome && outcome.ok === false) throw new Error(outcome.error?.message || "required delivery failed");
655
+ result.delivery.ok = true;
656
+ result.delivery.result = outcome;
657
+ } catch (error) {
658
+ result.delivery.ok = false;
659
+ result.delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
660
+ if (!result.error) result.error = result.delivery.error;
661
+ result.ok = false;
662
+ result.status = "failed";
663
+ }
238
664
  }
239
- return events;
665
+ result.diagnostic = result.error?.message || result.diagnostic;
666
+ result.timings.finishedAt = Date.now();
667
+ result.timings.durationMs = result.timings.finishedAt - result.timings.startedAt;
668
+ return result;
240
669
  }
241
670
 
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;
671
+ function createRunnerService(dependencies = {}) {
672
+ const getState = dependencies.getState || currentState;
673
+ const getStore = dependencies.getStore || (() => chatContext.getStore());
674
+ const resolveProvider = dependencies.resolveProvider || getProvider;
675
+ const promptBuilder = dependencies.buildTurnPrompt || buildTurnPrompt;
676
+ const invocationBuilder = dependencies.buildInvocation
677
+ || ((provider, context) => provider.buildMainInvocation(context));
678
+
679
+ function admit(prompt, cwd, replyToMsgId, opts = {}) {
680
+ const state = getState();
681
+ const store = getStore() || null;
682
+ return opts.runContext || captureRunContext({
683
+ state,
684
+ store,
685
+ prompt,
686
+ cwd,
687
+ replyToMsgId,
688
+ opts,
689
+ channelId: opts.channelId || store?.channelId || currentChannelId(),
690
+ canonicalUserId: opts.canonicalUserId || state.userId,
691
+ runId: opts.runId || null,
692
+ });
693
+ }
694
+
695
+ async function executeCaptured(prompt, runContext, { capture = false } = {}) {
696
+ const immediateFailure = (provider, failure) => settleImmediateRunResult(
697
+ preflightFailureResult(runContext, provider, failure),
698
+ runContext,
699
+ dependencies,
700
+ capture,
701
+ );
702
+ let provider;
703
+ try {
704
+ provider = resolveProvider(runContext.provider);
705
+ } catch (error) {
706
+ return immediateFailure({ id: runContext.provider, label: runContext.provider }, error);
707
+ }
708
+ if (!provider) {
709
+ return immediateFailure({ id: runContext.provider, label: runContext.provider }, {
710
+ code: "UNKNOWN_PROVIDER",
711
+ message: `Unknown provider: ${runContext.provider}`,
712
+ });
713
+ }
714
+ try {
715
+ const authFailure = typeof provider.preflightAuth === "function" ? provider.preflightAuth() : null;
716
+ if (authFailure) return immediateFailure(provider, authFailure);
717
+ } catch (error) {
718
+ return immediateFailure(provider, error);
719
+ }
720
+
721
+ let bundle;
722
+ let invocation;
723
+ try {
724
+ bundle = await promptBuilder(prompt, { runContext, provider });
725
+ invocation = invocationBuilder(provider, invocationContextForRun(runContext, bundle, provider));
726
+ if (dependencies.decorateInvocation) {
727
+ invocation = dependencies.decorateInvocation(invocation, runContext, bundle, provider);
728
+ }
729
+ invocation = validateInvocation(invocation);
730
+ for (const warning of invocation.warnings || []) {
731
+ if (dependencies.onInvocationWarning) {
732
+ await dependencies.onInvocationWarning(warning, runContext, provider);
733
+ } else {
734
+ console.warn(`[provider-warning] ${warning.code}: ${warning.message}`);
735
+ }
736
+ }
737
+ } catch (error) {
738
+ return immediateFailure(provider, error);
739
+ }
740
+
741
+ const result = await executeProviderInvocation({
742
+ runContext,
743
+ provider,
744
+ invocation,
745
+ cwd: runContext.cwd || runContext.project?.dir || process.cwd(),
746
+ spawnProcess: dependencies.spawnProcess || spawn,
747
+ timeoutMs: runContext.timeoutMs || dependencies.timeoutMs || MAX_PROCESS_TIMEOUT,
748
+ killTree: dependencies.killTree || killProcessTree,
749
+ killGraceMs: dependencies.killGraceMs,
750
+ cleanup: dependencies.cleanup
751
+ ? (proc, details) => dependencies.cleanup(proc, details, runContext)
752
+ : (async () => {}),
753
+ persistTranscript: dependencies.persistTranscript || null,
754
+ persistUsage: dependencies.persistUsage || null,
755
+ persistSession: dependencies.persistSession || null,
756
+ deliver: capture ? null : (dependencies.deliver || null),
757
+ onEvent: dependencies.onEvent ? (event) => dependencies.onEvent(event, runContext) : null,
758
+ onSpawn: dependencies.onSpawn ? (proc) => dependencies.onSpawn(proc, runContext) : null,
759
+ onStderr: dependencies.onStderr ? (chunk) => dependencies.onStderr(chunk, runContext) : null,
760
+ idleTimeoutMs: dependencies.idleTimeoutMs || 0,
761
+ });
762
+ result.recallMetadata = bundle.recallMetadata || null;
763
+ result.transcriptPaths = bundle.transcriptPaths || null;
764
+ return result;
765
+ }
766
+
767
+ // These wrappers are intentionally not async: admission and deep-freezing
768
+ // happen synchronously before prompt construction can yield.
769
+ function runAgentCapture(prompt, cwd, opts = {}) {
770
+ const admitted = admit(prompt, cwd, null, { ...opts, requiredDelivery: false });
771
+ const runContext = bindRunInputs(admitted, {
772
+ userPrompt: prompt,
773
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
774
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
775
+ });
776
+ return executeCaptured(prompt, runContext, { capture: true });
777
+ }
778
+
779
+ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
780
+ const admitted = admit(prompt, cwd, replyToMsgId, opts);
781
+ const runContext = bindRunInputs(admitted, {
782
+ userPrompt: prompt,
783
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
784
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
785
+ });
786
+ return executeCaptured(prompt, runContext, { capture: false });
787
+ }
788
+
789
+ function runAgentSilent(prompt, cwd, label, opts = {}) {
790
+ return runAgent(prompt, cwd, null, { ...opts, label, purpose: opts.purpose || "silent" });
791
+ }
792
+
793
+ return {
794
+ admit,
795
+ executeCaptured,
796
+ runAgent,
797
+ runAgentCapture,
798
+ runAgentSilent,
799
+ };
247
800
  }
248
801
 
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;
802
+ function createSideChatService(dependencies = {}) {
803
+ const promptBuilder = createSideChatPromptBuilder(dependencies.buildCoreInstructions);
804
+ const service = createRunnerService({
805
+ resolveProvider: dependencies.resolveProvider || resolveForegroundProvider,
806
+ buildTurnPrompt: dependencies.buildTurnPrompt || promptBuilder,
807
+ buildInvocation(provider, context) {
808
+ const isolated = Object.freeze({
809
+ ...context,
810
+ fresh: true,
811
+ sessionId: null,
812
+ admittedSessionId: null,
813
+ previousSessionId: null,
814
+ resumeSessionId: null,
815
+ continueSession: false,
816
+ readOnly: true,
817
+ permissionMode: "plan",
818
+ toolHookSettings: null,
819
+ imagePaths: [],
820
+ });
821
+ if (typeof provider.buildUtilityInvocation !== "function") {
822
+ throw stableRunError(`${provider.label || provider.id} does not support isolated side responses`, "UNSUPPORTED_PROVIDER_CAPABILITY");
823
+ }
824
+ return provider.buildUtilityInvocation(isolated);
825
+ },
826
+ decorateInvocation(invocation, runContext) {
827
+ return {
828
+ ...invocation,
829
+ env: {
830
+ ...invocation.env,
831
+ OC_PROVIDER: runContext.provider,
832
+ OC_RUN_ID: runContext.runId,
833
+ },
834
+ };
835
+ },
836
+ spawnProcess: dependencies.spawnProcess,
837
+ timeoutMs: dependencies.timeoutMs,
838
+ killTree: dependencies.killTree,
839
+ killGraceMs: dependencies.killGraceMs,
840
+ onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc)),
841
+ onStderr: dependencies.onStderr || ((chunk) => console.error("SIDE PROVIDER STDERR:", redactSensitive(chunk))),
842
+ cleanup: dependencies.cleanup || (async (proc, details, runContext) => {
843
+ sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc);
844
+ }),
845
+ });
846
+ return {
847
+ run(request) {
848
+ if (!request?.runContext || request.runContext.purpose !== "side-chat") {
849
+ throw new TypeError("an isolated side-chat request is required");
850
+ }
851
+ return service.executeCaptured(request.runContext.userPrompt, request.runContext, { capture: true });
852
+ },
853
+ };
854
+ }
855
+
856
+ function resolveForegroundProvider(providerId) {
857
+ return resolveProviderForRun(providerId);
254
858
  }
255
859
 
256
- function getActiveSessionKey(state = currentState()) {
257
- if (state.settings.backend === "cursor") return "cursorSessionId";
258
- if (state.settings.backend === "codex") return "codexSessionId";
259
- return "lastSessionId";
860
+ function getActiveSessionId() {
861
+ const state = currentState();
862
+ return getProviderSession(state, getActiveProvider(state), getProjectKey(state));
260
863
  }
261
864
 
262
865
  function resolveRunCwd(cwd) {
@@ -267,10 +870,9 @@ function resolveRunCwd(cwd) {
267
870
  const fallback = WORKSPACE && fs.existsSync(WORKSPACE) ? WORKSPACE : process.cwd();
268
871
  if (state.currentSession && state.currentSession.dir === requested) {
269
872
  console.warn(`[session-guard] repairing missing session dir ${requested} -> ${fallback}`);
873
+ const project = getProjectKey(state);
270
874
  state.currentSession = { ...state.currentSession, dir: fallback };
271
- state.lastSessionId = null;
272
- state.cursorSessionId = null;
273
- state.codexSessionId = null;
875
+ for (const provider of ["claude", "codex"]) clearProviderSession(state, provider, project);
274
876
  resetSessionUsage(state);
275
877
  saveState();
276
878
  } else {
@@ -288,147 +890,21 @@ function effectiveCompactThreshold(state = currentState()) {
288
890
 
289
891
  function shouldAutoCompact(state = currentState(), opts = {}) {
290
892
  if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
291
- if (!state[getActiveSessionKey(state)]) return false;
893
+ if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
292
894
  const threshold = effectiveCompactThreshold(state);
293
895
  if (threshold === 0) return false;
294
- const contextTokens = state.sessionUsage?.liveContextTokens || state.sessionUsage?.lastInputTokens || 0;
896
+ const activeUsage = state.sessionUsage;
897
+ const contextTokens = activeUsage?.liveContextTokens || activeUsage?.lastInputTokens || 0;
295
898
  if (contextTokens < threshold) return false;
296
899
  const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
297
900
  const farOverThreshold = contextTokens >= threshold * 2;
298
- if (!farOverThreshold && state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
901
+ const checkpointTime = Date.parse(activeUsage?.compactionCheckpoint?.at || "");
902
+ const scopedLastCompactedAt = Number(activeUsage?.lastCompactedAt)
903
+ || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
904
+ if (!farOverThreshold && scopedLastCompactedAt && (Date.now() - scopedLastCompactedAt) < minInterval) return false;
299
905
  return true;
300
906
  }
301
907
 
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
908
  function compactSummaryPrompt() {
433
909
  return [
434
910
  "Summarize this conversation for a fresh compacted continuation.",
@@ -624,1141 +1100,792 @@ function collectRecentTurns(state = currentState(), maxTurns = 6, maxCharsPerTur
624
1100
  }
625
1101
  }
626
1102
 
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");
1103
+ function ensureCompactionAckSchemaPath() {
1104
+ const directory = path.join(CONFIG_DIR, "provider-contracts");
1105
+ const filePath = path.join(directory, "compaction-ack.schema.json");
1106
+ const schema = {
1107
+ type: "object",
1108
+ properties: {
1109
+ acknowledgement: { type: "string", minLength: 1, maxLength: 240 },
1110
+ },
1111
+ required: ["acknowledgement"],
1112
+ additionalProperties: false,
1113
+ };
1114
+ const expected = `${JSON.stringify(schema, null, 2)}\n`;
1115
+ try {
1116
+ if (fs.readFileSync(filePath, "utf8") === expected) return filePath;
1117
+ } catch (_) {}
1118
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
1119
+ require("./fsutil").atomicWriteFileSync(filePath, expected);
1120
+ try { fs.chmodSync(filePath, 0o600); } catch (_) {}
1121
+ return filePath;
638
1122
  }
639
1123
 
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",
1124
+ function deriveCompactionRunContext(base, overrides = {}) {
1125
+ const providerSettings = {
1126
+ ...(base.providerSettings || {}),
1127
+ budget: null,
1128
+ worktree: false,
1129
+ permissionMode: "plan",
1130
+ };
1131
+ return deepFreeze({
1132
+ ...base,
1133
+ runId: overrides.runId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${base.runId}-${Date.now()}`),
1134
+ admittedAt: Date.now(),
1135
+ userPrompt: String(overrides.userPrompt || ""),
1136
+ purpose: overrides.purpose,
1137
+ label: overrides.label,
1138
+ providerSettings,
1139
+ sessionId: overrides.sessionId ?? null,
1140
+ admittedSessionId: overrides.sessionId ?? null,
1141
+ previousSessionId: overrides.previousSessionId ?? base.sessionId ?? null,
1142
+ resumeSessionId: overrides.resumeSessionId ?? null,
1143
+ continueSession: false,
1144
+ fresh: !!overrides.fresh,
1145
+ skipAutoCompact: true,
1146
+ requiredDelivery: false,
1147
+ readOnly: true,
1148
+ permissionMode: "plan",
1149
+ maxTurns: base.provider === "claude" ? 1 : null,
1150
+ outputSchemaPath: overrides.outputSchemaPath || null,
1151
+ imagePaths: [],
1152
+ attachments: [],
1153
+ voiceStream: false,
1154
+ lastInputWasVoice: false,
1155
+ });
1156
+ }
1157
+
1158
+ function successfulTerminalCapture(result, { requireSession = false } = {}) {
1159
+ return !!result?.ok && result.status === "succeeded" && result.terminalSeen === true
1160
+ && (!requireSession || (typeof result.sessionId === "string" && !!result.sessionId));
1161
+ }
1162
+
1163
+ function createCompactionService(dependencies = {}) {
1164
+ const getState = dependencies.getState || currentState;
1165
+ const captureContext = dependencies.captureRunContext || admitRunContext;
1166
+ const runCapture = dependencies.runCapture || runAgentCapture;
1167
+ const commitCompaction = dependencies.commitCompaction || commitProviderCompaction;
1168
+ const archiveBrief = dependencies.archiveBrief || archiveCompactionBrief;
1169
+ const appendDaySeed = dependencies.appendDaySeed || ((entry) => require("./day-seeds").appendDaySeed(entry));
1170
+ const collectRepoFacts = dependencies.collectRepoFacts || collectRepoStateFacts;
1171
+ const collectRecent = dependencies.collectRecentTurns || collectRecentTurns;
1172
+ const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
1173
+ const drainQueue = dependencies.drainQueue || drainQueuedMessages;
1174
+
1175
+ async function compact(cwd, opts = {}) {
1176
+ const state = getState();
1177
+ if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
1178
+ if (state.runningProcess || (state.preparingRun && !opts.admissionHeld)) {
1179
+ return { compacted: false, reason: "Finish or stop the active run before compacting." };
1180
+ }
1181
+ const provider = getActiveProvider(state);
1182
+ const project = getProjectKey(state);
1183
+ const oldSessionId = getProviderSession(state, provider, project);
1184
+ if (!project || !oldSessionId) return { compacted: false, reason: "No conversation." };
1185
+ const summaryPrompt = compactSummaryPrompt();
1186
+ const admitted = captureContext(summaryPrompt, cwd, null, {
1187
+ provider,
1188
+ resumeSessionId: oldSessionId,
1189
+ skipAutoCompact: true,
1190
+ label: "compact-summary",
1191
+ purpose: "compact-summary",
1192
+ requiredDelivery: false,
1193
+ timeoutMs: COMPACT_SUMMARY_TIMEOUT,
1194
+ });
1195
+ const summaryContext = deriveCompactionRunContext(admitted, {
1196
+ userPrompt: summaryPrompt,
1197
+ purpose: "compact-summary",
1198
+ label: "compact-summary",
1199
+ sessionId: oldSessionId,
1200
+ resumeSessionId: oldSessionId,
1201
+ previousSessionId: oldSessionId,
1202
+ fresh: false,
666
1203
  });
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);
1204
+ const operationId = opts.operationId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `compact-${Date.now()}`);
1205
+ const recentTurns = collectRecent(state);
1206
+ state.isCompacting = true;
1207
+ try {
1208
+ if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1209
+ const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1210
+ runContext: summaryContext,
1211
+ persist: false,
1212
+ });
1213
+ if (!successfulTerminalCapture(summaryRun)) {
1214
+ return { compacted: false, reason: summaryRun?.diagnostic || "Compaction summary did not finish successfully." };
672
1215
  }
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;
1216
+ const summary = summaryRun.text || "No prior context was returned by the summarizer.";
1217
+ const { fullBrief, condensed } = splitCompactionBrief(summary);
1218
+ const seedSummary = condensed ? `${fullBrief}\n\n${condensed}` : fullBrief;
1219
+ const repoFacts = collectRepoFacts(summaryContext.cwd || cwd);
1220
+ let seedPrompt = compactSeedPrompt(seedSummary, {
1221
+ briefPath: null,
1222
+ condensed: false,
1223
+ repoFacts,
1224
+ recentTurns,
1225
+ });
1226
+ let outputSchemaPath = null;
1227
+ if (provider === "codex") {
1228
+ outputSchemaPath = ackSchemaPath();
1229
+ seedPrompt += "\n\nReturn JSON matching the supplied output schema. Put the one short acknowledgement in the acknowledgement field.";
708
1230
  }
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;
1231
+ const seedContext = deriveCompactionRunContext(summaryContext, {
1232
+ userPrompt: seedPrompt,
1233
+ purpose: "compact-seed",
1234
+ label: "compact-seed",
1235
+ sessionId: null,
1236
+ resumeSessionId: null,
1237
+ previousSessionId: oldSessionId,
1238
+ fresh: true,
1239
+ outputSchemaPath,
1240
+ });
1241
+ const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1242
+ runContext: seedContext,
1243
+ persist: false,
1244
+ });
1245
+ if (!successfulTerminalCapture(seedRun, { requireSession: true })) {
1246
+ return { compacted: false, reason: seedRun?.diagnostic || "Compaction seed did not finish successfully." };
719
1247
  }
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
- });
1248
+ if (seedRun.sessionId === oldSessionId) {
1249
+ return { compacted: false, reason: "Compaction seed did not create a fresh provider session." };
1250
+ }
1251
+ let commit;
1252
+ try {
1253
+ commit = await commitCompaction(state, {
1254
+ operationId,
1255
+ canonicalUserId: summaryContext.canonicalUserId,
1256
+ project: summaryContext.project.name,
1257
+ provider: summaryContext.provider,
1258
+ previousSessionId: oldSessionId,
1259
+ newSessionId: seedRun.sessionId,
1260
+ title: `Compacted ${new Date().toLocaleDateString()}`,
1261
+ seedUsage: seedRun.usage || null,
1262
+ });
1263
+ } catch (error) {
1264
+ return { compacted: false, reason: `Compaction commit failed: ${redactSensitive(error.message)}` };
1265
+ }
1266
+ let briefPath = null;
1267
+ try { briefPath = archiveBrief(fullBrief, capturedStateView(state, summaryContext)); } catch (_) {}
1268
+ try {
1269
+ appendDaySeed({
1270
+ summary: condensed || fullBrief,
1271
+ project: `${summaryContext.project.name} (${summaryContext.project.dir})`,
1272
+ channel: summaryContext.channelId,
1273
+ briefPath,
1274
+ });
1275
+ } catch (_) {}
1276
+ return {
1277
+ compacted: true,
1278
+ operationId,
1279
+ oldSessionId,
1280
+ newSessionId: seedRun.sessionId,
1281
+ provider: summaryContext.provider,
1282
+ project: summaryContext.project.name,
1283
+ summary,
1284
+ commit,
1285
+ };
1286
+ } finally {
1287
+ state.isCompacting = false;
1288
+ try { await drainQueue(state); }
1289
+ catch (error) { console.warn(`[compact] queue drain failed: ${redactSensitive(error.message)}`); }
1290
+ }
1291
+ }
1292
+
1293
+ return { compact };
730
1294
  }
731
1295
 
732
1296
  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
- }
1297
+ return createCompactionService().compact(cwd, opts);
1298
+ }
750
1299
 
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 };
1300
+ function capturedStateView(state, runContext) {
1301
+ return {
1302
+ ...state,
1303
+ userId: runContext.canonicalUserId || state.userId,
1304
+ currentSession: runContext.project
1305
+ ? { name: runContext.project.name, dir: runContext.project.dir }
1306
+ : null,
1307
+ settings: {
1308
+ ...(runContext.providerSettings || {}),
1309
+ backend: runContext.provider,
1310
+ },
1311
+ };
784
1312
  }
785
1313
 
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);
1314
+ function capturedTranscriptMetadata(runContext, extra = {}) {
1315
+ return {
1316
+ provider: runContext.provider,
1317
+ project: runContext.project?.name || null,
1318
+ projectPath: runContext.project?.dir || null,
1319
+ runId: runContext.runId,
1320
+ purpose: runContext.purpose,
1321
+ ...(runContext.occurrenceId ? { occurrenceId: runContext.occurrenceId } : {}),
1322
+ ...(runContext.scheduledJobId ? { scheduledJobId: runContext.scheduledJobId } : {}),
1323
+ ...(runContext.originRunId ? { originRunId: runContext.originRunId } : {}),
1324
+ ...extra,
1325
+ };
1326
+ }
805
1327
 
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;
1328
+ function providerRunDiagnostic(result, runContext) {
1329
+ if (result.ok) return null;
1330
+ let provider;
1331
+ try { provider = getProvider(runContext.provider); }
1332
+ catch (_) { provider = { label: runContext.provider, authHelp: () => "" }; }
1333
+ const label = provider.label || runContext.provider;
1334
+ const code = result.error?.code || "PROVIDER_RUN_FAILED";
1335
+ if (code === "PROVIDER_UNAUTHENTICATED") {
1336
+ const help = typeof provider.authHelp === "function" ? provider.authHelp() : "";
1337
+ return `${label} authentication failed.${help ? `\n\n${help}` : ""}`;
814
1338
  }
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;
1339
+ if (code === "PROVIDER_USAGE_LIMIT") {
1340
+ return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
823
1341
  }
1342
+ const detail = result.stderr ? `\n\nStderr:\n${result.stderr.slice(-1200)}` : "";
1343
+ return `${label} run failed: ${result.error?.message || "unknown provider failure"}${detail}`;
1344
+ }
824
1345
 
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)}`);
830
- }
831
- }
1346
+ async function persistForegroundTranscript(result, runContext, state = currentState()) {
1347
+ const view = capturedStateView(state, runContext);
1348
+ const session = () => result.sessionId || runContext.sessionId || null;
1349
+ appendProjectTranscript(
1350
+ runContext.transcriptRole || "user",
1351
+ stripTranscriptPointerForStorage(runContext.userPrompt),
1352
+ capturedTranscriptMetadata(runContext, {
1353
+ sourceMessageId: runContext.replyToMsgId,
1354
+ fresh: runContext.fresh,
1355
+ continueSession: runContext.continueSession,
1356
+ resumeSessionId: runContext.resumeSessionId,
1357
+ label: runContext.label,
1358
+ }),
1359
+ view,
1360
+ session,
1361
+ { strict: true },
1362
+ );
1363
+ const role = result.ok ? "assistant" : "system-note";
1364
+ const text = result.ok
1365
+ ? (result.text || "(no output)")
1366
+ : (providerRunDiagnostic(result, runContext) || result.diagnostic || "Provider run failed");
1367
+ appendProjectTranscript(
1368
+ role,
1369
+ text,
1370
+ capturedTranscriptMetadata(runContext, {
1371
+ exitCode: result.exitCode,
1372
+ signal: result.signal,
1373
+ kind: result.ok ? undefined : "provider-error",
1374
+ label: runContext.label,
1375
+ }),
1376
+ view,
1377
+ session,
1378
+ { strict: true },
1379
+ );
1380
+ }
832
1381
 
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();
874
- state.statusMessageId = null;
875
- state.statusMessageChannel = null;
876
- 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;
1382
+ async function persistForegroundUsage(result, runContext, state = currentState(), store = chatContext.getStore()) {
1383
+ if (!result.usage) return;
1384
+ const previousUsageBySession = structuredClone(state.usageBySession || {});
1385
+ const previousAlertAt = state.usageAlertLastAt || 0;
895
1386
  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); }
1387
+ let usage = result.usage;
1388
+ let usageScope = result.usageScope || "turn_delta";
1389
+ let rawUsage = usage;
1390
+ let liveContextTokens = usageParts(usage, runContext.provider).context;
1391
+ const usageSessionId = result.sessionId || runContext.sessionId;
1392
+ const usageProject = runContext.project?.name || null;
1393
+ if (runContext.provider === "codex") {
1394
+ const normalized = normalizeCodexUsage(state, usageSessionId, usage, { project: usageProject });
1395
+ usage = normalized.usage;
1396
+ usageScope = normalized.usageScope;
1397
+ rawUsage = normalized.rawUsage;
1398
+ liveContextTokens = usageParts(rawUsage, "codex").context;
1399
+ }
1400
+ result.usage = usage;
1401
+ result.usageScope = usageScope;
1402
+ applyUsageToState(state, usage, result.costUsd || 0, {
1403
+ backend: runContext.provider,
1404
+ usageScope,
1405
+ liveContextTokens,
1406
+ project: usageProject,
1407
+ sessionId: usageSessionId,
916
1408
  });
1409
+ logTurnUsage(
1410
+ state,
1411
+ usage,
1412
+ result.costUsd || 0,
1413
+ (text) => chatContext.run(store, () => send(text)),
1414
+ {
1415
+ backend: runContext.provider,
1416
+ model: runContext.providerSettings?.model || null,
1417
+ userId: runContext.canonicalUserId,
1418
+ sessionId: usageSessionId,
1419
+ runId: runContext.runId,
1420
+ project: usageProject,
1421
+ usageScope,
1422
+ liveContextTokens,
1423
+ rawUsage,
1424
+ strict: true,
1425
+ },
1426
+ );
1427
+ saveState({ strict: true });
1428
+ } catch (error) {
1429
+ state.usageBySession = previousUsageBySession;
1430
+ state.usageAlertLastAt = previousAlertAt;
1431
+ throw error;
917
1432
  }
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; }
927
- }
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
- }
933
- if (flush) { const tail = spokenBuf.trim(); spokenBuf = ""; if (tail) dispatchSpoken(tail); }
1433
+ }
1434
+
1435
+ async function persistForegroundSession(result, runContext, state = currentState()) {
1436
+ if (!result.ok || !result.sessionId) return;
1437
+ const liveProvider = state.settings?.backend || null;
1438
+ const project = runContext.project?.name || null;
1439
+ const pointerUnchanged = getProviderSession(state, runContext.provider, project) === (runContext.previousSessionId || null);
1440
+ const attach = runContext.attachSession !== false && !!project && pointerUnchanged;
1441
+ if (attach) {
1442
+ setProviderSession(state, runContext.provider, project, result.sessionId);
1443
+ if (liveProvider === runContext.provider && sameCapturedProject(state, runContext)) state.isFirstMessage = false;
1444
+ saveState({ strict: true });
1445
+ }
1446
+ if (runContext.project?.name) {
1447
+ const title = runContext.fresh
1448
+ ? (runContext.userPrompt.length > 60 ? `${runContext.userPrompt.slice(0, 57)}...` : runContext.userPrompt)
1449
+ : null;
1450
+ recordSession(
1451
+ runContext.canonicalUserId,
1452
+ runContext.project.name,
1453
+ runContext.provider,
1454
+ result.sessionId,
1455
+ title,
1456
+ { strict: true },
1457
+ );
934
1458
  }
1459
+ // A wakeup can be created inside a fresh provider turn before that turn's
1460
+ // native session ID exists. The loopback stores the immutable origin run ID;
1461
+ // once the terminal result reveals the session, bind every matching job to
1462
+ // it before reporting session persistence complete.
1463
+ require("./jobs").bindOriginRunSession(
1464
+ runContext.runId,
1465
+ runContext.provider,
1466
+ runContext.project?.name,
1467
+ result.sessionId,
1468
+ );
1469
+ result.sessionAttached = attach;
1470
+ }
935
1471
 
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
- };
1472
+ async function deliverForegroundResult(result, runContext, state = currentState()) {
1473
+ if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
1474
+ const diagnostic = providerRunDiagnostic(result, runContext);
1475
+ if (diagnostic) result.diagnostic = diagnostic;
1476
+ let finalText = result.ok ? (result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
1003
1477
 
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}`);
1042
- }
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}` : ""}`);
1478
+ if (result.ok) {
1479
+ let guarded = false;
1480
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (error) {
1481
+ throw stableRunError(`Unable to classify reply recipient: ${error.message}`, "RUN_DELIVERY_GUARD_FAILED");
1482
+ }
1483
+ if (guarded) {
1484
+ const guard = await require("./enforcer").guardOutboundReply(finalText);
1485
+ if (!guard.allow) {
1486
+ if (!guard.escalated) {
1487
+ const held = await send("I need to check with the owner before I can answer that — I'll follow up.");
1488
+ if (!held.ok) throw new Error("failed to deliver guarded-reply notice");
1058
1489
  }
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.`);
1490
+ return { ok: true, held: true };
1069
1491
  }
1070
- } catch (e) { /* announcements are best-effort */ }
1071
- };
1072
-
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 */ }
1085
- };
1492
+ }
1493
+ }
1086
1494
 
1087
- let args;
1088
- try {
1089
- args = await buildClaudeArgs(prompt, opts);
1090
- } catch (e) {
1091
- state.preparingRun = false;
1092
- stopTyping();
1093
- throw e;
1495
+ const chunks = splitMessage(finalText);
1496
+ for (let index = 0; index < chunks.length; index += 1) {
1497
+ let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId } : {}));
1498
+ if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts());
1499
+ if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1094
1500
  }
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(() => {});
1501
+
1502
+ if (result.ok && runContext.lastInputWasVoice) {
1503
+ const { currentTransport } = require("./context");
1504
+ if (currentTransport() === "voice") {
1505
+ const sentences = splitSentences(finalText);
1506
+ let spoke = false;
1507
+ for (const sentence of sentences) {
1508
+ const clip = await synthSentenceMp3(sentence);
1509
+ if (clip) { spoke = true; await sendVoice(clip); }
1123
1510
  }
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(() => {});
1511
+ if (!spoke) {
1512
+ const voicePath = await textToVoice(finalText);
1513
+ if (voicePath) await sendVoice(voicePath);
1128
1514
  }
1515
+ await sendVoiceEnd();
1129
1516
  }
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
1517
  }
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;
1518
+ return { ok: true, chunks: chunks.length };
1519
+ }
1162
1520
 
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.`));
1168
- }
1169
- }, MAX_PROCESS_TIMEOUT);
1521
+ function decorateForegroundInvocation(invocation, runContext, bundle) {
1522
+ const env = { ...invocation.env };
1523
+ const lb = loopback.info();
1524
+ const compactionStage = runContext.purpose === "compact-summary" || runContext.purpose === "compact-seed";
1525
+ if (!compactionStage) {
1526
+ if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
1527
+ if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
1528
+ if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
1529
+ if (runContext.canonicalUserId) env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
1530
+ if (runContext.sessionId) env.OC_LAST_SESSION_ID = String(runContext.sessionId);
1531
+ if (lb?.url) env.OC_SEND_URL = lb.url;
1532
+ }
1533
+ env.OC_PROVIDER = runContext.provider;
1534
+ env.OC_RUN_ID = runContext.runId;
1535
+ if (runContext.project?.name) env.OC_PROJECT = String(runContext.project.name);
1536
+ if (runContext.project?.dir) env.OC_PROJECT_DIR = String(runContext.project.dir);
1537
+ if (runContext.sessionId) env.OC_SESSION_ID = String(runContext.sessionId);
1538
+ if (runContext.previousSessionId) env.OC_PREVIOUS_SESSION_ID = String(runContext.previousSessionId);
1539
+ if (runContext.providerSettings) env.OC_PROVIDER_SETTINGS = JSON.stringify(runContext.providerSettings);
1540
+ if (runContext.occurrenceId) env.OC_OCCURRENCE_ID = String(runContext.occurrenceId);
1541
+ if (runContext.scheduledJobId) env.OC_SCHEDULED_JOB_ID = String(runContext.scheduledJobId);
1542
+ if (runContext.originRunId) env.OC_ORIGIN_RUN_ID = String(runContext.originRunId);
1543
+ if (bundle.transcriptPaths?.transcriptPath) env.OC_TRANSCRIPT_PATH = bundle.transcriptPaths.transcriptPath;
1544
+ return { ...invocation, env };
1545
+ }
1170
1546
 
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);
1547
+ function startTypingHeartbeat(state, store) {
1548
+ const adapter = store?.adapter || currentAdapter();
1549
+ const channelId = store?.channelId || currentChannelId();
1550
+ if (!adapter || !channelId) return () => {};
1551
+ const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
1552
+ state.thinkingPhase = true;
1553
+ tick();
1554
+ state.typingHeartbeat = setInterval(tick, 4000);
1555
+ return () => {
1556
+ state.thinkingPhase = false;
1557
+ if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1558
+ state.typingHeartbeat = null;
1559
+ try { adapter.typingStop?.(channelId); } catch (_) {}
1176
1560
  };
1561
+ }
1177
1562
 
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);
1563
+ function settleImmediateEffects(result, runContext, { state, store, capture }) {
1564
+ const stages = [
1565
+ ["transcript", persistForegroundTranscript],
1566
+ ["usage", persistForegroundUsage],
1567
+ ["session", persistForegroundSession],
1568
+ ];
1569
+ return (async () => {
1570
+ for (const [name, hook] of stages) {
1571
+ if (!result.persistence[name].skipped) continue;
1572
+ result.persistence[name] = { ok: true, skipped: false };
1573
+ try { await hook(result, runContext, state, store); }
1574
+ catch (error) {
1575
+ result.persistence[name] = { ok: false, skipped: false, error: stableRunError(error.message, `RUN_${name.toUpperCase()}_PERSIST_FAILED`) };
1576
+ if (!result.error) result.error = result.persistence[name].error;
1577
+ result.ok = false;
1578
+ result.status = "failed";
1209
1579
  }
1210
- };
1211
-
1212
- const origin = group[0].origin;
1213
- if (origin) await chatContext.run(origin, runGroup);
1214
- else await runGroup();
1215
- };
1216
-
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
1580
  }
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.`);
1581
+ if (!capture && result.delivery.skipped) {
1582
+ result.delivery.skipped = false;
1583
+ try {
1584
+ result.delivery.result = await deliverForegroundResult(result, runContext, state);
1585
+ result.delivery.ok = true;
1586
+ } catch (error) {
1587
+ result.delivery.ok = false;
1588
+ result.delivery.error = stableRunError(error.message, "RUN_DELIVERY_FAILED");
1589
+ if (!result.error) result.error = result.delivery.error;
1590
+ result.ok = false;
1591
+ result.status = "failed";
1274
1592
  }
1275
- } catch (e) {
1276
- console.error("Progress update error:", e.message);
1277
1593
  }
1278
- if (state.runningProcess) scheduleUpdate();
1279
- };
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
- }
1384
- }
1385
- if (evt.type === "thread.started" && evt.thread_id) {
1386
- state.codexSessionId = evt.thread_id;
1387
- saveState();
1388
- }
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
- }
1406
- }
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
- }
1412
- }
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();
1594
+ result.diagnostic = result.ok ? null : (providerRunDiagnostic(result, runContext) || result.error?.message || "Provider run failed");
1595
+ return result;
1596
+ })();
1597
+ }
1598
+
1599
+ function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1600
+ const persistEffects = !capture || persistCapture;
1601
+ return createRunnerService({
1602
+ getState: () => state,
1603
+ getStore: () => store,
1604
+ resolveProvider: resolveForegroundProvider,
1605
+ buildTurnPrompt,
1606
+ buildInvocation(provider, context) {
1607
+ if (context.purpose === "compact-seed" && typeof provider.buildCompactionInvocation === "function") {
1608
+ return provider.buildCompactionInvocation(context);
1423
1609
  }
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();
1610
+ return provider.buildMainInvocation(context);
1611
+ },
1612
+ decorateInvocation: decorateForegroundInvocation,
1613
+ persistTranscript: persistEffects ? ((result, runContext) => persistForegroundTranscript(result, runContext, state)) : null,
1614
+ persistUsage: persistEffects ? ((result, runContext) => persistForegroundUsage(result, runContext, state, store)) : null,
1615
+ persistSession: persistEffects ? ((result, runContext) => persistForegroundSession(result, runContext, state)) : null,
1616
+ deliver: capture ? null : (result, runContext) => deliverForegroundResult(result, runContext, state),
1617
+ onSpawn(proc) {
1618
+ state.runningProcess = proc;
1619
+ if (ownsAdmissionLock) state.preparingRun = false;
1620
+ },
1621
+ onEvent(event) {
1622
+ if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1623
+ if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1624
+ const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1625
+ if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
1437
1626
  }
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
- }
1627
+ },
1628
+ onInvocationWarning(warning) {
1629
+ return send(`⚠️ ${warning.message}`);
1630
+ },
1631
+ onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
1632
+ cleanup: async (proc) => {
1633
+ if (state.runningProcess === proc) state.runningProcess = null;
1634
+ // Keep the single-flight lock armed while transcript/usage/session
1635
+ // persistence and required delivery settle after child close.
1636
+ if (ownsAdmissionLock) state.preparingRun = true;
1637
+ stopTyping();
1638
+ },
1639
+ idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
1442
1640
  });
1641
+ }
1443
1642
 
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));
1643
+ async function handoffMemoryReview(result, runContext, store) {
1644
+ if (!result.ok || !result.text || runContext.purpose !== "foreground") return;
1645
+ const recall = result.recallMetadata || {};
1646
+ packReview.reviewTurn({
1647
+ userText: runContext.userPrompt,
1648
+ assistantText: result.text,
1649
+ channelId: runContext.channelId,
1650
+ provider: runContext.provider,
1651
+ runId: runContext.runId,
1652
+ activePacks: recall.packDirs || [],
1653
+ signals: { tier: recall.tier || "", wrote: result.tools.some((event) => event.type === "tool_start" && ["Edit", "Write", "Bash", "Shell"].includes(event.name)) },
1654
+ source: (() => {
1655
+ try {
1656
+ const speaker = require("./people").findByHandle(store?.adapter?.type, String(store?.userId || store?.channelId || ""));
1657
+ return speaker ? { name: speaker.name, isOwner: !!speaker.isOwner } : null;
1658
+ } catch (_) { return null; }
1659
+ })(),
1660
+ announce: (text) => chatContext.run(store, () => send(text)),
1450
1661
  });
1662
+ }
1451
1663
 
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
- }
1664
+ function deferredRunResult() {
1665
+ let resolve;
1666
+ let reject;
1667
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
1668
+ return { promise, resolve, reject };
1669
+ }
1498
1670
 
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
- }
1671
+ function drainQueuedMessages(state) {
1672
+ if (!Array.isArray(state.messageQueue) || !state.messageQueue.length || state.runningProcess || state.preparingRun) return Promise.resolve();
1673
+ const item = state.messageQueue.shift();
1674
+ state.preparingRun = true;
1675
+ setImmediate(() => {
1676
+ const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
1677
+ const promise = item.store ? chatContext.run(item.store, execute) : execute();
1678
+ promise.then(item.deferred.resolve, item.deferred.reject);
1679
+ });
1680
+ return Promise.resolve();
1681
+ }
1523
1682
 
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.");
1683
+ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
1684
+ const tracksMainRun = ownsAdmissionLock && !capture;
1685
+ if (tracksMainRun) state.activeRunContext = runContext;
1686
+ const stopTyping = startTypingHeartbeat(state, store);
1687
+ const persistCapture = executionOptions.persistCapture !== false;
1688
+ const internalCapture = capture && !persistCapture;
1689
+ const service = createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture });
1690
+ let result;
1691
+ try {
1692
+ result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
1693
+ if ((result.persistence.transcript.skipped || (!capture && result.delivery.skipped)) && !internalCapture) {
1694
+ result = await settleImmediateEffects(result, runContext, { state, store, capture });
1600
1695
  }
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 */ }
1696
+ } catch (error) {
1697
+ result = preflightFailureResult(runContext, { id: runContext.provider, label: runContext.provider }, error);
1698
+ if (!internalCapture) {
1699
+ result = await settleImmediateEffects(result, runContext, { state, store, capture });
1619
1700
  }
1701
+ } finally {
1702
+ if (tracksMainRun && state.runningProcess) state.runningProcess = null;
1703
+ if (ownsAdmissionLock) state.preparingRun = false;
1704
+ if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
1705
+ stopTyping();
1706
+ }
1620
1707
 
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 */ }
1708
+ if (tracksMainRun) {
1709
+ sideChatCoordinator.noteMainResult(runContext, result);
1710
+ for (const item of state.messageQueue) {
1711
+ finalizeSideChatQueueItem(item, runContext, result);
1629
1712
  }
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 */ }
1713
+ }
1714
+ if (!internalCapture) await handoffMemoryReview(result, runContext, store);
1715
+ if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1716
+ state.settings.budget = null;
1717
+ try { saveState({ strict: true }); }
1718
+ catch (error) {
1719
+ result.ok = false;
1720
+ result.status = "failed";
1721
+ result.error = result.error || stableRunError(error.message, "RUN_SETTINGS_PERSIST_FAILED");
1722
+ result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
1638
1723
  }
1724
+ }
1725
+ if (!capture && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
1726
+ try { await compactActiveSession(runContext.project.dir, { notify: false }); }
1727
+ catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
1728
+ }
1729
+ if (!internalCapture) await drainQueuedMessages(state);
1730
+ return result;
1731
+ }
1639
1732
 
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
- }
1733
+ function admissionOptions(state, opts = {}) {
1734
+ const provider = opts.provider || state.settings?.backend || null;
1735
+ return { ...opts, provider };
1736
+ }
1655
1737
 
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
- }
1738
+ function admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
1739
+ const state = currentState();
1740
+ const store = chatContext.getStore() || null;
1741
+ const resolvedCwd = resolveRunCwd(cwd);
1742
+ return captureRunContext({
1743
+ state,
1744
+ store,
1745
+ prompt,
1746
+ cwd: resolvedCwd,
1747
+ replyToMsgId,
1748
+ opts: admissionOptions(state, opts),
1749
+ channelId: currentChannelId(),
1750
+ canonicalUserId: state.userId,
1751
+ runId: opts.runId || null,
1752
+ });
1753
+ }
1671
1754
 
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)),
1755
+ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
1756
+ const state = currentState();
1757
+ const store = chatContext.getStore() || null;
1758
+ const admitted = opts.runContext || admitRunContext(prompt, cwd, replyToMsgId, opts);
1759
+ const runContext = bindRunInputs(admitted, {
1760
+ userPrompt: prompt,
1761
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
1762
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
1763
+ lastInputWasVoice: opts.lastInputWasVoice !== undefined ? opts.lastInputWasVoice : admitted.lastInputWasVoice,
1764
+ voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
1765
+ });
1766
+
1767
+ if (!acquireRunLock(state)) {
1768
+ if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
1769
+ return sideChatCoordinator.respond({
1770
+ runContext,
1771
+ activeRunContext: state.activeRunContext,
1772
+ store,
1701
1773
  });
1702
1774
  }
1775
+ const deferred = deferredRunResult();
1776
+ state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
1777
+ send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId }).catch(() => {});
1778
+ return deferred.promise;
1779
+ }
1780
+ return executeAdmittedRun(runContext, state, store, false, true);
1781
+ }
1703
1782
 
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
- }
1783
+ function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
1784
+ return !!state?.runningProcess
1785
+ && opts.sideChat !== false
1786
+ && runContext?.purpose === "foreground"
1787
+ && coordinator.enabled();
1788
+ }
1709
1789
 
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
- }
1790
+ function runSideChatRequest(request, dependencies = {}) {
1791
+ return createSideChatService(dependencies).run(request);
1792
+ }
1717
1793
 
1718
- await drainQueuedMessages();
1719
- }));
1794
+ function withSideChatMainSession(runContext, sessionId) {
1795
+ if (!sessionId) return runContext;
1796
+ return deepFreeze({
1797
+ ...runContext,
1798
+ sessionId,
1799
+ admittedSessionId: sessionId,
1800
+ previousSessionId: sessionId,
1801
+ resumeSessionId: sessionId,
1802
+ continueSession: false,
1803
+ fresh: false,
1804
+ });
1805
+ }
1720
1806
 
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
- }));
1807
+ function createSideChatQueueItem(request, store, lineage, state, deferred) {
1808
+ const queuedContext = lineage.mainSessionId
1809
+ ? withSideChatMainSession(request.originalRunContext, lineage.mainSessionId)
1810
+ : request.originalRunContext;
1811
+ const awaitsActiveMain = !lineage.mainSessionId
1812
+ && lineage.mainRunId
1813
+ && state.activeRunContext?.runId === lineage.mainRunId;
1814
+ return {
1815
+ runContext: awaitsActiveMain ? null : queuedContext,
1816
+ store,
1817
+ deferred,
1818
+ queuedAt: Date.now(),
1819
+ followMainRunId: awaitsActiveMain ? lineage.mainRunId : null,
1820
+ pendingSideChat: awaitsActiveMain ? { originalRunContext: request.originalRunContext } : null,
1821
+ };
1730
1822
  }
1731
1823
 
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(); });
1824
+ function finalizeSideChatQueueItem(item, mainRunContext, result) {
1825
+ if (!item?.pendingSideChat || item.followMainRunId !== mainRunContext?.runId) return false;
1826
+ item.runContext = result?.ok && result.sessionId
1827
+ ? withSideChatMainSession(item.pendingSideChat.originalRunContext, result.sessionId)
1828
+ : item.pendingSideChat.originalRunContext;
1829
+ item.followMainRunId = null;
1830
+ item.pendingSideChat = null;
1831
+ return true;
1832
+ }
1833
+
1834
+ function enqueueSideChatRequest(request, store = null, lineage = {}) {
1835
+ if (!request?.originalRunContext || request.originalRunContext.purpose !== "foreground") {
1836
+ throw new TypeError("side-chat enqueue requires the original foreground run context");
1837
+ }
1838
+ const state = currentState();
1839
+ if (String(state.userId) !== String(request.canonicalUserId)) {
1840
+ const error = new Error("side-chat action belongs to another user");
1841
+ error.code = "SIDE_CHAT_FORBIDDEN";
1842
+ throw error;
1843
+ }
1844
+ const deferred = deferredRunResult();
1845
+ state.messageQueue.push(createSideChatQueueItem(request, store, lineage, state, deferred));
1846
+ deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
1847
+ if (!state.runningProcess && !state.preparingRun) drainQueuedMessages(state);
1848
+ return { queued: true, position: state.messageQueue.length || 1 };
1849
+ }
1850
+
1851
+ function runAgentCapture(prompt, cwd, opts = {}) {
1852
+ const state = currentState();
1853
+ const store = chatContext.getStore() || null;
1854
+ const admitted = opts.runContext || admitRunContext(prompt, cwd, null, { ...opts, requiredDelivery: false });
1855
+ const runContext = bindRunInputs(admitted, {
1856
+ userPrompt: prompt,
1857
+ imagePaths: opts.imagePaths !== undefined ? opts.imagePaths : admitted.imagePaths,
1858
+ attachments: opts.attachments !== undefined ? opts.attachments : admitted.attachments,
1859
+ lastInputWasVoice: opts.lastInputWasVoice !== undefined ? opts.lastInputWasVoice : admitted.lastInputWasVoice,
1860
+ voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
1750
1861
  });
1862
+ return executeAdmittedRun(runContext, state, store, true, false, { persistCapture: opts.persist !== false });
1863
+ }
1864
+
1865
+ function runAgentSilent(prompt, cwd, label, opts = {}) {
1866
+ return runAgent(prompt, cwd, null, { ...opts, label, purpose: opts.purpose || "silent" });
1751
1867
  }
1752
1868
 
1753
1869
  module.exports = {
1754
- runClaude,
1755
- runClaudeCapture,
1756
- runClaudeSilent,
1870
+ admitRunContext,
1871
+ capturedTranscriptMetadata,
1872
+ decorateForegroundInvocation,
1873
+ createRunnerService,
1874
+ createSideChatService,
1875
+ createSideChatQueueItem,
1876
+ finalizeSideChatQueueItem,
1877
+ executeProviderInvocation,
1878
+ invocationContextForRun,
1879
+ runAgent,
1880
+ runAgentCapture,
1881
+ runAgentSilent,
1882
+ shouldStartSideChat,
1883
+ runSideChatRequest,
1884
+ enqueueSideChatRequest,
1757
1885
  compactActiveSession,
1758
- buildSystemPrompt,
1886
+ createCompactionService,
1887
+ deriveCompactionRunContext,
1759
1888
  getActiveSessionId,
1760
- getActiveBinary,
1761
- getActiveSessionKey,
1762
1889
  shouldAutoCompact,
1763
1890
  effectiveCompactThreshold,
1764
1891
  usageParts,
@@ -1770,7 +1897,4 @@ module.exports = {
1770
1897
  archiveCompactionBrief,
1771
1898
  collectRepoStateFacts,
1772
1899
  collectRecentTurns,
1773
- formatProgress,
1774
- preflightClaudeAuthMessage,
1775
- claudeEmptyFailureMessage,
1776
1900
  };