shraga 0.1.93 → 0.1.95

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.
@@ -11,6 +11,7 @@
11
11
  * Only state.json answers "is the module on".
12
12
  * - Reconcile is idempotent: write-if-changed skills, upserts preserve enabled/runCount/lastRun.
13
13
  */
14
+ import { foldLegacyRuntimePin } from '../scheduler/builtins.ts';
14
15
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, rmSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
15
16
  import path from 'node:path';
16
17
  import { dataPath, DATA_DIR, PACKAGE_ROOT } from '../paths.ts';
@@ -305,6 +306,9 @@ function applyModule(rec: InstalledModule): void {
305
306
  const t = rendered.task as Record<string, unknown>;
306
307
  if (k in t && !t[k]) delete t[k];
307
308
  }
309
+ // A runtime knob is now expressed as the prompt's own `[engine:…,model:…]` directive, not as a
310
+ // shadow field — fold whatever the manifest templated into it.
311
+ foldLegacyRuntimePin(rendered.task as Record<string, any>);
308
312
  const existing = scheduler.getSchedule(id);
309
313
  const now = Date.now();
310
314
  const schedule: Schedule = {
@@ -56,6 +56,30 @@ const FAILURE_NOTIFIER_PROMPT = [
56
56
  const LEGACY_SESSION_LINE = /^.*<deployment URL>\/\?session=<sessionId>(.*)$/gm;
57
57
  const SESSION_LINE_FIX = " *Session:* <the payload's sessionUrl, verbatim — omit this line if absent>";
58
58
 
59
+ /**
60
+ * One source of truth for a prompt run's runtime: the leading `[engine:…,model:…]` directive of the
61
+ * prompt. `task.model`/`task.engine` were an INVISIBLE second copy — no UI ever showed them, which
62
+ * is how nine live schedules drifted to a half-pin (`model` set, `engine` not) that nobody could
63
+ * see or fix. Fold such a pin into the prompt exactly as runner.ts used to synthesize it, so the
64
+ * run is byte-identical for a `prompt` task and the selection is now editable where the prompt is.
65
+ * NOT byte-identical for a `promptFile` task: runner.ts treated `task.prompt` as dead text once
66
+ * `promptFile` was set, and it now survives as the directive-carrying prefix. No live schedule uses
67
+ * `promptFile`, and dropping the prefix instead would silently lose the pin these lines exist to
68
+ * preserve — so the prefix is the deliberate choice, not an oversight.
69
+ *
70
+ * Applied to schedules loaded from disk AND to a module's freshly rendered task, so a manifest that
71
+ * still templates a `model` knob keeps working instead of silently losing its pin.
72
+ * `bash`/`job` tasks have no prompt to hold a directive, so their (unreadable) pin is just dropped.
73
+ */
74
+ export function foldLegacyRuntimePin(task: Record<string, any>): void {
75
+ if (task.kind === 'prompt' && (task.model || task.engine)) {
76
+ const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
77
+ task.prompt = `[${pins.join(',')}] ${task.prompt ?? ''}`;
78
+ }
79
+ delete task.model;
80
+ delete task.engine;
81
+ }
82
+
59
83
  export function isSystemSchedule(schedule: Schedule): boolean {
60
84
  return schedule.scope === 'system';
61
85
  }
@@ -86,6 +110,16 @@ export function backfillScope(schedules: Schedule[]): void {
86
110
  if (task.kind === 'job' && task.command === 'bun run summarize:conversations') {
87
111
  task.command = SUMMARIZER_CMD;
88
112
  }
113
+ // Undo the `engine: 'claude-code'` pin reconcile stamped onto the failure notifier. It shipped on
114
+ // a premise the box's own logs disprove — nothing was ever misrouted; the deployment was simply
115
+ // globally on claude-code then and on agentx now — and it made the alarm the second casualty of
116
+ // the real failure mode (an org cap on the pinned provider). Narrow on purpose: exactly the
117
+ // state that clause produced (this id, that engine, no model), so it cannot eat a real choice.
118
+ if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && task.engine === 'claude-code' && !task.model) {
119
+ delete task.engine;
120
+ }
121
+ foldLegacyRuntimePin(task);
122
+
89
123
  // Heal a persisted failure-notifier prompt still carrying the un-supplied "<deployment URL>"
90
124
  // placeholder — reconcile won't touch task.prompt, so this is the only path that reaches it.
91
125
  if (s.id === FAILURE_NOTIFIER_SCHEDULE_ID && typeof task.prompt === 'string') {
@@ -138,11 +172,7 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
138
172
  match: { status: 'error' },
139
173
  throttle: { byFields: ['name', 'error'], windowSec: 21600 },
140
174
  },
141
- // Pinned to the always-registered engine ON PURPOSE. Left unpinned, this run inherits whatever
142
- // agent-config.json's global `engine` happens to be — and an optional engine can simply not
143
- // register on a given boot (add-on absent, missing API key), which is exactly the failure this
144
- // job exists to report. Its own alert must not be the second casualty.
145
- task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT, engine: 'claude-code' },
175
+ task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT },
146
176
  scope: 'system',
147
177
  createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
148
178
  createdAt: now,
@@ -167,16 +197,8 @@ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
167
197
  existing.scope = builtin.scope;
168
198
  existing.trigger = existing.trigger ?? builtin.trigger;
169
199
  existing.createdBy = builtin.createdBy;
