shraga 0.1.158 → 0.1.160

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.
@@ -13,8 +13,8 @@
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-z_8q_3F7.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-BLAQpfJ9.css">
16
+ <script type="module" crossorigin src="/assets/index-yBsLZ3yV.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-NtN1I_dh.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.158",
3
+ "version": "0.1.160",
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",
@@ -11,6 +11,8 @@ import { ClaudeAccountSection } from './ClaudeAccountSection';
11
11
 
12
12
  interface AgentConfig {
13
13
  model?: string;
14
+ /** Default model for scheduled runs; unset = same as `model`. */
15
+ scheduleModel?: string;
14
16
  engine?: string;
15
17
  systemPrompt?: string;
16
18
  allowedTools?: string[];
@@ -176,6 +178,18 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
176
178
  )
177
179
  : null;
178
180
 
181
+ /** The model <option>s (grouped by provider when the engine has several). `skipDefault` drops the "" entry. */
182
+ const modelOptions = (skipDefault = false) => {
183
+ const opts = (list: EngineModel[]) => list.filter((m) => !skipDefault || m.value).map((m) => (
184
+ <option key={m.value} value={m.value}>{m.label}</option>
185
+ ));
186
+ return groupedModels
187
+ ? groupedModels.map(([provider, providerModels]) => (
188
+ <optgroup key={provider} label={provider.charAt(0).toUpperCase() + provider.slice(1)}>{opts(providerModels)}</optgroup>
189
+ ))
190
+ : opts(models);
191
+ };
192
+
179
193
  const selectClass = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
180
194
 
