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