@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- package/web.js +159 -45
package/core/runner.js
CHANGED
|
@@ -1,36 +1,43 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
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 {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
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
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const previous =
|
|
94
|
-
|
|
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 =
|
|
120
|
+
const backend = opts.backend || getActiveProvider(state);
|
|
116
121
|
const parts = usageParts(usage, backend);
|
|
117
|
-
const
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
}
|
|
227
|
-
|
|
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
|
|
231
|
-
|
|
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
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
}
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
817
|
-
if (
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
state.
|
|
869
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
-
|
|
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
|
};
|