shraga 0.1.110 → 0.1.112
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/README.md +2 -1
- package/defaults/skills/mcp-server.md +2 -0
- package/defaults/skills/platform.md +3 -0
- package/defaults/skills/self-aware.md +1 -1
- package/dist/client/assets/{index-BaZu5Ojo.js → index-Dc1ljSt3.js} +2 -2
- package/dist/client/index.html +1 -1
- package/package.json +2 -2
- package/src/mcp-stdio-bridge.ts +25 -9
- package/src/server/claude.ts +15 -2
- package/src/server/data-sync.ts +7 -10
- package/src/server/directives.ts +8 -1
- package/src/server/engine/claude-code.ts +153 -18
- package/src/server/engine/claude-resume.ts +205 -0
- package/src/server/engine/types.ts +3 -0
- package/src/server/integrity-audit.ts +117 -46
- package/src/server/sessions.ts +16 -9
- package/src/server/shraga-config.ts +3 -0
package/dist/client/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-Dc1ljSt3.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-DIMte_k6.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.112",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"prepublishOnly": "bun run guard:publish && bun run build"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@anthropic-ai/claude-agent-sdk": "^0.
|
|
58
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.272",
|
|
59
59
|
"@livx.cc/mcp-firebase": "^0.1.14",
|
|
60
60
|
"@radix-ui/react-accordion": "^1.2.2",
|
|
61
61
|
"@radix-ui/react-dialog": "^1.1.4",
|
package/src/mcp-stdio-bridge.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// MCP_EXTRA_HEADERS — optional JSON object of extra headers sent with every request,
|
|
5
5
|
// for servers that need a second credential beyond the bearer token (e.g. an
|
|
6
6
|
// upstream app behind a proxy that gates its own admin ops on its own header).
|
|
7
|
+
// MCP_BRIDGE_TIMEOUT_MS — per-request bound for protocol calls (initialize, tools/list…), default 60s.
|
|
8
|
+
// MCP_BRIDGE_TOOL_TIMEOUT_MS — bound for tools/call, default 30min (a sync post_chat turn has no
|
|
9
|
+
// server-side wall clock). Without a bound, a frozen server hung the client forever on "connecting…".
|
|
10
|
+
const envMs = (v: string | undefined, fallback: number) => (Number(v) > 0 ? Number(v) : fallback);
|
|
11
|
+
const REQUEST_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TIMEOUT_MS, 60_000);
|
|
12
|
+
const TOOL_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TOOL_TIMEOUT_MS, 30 * 60_000);
|
|
7
13
|
const baseUrl = (process.env.SHRAGA_URL || process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
|
|
8
14
|
const mcpPath = process.env.SHRAGA_MCP_PATH || process.env.MCP_PATH || '/mcp';
|
|
9
15
|
const apiKey = process.env.SHRAGA_API_KEY || process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
|
|
@@ -33,11 +39,23 @@ async function sendMessage(message: any): Promise<void> {
|
|
|
33
39
|
if (sessionId) headers['mcp-session-id'] = sessionId;
|
|
34
40
|
Object.assign(headers, extraHeaders);
|
|
35
41
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
const limit = message?.method === 'tools/call' ? TOOL_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
|
|
43
|
+
// The signal also bounds the body read below, not just the headers.
|
|
44
|
+
const signal = AbortSignal.timeout(limit);
|
|
45
|
+
let res: Response;
|
|
46
|
+
let text: string;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(`${baseUrl}${mcpPath}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify(message),
|
|
52
|
+
signal,
|
|
53
|
+
});
|
|
54
|
+
text = res.status === 204 ? '' : await res.text();
|
|
55
|
+
} catch (e: any) {
|
|
56
|
+
if (signal.aborted) throw new Error(`no response from ${baseUrl} for ${message?.method ?? 'message'} within ${limit}ms`);
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
41
59
|
|
|
42
60
|
// Capture session ID from server on initialize
|
|
43
61
|
const newSession = res.headers.get('mcp-session-id');
|
|
@@ -49,7 +67,6 @@ async function sendMessage(message: any): Promise<void> {
|
|
|
49
67
|
|
|
50
68
|
if (contentType.includes('text/event-stream')) {
|
|
51
69
|
// SSE: parse and forward each data line
|
|
52
|
-
const text = await res.text();
|
|
53
70
|
for (const line of text.split('\n')) {
|
|
54
71
|
if (line.startsWith('data: ')) {
|
|
55
72
|
const data = line.slice(6).trim();
|
|
@@ -58,9 +75,8 @@ async function sendMessage(message: any): Promise<void> {
|
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
|
-
} else {
|
|
62
|
-
|
|
63
|
-
if (body) process.stdout.write(body + '\n');
|
|
78
|
+
} else if (text) {
|
|
79
|
+
process.stdout.write(text + '\n');
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
|
package/src/server/claude.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { summarizeText } from './summarize.ts';
|
|
|
3
3
|
import { dataSync } from './data-sync.ts';
|
|
4
4
|
import type { McpConfig } from './mcp.ts';
|
|
5
5
|
import type { ClaudeAccountRef } from './claude-account.ts';
|
|
6
|
-
import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
|
|
6
|
+
import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, setClaudeResume, type ConvMessage, type ConvBlock } from './sessions.ts';
|
|
7
7
|
import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
|
|
8
8
|
import {
|
|
9
9
|
resolveDefaultSkillsContent,
|
|
@@ -324,7 +324,10 @@ export async function* streamChat(opts: {
|
|
|
324
324
|
const stickyNames = opts.sessionId ? getSession(opts.sessionId)?.triggeredSkills ?? [] : [];
|
|
325
325
|
const newTriggerNames = discoveryEnabled ? matchTriggeredSkillNames(effectivePrompt, opts.context) : [];
|
|
326
326
|
const triggeredNames = [...new Set([...stickyNames, ...newTriggerNames])];
|
|
327
|
-
|
|
327
|
+
// Per-name blocks so an engine that resumes across turns can send only newly triggered ones. Joined
|
|
328
|
+
// exactly as skillInjectionBlocks(triggeredNames) joins them, so contextBlock is byte-identical.
|
|
329
|
+
const triggeredSkillBlocks = triggeredNames.map((n) => [n, skillInjectionBlocks([n])] as const).filter(([, b]) => b);
|
|
330
|
+
const triggeredSkills = triggeredSkillBlocks.map(([, b]) => b).join('\n');
|
|
328
331
|
|
|
329
332
|
// Per-skill turn budget. Resolved HERE, once every invoked skill is known (the slash command and
|
|
330
333
|
// the triggered/sticky set), and GAP-FILLING only: an inline `[turns:N]` or a session-pinned
|
|
@@ -347,6 +350,11 @@ export async function* streamChat(opts: {
|
|
|
347
350
|
const teamRoster = contacts.formatRoster();
|
|
348
351
|
const userContextBlock = getUserContextBlock(contact);
|
|
349
352
|
const contextBlock = [userBlock, userContextBlock, teamRoster, defaultSkills, triggeredSkills, skillIndex, mcpSkills, workspaceTree].filter(Boolean).join('\n');
|
|
353
|
+
const contextSections: Record<string, string> = Object.fromEntries(Object.entries({
|
|
354
|
+
user: userBlock, userContext: userContextBlock, roster: teamRoster, defaultSkills,
|
|
355
|
+
...Object.fromEntries(triggeredSkillBlocks.map(([n, b]) => [`skill:${n}`, b])),
|
|
356
|
+
skillIndex, mcpSkills, workspace: workspaceTree,
|
|
357
|
+
}).filter(([, v]) => v));
|
|
350
358
|
|
|
351
359
|
// Load conversation for the engine
|
|
352
360
|
const sessionId = opts.sessionId ?? crypto.randomUUID();
|
|
@@ -388,11 +396,16 @@ export async function* streamChat(opts: {
|
|
|
388
396
|
return;
|
|
389
397
|
}
|
|
390
398
|
console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
|
|
399
|
+
// Another engine's turn never reaches the claude-code transcript, so a stored SDK-resume mapping is
|
|
400
|
+
// stale from here on — mark it; the claude-code engine then falls back to a fresh query (engine-switch).
|
|
401
|
+
const resumeState = getSession(sessionId)?.claudeResume;
|
|
402
|
+
if (engine.name !== 'claude-code' && resumeState && !resumeState.interruptedBy) setClaudeResume(sessionId, undefined, { interruptedBy: engine.name });
|
|
391
403
|
|
|
392
404
|
yield* engine.stream({
|
|
393
405
|
prompt: turnPrompt,
|
|
394
406
|
conversation,
|
|
395
407
|
contextBlock,
|
|
408
|
+
contextSections,
|
|
396
409
|
attachments: opts.attachments,
|
|
397
410
|
images: opts.images,
|
|
398
411
|
sessionId,
|
package/src/server/data-sync.ts
CHANGED
|
@@ -213,30 +213,27 @@ export class DataSync {
|
|
|
213
213
|
} catch (err) {
|
|
214
214
|
console.error(`${TAG} Boot sync FAILED — serving stale data until the next pull:`, (err as Error).message);
|
|
215
215
|
}
|
|
216
|
-
// Defer
|
|
217
|
-
// WS connections and page loads
|
|
216
|
+
// Defer the post-boot scans (both async, but they still read/spawn a lot) past startup
|
|
217
|
+
// traffic — WS connections and page loads.
|
|
218
218
|
setTimeout(() => {
|
|
219
219
|
this.scanForConflictMarkers()
|
|
220
220
|
.catch(err => console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message))
|
|
221
|
-
// execSync inside the audit blocks too — keep it off the scan's tick so the two
|
|
222
|
-
// never add up into one long freeze.
|
|
223
|
-
.then(() => new Promise<void>(r => setImmediate(r)))
|
|
224
221
|
.then(() => this.runIntegrityAudit())
|
|
225
222
|
.catch(err => console.warn(`${TAG} Post-init integrity audit failed:`, (err as Error).message));
|
|
226
223
|
}, 60_000);
|
|
227
224
|
}
|
|
228
225
|
|
|
229
|
-
/** Compare HEAD against HEAD~1 to catch regressions. Notifies owner — never auto-reverts.
|
|
230
|
-
|
|
226
|
+
/** Compare HEAD against HEAD~1 to catch regressions. Notifies owner — never auto-reverts.
|
|
227
|
+
* Must stay async: the old execSync-per-file audit froze the event loop ~5 min on prod. */
|
|
228
|
+
private async runIntegrityAudit(): Promise<void> {
|
|
231
229
|
try {
|
|
232
|
-
const { audit } =
|
|
230
|
+
const { audit } = await import('./integrity-audit.ts');
|
|
233
231
|
// Churn paths are exempt for the same reason the shrink guard exempts them: a per-run worker
|
|
234
232
|
// log is written by one leg, rewritten by the next, and truncated whenever a run is killed
|
|
235
233
|
// mid-write. "invalid-json" on one of those is the normal end of an interrupted run, not
|
|
236
234
|
// corruption of shared data — and since the file stays in HEAD, the audit re-reported it on
|
|
237
235
|
// every sync. The audit exists to catch contacts.json being gutted.
|
|
238
|
-
const issues = (audit('HEAD~1')
|
|
239
|
-
.filter(i => !isChurnPath(i.file));
|
|
236
|
+
const issues = (await audit('HEAD~1', DATA_DIR)).filter(i => !isChurnPath(i.file));
|
|
240
237
|
if (issues.length) {
|
|
241
238
|
console.warn(`${TAG} ⚠️ DATA INTEGRITY: ${issues.length} issue(s) detected after sync:`);
|
|
242
239
|
for (const { kind, file, detail } of issues) {
|
package/src/server/directives.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface Directives {
|
|
|
4
4
|
thinking?: 'adaptive' | 'enabled' | 'disabled';
|
|
5
5
|
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
6
6
|
engine?: string;
|
|
7
|
+
/** claude-code SDK session resume for this conversation (overrides agent-config `sdkResume`). */
|
|
8
|
+
resume?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export interface ParsedPrompt {
|
|
@@ -28,7 +30,7 @@ import { MODEL_ALIASES } from './model-aliases.ts';
|
|
|
28
30
|
|
|
29
31
|
const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;
|
|
30
32
|
|
|
31
|
-
const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine'];
|
|
33
|
+
const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine', 'resume'];
|
|
32
34
|
|
|
33
35
|
/** MODEL_ALIASES only covers the bare Anthropic shorthands. A `provider/model` id
|
|
34
36
|
* (`cursor/composer-2.5`, `openai/gpt-5.6`) is already concrete — gating it on the alias table
|
|
@@ -179,6 +181,11 @@ function applyDirective(d: Directives, key: string, val: string): string | undef
|
|
|
179
181
|
case 'engine':
|
|
180
182
|
d.engine = val;
|
|
181
183
|
break;
|
|
184
|
+
case 'resume':
|
|
185
|
+
if (['on', 'true', '1'].includes(val)) d.resume = true;
|
|
186
|
+
else if (['off', 'false', '0'].includes(val)) d.resume = false;
|
|
187
|
+
else console.warn(`[directives] Invalid resume value: "${val}"`);
|
|
188
|
+
break;
|
|
182
189
|
default:
|
|
183
190
|
console.warn(`[directives] Unknown directive key: "${key}"`);
|
|
184
191
|
}
|
|
@@ -8,7 +8,12 @@ import { listSkills } from '../skills.ts';
|
|
|
8
8
|
import { loadAgents } from '../agents.ts';
|
|
9
9
|
import { registerProactiveMessage } from '../slack/sessions.ts';
|
|
10
10
|
import { registerPoll } from '../polls.ts';
|
|
11
|
-
import { getSession, setSessionModel, getSessionModel, type ConvMessage } from '../sessions.ts';
|
|
11
|
+
import { getSession, setSessionModel, getSessionModel, setClaudeResume, type ConvMessage } from '../sessions.ts';
|
|
12
|
+
import {
|
|
13
|
+
isResumeEnabled, decideClaudeTurn, claudeConfigDir, findClaudeTranscript, shortHash, sectionHashes, speakerKey,
|
|
14
|
+
sectionsAfterSubmit, isResumeFailure, conversationSummaryKey, buildContextDelta, buildResumePrompt, renderConvMessage,
|
|
15
|
+
type TurnPath, type ClaudeResumeState,
|
|
16
|
+
} from './claude-resume.ts';
|
|
12
17
|
import { DEFAULT_MODEL } from '../directives.ts';
|
|
13
18
|
import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
|
|
14
19
|
import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
|
|
@@ -95,15 +100,8 @@ function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPromp
|
|
|
95
100
|
const parts: string[] = [];
|
|
96
101
|
if (summary) parts.push(`<conversation_summary>\n${summary}\n</conversation_summary>`);
|
|
97
102
|
for (const m of recent) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
.filter((b) => b.type === 'text' || b.type === 'context')
|
|
101
|
-
.map((b) => {
|
|
102
|
-
if (b.type === 'context') return `[${(b as any).label}]: ${(b as any).text}`;
|
|
103
|
-
return (b as { type: 'text'; text: string }).text;
|
|
104
|
-
})
|
|
105
|
-
.filter(Boolean);
|
|
106
|
-
if (texts.length) parts.push(`${role}: ${texts.join('\n')}`);
|
|
103
|
+
const line = renderConvMessage(m);
|
|
104
|
+
if (line) parts.push(line);
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
if (parts.length) {
|
|
@@ -117,10 +115,11 @@ function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPromp
|
|
|
117
115
|
* Log prompt-cache effectiveness from the SDK result `usage`. The hit rate is
|
|
118
116
|
* cache_read / (cache_read + cache_creation + uncached input) — a low rate over
|
|
119
117
|
* many turns points to a silent prefix invalidator or sessions spread past the
|
|
120
|
-
* 5-min cache TTL.
|
|
121
|
-
*
|
|
118
|
+
* 5-min cache TTL. `path` says how the prompt was built — `fresh` (history re-sent in one message),
|
|
119
|
+
* `resume` (SDK session resumed, only the new message sent) or `fallback:<reason>` (resume enabled but a
|
|
120
|
+
* fresh query ran) — so journal lines can be A/B-compared per path.
|
|
122
121
|
*/
|
|
123
|
-
function logCacheUsage(usage: any, model: string): void {
|
|
122
|
+
function logCacheUsage(usage: any, model: string, path: TurnPath): void {
|
|
124
123
|
if (!usage) return;
|
|
125
124
|
const read = usage.cache_read_input_tokens ?? 0;
|
|
126
125
|
const created = usage.cache_creation_input_tokens ?? 0;
|
|
@@ -128,9 +127,47 @@ function logCacheUsage(usage: any, model: string): void {
|
|
|
128
127
|
const totalIn = read + created + fresh;
|
|
129
128
|
if (totalIn === 0) return;
|
|
130
129
|
const hitRate = ((read / totalIn) * 100).toFixed(1);
|
|
131
|
-
console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model}`);
|
|
130
|
+
console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model} path=${path}`);
|
|
132
131
|
}
|
|
133
132
|
|
|
133
|
+
/** How one query attempt is built. `persist` is set when resume is enabled: after a query that produced
|
|
134
|
+
* output, the CC session id + these facts are saved so the next turn can resume. */
|
|
135
|
+
interface RunPlan {
|
|
136
|
+
path: TurnPath;
|
|
137
|
+
prompt: string;
|
|
138
|
+
accountDir: string | null;
|
|
139
|
+
resumeId?: string;
|
|
140
|
+
persist?: Omit<ClaudeResumeState, 'claudeSessionId' | 'model' | 'interruptedBy'>;
|
|
141
|
+
/** Resume only: section hashes to store once the prompt is submitted (see sectionsAfterSubmit). */
|
|
142
|
+
submitSections?: Record<string, string>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Exit promises of CLI processes still running, per shraga session. A turn that took over a session (external
|
|
146
|
+
* steer) can start while the aborted run's CLI is still exiting; resuming then would put two writers on one
|
|
147
|
+
* transcript. */
|
|
148
|
+
const liveCli = new Map<string, Set<Promise<void>>>();
|
|
149
|
+
|
|
150
|
+
function trackCliExit(sessionId: string | undefined, child: { once: (event: string, fn: () => void) => unknown }): void {
|
|
151
|
+
if (!sessionId) return;
|
|
152
|
+
const set = liveCli.get(sessionId) ?? new Set();
|
|
153
|
+
liveCli.set(sessionId, set);
|
|
154
|
+
const exited = new Promise<void>((resolve) => { child.once('exit', resolve); child.once('error', resolve); });
|
|
155
|
+
set.add(exited);
|
|
156
|
+
void exited.then(() => { set.delete(exited); if (!set.size && liveCli.get(sessionId) === set) liveCli.delete(sessionId); });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** true once every CLI process of the session has exited, false if one is still alive after `ms`. */
|
|
160
|
+
async function cliExited(sessionId: string, ms: number): Promise<boolean> {
|
|
161
|
+
const set = liveCli.get(sessionId);
|
|
162
|
+
if (!set?.size) return true;
|
|
163
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
164
|
+
const timeout = new Promise<false>((r) => { timer = setTimeout(() => r(false), ms); });
|
|
165
|
+
try { return await Promise.race([Promise.all(set).then(() => true), timeout]); } finally { clearTimeout(timer); }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Events that prove a resumed query actually runs; until one arrives the attempt can still be retried fresh unseen. */
|
|
169
|
+
const LIVE_EVENTS = new Set<WsEvent['type']>(['text_delta', 'thinking_delta', 'tool_use', 'tool_use_input', 'tool_result', 'tool_result_image', 'done']);
|
|
170
|
+
|
|
134
171
|
/** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
|
|
135
172
|
* call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
|
|
136
173
|
function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
|
|
@@ -221,11 +258,75 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
221
258
|
];
|
|
222
259
|
}
|
|
223
260
|
|
|
261
|
+
/** How long a resume-enabled turn waits for an earlier CLI process on the session to exit before going fresh. */
|
|
262
|
+
static cliExitWaitMs = 5_000;
|
|
263
|
+
|
|
224
264
|
async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
|
|
265
|
+
let cliAlive = false;
|
|
266
|
+
if (opts.sessionId && isResumeEnabled(opts.directives, opts.config) && !(await cliExited(opts.sessionId, ClaudeCodeEngine.cliExitWaitMs))) {
|
|
267
|
+
cliAlive = true;
|
|
268
|
+
console.warn(`[claude] An earlier CLI process on session=${opts.sessionId} is still running after ${ClaudeCodeEngine.cliExitWaitMs}ms — not resuming its transcript`);
|
|
269
|
+
}
|
|
270
|
+
const plan = this.plan(opts, cliAlive);
|
|
271
|
+
if (plan.path !== 'resume') { yield* this.run(opts, plan); return; }
|
|
272
|
+
// Hold non-output events (init's model_resolved / switch notice) until the resumed query proves it
|
|
273
|
+
// runs, so a failed resume can be retried fresh with nothing duplicated on the user's side.
|
|
274
|
+
const attempt = this.run(opts, plan);
|
|
275
|
+
const held: WsEvent[] = [];
|
|
276
|
+
let live = false;
|
|
277
|
+
let r: IteratorResult<WsEvent, 'resume-failed' | void>;
|
|
278
|
+
try {
|
|
279
|
+
while (!(r = await attempt.next()).done) {
|
|
280
|
+
if (!live && !LIVE_EVENTS.has(r.value.type)) { held.push(r.value); continue; }
|
|
281
|
+
if (!live) { live = true; yield* held; }
|
|
282
|
+
yield r.value;
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
await attempt.return(undefined);
|
|
286
|
+
}
|
|
287
|
+
if (r.value !== 'resume-failed') { if (!live) yield* held; return; }
|
|
288
|
+
console.warn(`[claude] Resume of ${plan.resumeId} failed before any output — retrying fresh in the same turn (session=${opts.sessionId})`);
|
|
289
|
+
setClaudeResume(opts.sessionId, undefined);
|
|
290
|
+
yield* this.run(opts, this.freshPlan(opts, plan.accountDir, 'fallback:resume-failed', plan.persist));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private freshPlan(opts: EngineStreamOpts, accountDir: string | null, path: TurnPath, persist?: RunPlan['persist']): RunPlan {
|
|
294
|
+
return {
|
|
295
|
+
path, accountDir,
|
|
296
|
+
prompt: buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt),
|
|
297
|
+
...(persist ? { persist: { ...persist, sections: sectionHashes(opts.contextSections ?? { context: opts.contextBlock }), startedAt: Date.now() } } : {}),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Resume vs fresh, and why — see claude-resume.ts. */
|
|
302
|
+
private plan(opts: EngineStreamOpts, cliAlive: boolean): RunPlan {
|
|
303
|
+
const accountDir = claudeAccountDir(opts.userEmail);
|
|
304
|
+
if (!isResumeEnabled(opts.directives, opts.config)) return this.freshPlan(opts, accountDir, 'fresh');
|
|
305
|
+
const configDir = claudeConfigDir(accountDir);
|
|
306
|
+
const configDirHash = shortHash(configDir);
|
|
307
|
+
const state = opts.sessionId ? getSession(opts.sessionId)?.claudeResume : undefined;
|
|
308
|
+
const speaker = speakerKey(opts.uid, opts.userEmail);
|
|
309
|
+
const persist = { configDirHash, speaker, markId: opts.conversation.at(-1)?.id, summaryKey: conversationSummaryKey(opts.conversation), sections: {}, startedAt: Date.now() };
|
|
310
|
+
const decision = decideClaudeTurn({
|
|
311
|
+
enabled: true, state, conversation: opts.conversation, configDirHash, speaker, cliAlive, conversationReset: opts.conversationReset,
|
|
312
|
+
hasTranscript: (id) => !!findClaudeTranscript(id, configDir),
|
|
313
|
+
});
|
|
314
|
+
if (decision.path !== 'resume' || !state) return this.freshPlan(opts, accountDir, decision.path, persist);
|
|
315
|
+
const sections = opts.contextSections ?? { context: opts.contextBlock };
|
|
316
|
+
const hashes = sectionHashes(sections);
|
|
317
|
+
return {
|
|
318
|
+
path: 'resume', accountDir, resumeId: state.claudeSessionId,
|
|
319
|
+
prompt: buildResumePrompt(opts.prompt, decision.unseen, buildContextDelta(state.sections, sections)),
|
|
320
|
+
persist: { ...persist, sections: hashes, startedAt: state.startedAt },
|
|
321
|
+
submitSections: sectionsAfterSubmit(state.sections, hashes),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async *run(opts: EngineStreamOpts, plan: RunPlan): AsyncGenerator<WsEvent, 'resume-failed' | void> {
|
|
225
326
|
const { config, directives } = opts;
|
|
226
327
|
const cwd = APP_ROOT;
|
|
227
328
|
|
|
228
|
-
const fullPrompt =
|
|
329
|
+
const fullPrompt = plan.prompt;
|
|
229
330
|
const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
|
|
230
331
|
|
|
231
332
|
const sdkEnv: Record<string, string> = {};
|
|
@@ -247,8 +348,11 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
247
348
|
const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
|
|
248
349
|
setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
|
|
249
350
|
setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
|
|
351
|
+
// The subscription login's claude.ai connectors (Gmail/Calendar/… and this agent's own MCP) load
|
|
352
|
+
// regardless of settingSources; headless they are auth stubs or self-recursion. Our MCPs come from mcp-config.
|
|
353
|
+
setIfBlank('ENABLE_CLAUDEAI_MCP_SERVERS', 'false');
|
|
250
354
|
// Per-user subscription (workspace/users/<contactId>/.claude): run on that login, never the box's credentials.
|
|
251
|
-
const accountDir =
|
|
355
|
+
const accountDir = plan.accountDir;
|
|
252
356
|
if (accountDir) applyClaudeAccount(sdkEnv, accountDir);
|
|
253
357
|
// Which login this run is on (email/plan, never a token) — read lazily from the login's local
|
|
254
358
|
// files, only once init proves the run is on a subscription login (see init below).
|
|
@@ -256,6 +360,10 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
256
360
|
const accountRef = () => (accountRefP ??= claudeAccountRef(accountDir));
|
|
257
361
|
let runAccount: ClaudeAccountRef | undefined;
|
|
258
362
|
let sawInit = false;
|
|
363
|
+
// Did THIS attempt produce model output? Gates both the resume-failure retry and saving the session id
|
|
364
|
+
// (a resume that died on "No conversation found" can report a session id it never wrote a turn to).
|
|
365
|
+
let sawOutput = false;
|
|
366
|
+
let initModel: string | undefined;
|
|
259
367
|
sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
|
|
260
368
|
|
|
261
369
|
const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
|
|
@@ -267,6 +375,10 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
267
375
|
env: sdkEnv,
|
|
268
376
|
allowedTools,
|
|
269
377
|
cwd,
|
|
378
|
+
// Omitted = ALL sources, so the host's ~/.claude (CLAUDE.md, MCP instructions, hooks) rode into every
|
|
379
|
+
// query — ~24k tokens re-written to cache per fresh turn on a dev box. Keep 'project': the deployment
|
|
380
|
+
// ships its own .claude/skills (e.g. shraga-circles' video-ad-pipeline) that the agent relies on.
|
|
381
|
+
settingSources: ['project'],
|
|
270
382
|
permissionMode: permMode === 'bypassPermissions' ? 'acceptEdits' : permMode,
|
|
271
383
|
maxTurns,
|
|
272
384
|
includePartialMessages: true,
|
|
@@ -350,13 +462,18 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
350
462
|
const addonSuffix = getPromptSuffix(opts.turnHints);
|
|
351
463
|
options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
|
|
352
464
|
if (opts.abortController) options['abortController'] = opts.abortController;
|
|
465
|
+
if (plan.resumeId) options['resume'] = plan.resumeId;
|
|
353
466
|
// Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
|
|
354
467
|
// (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
|
|
355
468
|
// dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
|
|
356
469
|
// server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
|
|
357
470
|
// already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
|
|
358
471
|
// Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
|
|
359
|
-
options['spawnClaudeCodeProcess'] = spawnDetached
|
|
472
|
+
options['spawnClaudeCodeProcess'] = (cfg: Parameters<typeof spawnDetached>[0]) => {
|
|
473
|
+
const child = spawnDetached(cfg);
|
|
474
|
+
trackCliExit(opts.sessionId, child);
|
|
475
|
+
return child;
|
|
476
|
+
};
|
|
360
477
|
|
|
361
478
|
// Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
|
|
362
479
|
// server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
|
|
@@ -384,6 +501,8 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
384
501
|
: fullPrompt;
|
|
385
502
|
|
|
386
503
|
const q = query({ prompt, options: options as any });
|
|
504
|
+
// The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
|
|
505
|
+
if (plan.submitSections && opts.sessionId) setClaudeResume(opts.sessionId, undefined, { sections: plan.submitSections });
|
|
387
506
|
|
|
388
507
|
let lastSessionId = '';
|
|
389
508
|
let messageCount = 0;
|
|
@@ -442,6 +561,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
442
561
|
// default only counts when the SDK says it resolved a login, not an API key.
|
|
443
562
|
if ((authSource ?? (accountDir ? 'subscription' : undefined)) === 'subscription') runAccount = await accountRef();
|
|
444
563
|
if (m.model) {
|
|
564
|
+
initModel = m.model;
|
|
445
565
|
console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
|
|
446
566
|
// If the user explicitly asked to switch models via a [directive], announce the change
|
|
447
567
|
// inline so the response confirms the switch took effect.
|
|
@@ -487,7 +607,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
487
607
|
const raw = m.subtype ?? 'unknown';
|
|
488
608
|
const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
|
|
489
609
|
console.log(`[claude] Result: subtype=${m.subtype}→${sub} session=${lastSessionId} turns=${sdkTurns}/${maxTurns} cost=$${m.total_cost_usd?.toFixed(4) ?? '?'} msgs=${messageCount} deltas=${textDeltaCount} (${elapsed()})`);
|
|
490
|
-
logCacheUsage(m.usage, activeModel);
|
|
610
|
+
logCacheUsage(m.usage, activeModel, plan.path);
|
|
491
611
|
if (outstandingTasks.size > 0) {
|
|
492
612
|
if (!waitingForBg) {
|
|
493
613
|
waitingForBg = true; clearBgTimer();
|
|
@@ -507,6 +627,12 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
507
627
|
// `error_max_turns` also sets is_error but is a benign stop reason we already model.
|
|
508
628
|
if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
|
|
509
629
|
const detail = typeof m.result === 'string' && m.result ? m.result : `api_error_status=${m.api_error_status ?? '?'}`;
|
|
630
|
+
// The CLI's reason ("No conversation found with session ID …") rides in `errors`, not `result`.
|
|
631
|
+
const reasons = Array.isArray(m.errors) && m.errors.length ? m.errors.join('; ') : detail;
|
|
632
|
+
if (plan.resumeId && !sawOutput && isResumeFailure(reasons)) {
|
|
633
|
+
console.error(`[claude] Resume error result (subtype=${raw}) — ${reasons}`);
|
|
634
|
+
return 'resume-failed';
|
|
635
|
+
}
|
|
510
636
|
// A usage/rate-limit failure is about ONE login's quota — name it, or a routed user's own
|
|
511
637
|
// exhausted Pro plan reads as the shared agent subscription being out.
|
|
512
638
|
const limitHit = m.api_error_status === 429 || /\b(usage|rate.?limit(ed)?|hit your limit)\b/i.test(detail);
|
|
@@ -525,10 +651,12 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
525
651
|
if (m.type === 'stream_event') {
|
|
526
652
|
const event = m.event;
|
|
527
653
|
if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
|
|
654
|
+
sawOutput = true;
|
|
528
655
|
yield { type: 'thinking_delta', text: event.delta.thinking };
|
|
529
656
|
}
|
|
530
657
|
if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
531
658
|
textDeltaCount++;
|
|
659
|
+
sawOutput = true;
|
|
532
660
|
yield { type: 'text_delta', text: event.delta.text };
|
|
533
661
|
}
|
|
534
662
|
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
@@ -550,6 +678,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
550
678
|
|
|
551
679
|
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
|
|
552
680
|
turnCount++;
|
|
681
|
+
sawOutput = true;
|
|
553
682
|
for (const block of m.message.content) {
|
|
554
683
|
if (block.type === 'tool_use') {
|
|
555
684
|
pendingToolUses.set(block.id, { tool: block.name, input: block.input });
|
|
@@ -638,6 +767,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
638
767
|
return;
|
|
639
768
|
}
|
|
640
769
|
console.error(`[claude] Error after ${messageCount} msgs (${elapsed()}):`, err.message || err);
|
|
770
|
+
if (plan.resumeId && !sawOutput && isResumeFailure(err.message || String(err))) return 'resume-failed';
|
|
641
771
|
yield { type: 'error', message: err.message || String(err) };
|
|
642
772
|
return;
|
|
643
773
|
} finally {
|
|
@@ -645,6 +775,11 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
645
775
|
// The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
|
|
646
776
|
// widens the window in which the credentials sit on disk.
|
|
647
777
|
mcpConfigFile?.cleanup();
|
|
778
|
+
// Save the mapping once this attempt really ran a turn (incl. an aborted one — the transcript holds it).
|
|
779
|
+
if (plan.persist && opts.sessionId && lastSessionId && sawOutput) {
|
|
780
|
+
setClaudeResume(opts.sessionId, { ...plan.persist, claudeSessionId: lastSessionId, ...(initModel ? { model: initModel } : {}) });
|
|
781
|
+
if (plan.resumeId && plan.resumeId !== lastSessionId) console.warn(`[claude] Resume returned a new session id ${lastSessionId} (was ${plan.resumeId})`);
|
|
782
|
+
}
|
|
648
783
|
}
|
|
649
784
|
|
|
650
785
|
const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
|