170
- // A builtin's stored task is preserved (deployments edit the prompt) with one exception: an
171
- // engine/model pin is only meaningful as a PAIR. A stored task with a `model` but no `engine`
172
- // was resolved against whatever the ambient global config was at the time, which is how the
173
- // failure notifier ended up pinned to `composer-2.5` while running on claude-code. So when the
174
- // builtin pins an engine and the stored task pins NONE, adopt the builtin's pair wholesale.
175
- // An explicit stored `engine` is a real operator choice and is left untouched.
176
- if (builtin.task.kind !== 'job' && existing.task.kind !== 'job' && builtin.task.engine && !existing.task.engine) {
177
- existing.task.engine = builtin.task.engine;
178
- existing.task.model = builtin.task.model;
179
- }
200
+ // A builtin's stored task is otherwise preserved deployments edit the prompt (and with it
201
+ // the runtime directive), and those edits must survive an upgrade.
180
202
  } else {
181
203
  schedules.push(builtin);
182
204
  }
@@ -9,6 +9,17 @@ import type { Schedule, ScheduleRunSummary } from './types.ts';
9
9
  import { updateRunLockPid, clearRunningMarker, loadSchedules } from './storage.ts';
10
10
  import { readOutcome, clearOutcome, pendingDeadline, outcomePrompt, MAX_PENDING_MS } from './outcome.ts';
11
11
  import { addUnread } from '../unread.ts';
12
+ import { splitDirectiveGroups } from '../directives.ts';
13
+
14
+ /** The prompt a `prompt` task runs, from wherever it is stored. Its leading `[…]` directive is the
15
+ * task's runtime selection — there is no separate stored pin.
16
+ * With a `promptFile`, `prompt` is a PREFIX rather than dead text: the directive has to live
17
+ * somewhere the editor can rewrite, and that must not be someone's workspace file. */
18
+ function taskPromptText(task: { prompt?: string; promptFile?: string }): string {
19
+ if (!task.promptFile) return task.prompt ?? '';
20
+ const body = readFileSync(resolvePromptFile(task.promptFile), 'utf-8').trim();
21
+ return task.prompt ? `${task.prompt.trim()} ${body}` : body;
22
+ }
12
23
 
13
24
  export interface RunContext {
14
25
  sessionId: string;
@@ -194,13 +205,19 @@ export async function runSchedule(
194
205
 
195
206
  let prompt: string;
196
207
  if (resume) {
197
- // The original task prompt is already in the conversation from the interrupted run.
208
+ // The original task prompt is already in the conversation from the interrupted run — but a
209
+ // resume prompt is a fresh "continue where you left off" string, not the saved original, so the
210
+ // task's own leading `[engine:…,model:…]` directive has to be carried onto it. Without that the
211
+ // resumed turn runs on the global default: a different engine, and a different vendor's bill.
198
212
  prompt = resume.prompt;
213
+ const groups = task.kind === 'prompt' ? splitDirectiveGroups(taskPromptText(task), { strict: true }).groups : [];
214
+ const raw = groups.filter(Boolean).join(',');
215
+ if (raw) prompt = `[${raw}] ${prompt}`;
199
216
  } else if (task.kind === 'bash') {
200
217
  const cmd = override || task.command;
201
218
  prompt = `Run exactly this bash command and report the result concisely:\n\n\`\`\`bash\n${cmd}\n\`\`\``;
202
219
  } else {
203
- let base = task.promptFile ? readFileSync(resolvePromptFile(task.promptFile), 'utf-8').trim() : (task.prompt ?? '');
220
+ let base = taskPromptText(task);
204
221
  if (override) base = `${base}\n\n---\nAdditional instructions for this run:\n${override}`;
205
222
  if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
206
223
  prompt = base;
@@ -216,15 +233,6 @@ export async function runSchedule(
216
233
  prompt = `${prompt}\n\n---\n${outcomePrompt(sessionId)}`;
217
234
  }
218
235
 
219
- // task.engine/task.model ride the same prompt-directive channel users type by hand —
220
- // parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
221
- // choice into the saved prompt, so the session UI shows what the schedule actually requested.
222
- // Applied on RESUME too: a resume's prompt is a fresh "continue where you left off" string, not the
223
- // saved original, so skipping this here silently dropped the schedule's engine/model pin and let the
224
- // resumed turn run on the global default — a different engine, and a different vendor's bill.
225
- const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
226
- if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
227
-
228
236
  // Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
229
237
  if (!resume) {
230
238
  appendMessage(sessionId, {
@@ -19,11 +19,16 @@ export interface EventThrottle {
19
19
  windowSec: number;
20
20
  }
21
21
 
22
- /** `engine`/`model` pin the runtime a run uses (e.g. engine 'agentx' + model
23
- * 'cursor/composer-2.5'); both ride the prompt-directive channelsee runner.ts. */
22
+ /** The runtime a prompt run uses is NOT a field here: it is the leading `[engine:…,model:…]`
23
+ * directive of the prompt itself (or of the promptFile's contents) one source of truth, editable
24
+ * and visible in the same place the prompt is. A separate stored pin shadowed it invisibly, which
25
+ * is how nine live schedules ended up half-pinned (`model` set, `engine` not) with no UI to see it.
26
+ * `bash` and `job` carry no selector: `bash` has no prompt to hold a directive (its prompt is a
27
+ * fixed synthesized wrapper) and `job` never reaches an agent at all — both follow agent config.
28
+ * Pin a shell command's runtime by writing a prompt task that runs it. */
24
29
  export type Task =
25
- | { kind: 'prompt'; prompt?: string; promptFile?: string; model?: string; engine?: string }
26
- | { kind: 'bash'; command: string; model?: string; engine?: string }
30
+ | { kind: 'prompt'; prompt?: string; promptFile?: string }
31
+ | { kind: 'bash'; command: string }
27
32
  | { kind: 'job'; command: string };
28
33
 
29
34
  /** Visibility: 'system' schedules + their sessions are shared with all whitelisted users; 'user' is private to createdBy. */