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 +1 -1
- package/src/server/boot.ts +13 -5
- package/src/server/claude-usage.ts +6 -2
- package/src/server/claude.ts +21 -0
- package/src/server/mcp-server.ts +12 -3
- package/src/server/skills.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/server/boot.ts
CHANGED
|
@@ -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
|
-
}),
|
|
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:
|
|
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
|
-
|
|
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. */
|
package/src/server/claude.ts
CHANGED
|
@@ -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 = '';
|
package/src/server/mcp-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/server/skills.ts
CHANGED
|
@@ -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());
|