shraga 0.1.25 → 0.1.27

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.
@@ -156,7 +156,7 @@ For **vendor webhooks** that can't send shraga auth (Stripe, GitHub, …), add a
156
156
 
157
157
  The system emits these onto the bus automatically — use them as the `source` of an event trigger to react to the agent's own lifecycle:
158
158
 
159
- - **`schedule.finished`** — fired when any time/manual schedule run completes. Payload: `{ scheduleId, name, status, sessionId, sessionUrl?, error? }`. `status` is `ok` | `error` | `aborted`. Chain automations off it, e.g.:
159
+ - **`schedule.finished`** — fired when any time/manual schedule run completes. Payload: `{ scheduleId, name, status, sessionId, sessionUrl?, error? }`. `status` is `ok` | `error` | `aborted` — `aborted` covers both a user cancel and a run killed by a signal (a deploy/restart SIGTERMs in-flight jobs), so alerts matching `error` don't fire on interrupted runs. Chain automations off it, e.g.:
160
160
  ```json
161
161
  { "trigger": { "kind": "event", "source": "schedule.finished", "match": { "status": "error" } },
162
162
  "task": { "kind": "prompt", "prompt": "A scheduled run failed — investigate and post a summary." } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
@@ -74,7 +74,7 @@ export type WsEvent =
74
74
  | { type: 'text_delta'; text: string }
75
75
  | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
76
76
  | { type: 'tool_use_input'; toolUseId: string; input: unknown }
77
- | { type: 'tool_result'; toolUseId: string; output: string }
77
+ | { type: 'tool_result'; toolUseId: string; output: string; isError?: boolean }
78
78
  | { type: 'tool_result_image'; toolUseId: string; dataUrl: string }
79
79
  | { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown> }
80
80
  | { type: 'question_request'; id: string; questions: AskQuestion[] }
@@ -446,7 +446,9 @@ export class ClaudeCodeEngine implements AgentEngine {
446
446
  const output = contentArr.length > 0
447
447
  ? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
448
448
  : String(block.content ?? '');
449
- yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output };
449
+ // `is_error` is the ONLY signal that a tool failed — the text alone is indistinguishable
450
+ // from a successful result. Scheduled `bash` tasks rely on it to notice a non-zero exit.
451
+ yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output, isError: block.is_error === true };
450
452
 
451
453
  let foundImage = false;
452
454
  for (const c of contentArr) {
package/src/server/mcp.ts CHANGED
@@ -52,11 +52,52 @@ function isHttpConfig(s: McpServerConfig): s is McpHttpServerConfig {
52
52
  return s.type === 'http';
53
53
  }
54
54
 
55
+ /** `$VAR` / `${VAR}` placeholders inside a string. Same dialect as stdio `env` values, but also
56
+ * substitutes when EMBEDDED (`Bearer $TOKEN`) — a header value is never the bare variable. */
57
+ const PLACEHOLDER_RE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
58
+
59
+ /** Expand placeholders; unset var → '' (same as stdio env resolution). `found` records each hit. */
60
+ function expandPlaceholders(value: string, found: string[]): string {
61
+ return value.replace(PLACEHOLDER_RE, (_m, braced, bare) => {
62
+ const v = process.env[braced || bare] || '';
63
+ found.push(v);
64
+ return v;
65
+ });
66
+ }
67
+
68
+ /** Resolve `$VAR` placeholders in an http entry's headers and url.
69
+ * Without this, an authenticated remote MCP can only be configured by INLINING the secret into
70
+ * data/mcps/*.json — which shraga auto-commits and pushes to the data repo (see saveMcpConfig →
71
+ * dataSync.trackWrite). Entries with no placeholders are returned untouched. */
72
+ function resolveHttpConfig(name: string, server: McpHttpServerConfig): McpHttpServerConfig | null {
73
+ const found: string[] = [];
74
+ const out: McpHttpServerConfig = { ...server };
75
+ if (typeof server.url === 'string') out.url = expandPlaceholders(server.url, found);
76
+ if (server.headers) {
77
+ const headers: Record<string, string> = {};
78
+ for (const [k, v] of Object.entries(server.headers)) {
79
+ headers[k] = typeof v === 'string' ? expandPlaceholders(v, found) : String(v ?? '');
80
+ }
81
+ out.headers = headers;
82
+ }
83
+ // Env-gate, mirroring stdio: an entry that DECLARES placeholders but resolves them all empty isn't
84
+ // configured for this deployment — skip it rather than mounting with a broken `Bearer ` credential.
85
+ if (found.length > 0 && found.every((v) => !v)) {
86
+ console.log(`[mcp] ${name}: skipped — required env placeholders not set in this deployment`);
87
+ return null;
88
+ }
89
+ return out;
90
+ }
91
+
55
92
  /** Resolve env values: empty → process.env[same key], "$VAR" → process.env[VAR] */
56
93
  function resolveEnv(config: McpConfig): McpConfig {
57
94
  const resolved: McpConfig = {};
58
95
  for (const [name, server] of Object.entries(config)) {
59
- if (isHttpConfig(server)) { resolved[name] = server; continue; }
96
+ if (isHttpConfig(server)) {
97
+ const http = resolveHttpConfig(name, server);
98
+ if (http) resolved[name] = http;
99
+ continue;
100
+ }
60
101
  if (!server.env) { resolved[name] = server; continue; }
61
102
  const env: Record<string, string> = {};
62
103
  for (const [k, v] of Object.entries(server.env)) {
@@ -194,12 +194,21 @@ export async function runSchedule(
194
194
  let status: ScheduleRunSummary['status'] = 'ok';
195
195
  let error: string | undefined;
196
196
 
197
+ // A `bash` task is not exec'd — it's handed to the agent as a prompt, so the command's exit code
198
+ // reaches nobody: the agent reports the failure in prose, its own turn succeeds, the run is stored
199
+ // `ok`, and the failure notifier never fires. Track the tool_result of the task's OWN Bash call and
200
+ // fail the run with it, so a broken scheduled script alerts like a `job` does.
201
+ const taskBashToolUseIds = new Set<string>();
202
+ let bashFailure: string | undefined;
203
+
197
204
  onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
198
205
 
199
206
  try {
200
207
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
201
208
  status = 'ok';
202
209
  error = undefined;
210
+ bashFailure = undefined;
211
+ taskBashToolUseIds.clear();
203
212
  try {
204
213
  for await (const ev of streamChat({
205
214
  prompt,
@@ -217,10 +226,12 @@ export async function runSchedule(
217
226
  } else if (ev.type === 'tool_use') {
218
227
  if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
219
228
  assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
229
+ if (allowedCmd && ev.tool === 'Bash' && (ev.input as any)?.command === allowedCmd) taskBashToolUseIds.add(ev.toolUseId);
220
230
  } else if (ev.type === 'thinking_delta') {
221
231
  producedThinking = true;
222
232
  } else if (ev.type === 'tool_result') {
223
233
  assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
234
+ if (ev.isError && taskBashToolUseIds.has(ev.toolUseId)) bashFailure = ev.output;
224
235
  } else if (ev.type === 'done') {
225
236
  break;
226
237
  } else if (ev.type === 'error') {
@@ -238,6 +249,12 @@ export async function runSchedule(
238
249
  }
239
250
  }
240
251
 
252
+ // The agent turn can succeed while the command it was asked to run failed — that's the run failing.
253
+ if (status === 'ok' && bashFailure) {
254
+ status = 'error';
255
+ error = `Scheduled command failed:\n${bashFailure.slice(0, 4000)}`;
256
+ }
257
+
241
258
  if (status !== 'error') break;
242
259
 
243
260
  // The side-effect boundary. This is NOT an exact `ttft=-1` test — the engine emits events we
@@ -338,14 +355,20 @@ async function runJobSchedule(
338
355
  blocks: [{ type: 'text', text: `✅ Job completed successfully.\n\n${prefix}${output.trim() || '(no output)'}\n\`\`\`` }],