181
195
  return (
@@ -237,20 +251,22 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
237
251
  onChange={(e) => setConfig((c) => ({ ...c, model: e.target.value || undefined }))}
238
252
  className={selectClass}
239
253
  >
240
- {groupedModels ? (
241
- groupedModels.map(([provider, providerModels]) => (
242
- <optgroup key={provider} label={provider.charAt(0).toUpperCase() + provider.slice(1)}>
243
- {providerModels.map((m) => (
244
- <option key={m.value} value={m.value}>{m.label}</option>
245
- ))}
246
- </optgroup>
247
- ))
248
- ) : (
249
- models.map((m) => (
250
- <option key={m.value} value={m.value}>{m.label}</option>
251
- ))
252
- )}
254
+ {modelOptions()}
255
+ </select>
256
+ </div>
257
+
258
+ <div>
259
+ <label className="text-sm font-medium mb-1.5 block">Scheduled tasks model</label>
260
+ <select
261
+ disabled={!canGlobal}
262
+ value={config.scheduleModel ?? ''}
263
+ onChange={(e) => setConfig((c) => ({ ...c, scheduleModel: e.target.value || undefined }))}
264
+ className={selectClass}
265
+ >
266
+ <option value="">Same as default model ({(sessionId && !applyGlobally ? globalRef.current.model : config.model) || DEFAULT_MODEL})</option>
267
+ {modelOptions(true)}
253
268
  </select>
269
+ <p className="text-xs text-muted-foreground mt-1">Used by scheduled runs whose prompt has no <code>[model:…]</code> directive.</p>
254
270
  </div>
255
271
 
256
272
  {slots.settingsSections?.({ getToken, sessionId })}
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import cronstrue from 'cronstrue';
3
3
  import type { Schedule, Trigger, Task } from '@/lib/schedule-types';
4
4
  import { Button } from '../ui/button';
@@ -7,6 +7,7 @@ import { Textarea } from '../ui/textarea';
7
7
  import { AutocompleteTextarea } from '../AutocompleteTextarea';
8
8
  import { useEngines } from '@/hooks/useEngines';
9
9
  import { readRuntimeDirective, writeRuntimeDirective } from '@/lib/prompt-directives';
10
+ import { DEFAULT_MODEL } from '../../../shared/models';
10
11
 
11
12
  interface Props {
12
13
  getToken: () => Promise<string | null>;
@@ -72,6 +73,15 @@ function MatchEditor({ match, onChange }: { match: Record<string, string>; onCha
72
73
  * drift out of sync with the prompt the way a shadow `task.model` did. */
73
74
  function RuntimePicker({ prompt, onChange, getToken }: { prompt: string; onChange: (p: string) => void; getToken: () => Promise<string | null> }) {
74
75
  const { engines } = useEngines(getToken);
76
+ // The model an unpinned scheduled run actually uses: scheduleModel > model > built-in default.
77
+ const [defaultModel, setDefaultModel] = useState<string>();
78
+ useEffect(() => {
79
+ let alive = true;
80
+ getToken().then((token) => (token ? fetch('/api/config', { headers: { Authorization: `Bearer ${token}` } }).then((r) => r.json()) : null))
81
+ .then((c) => { if (alive && c) setDefaultModel(c.scheduleModel || c.model || DEFAULT_MODEL); })
82
+ .catch((e) => console.warn('[schedules] agent config load failed', e));
83
+ return () => { alive = false; };
84
+ }, [getToken]);
75
85
  // Until the registry has loaded we cannot tell a bare model token from prose, so a rewrite could
76
86
  // leave a stale duplicate pin behind. Read-only until then.
77
87
  const ready = engines.length > 0;
@@ -111,7 +121,7 @@ function RuntimePicker({ prompt, onChange, getToken }: { prompt: string; onChang
111
121
  {engines.map((e) => <option key={e.name} value={e.name}>{e.name}</option>)}
112
122
  </select>
113
123
  <select className={selectClass} disabled={!ready} value={sel.model ?? ''} onChange={(e) => set({ model: e.target.value || undefined })}>
114
- <option value="">Default model (agent config)</option>
124
+ <option value="">Default model ({(!sel.engineExplicit && defaultModel) || 'agent config'})</option>
115
125
  {!modelListed && <option value={sel.model}>{sel.model} — not offered by this engine</option>}
116
126
  {options.map(({ engine, models }) => (
117
127
  <optgroup key={engine} label={engine}>
@@ -31,6 +31,7 @@ import { setBroadcaster, emitToSession } from './session-bus.ts';
31
31
  import { TURN_CONTROL_EVENTS } from './turn-stream.ts';
32
32
  import * as scheduler from './scheduler/index.ts';
33
33
  import { initPolls } from './polls.ts';
34
+ import { isScheduledSession } from './scheduler/runner.ts';
34
35
  import { initBackgroundJobs } from './background-jobs.ts';
35
36
  import { pushEnabled } from './push/push.ts';
36
37
  import { upsertToken, removeToken } from './push/store.ts';
@@ -1365,7 +1366,7 @@ initPolls({
1365
1366
  // NOTHING until it ended — and an add-on engine's subagent events were dropped outright — so a
1366
1367
  // turn that dispatched a worker was indistinguishable from one that only claimed to.
1367
1368
  return await consumeStream(
1368
- streamChat({ principal: fromInternal({ uid, email: userEmail, lane: 'wake' }), prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
1369
+ streamChat({ principal: fromInternal({ uid, email: userEmail, lane: 'wake' }), prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }), scheduled: isScheduledSession(sessionId) }),
1369
1370
  undefined,
1370
1371
  {
1371
1372
  maxResultChars: STREAMED_RESULT_MAX,
@@ -207,6 +207,8 @@ export interface StreamChatOpts {
207
207
  * where an add-on's contributor reads its own keys. */
208
208
  turnHints?: Record<string, unknown>;
209
209
  context?: Record<string, string>;
210
+ /** Unattended scheduled run: `config.scheduleModel` (when set) replaces `config.model` as the default. */
211
+ scheduled?: boolean;
210
212
  }
211
213
 
212
214
  /** Run one agent turn. Security wrapper: resolves the principal's role/profile and audits turn.start / turn.end.
@@ -285,8 +287,16 @@ export async function taintSession(sessionId: string, principal: Principal | (()
285
287
  return lowerSessionFloor(sessionId, resolvePrincipal(sec.policy, p).rank);
286
288
  }
287
289
 
288
- async function* runTurn(opts: StreamChatOpts, guard?: TurnGuard): AsyncGenerator<WsEvent> {
290
+ /** The agent config a turn runs under. A scheduled run swaps in `scheduleModel` as its default model —
291
+ * at the config layer, so every engine's `directives.model || config.model` picks it up and an
292
+ * explicit `[model:…]` directive still wins. */
293
+ export function effectiveAgentConfig(opts: Pick<StreamChatOpts, 'scheduled'>): AgentConfig {
289
294
  const config = getAgentConfig();
295
+ return opts.scheduled && config.scheduleModel ? { ...config, model: config.scheduleModel } : config;
296
+ }
297
+
298
+ async function* runTurn(opts: StreamChatOpts, guard?: TurnGuard): AsyncGenerator<WsEvent> {
299
+ const config = effectiveAgentConfig(opts);
290
300
  const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
291
301
 
292
302
  // An explicit model selection that resolves to nothing is a HARD stop, for the same reason an
@@ -538,11 +538,13 @@ export class ClaudeCodeEngine implements AgentEngine {
538
538
  // where the profile grants Bash — job_start is arbitrary shell — and only for a real session id
539
539
  // (wake reports need a session to land in).
540
540
  if (durableJobs) {
541
- // The job store scrubs `*_API_KEY` from children, so a nested agent (the social legs' agentx worker)
542
- // died "No provider key found" (2026-09-24). Hand back the provider keys THIS turn's own env already
543
- // carries (profile-filtered by buildAgentEnv) — the same set the agentx engine gives its jobs.
544
- const providerEnv = Object.fromEntries(Object.entries(sdkEnv).filter(([k, v]) => /_API_KEY$/.test(k) && v));
545
- const server = jobsMcpServer({ sessionId: opts.sessionId!, uid: opts.uid, userEmail: opts.userEmail, cwd, env: providerEnv });
541
+ // The job store scrubs credentials from children, but here the turn's own Bash already sees this
542
+ // env (profile-filtered by buildAgentEnv). Scrubbing it made job_start strictly weaker than Bash, and
543
+ // the hook steers long scripts INTO job_start: a nested agent died "No provider key found" (2026-09-24),
544
+ // AGF Post-Daily lost MIXPANEL_API_SECRET_AGF/GOOGLE_SERVICE_ACCOUNT (2026-10-20). Hand the job the
545
+ // turn's env, minus PORT (the server's own; a job honouring it would shadow us).
546
+ const jobEnv = Object.fromEntries(Object.entries(sdkEnv).filter(([k, v]) => v && k !== 'PORT'));
547
+ const server = jobsMcpServer({ sessionId: opts.sessionId!, uid: opts.uid, userEmail: opts.userEmail, cwd, env: jobEnv });
546
548
  options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
547
549
  options['allowedTools'] = [...((options['allowedTools'] as string[] | undefined) ?? allowedTools), ...JOBS_TOOL_IDS];
548
550
  }
@@ -182,6 +182,10 @@ function formatEventBlock(e: EventContext): string {
182
182
  return `An external event triggered this automation.\nSource: ${e.source}\nEvent data:\n\`\`\`json\n${body}\n\`\`\``;
183
183
  }
184
184
 
185
+ /** Prefix of every session id a scheduled run mints — the marker that a session belongs to the scheduler. */
186
+ export const SCHEDULED_SESSION_PREFIX = 'sched-';
187
+ export const isScheduledSession = (sessionId: string): boolean => sessionId.startsWith(SCHEDULED_SESSION_PREFIX);
188
+
185
189
  export async function runSchedule(
186
190
  schedule: Schedule,
187
191
  onEvent: (ev: object) => void,
@@ -192,7 +196,7 @@ export async function runSchedule(
192
196
  ): Promise<ScheduleRunSummary> {
193
197
  const now = Date.now();
194
198
  // Resume reuses the interrupted run's session (same convo) instead of spawning a new one.
195
- const sessionId = resume ? resume.sessionId : `sched-${schedule.id}-${now}`;
199
+ const sessionId = resume ? resume.sessionId : `${SCHEDULED_SESSION_PREFIX}${schedule.id}-${now}`;
196
200
  const abortController = new AbortController();
197
201
  registerRun(sessionId, abortController);
198
202
 
@@ -326,6 +330,7 @@ export async function runSchedule(
326
330
  abortController,
327
331
  onPermissionRequest,
328
332
  foregroundBashCommand: allowedCmd ?? undefined,
333
+ scheduled: true,
329
334
  })) {
330
335
  onEvent({ type: 'session_stream', sessionId, event: ev });
331
336
  if (ev.type === 'text_delta') {
@@ -44,6 +44,8 @@ function isFullEntry(entry: McpEntry): entry is McpFullEntry {
44
44
 
45
45
  export interface AgentSettings {
46
46
  model?: string;
47
+ /** Model for scheduled (unattended) runs whose prompt names no `[model:…]`. Unset = `model`. */
48
+ scheduleModel?: string;
47
49
  engine?: string;
48
50
  systemPrompt?: string;
49
51
  allowedTools?: string[];