opencode-jobs 0.1.0

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/index.js ADDED
@@ -0,0 +1,1312 @@
1
+ // @bun
2
+ // src/tools.ts
3
+ import {
4
+ tool
5
+ } from "@opencode-ai/plugin";
6
+ import {
7
+ closeSync,
8
+ existsSync as existsSync4,
9
+ mkdirSync as mkdirSync3,
10
+ openSync,
11
+ readFileSync as readFileSync4,
12
+ rmSync as rmSync2
13
+ } from "fs";
14
+ import { spawn } from "child_process";
15
+ import path6 from "path";
16
+
17
+ // src/cron.ts
18
+ var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
19
+ var DOW_NAMES = {
20
+ sun: 0,
21
+ mon: 1,
22
+ tue: 2,
23
+ wed: 3,
24
+ thu: 4,
25
+ fri: 5,
26
+ sat: 6
27
+ };
28
+ var MONTH_NAMES = {
29
+ jan: 1,
30
+ feb: 2,
31
+ mar: 3,
32
+ apr: 4,
33
+ may: 5,
34
+ jun: 6,
35
+ jul: 7,
36
+ aug: 8,
37
+ sep: 9,
38
+ oct: 10,
39
+ nov: 11,
40
+ dec: 12
41
+ };
42
+ function parseBound(raw, field, min, max, names) {
43
+ const trimmed = raw.trim().toLowerCase();
44
+ const named = names?.[trimmed];
45
+ if (named !== undefined)
46
+ return named;
47
+ if (!/^\d+$/.test(trimmed))
48
+ throw new Error(`Invalid ${field} value "${raw}" in cron expression`);
49
+ const value = Number(trimmed);
50
+ if (value < min || value > max)
51
+ throw new Error(`${field} value ${String(value)} out of range ${String(min)}-${String(max)} in cron expression`);
52
+ return value;
53
+ }
54
+ function parseCronField(raw, field, min, max, names) {
55
+ if (raw.length === 0)
56
+ throw new Error(`Empty ${field} field in cron expression`);
57
+ const values = new Set;
58
+ for (const part of raw.split(",")) {
59
+ if (part.length === 0)
60
+ throw new Error(`Empty ${field} item in cron expression "${raw}"`);
61
+ const [rangePart = "", stepPart] = part.split("/", 2);
62
+ if (stepPart !== undefined && (!/^\d+$/.test(stepPart) || Number(stepPart) < 1)) {
63
+ throw new Error(`Invalid step "/${stepPart}" in ${field} field`);
64
+ }
65
+ const step = stepPart === undefined ? 1 : Number(stepPart);
66
+ let lo;
67
+ let hi;
68
+ if (rangePart === "*") {
69
+ lo = min;
70
+ hi = max;
71
+ } else if (rangePart.includes("-")) {
72
+ const pieces = rangePart.split("-");
73
+ if (pieces.length !== 2)
74
+ throw new Error(`Invalid range "${rangePart}" in ${field} field`);
75
+ const [loPart = "", hiPart = ""] = pieces;
76
+ lo = parseBound(loPart, field, min, max, names);
77
+ hi = parseBound(hiPart, field, min, max, names);
78
+ if (lo > hi)
79
+ throw new Error(`Descending range "${rangePart}" in ${field} field`);
80
+ } else {
81
+ lo = parseBound(rangePart, field, min, max, names);
82
+ hi = stepPart === undefined ? lo : max;
83
+ }
84
+ for (let v = lo;v <= hi; v += step) {
85
+ values.add(field === "dow" && v === 7 ? 0 : v);
86
+ }
87
+ }
88
+ return [...values].toSorted((a, b) => a - b);
89
+ }
90
+ function parseCron(expression) {
91
+ const fields = expression.trim().split(/\s+/);
92
+ if (fields.length !== 5)
93
+ throw new Error(`Expected 5-field cron expression, got ${String(fields.length)} fields: "${expression}"`);
94
+ const [minute = "", hour = "", dom = "", month = "", dow = ""] = fields;
95
+ return {
96
+ minute: parseCronField(minute, "minute", 0, 59),
97
+ hour: parseCronField(hour, "hour", 0, 23),
98
+ dom: parseCronField(dom, "day of month", 1, 31),
99
+ month: parseCronField(month, "month", 1, 12, MONTH_NAMES),
100
+ dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
101
+ };
102
+ }
103
+ function pad2(value) {
104
+ return String(value).padStart(2, "0");
105
+ }
106
+ function fmtList(values) {
107
+ return values.map((v) => pad2(v)).join(",");
108
+ }
109
+ function cronToOnCalendar(sets) {
110
+ const timePart = `${fmtList(sets.hour)}:${fmtList(sets.minute)}:00`;
111
+ const months = sets.month.length === 12 ? "*" : fmtList(sets.month);
112
+ const doms = sets.dom.length === 31 ? "*" : fmtList(sets.dom);
113
+ const dows = sets.dow.length === 7 ? "" : `${sets.dow.map((d) => DOW_LABELS[d]).join(",")} `;
114
+ if (sets.dow.length === 7)
115
+ return [`*-${months}-${doms} ${timePart}`];
116
+ if (sets.dom.length === 31 && sets.month.length === 12)
117
+ return [`${dows}*-*-* ${timePart}`];
118
+ return [
119
+ `${dows}*-${months}-* ${timePart}`,
120
+ `*-${months}-${doms} ${timePart}`
121
+ ];
122
+ }
123
+ function describeCron(sets) {
124
+ const timeDesc = sets.minute.length === 1 && sets.hour.length === 1 ? `at ${pad2(sets.hour[0] ?? 0)}:${pad2(sets.minute[0] ?? 0)}` : `at minute ${sets.minute.join(",")} of hour ${sets.hour.join(",")}`;
125
+ const isDowAll = sets.dow.length === 7;
126
+ const isDomAll = sets.dom.length === 31;
127
+ let dayDesc;
128
+ if (isDowAll && isDomAll)
129
+ dayDesc = "every day";
130
+ else if (isDowAll)
131
+ dayDesc = `on day ${sets.dom.join(",")} of the month`;
132
+ else if (isDomAll)
133
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")}`;
134
+ else
135
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")} or day ${sets.dom.join(",")}`;
136
+ const monthDesc = sets.month.length === 12 ? "" : ` in month ${sets.month.join(",")}`;
137
+ return `${timeDesc} ${dayDesc}${monthDesc}`;
138
+ }
139
+
140
+ // src/paths.ts
141
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from "fs";
142
+ import { createHash } from "crypto";
143
+ import path from "path";
144
+ import { homedir } from "os";
145
+ function configRoot() {
146
+ return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
147
+ }
148
+ function schedulerDirectory() {
149
+ return path.join(configRoot(), "opencode", "scheduler");
150
+ }
151
+ function registryPath() {
152
+ return path.join(schedulerDirectory(), "registry.json");
153
+ }
154
+ function scopeDirectory(scopeId) {
155
+ return path.join(schedulerDirectory(), "scopes", scopeId);
156
+ }
157
+ function runsDirectory(scopeId) {
158
+ return path.join(schedulerDirectory(), "runs", scopeId);
159
+ }
160
+ function runsFile(scopeId, slug) {
161
+ return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
162
+ }
163
+ function sessionStateDirectory(scopeId) {
164
+ return path.join(schedulerDirectory(), "sessions", scopeId);
165
+ }
166
+ function sessionStateFile(scopeId, slug) {
167
+ return path.join(sessionStateDirectory(scopeId), `${slug}.txt`);
168
+ }
169
+ function logDirectory(scopeId) {
170
+ return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
171
+ }
172
+ function logFile(scopeId, slug) {
173
+ return path.join(logDirectory(scopeId), `${slug}.log`);
174
+ }
175
+ function systemdUserDirectory() {
176
+ return path.join(configRoot(), "systemd", "user");
177
+ }
178
+ function jobsDirectory(workdir) {
179
+ return path.join(workdir, ".opencode", "scheduler", "jobs");
180
+ }
181
+ function unitBase(scopeId, slug) {
182
+ return `opencode-sched-${scopeId}-${slug}`;
183
+ }
184
+ function timerUnit(base) {
185
+ return `${base}.timer`;
186
+ }
187
+ function serviceUnit(base) {
188
+ return `${base}.service`;
189
+ }
190
+ function runScriptPath(scopeId, slug) {
191
+ return path.join(scopeDirectory(scopeId), `run-${slug}.sh`);
192
+ }
193
+ function slugify(input) {
194
+ const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
195
+ return slug.length > 0 ? slug : "job";
196
+ }
197
+ function shQuote(value) {
198
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
199
+ }
200
+ function unitQuote(value) {
201
+ if (/^[A-Za-z0-9_@:=./-]*$/.test(value))
202
+ return value;
203
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}"`;
204
+ }
205
+ function escapeUnitText(value) {
206
+ return value.replaceAll("%", "%%").replaceAll(/\s+/g, " ").trim();
207
+ }
208
+ function atomicWrite(file, content) {
209
+ mkdirSync(path.dirname(file), { recursive: true });
210
+ const temporary = `${file}.tmp`;
211
+ writeFileSync(temporary, content);
212
+ renameSync(temporary, file);
213
+ }
214
+ function atomicWriteExecutable(file, content) {
215
+ atomicWrite(file, content);
216
+ chmodSync(file, 493);
217
+ }
218
+ function nowIso() {
219
+ return new Date().toISOString();
220
+ }
221
+ function deriveScopeId(workdir) {
222
+ const abs = path.resolve(workdir);
223
+ const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
224
+ return `${slugify(path.basename(abs))}-${hash}`;
225
+ }
226
+
227
+ // src/job.ts
228
+ import { existsSync, readFileSync, readdirSync } from "fs";
229
+ import path2 from "path";
230
+
231
+ // src/json.ts
232
+ function isRecord(value) {
233
+ return typeof value === "object" && value !== null;
234
+ }
235
+ function stringProperty(record, key) {
236
+ const value = record[key];
237
+ return typeof value === "string" ? value : undefined;
238
+ }
239
+ function numberProperty(record, key) {
240
+ const value = record[key];
241
+ return typeof value === "number" ? value : undefined;
242
+ }
243
+ function errorMessage(error) {
244
+ return error instanceof Error ? error.message : String(error);
245
+ }
246
+
247
+ // src/job.ts
248
+ var SESSION_MODES = [
249
+ "new",
250
+ "persist",
251
+ "compact",
252
+ "compact+last"
253
+ ];
254
+ function nonEmptyString(value) {
255
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
256
+ }
257
+ function validateRunSpec(run, context) {
258
+ if (!isRecord(run))
259
+ throw new Error(`${context}: "run" must be an object`);
260
+ const prompt = nonEmptyString(run.prompt);
261
+ const command = nonEmptyString(run.command);
262
+ if (prompt !== undefined === (command !== undefined)) {
263
+ throw new Error(`${context}: "run" must set exactly one of "prompt" (natural language) or "command" (custom command name)`);
264
+ }
265
+ const commandArguments = stringProperty(run, "arguments");
266
+ const agent = stringProperty(run, "agent");
267
+ const model = stringProperty(run, "model");
268
+ if (command !== undefined) {
269
+ return {
270
+ command,
271
+ ...commandArguments !== undefined && { arguments: commandArguments },
272
+ ...agent !== undefined && { agent },
273
+ ...model !== undefined && { model }
274
+ };
275
+ }
276
+ if (commandArguments !== undefined) {
277
+ throw new Error(`${context}: "run.arguments" only applies to command jobs`);
278
+ }
279
+ return {
280
+ prompt: prompt ?? "",
281
+ ...agent !== undefined && { agent },
282
+ ...model !== undefined && { model }
283
+ };
284
+ }
285
+ function validateSession(value, context) {
286
+ if (value === undefined)
287
+ return "new";
288
+ const mode = typeof value === "string" ? SESSION_MODES.find((candidate) => candidate === value) : undefined;
289
+ if (mode === undefined) {
290
+ throw new Error(`${context}: "session" must be one of ${SESSION_MODES.map((candidate) => `"${candidate}"`).join(", ")}`);
291
+ }
292
+ return mode;
293
+ }
294
+ function validateTimeout(value, context) {
295
+ if (value === undefined)
296
+ return;
297
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
298
+ throw new Error(`${context}: "timeoutSeconds" must be a non-negative integer`);
299
+ }
300
+ return value;
301
+ }
302
+ function validateGuard(value, context) {
303
+ if (value === undefined)
304
+ return;
305
+ if (typeof value !== "string" || value.trim().length === 0) {
306
+ throw new Error(`${context}: "guard" must be a non-empty shell command string`);
307
+ }
308
+ return value;
309
+ }
310
+ function loadJobFile(file, expectedSlug) {
311
+ const stem = path2.basename(file).replaceAll(/\.json$/g, "");
312
+ try {
313
+ const object = JSON.parse(readFileSync(file, "utf8"));
314
+ if (!isRecord(object))
315
+ return { ok: false, error: `${stem}.json: not a job object` };
316
+ const slug = stringProperty(object, "slug") ?? stem;
317
+ if (slug !== stem)
318
+ return {
319
+ ok: false,
320
+ error: `${stem}.json: "slug" ("${slug}") must match filename`
321
+ };
322
+ if (expectedSlug !== undefined && slug !== expectedSlug)
323
+ return { ok: false, error: `${stem}.json: unexpected slug` };
324
+ const name = nonEmptyString(object.name);
325
+ if (name === undefined)
326
+ return { ok: false, error: `${stem}.json: "name" is required` };
327
+ const schedule = stringProperty(object, "schedule") ?? "";
328
+ try {
329
+ parseCron(schedule);
330
+ } catch (error) {
331
+ return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
332
+ }
333
+ const run = validateRunSpec(object.run, `${stem}.json`);
334
+ const session = validateSession(object.session, `${stem}.json`);
335
+ const guard = validateGuard(object.guard, `${stem}.json`);
336
+ const timeoutSeconds = validateTimeout(object.timeoutSeconds, `${stem}.json`);
337
+ return {
338
+ ok: true,
339
+ job: {
340
+ slug,
341
+ name,
342
+ schedule,
343
+ run,
344
+ ...session !== "new" && { session },
345
+ ...guard !== undefined && { guard },
346
+ ...timeoutSeconds !== undefined && { timeoutSeconds },
347
+ createdAt: stringProperty(object, "createdAt") ?? nowIso(),
348
+ updatedAt: stringProperty(object, "updatedAt") ?? nowIso()
349
+ }
350
+ };
351
+ } catch (error) {
352
+ return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
353
+ }
354
+ }
355
+ function loadJobs(workdir) {
356
+ const directory = jobsDirectory(workdir);
357
+ if (!existsSync(directory))
358
+ return { jobs: [], errors: [] };
359
+ const jobs = [];
360
+ const errors = [];
361
+ const entries = readdirSync(directory, { withFileTypes: true });
362
+ for (const entry of entries) {
363
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
364
+ continue;
365
+ const result = loadJobFile(path2.join(directory, entry.name));
366
+ if (result.ok)
367
+ jobs.push(result.job);
368
+ else
369
+ errors.push(result.error);
370
+ }
371
+ return {
372
+ jobs: jobs.toSorted((a, b) => a.slug.localeCompare(b.slug)),
373
+ errors
374
+ };
375
+ }
376
+ function saveJob(workdir, job) {
377
+ atomicWrite(path2.join(jobsDirectory(workdir), `${job.slug}.json`), `${JSON.stringify(job, undefined, 2)}
378
+ `);
379
+ }
380
+
381
+ // src/registry.ts
382
+ import { readFileSync as readFileSync2 } from "fs";
383
+ import path3 from "path";
384
+ function isRegistryEntry(value) {
385
+ if (!isRecord(value))
386
+ return false;
387
+ return stringProperty(value, "scopeId") !== undefined && stringProperty(value, "workdir") !== undefined && stringProperty(value, "enabledAt") !== undefined && stringProperty(value, "updatedAt") !== undefined && Array.isArray(value.jobs) && value.jobs.every((job) => typeof job === "string");
388
+ }
389
+ function loadRegistry() {
390
+ try {
391
+ const parsed = JSON.parse(readFileSync2(registryPath(), "utf8"));
392
+ if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.projects)) {
393
+ return { version: 1, projects: {} };
394
+ }
395
+ const projects = {};
396
+ for (const [key, value] of Object.entries(parsed.projects)) {
397
+ if (isRegistryEntry(value))
398
+ projects[key] = value;
399
+ }
400
+ return { version: 1, projects };
401
+ } catch {
402
+ return { version: 1, projects: {} };
403
+ }
404
+ }
405
+ function saveRegistry(registry) {
406
+ atomicWrite(registryPath(), `${JSON.stringify(registry, undefined, 2)}
407
+ `);
408
+ }
409
+ function registryEntry(workdir) {
410
+ return loadRegistry().projects[path3.resolve(workdir)];
411
+ }
412
+
413
+ // src/runs.ts
414
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
415
+ function parseRunRecord(value) {
416
+ const runId = stringProperty(value, "runId");
417
+ const slug = stringProperty(value, "slug");
418
+ const scopeId = stringProperty(value, "scopeId");
419
+ const startedAt = numberProperty(value, "startedAt");
420
+ const finishedAt = numberProperty(value, "finishedAt");
421
+ const durationMs = numberProperty(value, "durationMs");
422
+ const status = stringProperty(value, "status");
423
+ const exitCode = numberProperty(value, "exitCode");
424
+ const sessionId = stringProperty(value, "sessionId");
425
+ const startedBy = stringProperty(value, "startedBy");
426
+ return {
427
+ ...runId !== undefined && { runId },
428
+ ...slug !== undefined && { slug },
429
+ ...scopeId !== undefined && { scopeId },
430
+ ...startedAt !== undefined && { startedAt },
431
+ ...finishedAt !== undefined && { finishedAt },
432
+ ...durationMs !== undefined && { durationMs },
433
+ ...status !== undefined && { status },
434
+ ...exitCode !== undefined && { exitCode },
435
+ ...sessionId !== undefined && { sessionId },
436
+ ...startedBy !== undefined && { startedBy }
437
+ };
438
+ }
439
+ function readRunRecords(scopeId, slug, limit) {
440
+ const file = runsFile(scopeId, slug);
441
+ if (!existsSync2(file))
442
+ return [];
443
+ const records = [];
444
+ for (const line of readFileSync3(file, "utf8").split(`
445
+ `)) {
446
+ if (line.trim().length === 0)
447
+ continue;
448
+ try {
449
+ const value = JSON.parse(line);
450
+ if (isRecord(value))
451
+ records.push(parseRunRecord(value));
452
+ } catch {}
453
+ }
454
+ return records.slice(-limit);
455
+ }
456
+ function lastFinishedRun(records) {
457
+ for (const record of records.toReversed()) {
458
+ if (record.status !== undefined && record.status !== "running")
459
+ return record;
460
+ }
461
+ return;
462
+ }
463
+ function timestampOf(record) {
464
+ if (record.finishedAt !== undefined)
465
+ return new Date(record.finishedAt * 1000).toISOString();
466
+ if (record.startedAt !== undefined)
467
+ return new Date(record.startedAt * 1000).toISOString();
468
+ return "?";
469
+ }
470
+ function formatRunLine(record) {
471
+ const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
472
+ const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
473
+ const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
474
+ return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session} via ${record.startedBy ?? "?"}`;
475
+ }
476
+ function tailFile(file, lines, maxChars) {
477
+ if (!existsSync2(file))
478
+ return;
479
+ const content = readFileSync3(file, "utf8").trimEnd();
480
+ if (content.length === 0)
481
+ return "";
482
+ const tail = content.split(`
483
+ `).slice(-lines).join(`
484
+ `);
485
+ return tail.length > maxChars ? `...${tail.slice(-maxChars)}` : tail;
486
+ }
487
+
488
+ // src/systemd.ts
489
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync } from "fs";
490
+ import { spawnSync } from "child_process";
491
+ import path4 from "path";
492
+ import { homedir as homedir2 } from "os";
493
+ function findOpencode() {
494
+ const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
495
+ if (override !== undefined && override.length > 0)
496
+ return override;
497
+ const which = spawnSync("sh", ["-c", "command -v opencode"], {
498
+ encoding: "utf8"
499
+ });
500
+ const onPath = which.stdout.trim();
501
+ if (which.status === 0 && onPath.length > 0)
502
+ return onPath;
503
+ const candidates = [
504
+ path4.join(homedir2(), ".opencode/bin/opencode"),
505
+ "/usr/local/bin/opencode",
506
+ "/usr/bin/opencode"
507
+ ];
508
+ for (const candidate of candidates) {
509
+ if (existsSync3(candidate))
510
+ return candidate;
511
+ }
512
+ return "opencode";
513
+ }
514
+ function guardScriptLines(guard) {
515
+ return [
516
+ `guard=${shQuote(guard)}`,
517
+ 'sh -c "$guard"',
518
+ "guard_code=$?",
519
+ 'if [ "$guard_code" -ne 0 ]; then',
520
+ ' echo "guard exited $guard_code, skipping run"',
521
+ ' finish skipped "$guard_code"',
522
+ " exit 0",
523
+ "fi"
524
+ ];
525
+ }
526
+ var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
527
+ var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
528
+ var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
529
+ function extractSessionIdLines(target, indent = "") {
530
+ return [
531
+ `${indent}${target}="$(sed -n '${SESSION_ID_SED}' "$json_out" | head -n 1)"`
532
+ ];
533
+ }
534
+ function compactSessionLines() {
535
+ return [
536
+ "compact_session() {",
537
+ ' csid="$1"',
538
+ " if ! command -v curl >/dev/null 2>&1; then",
539
+ ' echo "scheduler: curl not available, skipping compaction"',
540
+ " return 0",
541
+ " fi",
542
+ ' serve_out="$(mktemp)"',
543
+ ' serve_err="$(mktemp)"',
544
+ ` OPENCODE_CONFIG_CONTENT='{"compaction":{"tail_turns":0}}' "$oc_bin" serve --port 0 >"$serve_out" 2>"$serve_err" &`,
545
+ " serve_pid=$!",
546
+ ' serve_port=""',
547
+ " tries=0",
548
+ ' while [ "$tries" -lt 100 ]; do',
549
+ String.raw` serve_port="$(sed -n 's/.*listening on http:\/\/127\.0\.0\.1:\([0-9][0-9]*\).*/\1/p' "$serve_out" | head -n 1)"`,
550
+ ' if [ -n "$serve_port" ]; then break; fi',
551
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
552
+ " sleep 0.1",
553
+ " tries=$((tries + 1))",
554
+ " done",
555
+ ' if [ -z "$serve_port" ]; then',
556
+ ' echo "scheduler: compaction server failed to start"',
557
+ ' sed -n "1,10p" "$serve_err" >&2',
558
+ ' kill "$serve_pid" 2>/dev/null',
559
+ ' wait "$serve_pid" 2>/dev/null',
560
+ ' rm -f "$serve_out" "$serve_err"',
561
+ " return 0",
562
+ " fi",
563
+ " healthy=0",
564
+ " tries=0",
565
+ ' while [ "$tries" -lt 50 ]; do',
566
+ ' if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$serve_port/global/health"; then',
567
+ " healthy=1",
568
+ " break",
569
+ " fi",
570
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
571
+ " sleep 0.2",
572
+ " tries=$((tries + 1))",
573
+ " done",
574
+ ' if [ "$healthy" -ne 1 ]; then',
575
+ ' echo "scheduler: compaction server never became healthy"',
576
+ ' kill "$serve_pid" 2>/dev/null',
577
+ ' wait "$serve_pid" 2>/dev/null',
578
+ ' rm -f "$serve_out" "$serve_err"',
579
+ " return 0",
580
+ " fi",
581
+ ' cs_provider=""',
582
+ ' cs_model=""',
583
+ ' case "$oc_model" in',
584
+ " */*)",
585
+ ' cs_provider="${oc_model%%/*}"',
586
+ ' cs_model="${oc_model#*/}"',
587
+ " ;;",
588
+ " esac",
589
+ ' if [ -z "$cs_provider" ]; then',
590
+ ' defaults="$(curl -s --max-time 10 "http://127.0.0.1:$serve_port/config/providers")"',
591
+ String.raw` cs_provider="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_PROVIDER_SED}' | head -n 1)"`,
592
+ String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
593
+ " fi",
594
+ ' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
595
+ ' echo "scheduler: could not resolve a model for compaction, skipping"',
596
+ ' kill "$serve_pid" 2>/dev/null',
597
+ ' wait "$serve_pid" 2>/dev/null',
598
+ ' rm -f "$serve_out" "$serve_err"',
599
+ " return 0",
600
+ " fi",
601
+ ' echo "scheduler: compacting session $csid (mode: $session_mode)"',
602
+ String.raw` result="$(curl -s --max-time 900 -X POST -H 'content-type: application/json' -d "{\"providerID\":\"$cs_provider\",\"modelID\":\"$cs_model\"}" "http://127.0.0.1:$serve_port/session/$csid/summarize")"`,
603
+ ' if [ "$result" != "true" ]; then',
604
+ ' echo "scheduler: compaction failed: $result"',
605
+ " fi",
606
+ ' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
607
+ String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
608
+ ` http="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 -X POST -H 'content-type: application/json' -d "$inject_body" "http://127.0.0.1:$serve_port/session/$csid/message")"`,
609
+ ' if [ "$http" != "200" ]; then',
610
+ ' echo "scheduler: keeping last result failed (HTTP $http)"',
611
+ " fi",
612
+ " fi",
613
+ ' kill "$serve_pid" 2>/dev/null',
614
+ ' wait "$serve_pid" 2>/dev/null',
615
+ ' rm -f "$serve_out" "$serve_err"',
616
+ "}"
617
+ ];
618
+ }
619
+ function runScriptContent(job, scopeId, opencodeBin) {
620
+ const mode = job.session ?? "new";
621
+ const isTracked = mode !== "new";
622
+ const isCompact = mode === "compact" || mode === "compact+last";
623
+ const isKeepLast = mode === "compact+last";
624
+ return [
625
+ "#!/bin/sh",
626
+ "set -u",
627
+ `slug=${shQuote(job.slug)}`,
628
+ `scope=${shQuote(scopeId)}`,
629
+ `oc_bin=${shQuote(opencodeBin)}`,
630
+ 'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
631
+ 'runs="$config_root/opencode/scheduler/runs/$scope"',
632
+ 'mkdir -p "$runs"',
633
+ 'record_file="$runs/$slug.jsonl"',
634
+ ...isTracked ? [
635
+ 'sessions="$config_root/opencode/scheduler/sessions/$scope"',
636
+ 'mkdir -p "$sessions"',
637
+ 'state_file="$sessions/$slug.txt"',
638
+ `session_mode=${shQuote(mode)}`,
639
+ 'prev_session=""',
640
+ 'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
641
+ ] : [],
642
+ 'started_by="${OPENCODE_SCHEDULER_STARTED_BY:-scheduled}"',
643
+ 'run_id="$(date +%s%N)-$$"',
644
+ "started=$(date +%s)",
645
+ 'new_session=""',
646
+ `export OPENCODE_PERMISSION='{"question":"deny"}'`,
647
+ 'export OPENCODE_SCHEDULER_RUN_ID="$run_id"',
648
+ "finish() {",
649
+ ' status="$1"',
650
+ ' code="$2"',
651
+ " ended=$(date +%s)",
652
+ String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" >> "$record_file"`,
653
+ "}",
654
+ "trap 'finish timeout 124; exit 124' TERM INT",
655
+ ...job.guard === undefined ? [] : guardScriptLines(job.guard),
656
+ String.raw`printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"startedBy":"%s","status":"running"}\n' "$run_id" "$slug" "$scope" "$started" "$started_by" >> "$record_file"`,
657
+ `oc_agent=${shQuote(job.run.agent ?? "")}`,
658
+ `oc_model=${shQuote(job.run.model ?? "")}`,
659
+ "prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
660
+ `oc_command=${shQuote("command" in job.run ? job.run.command : "")}`,
661
+ `oc_args=${shQuote("command" in job.run ? job.run.arguments ?? "" : "")}`,
662
+ `oc_prompt=${shQuote("prompt" in job.run ? job.run.prompt : "")}`,
663
+ `oc_keep_last=${String(isKeepLast ? 1 : 0)}`,
664
+ "run_opencode() {",
665
+ ' sess="$1"',
666
+ ' use_json="$2"',
667
+ " set -- run",
668
+ ' if [ -n "$oc_agent" ]; then set -- "$@" --agent "$oc_agent"; fi',
669
+ ' if [ -n "$oc_model" ]; then set -- "$@" --model "$oc_model"; fi',
670
+ ' if [ -n "$sess" ]; then set -- "$@" --session "$sess"; fi',
671
+ ' if [ "$use_json" -eq 1 ]; then set -- "$@" --format json; fi',
672
+ ' if [ "$oc_command_mode" -eq 1 ]; then',
673
+ ' set -- "$@" --command "$oc_command" -- "$oc_args"',
674
+ " else",
675
+ ' set -- "$@" -- "$oc_prompt"',
676
+ " fi",
677
+ ' "$oc_bin" "$@"',
678
+ "}",
679
+ ...isCompact ? compactSessionLines() : [],
680
+ ...isTracked ? [
681
+ 'json_out="$(mktemp)"',
682
+ 'run_opencode "$prev_session" 1 >"$json_out" 2>&1',
683
+ "code=$?",
684
+ 'cat "$json_out"',
685
+ ...extractSessionIdLines("new_session"),
686
+ 'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
687
+ ' echo "scheduler: session $prev_session not found, retrying with a fresh session"',
688
+ ' rm -f "$json_out"',
689
+ ' json_out="$(mktemp)"',
690
+ ' run_opencode "" 1 >"$json_out" 2>&1',
691
+ " code=$?",
692
+ ' cat "$json_out"',
693
+ ...extractSessionIdLines("new_session", " "),
694
+ "fi",
695
+ 'cs_text=""',
696
+ 'if [ "$oc_keep_last" -eq 1 ]; then',
697
+ ` cs_text="$(awk '`,
698
+ ' /"type":"text"/ {',
699
+ " line = $0",
700
+ String.raw` i = index(line, "\042text\042:\042")`,
701
+ " if (i == 0) next",
702
+ " i = i + 8",
703
+ ' out = ""',
704
+ " len = length(line)",
705
+ " while (i <= len) {",
706
+ " c = substr(line, i, 1)",
707
+ String.raw` if (c == "\\") {`,
708
+ " out = out substr(line, i, 2)",
709
+ " i = i + 2",
710
+ " continue",
711
+ " }",
712
+ String.raw` if (c == "\042") break`,
713
+ " out = out c",
714
+ " i = i + 1",
715
+ " }",
716
+ " result = out",
717
+ " n = n + 1",
718
+ " }",
719
+ " END {",
720
+ " if (n > 0) {",
721
+ " if (length(result) > 16000) {",
722
+ " result = substr(result, 1, 16000)",
723
+ String.raw` if (substr(result, 16000, 1) == "\\") result = substr(result, 1, 15999)`,
724
+ " }",
725
+ " print result",
726
+ " }",
727
+ " }",
728
+ ` ' "$json_out")"`,
729
+ "fi",
730
+ 'rm -f "$json_out"',
731
+ 'if [ -n "$new_session" ]; then',
732
+ String.raw` printf '%s\n' "$new_session" >"$state_file"`,
733
+ "fi"
734
+ ] : ['run_opencode "" 0', "code=$?"],
735
+ "trap - TERM INT",
736
+ 'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
737
+ ...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
738
+ "finish success 0",
739
+ "exit 0",
740
+ ""
741
+ ].join(`
742
+ `);
743
+ }
744
+ function serviceContent(job, options) {
745
+ const timeout = job.timeoutSeconds !== undefined && job.timeoutSeconds > 0 ? `TimeoutStartSec=${String(job.timeoutSeconds)}s` : "TimeoutStartSec=infinity";
746
+ const lines = [
747
+ "[Unit]",
748
+ `Description=OpenCode job: ${escapeUnitText(job.name)} (${job.slug})`,
749
+ "",
750
+ "[Service]",
751
+ "Type=oneshot",
752
+ `WorkingDirectory=${unitQuote(options.workdir)}`,
753
+ `Environment=${unitQuote(`PATH=${options.pathEnvironment}`)}`,
754
+ `ExecStart=/bin/sh ${unitQuote(options.runScript)}`,
755
+ timeout,
756
+ `StandardOutput=append:${unitQuote(options.log)}`,
757
+ `StandardError=append:${unitQuote(options.log)}`
758
+ ];
759
+ return `${lines.join(`
760
+ `)}
761
+ `;
762
+ }
763
+ function timerContent(job, onCalendars) {
764
+ return [
765
+ "[Unit]",
766
+ `Description=OpenCode job timer: ${escapeUnitText(job.name)} (${job.slug})`,
767
+ "",
768
+ "[Timer]",
769
+ ...onCalendars.map((calendar) => `OnCalendar=${calendar}`),
770
+ "Persistent=true",
771
+ "",
772
+ "[Install]",
773
+ "WantedBy=timers.target",
774
+ ""
775
+ ].join(`
776
+ `);
777
+ }
778
+ function systemctl(systemctlArguments) {
779
+ const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
780
+ encoding: "utf8"
781
+ });
782
+ return {
783
+ ok: result.status === 0,
784
+ stdout: result.stdout.trim(),
785
+ stderr: result.stderr.trim()
786
+ };
787
+ }
788
+ function systemdHint(stderr) {
789
+ if (/failed to connect|not connected|dbus/i.test(stderr)) {
790
+ return `
791
+ Hint: no systemd user session is reachable. Over SSH try enabling lingering: loginctl enable-linger $USER`;
792
+ }
793
+ return "";
794
+ }
795
+ function timerStatus(base) {
796
+ const result = systemctl([
797
+ "show",
798
+ timerUnit(base),
799
+ "-p",
800
+ "NextElapseUSecRealtime",
801
+ "-p",
802
+ "LastTriggerUSec",
803
+ "--value"
804
+ ]);
805
+ if (!result.ok)
806
+ return { next: undefined, last: undefined };
807
+ const [next = "n/a", last = "n/a"] = result.stdout.split(`
808
+ `, 2);
809
+ return {
810
+ next: next === "n/a" ? undefined : next,
811
+ last: last === "n/a" ? undefined : last
812
+ };
813
+ }
814
+ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
815
+ const script = runScriptPath(scopeId, job.slug);
816
+ const base = unitBase(scopeId, job.slug);
817
+ mkdirSync2(logDirectory(scopeId), { recursive: true });
818
+ mkdirSync2(runsDirectory(scopeId), { recursive: true });
819
+ atomicWriteExecutable(script, runScriptContent(job, scopeId, opencodeBin));
820
+ const onCalendars = cronToOnCalendar(parseCron(job.schedule));
821
+ atomicWrite(path4.join(systemdUserDirectory(), timerUnit(base)), timerContent(job, onCalendars));
822
+ atomicWrite(path4.join(systemdUserDirectory(), serviceUnit(base)), serviceContent(job, {
823
+ workdir: path4.resolve(workdir),
824
+ runScript: script,
825
+ log: logFile(scopeId, job.slug),
826
+ pathEnvironment
827
+ }));
828
+ return base;
829
+ }
830
+ function removeJobUnits(scopeId, slug) {
831
+ const base = unitBase(scopeId, slug);
832
+ systemctl(["disable", "--now", timerUnit(base)]);
833
+ for (const file of [
834
+ path4.join(systemdUserDirectory(), timerUnit(base)),
835
+ path4.join(systemdUserDirectory(), serviceUnit(base))
836
+ ]) {
837
+ if (existsSync3(file))
838
+ rmSync(file);
839
+ }
840
+ const script = runScriptPath(scopeId, slug);
841
+ if (existsSync3(script))
842
+ rmSync(script);
843
+ }
844
+ function removeStaleUnits(scopeId, expectedSlugs) {
845
+ const prefix = `opencode-sched-${scopeId}-`;
846
+ const removed = [];
847
+ if (existsSync3(systemdUserDirectory())) {
848
+ for (const entry of readdirSync2(systemdUserDirectory())) {
849
+ if (!entry.startsWith(prefix))
850
+ continue;
851
+ if (!entry.endsWith(".service") && !entry.endsWith(".timer"))
852
+ continue;
853
+ const slug = entry.slice(prefix.length).replaceAll(/\.(service|timer)$/g, "");
854
+ if (!expectedSlugs.has(slug)) {
855
+ rmSync(path4.join(systemdUserDirectory(), entry));
856
+ removed.push(slug);
857
+ }
858
+ }
859
+ }
860
+ if (existsSync3(scopeDirectory(scopeId))) {
861
+ const entries = readdirSync2(scopeDirectory(scopeId));
862
+ for (const entry of entries) {
863
+ const match = /^run-(.+)\.sh$/.exec(entry);
864
+ if (match === null)
865
+ continue;
866
+ const [, slug = ""] = match;
867
+ if (!expectedSlugs.has(slug))
868
+ rmSync(path4.join(scopeDirectory(scopeId), entry));
869
+ }
870
+ }
871
+ return removed;
872
+ }
873
+
874
+ // src/project.ts
875
+ import path5 from "path";
876
+ function enableProject(workdir) {
877
+ if (process.platform !== "linux")
878
+ throw new Error("Scheduled jobs are only supported on Linux (systemd user units)");
879
+ const { jobs, errors } = loadJobs(workdir);
880
+ if (errors.length > 0)
881
+ throw new Error(`Invalid job definitions:
882
+ ${errors.join(`
883
+ `)}`);
884
+ if (jobs.length === 0) {
885
+ throw new Error(`No job definitions found in ${jobsDirectory(workdir)}. Create one with schedule_job first.`);
886
+ }
887
+ const abs = path5.resolve(workdir);
888
+ const scopeId = deriveScopeId(abs);
889
+ const opencodeBin = findOpencode();
890
+ const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
891
+ const bases = jobs.map((job) => writeJobUnits(job, abs, scopeId, opencodeBin, pathEnvironment));
892
+ const removed = removeStaleUnits(scopeId, new Set(jobs.map((job) => job.slug)));
893
+ const reload = systemctl(["daemon-reload"]);
894
+ if (!reload.ok)
895
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
896
+ const failures = [];
897
+ for (const base of bases) {
898
+ const enable = systemctl(["enable", "--now", timerUnit(base)]);
899
+ if (!enable.ok)
900
+ failures.push(`${timerUnit(base)}: ${enable.stderr}${systemdHint(enable.stderr)}`);
901
+ }
902
+ const registry = loadRegistry();
903
+ const previous = registry.projects[abs];
904
+ registry.projects[abs] = {
905
+ scopeId,
906
+ workdir: abs,
907
+ enabledAt: previous?.enabledAt ?? nowIso(),
908
+ updatedAt: nowIso(),
909
+ jobs: jobs.map((job) => job.slug)
910
+ };
911
+ saveRegistry(registry);
912
+ const lines = [
913
+ `Enabled ${String(jobs.length)} job(s) for ${abs} (scope ${scopeId})`
914
+ ];
915
+ if (removed.length > 0)
916
+ lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
917
+ lines.push(...describeJobSchedules(jobs, scopeId));
918
+ if (failures.length > 0)
919
+ lines.push(`Timer activation failures:
920
+ ${failures.join(`
921
+ `)}`);
922
+ return lines.join(`
923
+ `);
924
+ }
925
+ function describeJobSchedules(jobs, scopeId) {
926
+ const lines = [];
927
+ for (const job of jobs) {
928
+ const sets = parseCron(job.schedule);
929
+ const next = timerStatus(unitBase(scopeId, job.slug)).next;
930
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
931
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}`);
932
+ }
933
+ return lines;
934
+ }
935
+ function disableProject(workdir) {
936
+ const abs = path5.resolve(workdir);
937
+ const entry = registryEntry(abs);
938
+ if (entry === undefined)
939
+ return `Project is not enabled: ${abs}`;
940
+ for (const slug of entry.jobs)
941
+ removeJobUnits(entry.scopeId, slug);
942
+ systemctl(["daemon-reload"]);
943
+ const registry = loadRegistry();
944
+ const { [abs]: _omitted, ...remainingProjects } = registry.projects;
945
+ registry.projects = remainingProjects;
946
+ saveRegistry(registry);
947
+ return [
948
+ `Disabled ${String(entry.jobs.length)} job(s) for ${abs}`,
949
+ `Run history kept at ${runsDirectory(entry.scopeId)}`,
950
+ `Logs kept at ${logDirectory(entry.scopeId)}`
951
+ ].join(`
952
+ `);
953
+ }
954
+
955
+ // src/tools.ts
956
+ function ok(output) {
957
+ return { output };
958
+ }
959
+ function fail(message) {
960
+ return { output: `Error: ${message}`, metadata: { error: true } };
961
+ }
962
+ function tryParseCron(schedule) {
963
+ try {
964
+ return parseCron(schedule);
965
+ } catch {
966
+ return;
967
+ }
968
+ }
969
+ function listJobsOutput(directory) {
970
+ const { jobs, errors } = loadJobs(directory);
971
+ const entry = registryEntry(directory);
972
+ const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
973
+ const lines = [header];
974
+ if (jobs.length === 0)
975
+ lines.push("No job definitions. Create one with schedule_job.");
976
+ for (const job of jobs) {
977
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
978
+ const sets = tryParseCron(job.schedule);
979
+ if (sets === undefined) {
980
+ lines.push(`- ${job.slug}: INVALID schedule "${job.schedule}"`);
981
+ continue;
982
+ }
983
+ const records = readRunRecords(scopeId, job.slug, 20);
984
+ const last = lastFinishedRun(records);
985
+ const lastDesc = last === undefined ? ", last: never" : `, last: ${last.status ?? "?"} ${formatRunLine(last)}`;
986
+ const next = entry === undefined ? undefined : timerStatus(unitBase(scopeId, job.slug)).next;
987
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
988
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
989
+ }
990
+ lines.push(...errors.map((error) => `! ${error}`));
991
+ return ok(lines.join(`
992
+ `));
993
+ }
994
+ function scheduleJobOutput(input, directory) {
995
+ const slug = slugify(input.slug ?? input.name);
996
+ let sets;
997
+ try {
998
+ sets = parseCron(input.schedule);
999
+ } catch (error) {
1000
+ return fail(errorMessage(error));
1001
+ }
1002
+ const run = validateRunSpec(input, "job");
1003
+ const session = validateSession(input.session, "job");
1004
+ const guard = validateGuard(input.guard, "job");
1005
+ const timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
1006
+ const existing = loadJobFile(path6.join(jobsDirectory(directory), `${slug}.json`), slug);
1007
+ const job = {
1008
+ slug,
1009
+ name: input.name,
1010
+ schedule: input.schedule,
1011
+ run,
1012
+ ...session !== "new" && { session },
1013
+ ...guard !== undefined && { guard },
1014
+ ...timeoutSeconds !== undefined && { timeoutSeconds },
1015
+ createdAt: existing.ok ? existing.job.createdAt : nowIso(),
1016
+ updatedAt: nowIso()
1017
+ };
1018
+ saveJob(directory, job);
1019
+ const relativePath = `.opencode/scheduler/jobs/${slug}.json`;
1020
+ const lines = [
1021
+ `${existing.ok ? "Updated" : "Created"} job "${job.name}" (${slug})`,
1022
+ `Definition: ${relativePath} (${job.schedule} \u2014 ${describeCron(sets)})`
1023
+ ];
1024
+ if (session !== "new")
1025
+ lines.push(`Session: ${session}`);
1026
+ const entry = registryEntry(directory);
1027
+ if (entry === undefined) {
1028
+ lines.push("Project not enabled yet. Run enable_project to install the systemd timer.");
1029
+ return ok(lines.join(`
1030
+ `));
1031
+ }
1032
+ const abs = path6.resolve(directory);
1033
+ const opencodeBin = findOpencode();
1034
+ const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1035
+ const base = writeJobUnits(job, abs, entry.scopeId, opencodeBin, pathEnvironment);
1036
+ const reload = systemctl(["daemon-reload"]);
1037
+ const enable = systemctl(["enable", "--now", timerUnit(base)]);
1038
+ if (!reload.ok || !enable.ok) {
1039
+ const stderr = reload.ok ? enable.stderr : reload.stderr;
1040
+ return fail(`Saved ${relativePath} but systemd re-sync failed: ${stderr}${systemdHint(stderr)}`);
1041
+ }
1042
+ if (!entry.jobs.includes(slug)) {
1043
+ const registry = loadRegistry();
1044
+ const current = registry.projects[abs];
1045
+ if (current !== undefined) {
1046
+ current.jobs = [...new Set([...current.jobs, slug])];
1047
+ current.updatedAt = nowIso();
1048
+ saveRegistry(registry);
1049
+ }
1050
+ }
1051
+ const next = timerStatus(base).next;
1052
+ const nextDesc = next === undefined ? "" : `, next run: ${next}`;
1053
+ lines.push(`Re-synced systemd units (project enabled)${nextDesc}`);
1054
+ return ok(lines.join(`
1055
+ `));
1056
+ }
1057
+ function showJobOutput(slugInput, directory) {
1058
+ const file = path6.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
1059
+ if (!existsSync4(file)) {
1060
+ return fail(`No job "${slugInput}" in ${jobsDirectory(directory)}. Use list_jobs to see definitions.`);
1061
+ }
1062
+ const result = loadJobFile(file);
1063
+ if (!result.ok)
1064
+ return fail(result.error);
1065
+ const job = result.job;
1066
+ const sets = parseCron(job.schedule);
1067
+ const entry = registryEntry(directory);
1068
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1069
+ const runDesc = "prompt" in job.run ? `prompt "${job.run.prompt}"` : `command "${job.run.command}"${job.run.arguments === undefined ? "" : ` "${job.run.arguments}"`}`;
1070
+ const lines = [
1071
+ `${job.name} (${job.slug})`,
1072
+ `Schedule: ${job.schedule} \u2014 ${describeCron(sets)}`,
1073
+ `Definition: .opencode/scheduler/jobs/${job.slug}.json (updated ${job.updatedAt})`,
1074
+ `Run: ${runDesc}`
1075
+ ];
1076
+ if (job.guard !== undefined)
1077
+ lines.push(`Guard: ${job.guard} (must exit 0 for the run to start)`);
1078
+ if (job.session !== undefined) {
1079
+ const state = sessionStateFile(scopeId, job.slug);
1080
+ const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
1081
+ lines.push(`Session: ${job.session}${sessionId.length > 0 ? ` \u2014 current ${sessionId}` : " \u2014 no session yet"}`);
1082
+ }
1083
+ if (job.run.agent !== undefined)
1084
+ lines.push(`Agent: ${job.run.agent}`);
1085
+ if (job.run.model !== undefined)
1086
+ lines.push(`Model: ${job.run.model}`);
1087
+ if (job.timeoutSeconds !== undefined && job.timeoutSeconds > 0) {
1088
+ lines.push(`Timeout: ${String(job.timeoutSeconds)}s (systemd TimeoutStartSec)`);
1089
+ }
1090
+ if (entry === undefined) {
1091
+ lines.push("Enabled: no (run enable_project to install the timer)");
1092
+ } else {
1093
+ const base = unitBase(scopeId, job.slug);
1094
+ const status = timerStatus(base);
1095
+ lines.push(`Enabled: yes (timer ${timerUnit(base)})`);
1096
+ if (status.next !== undefined)
1097
+ lines.push(`Next run: ${status.next}`);
1098
+ if (status.last !== undefined)
1099
+ lines.push(`Last trigger: ${status.last}`);
1100
+ lines.push(`Log: ${logFile(scopeId, job.slug)}`);
1101
+ }
1102
+ const records = readRunRecords(scopeId, job.slug, 10);
1103
+ if (records.length > 0) {
1104
+ lines.push("Recent runs:");
1105
+ for (const record of records.toReversed())
1106
+ lines.push(` ${formatRunLine(record)}`);
1107
+ } else {
1108
+ lines.push("Recent runs: none");
1109
+ }
1110
+ return ok(lines.join(`
1111
+ `));
1112
+ }
1113
+ function removeJobDefinitionOutput(slugInput, directory) {
1114
+ const slug = slugify(slugInput);
1115
+ const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1116
+ if (!existsSync4(file))
1117
+ return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1118
+ rmSync2(file);
1119
+ const lines = [
1120
+ `Deleted job definition .opencode/scheduler/jobs/${slug}.json`
1121
+ ];
1122
+ const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
1123
+ const state = sessionStateFile(scopeId, slug);
1124
+ if (existsSync4(state)) {
1125
+ rmSync2(state);
1126
+ lines.push(`Removed session state ${state}`);
1127
+ }
1128
+ const abs = path6.resolve(directory);
1129
+ const entry = registryEntry(directory);
1130
+ if (entry?.jobs.includes(slug)) {
1131
+ removeJobUnits(entry.scopeId, slug);
1132
+ systemctl(["daemon-reload"]);
1133
+ const registry = loadRegistry();
1134
+ const current = registry.projects[abs];
1135
+ if (current !== undefined) {
1136
+ current.jobs = current.jobs.filter((jobSlug) => jobSlug !== slug);
1137
+ current.updatedAt = nowIso();
1138
+ if (current.jobs.length === 0)
1139
+ omitProject(registry, abs);
1140
+ saveRegistry(registry);
1141
+ }
1142
+ lines.push("Removed systemd units (project was enabled)");
1143
+ }
1144
+ return ok(lines.join(`
1145
+ `));
1146
+ }
1147
+ function omitProject(registry, workdir) {
1148
+ const { [workdir]: _omitted, ...remaining } = registry.projects;
1149
+ registry.projects = remaining;
1150
+ }
1151
+ function runJobNowOutput(slugInput, directory) {
1152
+ const slug = slugify(slugInput);
1153
+ const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1154
+ if (!existsSync4(file))
1155
+ return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1156
+ const entry = registryEntry(directory);
1157
+ if (entry === undefined) {
1158
+ return fail(`Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`);
1159
+ }
1160
+ const script = runScriptPath(entry.scopeId, slug);
1161
+ if (!existsSync4(script)) {
1162
+ return fail(`Run script missing for "${slug}". Run enable_project to (re)install units.`);
1163
+ }
1164
+ const log = logFile(entry.scopeId, slug);
1165
+ mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
1166
+ const fd = openSync(log, "a");
1167
+ const child = spawn("/bin/sh", [script], {
1168
+ cwd: path6.resolve(directory),
1169
+ env: { ...process.env, OPENCODE_SCHEDULER_STARTED_BY: "manual" },
1170
+ stdio: ["ignore", fd, fd]
1171
+ });
1172
+ child.unref();
1173
+ closeSync(fd);
1174
+ const tail = tailFile(log, 5, 2000);
1175
+ const parts = [
1176
+ `Started "${slug}" manually (pid ${String(child.pid)})`,
1177
+ `Log: ${log}`
1178
+ ];
1179
+ if (tail?.length)
1180
+ parts.push(`Log tail:
1181
+ ${tail}`);
1182
+ return ok(parts.join(`
1183
+ `));
1184
+ }
1185
+ function jobLogsOutput(slugInput, lineCountInput, directory) {
1186
+ const entry = registryEntry(directory);
1187
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1188
+ const log = logFile(scopeId, slugify(slugInput));
1189
+ const lineCount = Math.min(lineCountInput ?? 100, 500);
1190
+ const tail = tailFile(log, lineCount, 20000);
1191
+ if (tail === undefined)
1192
+ return ok(`No log yet for "${slugInput}" (expected at ${log})`);
1193
+ if (tail.length === 0)
1194
+ return ok(`Log is empty: ${log}`);
1195
+ return ok(`${log} (tail):
1196
+ ${tail}`);
1197
+ }
1198
+ function listProjectsOutput() {
1199
+ const registry = loadRegistry();
1200
+ const entries = Object.values(registry.projects).toSorted((a, b) => a.workdir.localeCompare(b.workdir));
1201
+ if (entries.length === 0)
1202
+ return ok("No projects with scheduled jobs are registered.");
1203
+ const lines = ["Registry: ~/.config/opencode/scheduler/registry.json"];
1204
+ for (const entry of entries) {
1205
+ const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
1206
+ lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
1207
+ }
1208
+ return ok(lines.join(`
1209
+ `));
1210
+ }
1211
+ var listJobsTool = tool({
1212
+ description: "List scheduled job definitions for the current project (from .opencode/scheduler/jobs/), including enabled state, next run, and last run status.",
1213
+ args: {},
1214
+ execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
1215
+ });
1216
+ var scheduleJobTool = tool({
1217
+ description: "Create or update a scheduled job definition in the current project (.opencode/scheduler/jobs/<slug>.json, git-committable). Schedule is a 5-field cron expression. Set either prompt (natural language) or command (custom command name). If the project is enabled, systemd units are re-synced automatically.",
1218
+ args: {
1219
+ name: tool.schema.string().describe("Human-readable job name"),
1220
+ schedule: tool.schema.string().describe('5-field cron expression, e.g. "0 9 * * *" (daily 9am), "0 */6 * * *" (every 6h), "30 8 * * 1" (Mon 8:30)'),
1221
+ prompt: tool.schema.string().optional().describe("Natural language prompt the job runs via `opencode run`"),
1222
+ command: tool.schema.string().optional().describe("Custom command name to run instead of a prompt"),
1223
+ arguments: tool.schema.string().optional().describe("Arguments passed to the custom command"),
1224
+ session: tool.schema.string().optional().describe(`Session continuity between runs: "new" (default, fresh session each run), "persist" (continue the same session), "compact" (continue the same session; after each run the history is compacted into a summary the next run starts from), "compact+last" (like compact, but the run's final result message is re-injected after the summary so the next run starts from summary plus last result)`),
1225
+ guard: tool.schema.string().optional().describe('Shell command run before the job; the run only starts if it exits 0, otherwise it is recorded as skipped (applies to run_job too). E.g. "! git diff --quiet" to run only when the repo has changes'),
1226
+ agent: tool.schema.string().optional().describe("Agent to use for the run"),
1227
+ model: tool.schema.string().optional().describe("Model to use for the run"),
1228
+ timeoutSeconds: tool.schema.number().optional().describe("Hard timeout in seconds (0 or omitted disables). systemd stops the run with SIGTERM after this"),
1229
+ slug: tool.schema.string().optional().describe("URL-safe identifier; defaults to a slugified name")
1230
+ },
1231
+ execute: (input, context) => Promise.resolve(scheduleJobOutput(input, context.directory))
1232
+ });
1233
+ var showJobTool = tool({
1234
+ description: "Show full details for one scheduled job: definition, cron description, systemd install state, and recent run history.",
1235
+ args: {
1236
+ slug: tool.schema.string().describe("Job slug (see list_jobs)")
1237
+ },
1238
+ execute: (input, context) => Promise.resolve(showJobOutput(input.slug, context.directory))
1239
+ });
1240
+ var jobDeletionTool = tool({
1241
+ description: "Delete a scheduled job definition from the current project. If the project is enabled, its systemd units are removed too. Run history and logs are kept.",
1242
+ args: {
1243
+ slug: tool.schema.string().describe("Job slug to delete")
1244
+ },
1245
+ execute: (input, context) => Promise.resolve(removeJobDefinitionOutput(input.slug, context.directory))
1246
+ });
1247
+ var runJobTool = tool({
1248
+ description: "Run a scheduled job immediately, fire-and-forget, using the exact frozen run script the timer would use. Appends to the same log and run history. The job's project must be enabled.",
1249
+ args: {
1250
+ slug: tool.schema.string().describe("Job slug to run now")
1251
+ },
1252
+ execute: (input, context) => Promise.resolve(runJobNowOutput(input.slug, context.directory))
1253
+ });
1254
+ var jobLogsTool = tool({
1255
+ description: "Show the tail of a scheduled job's log file (scheduled and manual runs both append to it).",
1256
+ args: {
1257
+ slug: tool.schema.string().describe("Job slug"),
1258
+ lines: tool.schema.number().optional().describe("Number of lines to show (default 100)")
1259
+ },
1260
+ execute: (input, context) => Promise.resolve(jobLogsOutput(input.slug, input.lines, context.directory))
1261
+ });
1262
+ var enableProjectTool = tool({
1263
+ description: "Enable scheduled jobs for the current project: installs a systemd user service+timer per job definition in .opencode/scheduler/jobs/, registers the project in the global registry (~/.config/opencode/scheduler/registry.json), and removes stale units for deleted jobs. Idempotent, so it also re-syncs after job definitions change. Linux only.",
1264
+ args: {},
1265
+ execute: (_input, context) => Promise.resolve(enableProjectOutput(context.directory))
1266
+ });
1267
+ function enableProjectOutput(directory) {
1268
+ try {
1269
+ return ok(enableProject(directory));
1270
+ } catch (error) {
1271
+ return fail(errorMessage(error));
1272
+ }
1273
+ }
1274
+ var disableProjectTool = tool({
1275
+ description: "Disable scheduled jobs for the current project: stops and removes its systemd timers/services and removes the registry entry. Job definitions stay in the repo, and run history/logs are kept.",
1276
+ args: {},
1277
+ execute: (_input, context) => Promise.resolve(disableProjectOutput(context.directory))
1278
+ });
1279
+ function disableProjectOutput(directory) {
1280
+ try {
1281
+ return ok(disableProject(directory));
1282
+ } catch (error) {
1283
+ return fail(errorMessage(error));
1284
+ }
1285
+ }
1286
+ var listProjectsTool = tool({
1287
+ description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/scheduler/registry.json).",
1288
+ args: {},
1289
+ execute: () => Promise.resolve(listProjectsOutput())
1290
+ });
1291
+ var schedulerTools = {
1292
+ schedule_job: scheduleJobTool,
1293
+ list_jobs: listJobsTool,
1294
+ get_job: showJobTool,
1295
+ delete_job: jobDeletionTool,
1296
+ run_job: runJobTool,
1297
+ job_logs: jobLogsTool,
1298
+ enable_project: enableProjectTool,
1299
+ disable_project: disableProjectTool,
1300
+ list_projects: listProjectsTool
1301
+ };
1302
+
1303
+ // src/index.ts
1304
+ var src_default = () => {
1305
+ if (process.platform !== "linux") {
1306
+ console.error("[opencode-jobs] warning: this is not a Linux host, systemd user timers are unavailable \u2014 scheduling tools will not work here");
1307
+ }
1308
+ return Promise.resolve({ tool: schedulerTools });
1309
+ };
1310
+ export {
1311
+ src_default as default
1312
+ };