shraga 0.1.56 → 0.1.58
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.58",
|
|
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",
|
package/src/server/boot.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFla
|
|
|
26
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
27
27
|
import { slackFeature } from './slack/feature.ts';
|
|
28
28
|
import { dataPath } from './paths.ts';
|
|
29
|
-
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount,
|
|
29
|
+
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
30
30
|
import { setBroadcaster } from './session-bus.ts';
|
|
31
31
|
import * as scheduler from './scheduler/index.ts';
|
|
32
32
|
import { initPolls } from './polls.ts';
|
|
@@ -1325,7 +1325,6 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
|
|
|
1325
1325
|
if (blocks.length) writePartial(sid, blocks);
|
|
1326
1326
|
}, 5_000);
|
|
1327
1327
|
|
|
1328
|
-
resetRetryCount(sid);
|
|
1329
1328
|
setRunStatus(sid, 'running', 'web');
|
|
1330
1329
|
|
|
1331
1330
|
let stopReason = '';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
2
3
|
import { readFileSync } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { signInternalToken } from '../auth.ts';
|
|
@@ -118,18 +119,44 @@ function logCacheUsage(usage: any, model: string): void {
|
|
|
118
119
|
console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model}`);
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
|
|
123
|
+
* call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
|
|
124
|
+
function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
|
|
125
|
+
const child = spawn(cfg.command, cfg.args, {
|
|
126
|
+
cwd: cfg.cwd,
|
|
127
|
+
env: cfg.env,
|
|
128
|
+
signal: cfg.signal,
|
|
129
|
+
// Keep SDK debug output visible when it is asked for; otherwise the SDK's own default.
|
|
130
|
+
stdio: ['pipe', 'pipe', cfg.env.DEBUG_CLAUDE_AGENT_SDK ? 'inherit' : 'ignore'],
|
|
131
|
+
detached: true,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
stdin: child.stdin,
|
|
136
|
+
stdout: child.stdout,
|
|
137
|
+
get killed() { return child.killed; },
|
|
138
|
+
get exitCode() { return child.exitCode; },
|
|
139
|
+
kill: child.kill.bind(child),
|
|
140
|
+
on: child.on.bind(child),
|
|
141
|
+
once: child.once.bind(child),
|
|
142
|
+
off: child.off.bind(child),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
121
146
|
const INLINE_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);
|
|
122
147
|
|
|
123
148
|
async function* buildAttachmentPrompt(text: string, attachments: { path: string; name: string; mimeType: string }[], sessionId: string): AsyncIterable<any> {
|
|
124
149
|
const content: any[] = [];
|
|
125
150
|
const fileRefs: string[] = [];
|
|
126
151
|
const audioRefs: string[] = [];
|
|
152
|
+
const inlined: string[] = [];
|
|
127
153
|
for (const att of attachments) {
|
|
128
154
|
if (INLINE_MIMES.has(att.mimeType)) {
|
|
129
155
|
try {
|
|
130
156
|
const buf = readFileSync(att.path);
|
|
131
157
|
const blockType = att.mimeType === 'application/pdf' ? 'document' : 'image';
|
|
132
158
|
content.push({ type: blockType, source: { type: 'base64', media_type: att.mimeType, data: buf.toString('base64') } });
|
|
159
|
+
inlined.push(`${att.name} (${att.path})`);
|
|
133
160
|
} catch (err) {
|
|
134
161
|
console.error(`[claude] Failed to read attachment ${att.path}:`, err);
|
|
135
162
|
fileRefs.push(`${att.name} (at ${att.path} — failed to read)`);
|
|
@@ -143,6 +170,12 @@ async function* buildAttachmentPrompt(text: string, attachments: { path: string;
|
|
|
143
170
|
}
|
|
144
171
|
if (audioRefs.length > 0) text += `\n\n[Audio attached — transcribe with the mcp-audio tool (post_audio_transcribe { file }) before answering]: ${audioRefs.join(', ')}`;
|
|
145
172
|
if (fileRefs.length > 0) text += `\n\n[Attached files — use Read tool to access]: ${fileRefs.join(', ')}`;
|
|
173
|
+
// Say IN THE TEXT that the inlined blocks exist. `text` carries the whole conversation history, so
|
|
174
|
+
// the image blocks sit tens of thousands of tokens above the actual question and the model has
|
|
175
|
+
// answered "I don't see any images attached" to a message that had four — the transcript proves the
|
|
176
|
+
// blocks were sent. This line makes the prompt agree with its own content, and names the on-disk
|
|
177
|
+
// paths so the model can Read them if it still can't see a block.
|
|
178
|
+
if (inlined.length > 0) text += `\n\n[${inlined.length} file(s) are attached to THIS message and included above as image/document blocks — look at them: ${inlined.join(', ')}]`;
|
|
146
179
|
content.push({ type: 'text', text });
|
|
147
180
|
yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
|
|
148
181
|
}
|
|
@@ -273,6 +306,14 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
273
306
|
const addonSuffix = getPromptSuffix(opts.turnHints);
|
|
274
307
|
options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
|
|
275
308
|
if (opts.abortController) options['abortController'] = opts.abortController;
|
|
309
|
+
// Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
|
|
310
|
+
// (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
|
|
311
|
+
// dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
|
|
312
|
+
// server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
|
|
313
|
+
// already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
|
|
314
|
+
// Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
|
|
315
|
+
options['spawnClaudeCodeProcess'] = spawnDetached;
|
|
316
|
+
|
|
276
317
|
// Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
|
|
277
318
|
// server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
|
|
278
319
|
// writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
|
|
@@ -72,6 +72,13 @@ export class SelfUpgrade {
|
|
|
72
72
|
if (running.length) {
|
|
73
73
|
reasons.push(`${running.length} scheduled job(s) still running (${running.join(', ')}) — restarting now would kill them mid-flight; retry when idle or pass {"force":true}`);
|
|
74
74
|
}
|
|
75
|
+
// Same failure, other origin: an interactive Slack/web turn is not a scheduled job, so the
|
|
76
|
+
// check above never saw it and upgrades cut live replies off mid-sentence (143). Any held
|
|
77
|
+
// session lock means someone is mid-turn.
|
|
78
|
+
const turns = this.o.runningTurns();
|
|
79
|
+
if (turns) {
|
|
80
|
+
reasons.push(`${turns} agent turn(s) in flight — restarting now would cut the reply off mid-stream; retry when idle or pass {"force":true}`);
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
const pkgPath = path.join(this.o.appRoot, 'package.json');
|
|
77
84
|
|
|
@@ -254,6 +261,13 @@ export class SelfUpgradeOptions {
|
|
|
254
261
|
catch (err) { console.warn(`${TAG} could not read running jobs:`, (err as Error).message); return []; }
|
|
255
262
|
};
|
|
256
263
|
|
|
264
|
+
/** Interactive agent turns in flight (Slack/web/API), counted off the live session locks. Same
|
|
265
|
+
* lazy+guarded shape as runningJobs, and injectable for the same reason. */
|
|
266
|
+
public runningTurns: () => number = () => {
|
|
267
|
+
try { return require('../sessions.ts').getActiveLockCount() as number; }
|
|
268
|
+
catch (err) { console.warn(`${TAG} could not read running turns:`, (err as Error).message); return 0; }
|
|
269
|
+
};
|
|
270
|
+
|
|
257
271
|
/** Injectable so the preflight is testable without depending on the test host's PATH. */
|
|
258
272
|
public hasPython3: () => boolean = () => {
|
|
259
273
|
try { return spawnSync('python3', ['--version'], { stdio: 'ignore' }).status === 0; }
|
package/src/server/sessions.ts
CHANGED
|
@@ -270,7 +270,16 @@ export function setRunStatus(sessionId: string, status: 'running' | 'idle', orig
|
|
|
270
270
|
if (!s) return;
|
|
271
271
|
s.runStatus = status;
|
|
272
272
|
if (origin) s.runOrigin = origin;
|
|
273
|
-
if (status === 'idle')
|
|
273
|
+
if (status === 'idle') {
|
|
274
|
+
s.lastStopReason = stopReason;
|
|
275
|
+
// runRetryCount caps crash-resume loops (interrupt → resume → interrupt). Reaching 'idle' at all
|
|
276
|
+
// proves this turn was NOT killed by a restart — the guard above drops idle writes during a
|
|
277
|
+
// drain, and a hard kill never gets here — so the loop is broken and the count starts over.
|
|
278
|
+
// It used to be reset only on the web path, so Slack and scheduler sessions accumulated it
|
|
279
|
+
// forever: after two interruptions in their whole life they permanently lost auto-resume and
|
|
280
|
+
// every later restart ended at "please send a message to continue".
|
|
281
|
+
s.runRetryCount = 0;
|
|
282
|
+
}
|
|
274
283
|
s.lastModified = Date.now();
|
|
275
284
|
saveIndex(sessions);
|
|
276
285
|
}
|
package/src/server/slack/bot.ts
CHANGED
|
@@ -385,7 +385,10 @@ export async function retrySlackSession(session: SessionMeta, prompt: string): P
|
|
|
385
385
|
onUserQuestion: makeSlackQuestionHandler({ channel, threadTs, useUserToken }),
|
|
386
386
|
}),
|
|
387
387
|
session.sessionId,
|
|
388
|
-
|
|
388
|
+
// Partial-capture ON: a recovery turn is the one most likely to be interrupted again (a
|
|
389
|
+
// restart loop, an upgrade retry), and with it off the second interruption had nothing to
|
|
390
|
+
// preserve and resumed from the original prompt instead of the cut-off point.
|
|
391
|
+
{ partial: true, artifacts: false },
|
|
389
392
|
),
|
|
390
393
|
);
|
|
391
394
|
if (replyTs) { console.log(`[slack-bot] recovery reply posted: ${channel} ts=${replyTs}`); invalidateChannelContext(channel); }
|