shraga 0.1.97 → 0.1.99
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/dist/client/assets/{index-QkJewVCL.js → index-BZnO7Mn0.js} +194 -194
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/components/ChatView.tsx +38 -7
- package/src/client/components/Sidebar.tsx +25 -7
- package/src/server/claude.ts +3 -0
- package/src/server/engine/claude-code.ts +1 -1
- package/src/server/engine/types.ts +3 -0
- package/src/server/hooks.ts +10 -4
- package/src/server/scheduler/runner.ts +7 -1
package/dist/client/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-BZnO7Mn0.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-DlgCLI89.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.99",
|
|
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",
|
|
@@ -127,10 +127,41 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
|
|
|
127
127
|
const screenMap = screenMapRef.current;
|
|
128
128
|
|
|
129
129
|
// Only the last slice is mounted — an agent thread runs to thousands of blocks and the DOM
|
|
130
|
-
// (not React) is what goes sluggish.
|
|
131
|
-
|
|
132
|
-
const
|
|
130
|
+
// (not React) is what goes sluggish. Scrolling to the top reveals the next slice, so a long
|
|
131
|
+
// thread walks backwards a page at a time instead of mounting 12k rows in one jump.
|
|
132
|
+
const [limit, setLimit] = useState(VISIBLE_MESSAGES);
|
|
133
|
+
const hiddenCount = Math.max(0, messages.length - limit);
|
|
133
134
|
const visibleMessages = hiddenCount > 0 ? messages.slice(hiddenCount) : messages;
|
|
135
|
+
const showEarlier = useCallback(() => setLimit((n) => n + VISIBLE_MESSAGES), []);
|
|
136
|
+
|
|
137
|
+
const pendingAnchor = useRef<{ box: HTMLDivElement | null; before: number } | null>(null);
|
|
138
|
+
// Reveal-on-scroll, with the button below as the visible fallback. The scroll position is pinned
|
|
139
|
+
// to the same message afterwards: prepending rows above the viewport otherwise yanks the thread.
|
|
140
|
+
const topRef = useRef<HTMLDivElement>(null);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const el = topRef.current;
|
|
143
|
+
if (!el || hiddenCount === 0) return;
|
|
144
|
+
const io = new IntersectionObserver((entries) => {
|
|
145
|
+
if (!entries.some((e) => e.isIntersecting)) return;
|
|
146
|
+
// On mount the thread is pinned to the bottom and the top sentinel can be "visible" simply
|
|
147
|
+
// because nothing has scrolled yet — expanding there would cascade the whole 12k thread into
|
|
148
|
+
// the DOM, which is exactly what the window exists to prevent. Only reveal on a real scroll up.
|
|
149
|
+
if (isNearBottom.current) return;
|
|
150
|
+
const box = scrollRef.current;
|
|
151
|
+
const before = box?.scrollHeight ?? 0;
|
|
152
|
+
pendingAnchor.current = { box, before };
|
|
153
|
+
showEarlier();
|
|
154
|
+
}, { rootMargin: '400px' });
|
|
155
|
+
io.observe(el);
|
|
156
|
+
return () => io.disconnect();
|
|
157
|
+
}, [hiddenCount, limit, showEarlier]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const a = pendingAnchor.current;
|
|
161
|
+
if (!a?.box) return;
|
|
162
|
+
pendingAnchor.current = null;
|
|
163
|
+
a.box.scrollTop += a.box.scrollHeight - a.before; // keep the reader on the same message
|
|
164
|
+
}, [limit]);
|
|
134
165
|
|
|
135
166
|
useEffect(() => {
|
|
136
167
|
if (isNearBottom.current) {
|
|
@@ -172,10 +203,10 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
|
|
|
172
203
|
</button>
|
|
173
204
|
</div>
|
|
174
205
|
{hiddenCount > 0 && (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
206
|
+
// Scroll-based, like the sidebar: scrolling to the top reveals the previous slice. A
|
|
207
|
+
// status row, not a control — the earlier button read as a dead box at the top of a thread.
|
|
208
|
+
<div ref={topRef} className="py-3 text-center text-[11px] text-muted-foreground/60">
|
|
209
|
+
{hiddenCount} earlier message{hiddenCount === 1 ? '' : 's'} · scroll up to load
|
|
179
210
|
</div>
|
|
180
211
|
)}
|
|
181
212
|
{visibleMessages.map((msg, i) => {
|
|
@@ -120,6 +120,26 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
120
120
|
}
|
|
121
121
|
}, [activeSessionId, filtered, activeRow]);
|
|
122
122
|
|
|
123
|
+
// Load the next page when the end of the list comes into view. A list that simply stops when you
|
|
124
|
+
// scroll to the bottom reads as broken — the button below stays as the visible fallback, but it
|
|
125
|
+
// is not the mechanism. `root: null` still respects the Radix viewport's clipping, so the sentinel
|
|
126
|
+
// only intersects once the user has actually scrolled near the end.
|
|
127
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
128
|
+
const loadMoreRef = useRef(loadMore);
|
|
129
|
+
loadMoreRef.current = loadMore;
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const el = sentinelRef.current;
|
|
132
|
+
if (!el || !cursor || unreadOnly) return;
|
|
133
|
+
const io = new IntersectionObserver(
|
|
134
|
+
(entries) => { if (entries.some((e) => e.isIntersecting)) loadMoreRef.current(); },
|
|
135
|
+
{ rootMargin: '300px' },
|
|
136
|
+
);
|
|
137
|
+
io.observe(el);
|
|
138
|
+
return () => io.disconnect();
|
|
139
|
+
// `loading` is a dep so the observer re-arms after a page lands: if the end is STILL in view
|
|
140
|
+
// (a short page, a tall sidebar), re-observing fires again and walks on to the next page.
|
|
141
|
+
}, [cursor, loading, unreadOnly]);
|
|
142
|
+
|
|
123
143
|
useEffect(() => {
|
|
124
144
|
getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
|
|
125
145
|
}, []);
|
|
@@ -212,13 +232,11 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
212
232
|
{activeRow && renderRow(activeRow)}
|
|
213
233
|
{filtered.map(renderRow)}
|
|
214
234
|
{!unreadOnly && cursor && (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
>
|
|
220
|
-
{loading ? 'Loading…' : 'Show older'}
|
|
221
|
-
</button>
|
|
235
|
+
// Scroll-based: reaching the end of the list loads the next page. This is a status row,
|
|
236
|
+
// not a control — a "Show older" button read as a dead grey box at the end of the list.
|
|
237
|
+
<div ref={sentinelRef} className="py-3 text-center text-[11px] text-muted-foreground/60">
|
|
238
|
+
{loading ? 'Loading older…' : '···'}
|
|
239
|
+
</div>
|
|
222
240
|
)}
|
|
223
241
|
</div>
|
|
224
242
|
</ScrollArea>
|
package/src/server/claude.ts
CHANGED
|
@@ -209,6 +209,8 @@ export async function* streamChat(opts: {
|
|
|
209
209
|
onPermissionRequest?: PermissionHandler;
|
|
210
210
|
onDestructiveApproval?: PermissionHandler;
|
|
211
211
|
onUserQuestion?: QuestionHandler;
|
|
212
|
+
/** See EngineStreamOpts.foregroundBashCommand. */
|
|
213
|
+
foregroundBashCommand?: string;
|
|
212
214
|
conversationReset?: boolean;
|
|
213
215
|
/** Opaque per-send bag from the client. Never interpreted here — handed to the turn-context seam,
|
|
214
216
|
* where an add-on's contributor reads its own keys. */
|
|
@@ -401,6 +403,7 @@ export async function* streamChat(opts: {
|
|
|
401
403
|
onPermissionRequest: opts.onPermissionRequest,
|
|
402
404
|
onDestructiveApproval: opts.onDestructiveApproval,
|
|
403
405
|
onUserQuestion: opts.onUserQuestion,
|
|
406
|
+
foregroundBashCommand: opts.foregroundBashCommand,
|
|
404
407
|
turnHints: opts.turnHints,
|
|
405
408
|
conversationReset: opts.conversationReset,
|
|
406
409
|
context: opts.context,
|
|
@@ -268,7 +268,7 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
268
268
|
skills: listSkills(),
|
|
269
269
|
disallowedTools: ['Skill'],
|
|
270
270
|
agents: loadAgents(),
|
|
271
|
-
hooks: buildHooks(),
|
|
271
|
+
hooks: buildHooks({ exemptBashCommand: opts.foregroundBashCommand }),
|
|
272
272
|
};
|
|
273
273
|
|
|
274
274
|
const userHandler = opts.onPermissionRequest;
|
|
@@ -23,6 +23,9 @@ export interface EngineStreamOpts {
|
|
|
23
23
|
onPermissionRequest?: PermissionHandler;
|
|
24
24
|
onDestructiveApproval?: PermissionHandler;
|
|
25
25
|
onUserQuestion?: QuestionHandler;
|
|
26
|
+
/** The single command this turn was spawned to run in the foreground (a `bash` schedule).
|
|
27
|
+
* Exempted from the long-running-script background guard — see server/hooks.ts. */
|
|
28
|
+
foregroundBashCommand?: string;
|
|
26
29
|
/** Opaque per-send hints bag, forwarded verbatim from the client. The core interprets no key of it;
|
|
27
30
|
* an add-on engine reads its own keys (e.g. a duplex engine's `voice` marker). */
|
|
28
31
|
turnHints?: Record<string, unknown>;
|
package/src/server/hooks.ts
CHANGED
|
@@ -19,7 +19,7 @@ const LONG_RUNNING_PATTERNS = [
|
|
|
19
19
|
* which lets the CLI manage the process asynchronously (the model can
|
|
20
20
|
* continue working and gets notified when the command finishes).
|
|
21
21
|
*/
|
|
22
|
-
const forceBackgroundForScripts: HookCallback
|
|
22
|
+
const forceBackgroundForScripts = (exemptCommand?: string): HookCallback => async (input) => {
|
|
23
23
|
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
24
24
|
const { tool_name, tool_input } = input as PreToolUseHookInput;
|
|
25
25
|
if (tool_name !== 'Bash') return {};
|
|
@@ -27,6 +27,10 @@ const forceBackgroundForScripts: HookCallback = async (input) => {
|
|
|
27
27
|
const ti = tool_input as Record<string, unknown>;
|
|
28
28
|
const cmd = ti.command as string | undefined;
|
|
29
29
|
if (!cmd || ti.run_in_background) return {};
|
|
30
|
+
// The turn EXISTS to run this one command (a `bash` schedule): there is no other work to
|
|
31
|
+
// continue with, and its exit code is the run's verdict. Backgrounding it only hides that
|
|
32
|
+
// exit code behind a task id, so the guard must not fire on it.
|
|
33
|
+
if (exemptCommand && cmd === exemptCommand) return {};
|
|
30
34
|
|
|
31
35
|
const isLongRunning = LONG_RUNNING_PATTERNS.some(p => p.test(cmd));
|
|
32
36
|
if (!isLongRunning) return {};
|
|
@@ -130,11 +134,13 @@ const guardFirebaseReads: HookCallback = async (input) => {
|
|
|
130
134
|
};
|
|
131
135
|
};
|
|
132
136
|
|
|
133
|
-
/** Build the hooks map to pass into SDK query() options.
|
|
134
|
-
|
|
137
|
+
/** Build the hooks map to pass into SDK query() options.
|
|
138
|
+
* `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
|
|
139
|
+
* (a `bash` schedule's command) — see forceBackgroundForScripts. */
|
|
140
|
+
export function buildHooks(opts?: { exemptBashCommand?: string }): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
|
|
135
141
|
return {
|
|
136
142
|
PreToolUse: [
|
|
137
|
-
{ matcher: 'Bash', hooks: [forceBackgroundForScripts] },
|
|
143
|
+
{ matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand)] },
|
|
138
144
|
{ matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
|
|
139
145
|
{ matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
|
|
140
146
|
],
|
|
@@ -311,6 +311,7 @@ export async function runSchedule(
|
|
|
311
311
|
mcpServers,
|
|
312
312
|
abortController,
|
|
313
313
|
onPermissionRequest,
|
|
314
|
+
foregroundBashCommand: allowedCmd ?? undefined,
|
|
314
315
|
})) {
|
|
315
316
|
onEvent({ type: 'session_stream', sessionId, event: ev });
|
|
316
317
|
if (ev.type === 'text_delta') {
|
|
@@ -331,7 +332,12 @@ export async function runSchedule(
|
|
|
331
332
|
} else if (ev.type === 'tool_result') {
|
|
332
333
|
assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
333
334
|
const use = toolUses.get(ev.toolUseId);
|
|
334
|
-
|
|
335
|
+
// LAST result wins, failures and successes alike. A latched first failure survived the
|
|
336
|
+
// agent's own successful retry of the same command, so a run that ended green was
|
|
337
|
+
// reported as a failed schedule and paged a human about a healthy check.
|
|
338
|
+
if (allowedCmd && use?.tool === 'Bash' && (use.input as any)?.command === allowedCmd) {
|
|
339
|
+
bashFailure = ev.isError ? ev.output : undefined;
|
|
340
|
+
}
|
|
335
341
|
} else if (ev.type === 'done') {
|
|
336
342
|
break;
|
|
337
343
|
} else if (ev.type === 'error') {
|