shraga 0.1.75 → 0.1.76

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.75",
3
+ "version": "0.1.76",
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",
@@ -21,7 +21,7 @@ import express from 'express';
21
21
  import { WebSocketServer, WebSocket } from 'ws';
22
22
  import { requireAuth, verifyBearer, authenticateToken, AUTH_PROVIDER, localLogin, addLocalUser, localUserCount } from './auth.ts';
23
23
  import { getMcpConfig, getRawMcpConfig, getResolvedMcpConfig, getGlobalMcpConfig, saveMcpConfig, maskEnvValues, mergeWithOriginal, type McpConfig } from './mcp.ts';
24
- import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAuthSource, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
24
+ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAuthSource, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent, MAX_TURNS_NOTICE } from './claude.ts';
25
25
  import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
26
26
  import { registerSpaCatchAll } from './spa-catchall.ts';
27
27
  import { slackFeature } from './slack/feature.ts';
@@ -576,7 +576,7 @@ app.get('/api/schedules/:id/runs', requireAuth, (req, res) => {
576
576
  */
577
577
  type RunChatTurnResult =
578
578
  | { status: 'busy' }
579
- | { sessionId: string; text: string; blocks: ConvBlock[] }
579
+ | { sessionId: string; text: string; blocks: ConvBlock[]; stopReason?: string }
580
580
  | { sessionId: string; error: string };
581
581
 
582
582
  async function runChatTurn(
@@ -604,6 +604,14 @@ async function runChatTurn(
604
604
  appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'api', senderName: userName });
605
605
  setRunStatus(sid, 'running', 'web');
606
606
 
607
+ let stopReason: string | undefined;
608
+ const onEvent = (ev: WsEvent) => {
609
+ // consumeStream() drops the `done` event's stopReason, so every non-WS transport lost the fact
610
+ // that the turn was TRUNCATED and returned the partial answer as if it were complete.
611
+ if (ev.type === 'done') stopReason = (ev as any).stopReason;
612
+ hooks?.onEvent?.(ev);
613
+ };
614
+
607
615
  try {
608
616
  const blocks = await consumeStream(streamChat({
609
617
  prompt,
@@ -615,7 +623,7 @@ async function runChatTurn(
615
623
  abortController,
616
624
  context: opts.context ?? { source: 'api', user: userEmail },
617
625
  onPermissionRequest: async () => ({ allow: true }),
618
- }), hooks?.onEvent);
626
+ }), onEvent);
619
627
  if (blocks.length) {
620
628
  appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks });
621
629
  }
