@inetafrica/open-claudia 2.14.8 → 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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -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 +105 -7
  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 +127 -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 +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
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 } = 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,1132 +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);
805
-
806
- if (state.runningProcess) {
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;
814
- }
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
+ }
815
1327
 
816
- const authPreflight = preflightClaudeAuthMessage();
817
- if (authPreflight) {
818
- await send(authPreflight, { replyTo: replyToMsgId });
819
- 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}` : ""}`;
820
1338
  }
821
-
822
- if (state.currentSession && shouldAutoCompact(state, opts)) {
823
- try {
824
- await compactActiveSession(state.currentSession.dir, { notify: true, message: "Context is getting large, compacting before I handle this…" });
825
- } catch (e) {
826
- console.warn(`[preflight-compact] failed: ${redactSensitive(e.message)}`);
827
- }
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.`;
828
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
+ }
829
1345
 
830
- appendProjectTranscript("user", prompt, {
831
- sourceMessageId: replyToMsgId || null,
832
- fresh: !!opts.fresh,
833
- continueSession: !!opts.continueSession,
834
- resumeSessionId: opts.resumeSessionId || null,
835
- }, state, getActiveSessionId);
836
-
837
- // Typing heartbeat: keep the indicator alive continuously from message
838
- // receipt through the recall/discoverer phase and into streaming, not just
839
- // for the ~5s a single typing action lasts. Stored on state so /stop can
840
- // clear it instantly. Cleared on close/error/cancel.
841
- // While true, the bot is reasoning before its first output token, so the
842
- // typing heartbeat advertises the richer "thinking" state. Flipped to false
843
- // the moment the first assistant text / tool_use / text_delta arrives.
844
- state.thinkingPhase = true;
845
- const startTyping = () => {
846
- if (!adapter || !channelId) return;
847
- adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
848
- if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {}), 4000);
849
- };
850
- // Called on the first real output token to leave the thinking phase and, if
851
- // the turn is still live, immediately advertise plain "typing" so the client
852
- // flips without waiting for the next 4s heartbeat.
853
- const endThinking = () => {
854
- if (!state.thinkingPhase) return;
855
- state.thinkingPhase = false;
856
- if (adapter && channelId && state.typingHeartbeat) adapter.typing(channelId).catch(() => {});
857
- };
858
- const stopTyping = () => {
859
- state.thinkingPhase = false;
860
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
861
- // Telegram's chat action auto-expires, but socket-based channels (Kazee)
862
- // keep showing "typing…" until told to stop. Optional on the adapter.
863
- if (adapter && channelId) { try { adapter.typingStop?.(channelId); } catch (e) {} }
864
- };
865
- // Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
866
- // it sets state.cancelRequested and we bail at the checkpoint before spawning.
867
- state.preparingRun = true;
868
- state.cancelRequested = false;
869
- startTyping();
870
- state.statusMessageId = null;
871
- state.statusMessageChannel = null;
872
- state.streamBuffer = "";
873
- let assistantText = "";
874
-
875
- // Phase 3 relationship guardrail: is this turn's speaker an EXTERNAL (a
876
- // non-owner)? Computed once per turn. For the owner (and any no-context run)
877
- // this is false, so every guardrail branch below — the streaming-preview
878
- // suppression, the disabled unvetted voice-out, and the final-reply vet — is a
879
- // strict no-op and the owner path stays byte-for-byte unchanged (R9/R14).
880
- let guardedTurn = false;
881
- try { guardedTurn = require("./relationship").isCurrentSpeakerGuarded(); } catch (e) { guardedTurn = false; }
882
-
883
- // Voice streaming-out: on voice turns we speak each finished sentence as it is
884
- // generated (off the partial text_delta events) so the first audio plays while
885
- // the rest of the reply is still being written — far lower time-to-first-sound
886
- // than synthesizing one pass over the whole reply at the end. Reads the delta
887
- // stream only; the text/transcript channel still reads whole-message events, so
888
- // chat transports are completely unaffected. Disabled for guarded turns: a
889
- // streamed clip would speak unvetted text aloud before the guard sees it.
890
- let voiceStreaming = false;
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
+ }
1381
+
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;
891
1386
  try {
892
- const { currentTransport } = require("./context");
893
- voiceStreaming = !guardedTurn && !!state.lastInputWasVoice && currentTransport() === "voice";
894
- } catch { voiceStreaming = false; }
895
- let spokenBuf = ""; // text_delta accumulator awaiting a sentence boundary
896
- let ttsChain = Promise.resolve(); // ordered send queue so clips play in order
897
- let spokeAnyStreamed = false;
898
- const SPOKEN_MIN_CHARS = 40; // don't fire TTS on tiny fragments ("Hi.")
899
- // Voice latency probe (measurement only) timestamps filled in as the turn runs.
900
- // Spawn time is `startTime` (captured just after spawn below).
901
- let vlFirstSysAt = null, vlFirstTokenAt = null, vlFirstAudioAt = null, vlResultAt = null;
902
- function dispatchSpoken(text) {
903
- const clean = redactSensitive(text);
904
- if (!clean.trim()) return;
905
- spokeAnyStreamed = true;
906
- const synthP = synthSentenceMp3(clean); // start synth now (parallel)
907
- ttsChain = ttsChain.then(async () => { // but send strictly in order
908
- try {
909
- const clip = await synthP;
910
- if (clip) { await sendVoice(clip); if (vlFirstAudioAt == null) vlFirstAudioAt = Date.now(); }
911
- } 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,
912
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;
913
1432
  }
914
- function pumpSpoken(flush) {
915
- // Cut the smallest prefix that ends in a sentence terminator and is at least
916
- // SPOKEN_MIN_CHARS long, dispatch it, repeat. On flush, send whatever remains.
917
- while (true) {
918
- const re = /[.!?]+(?=\s|$)/g;
919
- let idx = -1, m;
920
- while ((m = re.exec(spokenBuf)) !== null) {
921
- const end = m.index + m[0].length;
922
- if (end >= SPOKEN_MIN_CHARS) { idx = end; break; }
923
- }
924
- if (idx === -1) break;
925
- const sentence = spokenBuf.slice(0, idx).trim();
926
- spokenBuf = spokenBuf.slice(idx).replace(/^\s+/, "");
927
- if (sentence) dispatchSpoken(sentence);
928
- }
929
- 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 });
930
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
+ );
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
+ }
931
1471
 
932
- let toolUses = [];
933
- let currentTool = null;
934
- let currentToolDetail = "";
935
- // True context-window occupancy is the largest single API call's prefix
936
- // (input + cache_read + cache_creation), NOT the per-turn sum the result
937
- // event reports — that sum re-counts the cached prefix once per tool
938
- // round-trip and balloons to millions. Track the peak per-call prefix.
939
- let peakContextTokens = 0;
940
-
941
- // Hermes-style skill announcements: one chat line when a learned skill
942
- // is invoked or its SKILL.md is written/patched. Deduped per turn.
943
- const skillNotified = new Set();
944
- const notifySkill = (key, text) => {
945
- if (externalSpeaker) return; // never leak internal activity to a non-owner
946
- if (skillNotified.has(key)) return;
947
- skillNotified.add(key);
948
- chatContext.run(store, () => send(text).catch(() => {}));
949
- };
950
- // Tool-activity visibility (surfaced / ran / created-updated). Off by default,
951
- // toggled per-channel with /tooltrace — mirrors /recall's showRecall. Dedup +
952
- // delivery reuse notifySkill so a tool touched twice a turn announces once.
953
- const notifyToolTrace = (key, text) => {
954
- if (!settings.showToolTrace) return;
955
- notifySkill(key, text);
956
- };
957
- const noteSkillToolUse = (toolName, input) => {
958
- try {
959
- if (toolName === "Skill" && input?.skill) {
960
- notifySkill(`use:${input.skill}`, `Using skill: ${input.skill}`);
961
- return;
962
- }
963
- const filePath = input?.file_path || input?.filePath;
964
- if ((toolName === "Write" || toolName === "Edit") && filePath) {
965
- const packDir = packsLib.packNameFromPath(filePath);
966
- if (packDir) {
967
- if (packsLib.readPack(packDir)) notifySkill(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
968
- else notifySkill(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
969
- try {
970
- packsLib.recordForegroundWrite(packDir, {
971
- tool: toolName,
972
- oldString: input?.old_string ?? input?.oldString,
973
- content: input?.content,
974
- });
975
- } catch (e) {}
976
- return;
977
- }
978
- const entSlug = entitiesLib.entityNameFromPath(filePath);
979
- if (entSlug) {
980
- if (entitiesLib.readEntity(entSlug)) notifySkill(`entity:${entSlug}`, `👤 Updating what I know about ${entSlug}…`);
981
- else notifySkill(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
982
- return;
983
- }
984
- const toolName2 = toolsLib.toolNameFromPath(filePath);
985
- if (toolName2) {
986
- if (toolsLib.findTool(toolName2)) notifyToolTrace(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
987
- else notifyToolTrace(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
988
- return;
989
- }
990
- const dir = skillsLib.skillNameFromPath(filePath);
991
- if (!dir) return;
992
- // The tool_use event precedes the actual write, so existence now
993
- // distinguishes a brand-new skill from a patch.
994
- if (skillsLib.skillExists(dir)) notifySkill(`write:${dir}`, `Updating skill: ${dir}`);
995
- else notifySkill(`write:${dir}`, `Learning new skill: ${dir} — /skills show ${dir} to inspect, /skills remove ${dir} to drop it.`);
996
- }
997
- } catch (e) { /* announcements are best-effort */ }
998
- };
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");
999
1477
 
1000
- // Read-side recall: fire "📖 Recalled my notes on …" only when the agent
1001
- // actually pulls a full pack/entity via the CLI (it decided it was worth
1002
- // reading), not when a headline was auto-injected. Deduped per turn through
1003
- // notifySkill's Set so repeated `pack show` calls don't spam the chat.
1004
- // Nodes the agent actually OPENED this turn (📖). This is the co-use signal
1005
- // the recall graph reinforces on — actually-read, not merely surfaced.
1006
- const openedThisTurn = new Set();
1007
- // Reusable tools the agent RAN this turn, in order, as { name, shape }. Feeds
1008
- // two graphs (core/tool-graph.js): names → directed follows-edges ("you ran X
1009
- // you usually run Y next"); shapes pack↔tool edges ("in this topic, tool X
1010
- // is used as `…`"). Order matters here (unlike openedThisTurn, a Set), because
1011
- // a tool chain is a pipeline.
1012
- const toolRunsThisTurn = [];
1013
- const noteRecallFromShell = (command) => {
1014
- try {
1015
- const cmd = String(command || "");
1016
- // 5c raw-op audit: every Bash command in the stream passes here, so
1017
- // operational work done raw — heredoc scripts, DB clients, mutating
1018
- // kubectl — gets stamped to the guard log for the dream's KPI pass.
1019
- // Observation only; blocking is the deny-gate hook's job, not this.
1020
- try { require("./tool-guard").noteRawOp(cmd); } catch (e) { /* best-effort */ }
1021
- if (!cmd.includes("open-claudia")) return;
1022
- let m;
1023
- const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
1024
- while ((m = packRe.exec(cmd))) {
1025
- const dir = m[1];
1026
- const pack = packsLib.readPack(dir);
1027
- const name = (pack && (pack.name || pack.dir)) || dir;
1028
- openedThisTurn.add(`pack:${dir}`);
1029
- notifySkill(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
1030
- }
1031
- const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
1032
- while ((m = entRe.exec(cmd))) {
1033
- const slug = m[1];
1034
- const ent = entitiesLib.readEntity(slug);
1035
- const name = (ent && (ent.name || ent.slug)) || slug;
1036
- openedThisTurn.add(`entity:${slug}`);
1037
- notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
1038
- }
1039
- // `open-claudia tool run|exec <name> <args>` — record each run as
1040
- // { name, shape }. The name feeds the directed follows-graph (what runs
1041
- // next); the sanitized command shape feeds pack↔tool capture (how a tool
1042
- // is used in a topic). Parsing lives in tool-graph so it stays testable.
1043
- try {
1044
- for (const run of require("./tool-graph").parseToolRuns(cmd)) {
1045
- toolRunsThisTurn.push(run);
1046
- // Visibility (gated by /tooltrace): one 🔧 line per distinct
1047
- // tool+verb this turn — sanitized command shape plus the verb's own
1048
- // doc sentence (verbDoc: parsed mechanically from the tool header,
1049
- // zero model cost). Telemetry (recordRun) fires at end-of-turn.
1050
- const verb = (String(run.shape || "").split(/\s+/)[1] || "");
1051
- let doc = "";
1052
- try { doc = toolsLib.verbDoc(run.name, verb); } catch (e) {}
1053
- 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");
1054
1489
  }
1055
- } catch (e) { /* graph optional (old node) */ }
1056
- // `open-claudia tool add <src> [--name X]` — a tool being saved/updated.
1057
- // The Write/Edit path above only catches direct edits to TOOLS_DIR; `tool
1058
- // add` copies an external file in, so detect it here. Gated by /tooltrace.
1059
- const addRe = /\btool\s+add\s+(\S+)([^\n;|&]*)/gi;
1060
- while ((m = addRe.exec(cmd))) {
1061
- let name = (m[2].match(/--name\s+["']?([^\s"']+)/i) || [])[1];
1062
- if (!name) name = (String(m[1]).split(/[\\/]/).pop() || "").replace(/\.[^.]+$/, "");
1063
- name = toolsLib.sanitizeName(name);
1064
- if (name) notifyToolTrace(`added:${name}`, `🛠️ Saved tool: ${name} — open-claudia tool show ${name} to inspect.`);
1490
+ return { ok: true, held: true };
1065
1491
  }
1066
- } catch (e) { /* announcements are best-effort */ }
1067
- };
1068
-
1069
- // The same 📖 signal when the agent opens a note with the Read tool instead
1070
- // of the CLI: noteRecallFromShell only sees shell commands, so a raw-file
1071
- // read of a pack/entity would otherwise be invisible to both the banner and
1072
- // the co-use graph. Deduped against the CLI path via the shared notifySkill
1073
- // key, so reading the same node both ways still announces only once.
1074
- const noteRecallFromReadPath = (filePath) => {
1075
- try {
1076
- const node = recallNodeFromPath(filePath);
1077
- if (!node) return;
1078
- openedThisTurn.add(`${node.kind}:${node.id}`);
1079
- notifySkill(`recall:${node.kind}:${node.id}`, `📖 Recalled my notes on: ${node.name}`);
1080
- } catch (e) { /* announcements are best-effort */ }
1081
- };
1492
+ }
1493
+ }
1082
1494
 