339
356
  });
340
357
  } catch (err: any) {
341
- status = abortController.signal.aborted ? 'aborted' : 'error';
358
+ // A job killed by a SIGNAL was INTERRUPTED, not failed — the usual cause is systemd stopping the
359
+ // service (a deploy/restart), which SIGTERMs the whole cgroup including in-flight job children.
360
+ // Reporting that as `error` fires the failure notifier with an alert nobody can act on, whose
361
+ // "error" is just the job's own progress output (the process never got to throw anything).
362
+ // `aborted` is the existing status for "ended without failing" and the notifier ignores it.
363
+ const killed = typeof err?.signal === 'string';
364
+ status = abortController.signal.aborted || killed ? 'aborted' : 'error';
342
365
  error = err?.message ?? String(err);
343
366
  appendMessage(sessionId, {
344
367
  id: crypto.randomUUID(),
345
368
  role: 'assistant',
346
- blocks: [{ type: 'text', text: `❌ Job failed.\n\n${prefix}${error}\n\`\`\`` }],
369
+ blocks: [{ type: 'text', text: `${killed ? '⏹️ Job interrupted' : '❌ Job failed'}.\n\n${prefix}${error}\n\`\`\`` }],
347
370
  });
348
- console.error(`[scheduler] job run error for ${schedule.id}:`, error);
371
+ console[killed ? 'warn' : 'error'](`[scheduler] job run ${killed ? 'interrupted' : 'error'} for ${schedule.id}:`, error);
349
372
  } finally {
350
373
  clearInterval(partialInterval);
351
374
  unregisterLivePartial(sessionId);
@@ -395,10 +418,19 @@ function runCommandWithMarker(command: string, abortController: AbortController,
395
418
 
396
419
  abortController.signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
397
420
  child.on('error', reject);
398
- child.on('close', (code) => {
421
+ child.on('close', (code, signal) => {
399
422
  const trimmed = output.trim();
400
- if (code === 0) resolve(trimmed);
401
- else reject(new Error(trimmed || `Command failed with exit code ${code}`));
423
+ if (code === 0) return resolve(trimmed);
424
+ // Always state HOW it ended. The old form used the command's own output as the message whenever
425
+ // it had produced any, so a killed job reported its progress logs as "the error" and the actual
426
+ // cause (exit code / signal) was lost — the failure was captured, but not why.
427
+ const how = signal ? `killed by ${signal}` : `exit code ${code}`;
428
+ const err: NodeJS.ErrnoException & { signal?: string } = new Error(
429
+ trimmed ? `Command ${how}. Output:\n${trimmed}` : `Command ${how}`,
430
+ );
431
+ // Carried so callers can tell an interrupted run (deploy/restart) from a real failure.
432
+ if (signal) err.signal = signal;
433
+ reject(err);
402
434
  });
403
435
  });
404
436
  }