@@ -623,7 +631,7 @@ async function runChatTurn(
623
631
  const meta = getSession(sid);
624
632
  notifyUnread(uid, sid, text.slice(0, 120) || '(completed)', 'response', meta?.title);
625
633
  broadcast({ type: 'session_messages_changed', sessionId: sid });
626
- return { sessionId: sid, text, blocks };
634
+ return { sessionId: sid, text, blocks, stopReason };
627
635
  } catch (err: any) {
628
636
  console.error(`[chat-turn] error:`, err.message);
629
637
  return { sessionId: sid, error: err.message };
@@ -1460,7 +1468,7 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1460
1468
  } else if (event.type === 'done') {
1461
1469
  stopReason = event.stopReason ?? 'end_turn';
1462
1470
  if (stopReason === 'max_turns_reached') {
1463
- assistantBlocks.push({ type: 'text', text: '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.' });
1471
+ assistantBlocks.push({ type: 'text', text: MAX_TURNS_NOTICE });
1464
1472
  }
1465
1473
  if (!assistantText && !thinkingText && assistantBlocks.length === 0 && !event.builtinHandled) {
1466
1474
  const fallback = '⚠️ No response was generated. Try rephrasing or sending again.';
@@ -50,8 +50,12 @@ export class ClaudeUsageOptions {
50
50
  timeoutMs = 8_000;
51
51
  /** A 429 is not a transient blip here: retrying it on the ordinary TTL is what keeps a box wedged in
52
52
  * the penalty box all day (25 straight 429s on prod). Each consecutive 429 climbs this ladder and a
53
- * success drops back to the first rung; a `Retry-After` header wins over the rung when it is longer. */
54
- rateLimitBackoffMs = [60_000, 300_000, 900_000, 1_800_000];
53
+ * success drops back to the first rung; a `Retry-After` header wins over the rung when it is longer.
54
+ * Capped at 15m, not 30m: on a busy box (many CLI sessions sharing one account quota) upstream can
55
+ * answer 429 for hours with a ~73s Retry-After, and every attempt is a chance at the first reading
56
+ * that unhides the gauge. A failed attempt no longer costs the user anything — the last known-good
57
+ * reading stays on screen — so a slightly shorter ceiling is the better trade. */
58
+ rateLimitBackoffMs = [60_000, 300_000, 600_000, 900_000];
55
59
  /** macOS stores Claude Code's OAuth credentials in the login Keychain and writes NO credentials
56
60
  * file, so on darwin an absent file is not proof of an API-key deployment — we look there second.
57
61
  * Linux keeps the file as the only source; we never shell out there. */
@@ -13,6 +13,7 @@ import {
13
13
  getSkill,
14
14
  getMcpCommandPrompt,
15
15
  parseSkillFrontmatter,
16
+ resolveSkillTurns,
16
17
  } from './skills.ts';
17
18
 
18
19
  import { buildWorkspaceContextBlock, expandWorkspaceMentions } from './workspace.ts';
@@ -306,6 +307,22 @@ export async function* streamChat(opts: {
306
307
  const newTriggerNames = discoveryEnabled ? matchTriggeredSkillNames(effectivePrompt, opts.context) : [];
307
308
  const triggeredNames = [...new Set([...stickyNames, ...newTriggerNames])];
308
309
  const triggeredSkills = skillInjectionBlocks(triggeredNames);
310
+
311
+ // Per-skill turn budget. Resolved HERE, once every invoked skill is known (the slash command and
312
+ // the triggered/sticky set), and GAP-FILLING only: an inline `[turns:N]` or a session-pinned
313
+ // value already sits in `directives` and is left alone. The global `config.maxTurns` is applied
314
+ // later still, by the engine (`directives.turns ?? config.maxTurns`), so a skill budget slots
315
+ // cleanly between the two without touching the global default.
316
+ // NOTE the deliberate asymmetry with `model` above, which OVERWRITES an inline directive: a
317
+ // long-running skill needs a floor, not the last word — the human who typed `[turns:5]` to probe
318
+ // it cheaply must still get 5.
319
+ if (!directives.turns) {
320
+ const skillTurns = resolveSkillTurns([slashCmd?.command, ...triggeredNames]);
321
+ if (skillTurns) {
322
+ directives.turns = skillTurns;
323
+ console.log(`[claude] Directives: ${JSON.stringify(directives)} (turns from skill frontmatter)`);
324
+ }
325
+ }
309
326
  const workspaceTree = buildWorkspaceContextBlock();
310
327
  const contact = opts.userEmail ? contacts.find({ email: opts.userEmail }) : null;
311
328
  const userBlock = contacts.formatUserBlock(contact);
@@ -366,6 +383,10 @@ export async function* streamChat(opts: {
366
383
  });
367
384
  }
368
385
 
386
+ /** The notice appended to a turn that ran out of steps. Shared so every transport says the same
387
+ * thing — the MCP path said NOTHING at all, so a truncated turn arrived looking finished. */
388
+ export const MAX_TURNS_NOTICE = '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.';
389
+
369
390
  export async function consumeStream(stream: AsyncGenerator<WsEvent>, onEvent?: (ev: WsEvent) => void): Promise<ConvBlock[]> {
370
391
  let text = '';
371
392
  let thinking = '';
@@ -10,7 +10,7 @@ import { listSkills, getSkill, saveSkill } from './skills.ts';
10
10
  import { getAllSessions, loadConversation, isSessionLocked } from './sessions.ts';
11
11
  import * as scheduler from './scheduler/index.ts';
12
12
  import { buildReport } from './downtime.ts';
13
- import { getAgentConfig } from './claude.ts';
13
+ import { getAgentConfig, MAX_TURNS_NOTICE } from './claude.ts';
14
14
  import { validateApiKey } from './api-keys.ts';
15
15
  import { verifyMcpToken } from './auth.ts';
16
16
  import { makeProgressEmitter } from './mcp-progress.ts';
@@ -19,7 +19,7 @@ import type { WsEvent } from './claude.ts';
19
19
 
20
20
  export type RunChatTurnResult =
21
21
  | { status: 'busy' }
22
- | { sessionId: string; text: string; blocks: unknown[] }
22
+ | { sessionId: string; text: string; blocks: unknown[]; stopReason?: string }
23
23
  | { sessionId: string; error: string };
24
24
 
25
25
  export type RunChatTurn = (
@@ -318,7 +318,16 @@ export function createShragaMcp(deps: McpServerDeps) {
318
318
  const result = await deps.runChatTurn(turn, { onEvent: makeProgressEmitter(captureMcpProgress()) });
319
319
  if ('status' in result) return json({ error: 'Session is already processing a request' }, { status: 409 });
320
320
  if ('error' in result) return json({ error: result.error, sessionId: result.sessionId }, { status: 500 });
321
- return json({ sessionId: result.sessionId, text: result.text, blocks: result.blocks });
321
+ // A turn that hit the step ceiling is a PARTIAL answer. Without this the MCP caller got the
322
+ // partial text and no signal at all, which is what "the agent stalled" reports actually were.
323
+ const truncated = result.stopReason === 'max_turns_reached';
324
+ return json({
325
+ sessionId: result.sessionId,
326
+ text: truncated ? result.text + MAX_TURNS_NOTICE : result.text,
327
+ blocks: result.blocks,
328
+ ...(result.stopReason ? { stopReason: result.stopReason } : {}),
329
+ ...(truncated ? { truncated: true } : {}),
330
+ });
322
331
  } catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
323
332
  });
324
333
 
@@ -18,6 +18,9 @@ export interface SkillMeta {
18
18
  model?: string;
19
19
  allowedTools?: string[];
20
20
  argumentHint?: string;
21
+ /** Per-skill turn budget. Gap-fills `directives.turns` for the turn that invokes the skill —
22
+ * inline `[turns:N]` and a session-pinned value both still win. Capped at MAX_SKILL_TURNS. */
23
+ turns?: number;
21
24
  triggers?: string[];
22
25
  expires?: string;
23
26
  origin?: string;
@@ -26,6 +29,24 @@ export interface SkillMeta {
26
29
  managedBy?: string;
27
30
  }
28
31
 
32
+ /** Ceiling on a skill's self-declared turn budget. A skill file is data — it is edited without a
33
+ * deploy and shipped by rsync — so an unbounded number there is an unattended spend hazard. 300 is
34
+ * one step above the 250 the video-ad pipeline needs in practice, so no real skill is clipped,
35
+ * while a typo'd `turns: 30000` costs a bounded worst case instead of an open-ended one. A human
36
+ * typing `[turns:N]` inline is NOT capped — that is a deliberate, attended choice. */
37
+ export const MAX_SKILL_TURNS = 300;
38
+
39
+ /** Largest turn budget declared by any of the named skills, clamped to MAX_SKILL_TURNS. */
40
+ export function resolveSkillTurns(names: (string | undefined)[]): number | undefined {
41
+ let best = 0;
42
+ for (const name of names) {
43
+ if (!name) continue;
44
+ const t = getSkill(name)?.meta.turns;
45
+ if (t && t > best) best = t;
46
+ }
47
+ return best ? Math.min(best, MAX_SKILL_TURNS) : undefined;
48
+ }
49
+
29
50
  export function isExpired(meta: SkillMeta): boolean {
30
51
  if (!meta.expires) return false;
31
52
  return new Date(meta.expires).getTime() < Date.now();
@@ -61,6 +82,7 @@ export function parseSkillFrontmatter(content: string): { meta: SkillMeta; body:
61
82
  if (key === 'model') meta.model = val;
62
83
  if (key === 'allowed-tools') meta.allowedTools = val.split(',').map(s => s.trim());
63
84
  if (key === 'argument-hint') meta.argumentHint = val;
85
+ if ((key === 'turns' || key === 'max-turns') && /^\d+$/.test(val)) meta.turns = parseInt(val, 10);
64
86
  if (key === 'triggers' && val) {
65
87
  try { meta.triggers = JSON.parse(val); } catch {
66
88
  meta.triggers = val.split(',').map(s => s.trim());