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.56",
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",
@@ -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, resetRetryCount, 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';
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; }
@@ -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') s.lastStopReason = stopReason;
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
  }
@@ -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
- { partial: false, artifacts: false },
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); }