1083
- let args;
1084
- try {
1085
- args = await buildClaudeArgs(prompt, opts);
1086
- } catch (e) {
1087
- state.preparingRun = false;
1088
- stopTyping();
1089
- 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}`);
1090
1500
  }
1091
- // Recall announcements are now fired at READ time, not injection time:
1092
- // matched packs/entities enter context only as small headlines (see
1093
- // system-prompt.js recallHeadline). The "📖 Recalled my notes on …" line is
1094
- // emitted by noteRecallFromShell below when the agent actually runs
1095
- // `open-claudia pack show <dir>` / `entity show <slug>` — so the banner
1096
- // reflects what was read, not what was pushed. (consumeLastInjected is
1097
- // drained here to keep the per-turn buffer from leaking into the next turn.)
1098
- let turnRecallTier = "";
1099
- // Pack dirs surfaced (recall-matched) for this turn joined with
1100
- // openedThisTurn at end-of-turn to tell the post-turn reviewer which packs
1101
- // this turn's work belongs to (attribution + safe State rewrites).
1102
- let surfacedPackDirs = [];
1103
- try {
1104
- const injected = require("./system-prompt").consumeLastInjected();
1105
- if (injected && Array.isArray(injected.packDirs)) surfacedPackDirs = injected.packDirs;
1106
- if (injected && injected.recall) {
1107
- const r = injected.recall;
1108
- turnRecallTier = String(r.tier || "");
1109
- const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1110
- const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
1111
- // Debug traces are owner-only: recall/tool/engine internals must never
1112
- // leak to an external speaker even when a debug toggle is left on.
1113
- const traceOwnerOnly = !externalSpeaker;
1114
- // 🧠 packs/entities recall — gated by /recall.
1115
- if (traceOwnerOnly && settings.showRecall) {
1116
- const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
1117
- if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1118
- 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); }
1119
1510
  }
1120
- // 🔧 tools surfaced this turn (first-class discoverer nodes) — gated by /tooltrace.
1121
- if (traceOwnerOnly && settings.showToolTrace) {
1122
- const toolLines = fmt(r.tools || [], "🔧");
1123
- 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);
1124
1514
  }
1515
+ await sendVoiceEnd();
1125
1516
  }
1126
- } catch (e) { /* best-effort */ }
1127
- // /stop landed during the pre-spawn window (recall/compaction): bail before
1128
- // spawning. The /stop handler already acknowledged in chat, so stay silent.
1129
- if (state.cancelRequested) {
1130
- state.cancelRequested = false;
1131
- state.preparingRun = false;
1132
- stopTyping();
1133
- return;
1134
1517
  }
1135
- const binaryPath = getActiveBinary();
1136
- const proc = spawn(binaryPath, args, {
1137
- cwd,
1138
- env: subprocessEnv(),
1139
- stdio: ["ignore", "pipe", "pipe"],
1140
- detached: process.platform !== "win32",
1141
- });
1142
-
1143
- state.runningProcess = proc;
1144
- state.preparingRun = false;
1145
- const startTime = Date.now();
1146
- let longRunningNotified = false;
1147
-
1148
- // Inactivity watchdog. The live bot runs through this modular runner
1149
- // (`bot.js` → `core/runner.js`), so the guard must live here, not only in
1150
- // the legacy monolith. `state.runningProcess` is single-flight and normally
1151
- // clears only on child close/error; a silent wedged child can otherwise pin it
1152
- // forever and leave every later message queued. Treat child stdout/stderr as
1153
- // liveness and stop the process group if it goes quiet past the idle limit.
1154
- const idleLimitMs = Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000);
1155
- let lastActivity = Date.now();
1156
- let watchdogTripped = false;
1157
- let watchdogNotice = null;
1518
+ return { ok: true, chunks: chunks.length };
1519
+ }
1158
1520
 
1159
- const processTimeout = setTimeout(() => {
1160
- if (state.runningProcess === proc) {
1161
- killProcessTree(proc.pid, "SIGTERM");
1162
- setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
1163
- chatContext.run(store, () => send(`Task timed out after ${MAX_PROCESS_TIMEOUT / 60000} minutes. Stopped.`));
1164
- }
1165
- }, 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
+ }
1166
1546
 
1167
- let lastUpdate = "";
1168
- const scheduleUpdate = () => {
1169
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
1170
- const interval = elapsed > 120 ? 5000 : 2000;
1171
- 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 (_) {}
1172
1560
  };
1561
+ }
1173
1562
 
1174
- const drainQueuedMessages = async () => {
1175
- if (state.messageQueue.length === 0 || !state.currentSession) return;
1176
- const drained = state.messageQueue.splice(0);
1177
- // Handle one same-origin run at a time; requeue the remainder so the
1178
- // finishing turn's close→drain tail-recursion picks up the next origin
1179
- // group. Each group's reply is scoped to the channel it came from, so a
1180
- // message queued from one of the owner's channels during a run on another
1181
- // is still answered on its own channel (unified identity, one run-lock).
1182
- const { group, rest } = splitLeadingOriginGroup(drained);
1183
- if (rest.length) state.messageQueue.unshift(...rest);
1184
-
1185
- const runGroup = async () => {
1186
- if (group.length === 1) {
1187
- const only = group[0];
1188
- await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1189
- } else {
1190
- const fmtTime = (ts) => {
1191
- const d = new Date(ts);
1192
- const hh = String(d.getHours()).padStart(2, "0");
1193
- const mm = String(d.getMinutes()).padStart(2, "0");
1194
- const ss = String(d.getSeconds()).padStart(2, "0");
1195
- return `${hh}:${mm}:${ss}`;
1196
- };
1197
- const numbered = group.map((m, idx) => `[${idx + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1198
- const batched =
1199
- `While you were working the user sent these ${group.length} follow-up messages (oldest first):\n\n` +
1200
- `${numbered}\n\n` +
1201
- `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1202
- `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1203
- const last = group[group.length - 1];
1204
- 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";
1205
1579
  }
1206
- };
1207
-
1208
- const origin = group[0].origin;
1209
- if (origin) await chatContext.run(origin, runGroup);
1210
- else await runGroup();
1211
- };
1212
-
1213
- const updateProgress = async () => {
1214
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
1215
- if (!watchdogTripped && state.runningProcess === proc && Date.now() - lastActivity > idleLimitMs) {
1216
- watchdogTripped = true;
1217
- const idleMin = Math.max(1, Math.floor((Date.now() - lastActivity) / 60000));
1218
- 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.`;
1219
- console.error(`[watchdog] child pid ${proc.pid} idle ${idleMin}min (limit ${Math.floor(idleLimitMs / 60000)}min) — killing`);
1220
- try { killProcessTree(proc.pid, "SIGTERM"); }
1221
- catch (e) { try { proc.kill("SIGTERM"); } catch (e2) {} }
1222
- setTimeout(() => { try { killProcessTree(proc.pid, "SIGKILL"); } catch (e) {} }, 3000);
1223
- setTimeout(() => chatContext.run(store, async () => {
1224
- if (state.runningProcess === proc) {
1225
- state.runningProcess = null;
1226
- state.preparingRun = false;
1227
- stopTyping();
1228
- clearTimeout(state.streamInterval); state.streamInterval = null;
1229
- clearTimeout(processTimeout);
1230
- state.statusMessageId = null;
1231
- if (settings.budget) settings.budget = null;
1232
- saveState();
1233
- await drainQueuedMessages();
1234
- }
1235
- }), 10000);
1236
- try { await send(watchdogNotice, { replyTo: replyToMsgId }); } catch (e) {}
1237
- state.statusMessageId = null;
1238
- return;
1239
1580
  }
1240
- try {
1241
- if (adapter && channelId) adapter.typing(channelId).catch(() => {});
1242
- // For guarded (external) turns the live preview must not echo unvetted
1243
- // assistant text show tool/elapsed status only until the final reply is
1244
- // vetted and delivered.
1245
- const display = formatProgress(guardedTurn ? "" : assistantText, toolUses, currentTool, elapsed, currentToolDetail);
1246
- if (display && display !== lastUpdate) {
1247
- // A statusMessageId minted on another channel (shared per-canonical
1248
- // state under unified identity) can't be edited here — drop it so a
1249
- // fresh preview is created on this channel instead of 500-ing.
1250
- if (state.statusMessageId && state.statusMessageChannel !== channelId) {
1251
- state.statusMessageId = null;
1252
- state.statusMessageChannel = null;
1253
- }
1254
- if (!state.statusMessageId && assistantText) {
1255
- state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
1256
- state.statusMessageChannel = channelId;
1257
- } else if (canEditStatus()) {
1258
- await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
1259
- }
1260
- lastUpdate = display;
1261
- }
1262
- if (elapsed > 300 && !longRunningNotified) {
1263
- longRunningNotified = true;
1264
- 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";
1265
1592
  }
1266
- } catch (e) {
1267
- console.error("Progress update error:", e.message);
1268
1593
  }
1269
- if (state.runningProcess) scheduleUpdate();
1270
- };
1271
- scheduleUpdate();
1272
-
1273
- proc.stdout.on("data", (data) => {
1274
- lastActivity = Date.now();
1275
- state.streamBuffer += data.toString();
1276
- const events = parseStreamEvents(state.streamBuffer);
1277
- const lastNewline = state.streamBuffer.lastIndexOf("\n");
1278
- state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
1279
- for (const evt of events) {
1280
- // Leave the "thinking" phase the moment the model produces any real
1281
- // output — streamed text, a finished assistant block, a tool call, or a
1282
- // terminal result. Idempotent (guards on state.thinkingPhase internally).
1283
- if (state.thinkingPhase && (
1284
- (evt.type === "stream_event" && evt.event?.type === "content_block_delta" && evt.event.delta?.type === "text_delta") ||
1285
- (evt.type === "assistant" && evt.message?.content) ||
1286
- (evt.type === "tool_call" && evt.subtype === "started") ||
1287
- evt.type === "result"
1288
- )) {
1289
- endThinking();
1290
- }
1291
- // Voice latency probe: first "system" event = CLI ready (cold-start done);
1292
- // "result" = generation finished (before the TTS tail drains).
1293
- if (voiceStreaming && vlFirstSysAt == null && evt.type === "system") vlFirstSysAt = Date.now();
1294
- if (voiceStreaming && vlResultAt == null && evt.type === "result") vlResultAt = Date.now();
1295
- // Voice streaming-out: speak finished sentences as the model writes them.
1296
- // Only text_delta is spoken; thinking_delta and tool events are ignored.
1297
- if (voiceStreaming && evt.type === "stream_event"
1298
- && evt.event?.type === "content_block_delta"
1299
- && evt.event.delta?.type === "text_delta"
1300
- && typeof evt.event.delta.text === "string") {
1301
- if (vlFirstTokenAt == null) vlFirstTokenAt = Date.now();
1302
- spokenBuf += evt.event.delta.text;
1303
- pumpSpoken(false);
1304
- }
1305
- if (evt.type === "assistant" && evt.message?.usage) {
1306
- const callPrefix = usageParts(evt.message.usage, settings.backend || "claude").context;
1307
- if (callPrefix > peakContextTokens) peakContextTokens = callPrefix;
1308
- }
1309
- if (evt.type === "assistant" && evt.message?.content) {
1310
- for (const block of evt.message.content) {
1311
- if (block.type === "text") assistantText += block.text;
1312
- else if (block.type === "tool_use") {
1313
- currentTool = block.name;
1314
- toolUses.push(block.name);
1315
- const input = block.input || {};
1316
- if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
1317
- else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1318
- else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1319
- else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
1320
- else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
1321
- else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
1322
- else if (block.name === "Skill" && input.skill) currentToolDetail = input.skill;
1323
- else currentToolDetail = "";
1324
- noteSkillToolUse(block.name, input);
1325
- if (block.name === "Bash" && input.command) noteRecallFromShell(input.command);
1326
- else if (block.name === "Read") noteRecallFromReadPath(input.file_path || input.filePath);
1327
- }
1328
- }
1329
- }
1330
- if (evt.type === "tool_call" && evt.subtype === "started" && evt.tool_call) {
1331
- const tc = evt.tool_call;
1332
- if (tc.shellToolCall) {
1333
- const a = tc.shellToolCall.args || {};
1334
- currentTool = "Shell"; toolUses.push("Shell");
1335
- currentToolDetail = (a.description || a.command || "").slice(0, 80);
1336
- if (a.command) noteRecallFromShell(a.command);
1337
- } else if (tc.readToolCall) {
1338
- currentTool = "Read"; toolUses.push("Read");
1339
- const readPath = tc.readToolCall.args?.path;
1340
- currentToolDetail = (readPath || "").split("/").slice(-2).join("/");
1341
- noteRecallFromReadPath(readPath);
1342
- } else if (tc.editToolCall) {
1343
- currentTool = "Edit"; toolUses.push("Edit");
1344
- currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
1345
- noteSkillToolUse("Edit", tc.editToolCall.args);
1346
- } else if (tc.writeToolCall) {
1347
- currentTool = "Write"; toolUses.push("Write");
1348
- currentToolDetail = (tc.writeToolCall.args?.filePath || "").split("/").slice(-2).join("/");
1349
- noteSkillToolUse("Write", tc.writeToolCall.args);
1350
- } else if (tc.grepToolCall) {
1351
- currentTool = "Grep"; toolUses.push("Grep");
1352
- currentToolDetail = (tc.grepToolCall.args?.pattern || "").slice(0, 40);
1353
- } else if (tc.globToolCall) {
1354
- currentTool = "Glob"; toolUses.push("Glob");
1355
- currentToolDetail = (tc.globToolCall.args?.globPattern || "").slice(0, 40);
1356
- } else if (tc.createPlanToolCall) {
1357
- currentTool = "Plan"; toolUses.push("Plan");
1358
- const plan = tc.createPlanToolCall.args || {};
1359
- let planText = "";
1360
- if (plan.name) planText += `**${plan.name}**\n\n`;
1361
- if (plan.plan) planText += plan.plan + "\n";
1362
- if (plan.todos && plan.todos.length) {
1363
- planText += "\n**Tasks:**\n";
1364
- for (const todo of plan.todos) {
1365
- planText += `• ${todo.content || todo.id}\n`;
1366
- }
1367
- }
1368
- if (planText) assistantText = planText;
1369
- currentToolDetail = plan.name || "creating plan";
1370
- } else {
1371
- const toolKey = Object.keys(tc)[0] || "unknown";
1372
- currentTool = toolKey.replace("ToolCall", ""); toolUses.push(currentTool);
1373
- currentToolDetail = "";
1374
- }
1375
- }
1376
- if (evt.type === "thread.started" && evt.thread_id) {
1377
- state.codexSessionId = evt.thread_id;
1378
- saveState();
1379
- }
1380
- if (evt.type === "item.started" && evt.item) {
1381
- const it = evt.item;
1382
- if (it.type === "command_execution" && it.command) {
1383
- currentTool = "Shell"; toolUses.push("Shell");
1384
- currentToolDetail = String(it.command).slice(0, 80);
1385
- noteRecallFromShell(it.command);
1386
- } else if (it.type === "file_change" && (it.path || it.file_path)) {
1387
- currentTool = "Edit"; toolUses.push("Edit");
1388
- const p = it.path || it.file_path;
1389
- currentToolDetail = String(p).split("/").slice(-2).join("/");
1390
- } else if (it.type === "web_search" && it.query) {
1391
- currentTool = "Web"; toolUses.push("Web");
1392
- currentToolDetail = String(it.query).slice(0, 60);
1393
- } else if (it.type) {
1394
- currentTool = it.type; toolUses.push(it.type);
1395
- currentToolDetail = "";
1396
- }
1397
- }
1398
- if (evt.type === "item.completed" && evt.item) {
1399
- const it = evt.item;
1400
- if (it.type === "agent_message" && typeof it.text === "string") {
1401
- assistantText += (assistantText ? "\n" : "") + it.text;
1402
- }
1403
- }
1404
- if (evt.type === "turn.completed" && evt.usage && settings.backend === "codex") {
1405
- const session = state.codexSessionId || evt.thread_id || getActiveSessionId();
1406
- const normalized = normalizeCodexUsage(state, session, evt.usage);
1407
- const rawParts = usageParts(evt.usage, "codex");
1408
- applyUsageToState(state, normalized.usage, 0, {
1409
- usageScope: normalized.usageScope,
1410
- liveContextTokens: rawParts.context,
1411
- });
1412
- logTurnUsage(state, normalized.usage, 0, (text) => chatContext.run(store, () => send(text)), normalized);
1413
- 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);
1414
1609
  }
1415
- if (evt.type === "result" && evt.session_id) {
1416
- if (settings.backend === "cursor") { state.cursorSessionId = evt.session_id; }
1417
- else { state.lastSessionId = evt.session_id; }
1418
- // Codex already reports usage at turn.completed. A result usage block,
1419
- // if present, is the same cumulative total and must not be counted again.
1420
- if (evt.usage && settings.backend !== "codex") {
1421
- const peakOpts = { usageScope: "turn_delta" };
1422
- if (peakContextTokens > 0) peakOpts.liveContextTokens = peakContextTokens;
1423
- applyUsageToState(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, peakOpts);
1424
- logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)), peakOpts);
1425
- }
1426
- if (typeof evt.total_cost_usd === "number" && settings.backend === "codex") state.sessionUsage.costUsd += evt.total_cost_usd;
1427
- 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 (_) {} }
1428
1626
  }
1429
- // Fallback only: evt.result is just the final text segment, and assigning
1430
- // it unconditionally wiped everything the model said before tool calls.
1431
- if (evt.type === "result" && evt.result && !assistantText.trim()) assistantText = evt.result;
1432
- }
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),
1433
1640
  });
1641
+ }
1434
1642
 
1435
- let stderrBuffer = "";
1436
- proc.stderr.on("data", (d) => {
1437
- lastActivity = Date.now();
1438
- const chunk = d.toString();
1439
- stderrBuffer += chunk;
1440
- 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)),
1441
1661
  });
1662
+ }
1442
1663
 
1443
- proc.on("close", (code) => chatContext.run(store, async () => {
1444
- state.runningProcess = null;
1445
- state.preparingRun = false;
1446
- stopTyping();
1447
- clearTimeout(state.streamInterval); state.streamInterval = null;
1448
- clearTimeout(processTimeout);
1449
-
1450
- if (watchdogTripped) {
1451
- const note = watchdogNotice || "Task stalled; watchdog stopped the child process.";
1452
- appendProjectTranscript("system-note", note, { exitCode: code, kind: "watchdog" }, state, getActiveSessionId);
1453
- if (settings.budget) settings.budget = null;
1454
- state.statusMessageId = null;
1455
- await drainQueuedMessages();
1456
- return;
1457
- }
1458
-
1459
- if (settings.backend === "claude" && isClaudeAuthErrorText(stderrBuffer)) {
1460
- const authText = claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800));
1461
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1462
- await send(authText, { replyTo: replyToMsgId });
1463
- return;
1464
- }
1465
- if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1466
- const authText = "Cursor authentication error. Run `agent login` on this machine, then retry.";
1467
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1468
- await send(authText, { replyTo: replyToMsgId });
1469
- return;
1470
- }
1471
- if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
1472
- const authText = "Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.";
1473
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
1474
- await send(authText, { replyTo: replyToMsgId });
1475
- return;
1476
- }
1477
-
1478
- // Phase 3: set when an external reply is held by the guardrail — suppresses
1479
- // delivery AND the post-turn reviewer (a blocked turn must not accrete into
1480
- // the owner's memory). Declared out here so it's visible at reviewTurn below.
1481
- let suppressDelivery = false;
1482
- try {
1483
- if (code !== 0 && code !== null && !assistantText.trim()) {
1484
- const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
1485
- appendProjectTranscript("system-note", failureText, { exitCode: code, kind: "backend-error" }, state, getActiveSessionId);
1486
- await send(failureText, { replyTo: replyToMsgId });
1487
- return;
1488
- }
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
+ }
1489
1670
 
1490
- const finalText = redactSensitive(assistantText || "(no output)");
1491
- appendProjectTranscript("assistant", finalText, {
1492
- exitCode: code,
1493
- sourceMessageId: typeof state.statusMessageId === "string" ? state.statusMessageId : null,
1494
- }, state, getActiveSessionId);
1495
-
1496
- // Phase 3 (3.3): vet the outbound reply for external speakers. Owner/no-
1497
- // context → allow instantly (guardOutboundReply returns allow without a
1498
- // model call). On block/escalate the guard has already told the external
1499
- // "let me check with <owner>" and raised an owner approval, so we suppress
1500
- // delivery here; the vetted text is delivered later by the apr: handler on
1501
- // approval. The blocked text is still recorded to the transcript above so
1502
- // the owner can see what was held.
1503
- if (guardedTurn) {
1504
- let g = { allow: true };
1505
- try { g = await require("./enforcer").guardOutboundReply(finalText); }
1506
- catch (e) { g = { allow: false, escalated: false, reason: e.message }; }
1507
- if (!g.allow) {
1508
- suppressDelivery = true;
1509
- if (!g.escalated) {
1510
- try { await send("I need to check with the owner before I can answer that — I'll follow up."); } catch (e) {}
1511
- }
1512
- }
1513
- }
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
+ }
1514
1682
 
1515
- const chunks = splitMessage(finalText);
1516
- const firstChunk = chunks[0];
1517
-
1518
- if (suppressDelivery) {
1519
- // The reply was held for the owner: nothing is spoken or sent to the
1520
- // external now. Clear the voice-input flag so it can't leak into the
1521
- // next turn (no TTS flush runs on this path).
1522
- state.lastInputWasVoice = false;
1523
- if (canEditStatus()) { try { await editMessage(state.statusMessageId, "Checking with the owner…", telegramHtmlOpts()); } catch (e) {} }
1524
- } else if (canEditStatus() && chunks.length === 1) {
1525
- // Edit the streaming preview into the final reply — but only if that
1526
- // preview was minted on THIS channel. A foreign-channel id falls through
1527
- // to a fresh send below (never edited cross-channel, which would 500).
1528
- await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
1529
- } else {
1530
- const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
1531
- if (!sent) await send(firstChunk);
1532
- for (let i = 1; i < chunks.length; i++) {
1533
- await send(chunks[i], telegramHtmlOpts());
1534
- }
1535
- }
1536
- if (!suppressDelivery && code !== 0 && code !== null) await send(`Exit code: ${code}`);
1537
-
1538
- if (!suppressDelivery && state.lastInputWasVoice) {
1539
- state.lastInputWasVoice = false;
1540
- if (voiceStreaming) {
1541
- // Sentences were already being spoken as the model wrote them. Flush
1542
- // the trailing partial sentence, wait for the ordered send queue to
1543
- // drain, then close the turn so the client re-arms the mic.
1544
- pumpSpoken(true);
1545
- await ttsChain;
1546
- if (!spokeAnyStreamed) {
1547
- // Tool-only / empty turn produced no spoken text — say the final
1548
- // text once so the user still hears a reply.
1549
- const voicePath = await textToVoice(finalText);
1550
- if (voicePath) await sendVoice(voicePath);
1551
- }
1552
- await sendVoiceEnd();
1553
- // Per-stage latency breakdown (measurement only — temporary debug footer).
1554
- try {
1555
- const vl = state.voiceLat || {};
1556
- const endAt = Date.now();
1557
- const sec = (n) => n != null ? (n / 1000).toFixed(1) + "s" : "?";
1558
- const diff = (a, b) => (a != null && b != null) ? sec(b - a) : "?";
1559
- const fromStart = (t) => diff(vl.startAt, t);
1560
- const line = `⏱ dl ${sec(vl.downloadMs)} · stt ${sec(vl.sttMs)}`
1561
- + ` · cli ${diff(startTime, vlFirstSysAt)} · ttft ${diff(vlFirstSysAt, vlFirstTokenAt)}`
1562
- + ` · gen ${diff(vlFirstTokenAt, vlResultAt)} · 1st-audio ${fromStart(vlFirstAudioAt)}`
1563
- + ` · total ${fromStart(endAt)}`;
1564
- console.log(`[VOICE-LAT] ${line}`);
1565
- await send(line);
1566
- } catch (e) { /* metrics best-effort */ }
1567
- state.voiceLat = null;
1568
- } else {
1569
- // Non-streamed fallback. Spoken replies belong to the hands-free voice
1570
- // channel; on chat transports (Telegram/Kazee) an auto voice note on
1571
- // every voice input is unwanted noise, so gate it to the voice channel.
1572
- const { currentTransport } = require("./context");
1573
- if (currentTransport() === "voice") {
1574
- const sentences = splitSentences(finalText);
1575
- let spokeAny = false;
1576
- for (const sentence of sentences) {
1577
- const clip = await synthSentenceMp3(sentence);
1578
- if (clip) { spokeAny = true; await sendVoice(clip); }
1579
- }
1580
- if (!spokeAny) {
1581
- const voicePath = await textToVoice(finalText);
1582
- if (voicePath) await sendVoice(voicePath);
1583
- }
1584
- await sendVoiceEnd();
1585
- }
1586
- }
1587
- }
1588
- } catch (e) {
1589
- console.error("Final message delivery failed:", e.message);
1590
- 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 });
1591
1695
  }
1592
- if (settings.budget) settings.budget = null;
1593
- state.statusMessageId = null;
1594
-
1595
- // Outcome signal: only learn from turns that actually completed. A turn that
1596
- // errored out is not evidence that a recalled pattern "helped" — reinforcing
1597
- // or recording reuse on it would teach the wrong lesson (gap: reinforcement
1598
- // must track "helped", not merely "opened").
1599
- const turnSucceeded = (code === 0 || code === null);
1600
-
1601
- // Hebbian co-use: nodes the agent actually opened together this turn get
1602
- // their `related` edges reinforced, so future spreading activation pulls
1603
- // the cluster together. Reinforce on co-USE (📖), never co-recall.
1604
- if (turnSucceeded && openedThisTurn.size > 0) {
1605
- try {
1606
- const recallGraph = require("./recall/graph");
1607
- if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
1608
- require("./recall/metrics").logUse([...openedThisTurn]);
1609
- } 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 });
1610
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
+ }
1611
1707
 
1612
- // Directed tool-graph: a chain of reusable tools run in succession this turn
1613
- // (auth → list → download) becomes follows-edges, so next time the first
1614
- // runs we can surface "usually followed by …". Kept in a tool-specific graph
1615
- // so it never bleeds into recall's spreading activation. Reinforce only on a
1616
- // successful turn, and only when ≥2 ran (a single run has no succession).
1617
- if (turnSucceeded && toolRunsThisTurn.length > 1) {
1618
- try { require("./tool-graph").reinforceSequence(toolRunsThisTurn.map((t) => t.name)); }
1619
- catch (e) { /* best-effort */ }
1708
+ if (tracksMainRun) {
1709
+ sideChatCoordinator.noteMainResult(runContext, result);
1710
+ for (const item of state.messageQueue) {
1711
+ finalizeSideChatQueueItem(item, runContext, result);
1620
1712
  }
1621
-
1622
- // Tool usage telemetry: a JSON sidecar (runCount + lastUsed per tool) that
1623
- // works on every node version (unlike the SQLite graphs above). matchTools
1624
- // breaks score ties by runCount so well-worn tools surface first, and dream
1625
- // hygiene flags tools created-but-never-run. Fires for any successful run.
1626
- if (turnSucceeded && toolRunsThisTurn.length) {
1627
- try { for (const t of toolRunsThisTurn) toolsLib.recordRun(t.name); }
1628
- 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;
1629
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
+ }
1630
1732
 
1631
- // Pack↔tool contextual edges: a reusable tool run while a context pack was
1632
- // open this turn records "this tool was used in this topic, as `<shape>`",
1633
- // so next time the pack is active the discoverer can surface the concrete
1634
- // invocation instead of leaving the agent to re-derive it. Fires even for a
1635
- // single run (one tool-in-a-pack is still a usage) — unlike the follows-graph
1636
- // above which needs a succession. Best-effort; never blocks the turn.
1637
- if (turnSucceeded && toolRunsThisTurn.length && openedThisTurn.size) {
1638
- try {
1639
- const tg = require("./tool-graph");
1640
- const packDirs = [...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5));
1641
- for (const dir of packDirs) {
1642
- for (const run of toolRunsThisTurn) tg.recordPackTool(dir, run.name, { command: run.shape, bump: 1 });
1643
- }
1644
- } catch (e) { /* best-effort */ }
1645
- }
1733
+ function admissionOptions(state, opts = {}) {
1734
+ const provider = opts.provider || state.settings?.backend || null;
1735
+ return { ...opts, provider };
1736
+ }
1646
1737
 
1647
- // Close the learning loop: when an ABILITY pack is opened in the same turn
1648
- // as a project (context) pack, the ability was demonstrably applied while
1649
- // working on that project. recordCoUse grows the ability's applied_on, which
1650
- // forms the project→ability governed-by edge on the next structural sync —
1651
- // so reuse transfers AUTOMATICALLY from actual use, without waiting on the
1652
- // reviewer to infer it. This runs before the (async) reviewer, so it wins
1653
- // the race and the reviewer sees applied_on already set (no double-announce).
1654
- if (turnSucceeded && openedThisTurn.size > 1) {
1655
- try {
1656
- for (const t of packsLib.recordCoUse([...openedThisTurn])) {
1657
- notifySkill(`applied:${t.ability}:${t.project}`,
1658
- `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} — it transfers there now too.`);
1659
- }
1660
- } catch (e) { /* best-effort */ }
1661
- }
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
+ }
1754
+
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
+ });
1662
1766
 
1663
- // Post-turn pack review: fire-and-forget on a cheap model; never
1664
- // blocks queue drain or the next turn. Skipped when the reply was held by
1665
- // the external guardrail — a blocked turn must not accrete into memory.
1666
- if ((code === 0 || code === null) && assistantText.trim() && !suppressDelivery) {
1667
- const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "Bash", "Shell", "Skill", "Task", "Agent"]);
1668
- packReview.reviewTurn({
1669
- userText: prompt,
1670
- assistantText,
1671
- channelId,
1672
- // Packs this turn demonstrably worked with: surfaced by recall for the
1673
- // incoming message, or explicitly opened by the agent. The reviewer
1674
- // sees their full current sections and must attribute updates here —
1675
- // prevents cross-project misattribution and blind State rewrites.
1676
- activePacks: [...new Set([
1677
- ...surfacedPackDirs,
1678
- ...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
1679
- ])],
1680
- signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
1681
- // Provenance (2.5): who contributed this turn, so accreted Log/Journal
1682
- // lines from a non-owner get stamped "(via <name>)". Resolved from the
1683
- // live store here because the reviewer applies in an async continuation.
1684
- source: (() => {
1685
- try {
1686
- const sp = require("./people").findByHandle(store.adapter?.type, store.channelId);
1687
- if (sp) return { name: sp.name, isOwner: !!sp.isOwner };
1688
- } catch (e) {}
1689
- return null;
1690
- })(),
1691
- announce: (text) => chatContext.run(store, () => send(text)),
1767
+ if (!acquireRunLock(state)) {
1768
+ if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
1769
+ return sideChatCoordinator.respond({
1770
+ runContext,
1771
+ activeRunContext: state.activeRunContext,
1772
+ store,
1692
1773
  });
1693
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
+ }
1694
1782
 
1695
- if (state.lastSessionId && state.currentSession) {
1696
- const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
1697
- recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
1698
- state.isFirstMessage = false;
1699
- }
1783
+ function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
1784
+ return !!state?.runningProcess
1785
+ && opts.sideChat !== false
1786
+ && runContext?.purpose === "foreground"
1787
+ && coordinator.enabled();
1788
+ }
1700
1789
 
1701
- if (state.messageQueue.length === 0 && state.currentSession && shouldAutoCompact(state)) {
1702
- try {
1703
- await compactActiveSession(state.currentSession.dir, { notify: false });
1704
- } catch (e) {
1705
- console.warn(`[proactive-compact] failed: ${redactSensitive(e.message)}`);
1706
- }
1707
- }
1790
+ function runSideChatRequest(request, dependencies = {}) {
1791
+ return createSideChatService(dependencies).run(request);
1792
+ }
1708
1793
 
1709
- await drainQueuedMessages();
1710
- }));
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
+ }
1711
1806
 
1712
- proc.on("error", (err) => chatContext.run(store, async () => {
1713
- state.runningProcess = null;
1714
- state.preparingRun = false;
1715
- stopTyping();
1716
- clearTimeout(state.streamInterval);
1717
- clearTimeout(processTimeout);
1718
- await send(`Error: ${err.message}`);
1719
- state.statusMessageId = null;
1720
- }));
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
+ };
1721
1822
  }
1722
1823
 
1723
- async function runClaudeSilent(prompt, cwd, label) {
1724
- const dynamicPrompt = await promptWithDynamicContext(prompt);
1725
- return new Promise((resolve) => {
1726
- const args = ["-p", "--output-format", "text", "--verbose",
1727
- "--append-system-prompt", buildSystemPrompt(),
1728
- "--dangerously-skip-permissions", dynamicPrompt];
1729
- const proc = spawn(CLAUDE_PATH, args, {
1730
- cwd, env: claudeSubprocessEnv(),
1731
- stdio: ["ignore", "pipe", "pipe"],
1732
- });
1733
- let output = "";
1734
- proc.stdout.on("data", (d) => { output += d.toString(); });
1735
- proc.on("close", async () => {
1736
- const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
1737
- for (const c of chunks) await send(c);
1738
- resolve();
1739
- });
1740
- 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,
1741
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" });
1742
1867
  }
1743
1868
 
1744
1869
  module.exports = {
1745
- runClaude,
1746
- runClaudeCapture,
1747
- 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,
1748
1885
  compactActiveSession,
1749
- buildSystemPrompt,
1886
+ createCompactionService,
1887
+ deriveCompactionRunContext,
1750
1888
  getActiveSessionId,
1751
- getActiveBinary,
1752
- getActiveSessionKey,
1753
1889
  shouldAutoCompact,
1754
1890
  effectiveCompactThreshold,
1755
1891
  usageParts,
@@ -1761,7 +1897,4 @@ module.exports = {
1761
1897
  archiveCompactionBrief,
1762
1898
  collectRepoStateFacts,
1763
1899
  collectRecentTurns,
1764
- formatProgress,
1765
- preflightClaudeAuthMessage,
1766
- claudeEmptyFailureMessage,
1767
1900
  };