shraga 0.1.56 → 0.1.57

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.57",
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",
@@ -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; }