opencode-jobs 0.2.0 → 1.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/cli.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path6 from "node:path";
4
+ import path9 from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  // src/install.ts
8
- import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
8
+ import { existsSync as existsSync4, readFileSync as readFileSync3, rmSync as rmSync2, rmdirSync as rmdirSync2, statSync } from "node:fs";
9
9
  import { spawnSync as spawnSync2 } from "node:child_process";
10
- import path5 from "node:path";
10
+ import path7 from "node:path";
11
11
  import { z as z3 } from "zod";
12
12
 
13
13
  // src/paths.ts
@@ -22,32 +22,41 @@ function stateRoot() {
22
22
  return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
23
23
  }
24
24
  function worktreesDirectory(scopeId) {
25
- return path.join(stateRoot(), "opencode", "scheduler", "worktrees", scopeId);
25
+ return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
26
26
  }
27
27
  function locksDirectory(scopeId) {
28
- return path.join(schedulerDirectory(), "locks", scopeId);
28
+ return path.join(jobsStateDirectory(), "locks", scopeId);
29
29
  }
30
- function schedulerDirectory() {
31
- return path.join(configRoot(), "opencode", "scheduler");
30
+ function jobsStateDirectory() {
31
+ return path.join(configRoot(), "opencode", "jobs");
32
32
  }
33
33
  function registryPath() {
34
- return path.join(schedulerDirectory(), "registry.json");
34
+ return path.join(jobsStateDirectory(), "registry.json");
35
35
  }
36
36
  function scopeDirectory(scopeId) {
37
- return path.join(schedulerDirectory(), "scopes", scopeId);
37
+ return path.join(jobsStateDirectory(), "scopes", scopeId);
38
38
  }
39
39
  function runsDirectory(scopeId) {
40
- return path.join(schedulerDirectory(), "runs", scopeId);
40
+ return path.join(jobsStateDirectory(), "runs", scopeId);
41
+ }
42
+ function runsFile(scopeId, slug) {
43
+ return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
41
44
  }
42
45
  function sessionStateDirectory(scopeId) {
43
- return path.join(schedulerDirectory(), "sessions", scopeId);
46
+ return path.join(jobsStateDirectory(), "sessions", scopeId);
44
47
  }
45
48
  function logDirectory(scopeId) {
46
- return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
49
+ return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
50
+ }
51
+ function logFile(scopeId, slug) {
52
+ return path.join(logDirectory(scopeId), `${slug}.log`);
47
53
  }
48
54
  function systemdUserDirectory() {
49
55
  return path.join(configRoot(), "systemd", "user");
50
56
  }
57
+ function jobsDirectory(workdir) {
58
+ return path.join(workdir, ".opencode", "jobs");
59
+ }
51
60
  function unitBase(scopeId, slug) {
52
61
  return `opencode-sched-${scopeId}-${slug}`;
53
62
  }
@@ -64,12 +73,30 @@ function slugify(input) {
64
73
  const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
65
74
  return slug.length > 0 ? slug : "job";
66
75
  }
76
+ function shQuote(value) {
77
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
78
+ }
79
+ function unitQuote(value) {
80
+ if (/^[A-Za-z0-9_@:=./-]*$/.test(value))
81
+ return value;
82
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}"`;
83
+ }
84
+ function escapeUnitText(value) {
85
+ return value.replaceAll("%", "%%").replaceAll(/\s+/g, " ").trim();
86
+ }
67
87
  function atomicWrite(file, content) {
68
88
  mkdirSync(path.dirname(file), { recursive: true });
69
89
  const temporary = `${file}.tmp`;
70
90
  writeFileSync(temporary, content);
71
91
  renameSync(temporary, file);
72
92
  }
93
+ function atomicWriteExecutable(file, content) {
94
+ atomicWrite(file, content);
95
+ chmodSync(file, 493);
96
+ }
97
+ function nowIso() {
98
+ return new Date().toISOString();
99
+ }
73
100
  function deriveScopeId(workdir) {
74
101
  const abs = path.resolve(workdir);
75
102
  const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
@@ -77,12 +104,15 @@ function deriveScopeId(workdir) {
77
104
  }
78
105
 
79
106
  // src/project.ts
80
- import path4 from "node:path";
107
+ import path5 from "node:path";
81
108
 
82
109
  // src/job.ts
110
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
111
+ import path2 from "node:path";
83
112
  import { z } from "zod";
84
113
 
85
114
  // src/cron.ts
115
+ var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
86
116
  var DOW_NAMES = {
87
117
  sun: 0,
88
118
  mon: 1,
@@ -167,6 +197,42 @@ function parseCron(expression) {
167
197
  dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
168
198
  };
169
199
  }
200
+ function pad2(value) {
201
+ return String(value).padStart(2, "0");
202
+ }
203
+ function fmtList(values) {
204
+ return values.map((v) => pad2(v)).join(",");
205
+ }
206
+ function cronToOnCalendar(sets) {
207
+ const timePart = `${fmtList(sets.hour)}:${fmtList(sets.minute)}:00`;
208
+ const months = sets.month.length === 12 ? "*" : fmtList(sets.month);
209
+ const doms = sets.dom.length === 31 ? "*" : fmtList(sets.dom);
210
+ const dows = sets.dow.length === 7 ? "" : `${sets.dow.map((d) => DOW_LABELS[d]).join(",")} `;
211
+ if (sets.dow.length === 7)
212
+ return [`*-${months}-${doms} ${timePart}`];
213
+ if (sets.dom.length === 31 && sets.month.length === 12)
214
+ return [`${dows}*-*-* ${timePart}`];
215
+ return [
216
+ `${dows}*-${months}-* ${timePart}`,
217
+ `*-${months}-${doms} ${timePart}`
218
+ ];
219
+ }
220
+ function describeCron(sets) {
221
+ 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(",")}`;
222
+ const isDowAll = sets.dow.length === 7;
223
+ const isDomAll = sets.dom.length === 31;
224
+ let dayDesc;
225
+ if (isDowAll && isDomAll)
226
+ dayDesc = "every day";
227
+ else if (isDowAll)
228
+ dayDesc = `on day ${sets.dom.join(",")} of the month`;
229
+ else if (isDomAll)
230
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")}`;
231
+ else
232
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")} or day ${sets.dom.join(",")}`;
233
+ const monthDesc = sets.month.length === 12 ? "" : ` in month ${sets.month.join(",")}`;
234
+ return `${timeDesc} ${dayDesc}${monthDesc}`;
235
+ }
170
236
 
171
237
  // src/json.ts
172
238
  function errorMessage(error) {
@@ -258,10 +324,82 @@ var jobFileSchema = z.strictObject({
258
324
  createdAt: z.string().optional(),
259
325
  updatedAt: z.string().optional()
260
326
  });
327
+ function formatValidationError(error) {
328
+ const issue = error.issues[0];
329
+ if (issue === undefined)
330
+ return "invalid job definition";
331
+ const field = issue.path.join(".");
332
+ return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
333
+ }
334
+ function loadJobFile(file, expectedSlug) {
335
+ const stem = path2.basename(file, ".json");
336
+ try {
337
+ const object = JSON.parse(readFileSync(file, "utf8"));
338
+ const result = jobFileSchema.safeParse(object);
339
+ if (!result.success) {
340
+ return {
341
+ ok: false,
342
+ error: `${stem}.json: ${formatValidationError(result.error)}`
343
+ };
344
+ }
345
+ const definition = result.data;
346
+ const slug = definition.slug ?? stem;
347
+ if (slug !== stem)
348
+ return {
349
+ ok: false,
350
+ error: `${stem}.json: "slug" ("${slug}") must match filename`
351
+ };
352
+ if (expectedSlug !== undefined && slug !== expectedSlug)
353
+ return { ok: false, error: `${stem}.json: unexpected slug` };
354
+ const timestamp = nowIso();
355
+ return {
356
+ ok: true,
357
+ job: {
358
+ slug,
359
+ name: definition.name,
360
+ schedule: definition.schedule,
361
+ run: definition.run,
362
+ ...definition.session !== "new" && { session: definition.session },
363
+ ...definition.guard !== undefined && { guard: definition.guard },
364
+ ...definition.worktree !== undefined && {
365
+ worktree: definition.worktree
366
+ },
367
+ ...definition.timeoutSeconds !== undefined && {
368
+ timeoutSeconds: definition.timeoutSeconds
369
+ },
370
+ createdAt: definition.createdAt ?? timestamp,
371
+ updatedAt: definition.updatedAt ?? timestamp
372
+ }
373
+ };
374
+ } catch (error) {
375
+ return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
376
+ }
377
+ }
378
+ function loadJobs(workdir) {
379
+ const directory = jobsDirectory(workdir);
380
+ if (!existsSync(directory))
381
+ return { jobs: [], errors: [] };
382
+ const jobs = [];
383
+ const errors = [];
384
+ const entries = readdirSync(directory, { withFileTypes: true });
385
+ for (const entry of entries) {
386
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
387
+ continue;
388
+ const result = loadJobFile(path2.join(directory, entry.name));
389
+ if (result.ok)
390
+ jobs.push(result.job);
391
+ else
392
+ errors.push(result.error);
393
+ }
394
+ return {
395
+ jobs: jobs.toSorted((a, b) => a.slug.localeCompare(b.slug)),
396
+ errors
397
+ };
398
+ }
261
399
 
262
400
  // src/registry.ts
263
- import { readFileSync } from "node:fs";
264
- import path2 from "node:path";
401
+ import { readFileSync as readFileSync2 } from "node:fs";
402
+ import path3 from "node:path";
265
403
  import { z as z2 } from "zod";
266
404
  var registryEntrySchema = z2.looseObject({
267
405
  scopeId: z2.string(),
@@ -274,19 +412,22 @@ var registryFileSchema = z2.object({
274
412
  version: z2.literal(1),
275
413
  projects: z2.record(z2.string(), z2.unknown())
276
414
  });
415
+ function readRegistryFile(file) {
416
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
417
+ const result = registryFileSchema.safeParse(parsed);
418
+ if (!result.success)
419
+ throw new Error(`Invalid job registry: ${file}`);
420
+ const projects = {};
421
+ for (const [key, value] of Object.entries(result.data.projects)) {
422
+ const entry = registryEntrySchema.safeParse(value);
423
+ if (entry.success)
424
+ projects[key] = entry.data;
425
+ }
426
+ return { version: 1, projects };
427
+ }
277
428
  function loadRegistry() {
278
429
  try {
279
- const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
280
- const result = registryFileSchema.safeParse(parsed);
281
- if (!result.success)
282
- return { version: 1, projects: {} };
283
- const projects = {};
284
- for (const [key, value] of Object.entries(result.data.projects)) {
285
- const entry = registryEntrySchema.safeParse(value);
286
- if (entry.success)
287
- projects[key] = entry.data;
288
- }
289
- return { version: 1, projects };
430
+ return readRegistryFile(registryPath());
290
431
  } catch {
291
432
  return { version: 1, projects: {} };
292
433
  }
@@ -296,16 +437,398 @@ function saveRegistry(registry) {
296
437
  `);
297
438
  }
298
439
  function registryEntry(workdir) {
299
- return loadRegistry().projects[path2.resolve(workdir)];
440
+ return loadRegistry().projects[path3.resolve(workdir)];
300
441
  }
301
442
 
302
443
  // src/systemd.ts
303
- import { existsSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "node:fs";
444
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync } from "node:fs";
304
445
  import { spawnSync } from "node:child_process";
305
- import path3 from "node:path";
446
+ import path4 from "node:path";
447
+ import { homedir as homedir2 } from "node:os";
448
+ function findOpencode() {
449
+ const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
450
+ if (override !== undefined && override.length > 0)
451
+ return override;
452
+ const which = spawnSync("sh", ["-c", "command -v opencode"], {
453
+ encoding: "utf8"
454
+ });
455
+ const onPath = which.stdout.trim();
456
+ if (which.status === 0 && onPath.length > 0)
457
+ return onPath;
458
+ const candidates = [
459
+ path4.join(homedir2(), ".opencode/bin/opencode"),
460
+ "/usr/local/bin/opencode",
461
+ "/usr/bin/opencode"
462
+ ];
463
+ for (const candidate of candidates) {
464
+ if (existsSync2(candidate))
465
+ return candidate;
466
+ }
467
+ return "opencode";
468
+ }
469
+ function guardScriptLines(guard) {
470
+ return [
471
+ `guard=${shQuote(guard)}`,
472
+ 'sh -c "$guard"',
473
+ "guard_code=$?",
474
+ 'if [ "$guard_code" -ne 0 ]; then',
475
+ ' echo "guard exited $guard_code, skipping run"',
476
+ ' finish skipped "$guard_code"',
477
+ " exit 0",
478
+ "fi"
479
+ ];
480
+ }
481
+ function worktreeDefaultRoot(scopeId) {
482
+ return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
483
+ }
484
+ function worktreePrologueLines(job, scopeId) {
485
+ const base = job.worktree?.base;
486
+ return [
487
+ "wt_enabled=1",
488
+ 'orig_pwd="$(pwd)"',
489
+ 'lock_dir="$config_root/opencode/jobs/locks/$scope"',
490
+ 'mkdir -p "$lock_dir"',
491
+ 'exec 9>"$lock_dir/$slug.lock"',
492
+ "if ! flock -n 9; then",
493
+ ' echo "opencode-jobs: another run of $slug is already active, skipping"',
494
+ " finish skipped 0",
495
+ " exit 0",
496
+ "fi",
497
+ base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
498
+ 'if ! mkdir -p "$wt_root"; then',
499
+ ' echo "opencode-jobs: cannot create worktree base $wt_root"',
500
+ " finish failed 1",
501
+ " exit 1",
502
+ "fi",
503
+ 'wt_root="$(cd "$wt_root" && pwd)"',
504
+ 'wt_path="$wt_root/$slug"',
505
+ 'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
506
+ `wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
507
+ 'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
508
+ 'wt_sub="${wt_sub%/}"',
509
+ 'if [ -d "$wt_path" ]; then',
510
+ ' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
511
+ " wt_stale_saved=1",
512
+ ' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
513
+ ' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
514
+ ' git -C "$wt_path" -c user.name=opencode-jobs -c user.email=jobs@opencode.invalid commit --no-gpg-sign -m "opencode-jobs: $slug recovery (stale worktree)" >/dev/null 2>&1 || wt_stale_saved=0',
515
+ " fi",
516
+ ' if [ "$wt_stale_saved" -eq 1 ]; then',
517
+ ' git worktree remove --force "$wt_path" >/dev/null 2>&1',
518
+ ' rm -rf "$wt_path"',
519
+ " else",
520
+ ' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
521
+ ' wt_branch=""',
522
+ " finish failed 1",
523
+ " exit 1",
524
+ " fi",
525
+ " else",
526
+ ' echo "opencode-jobs: removing unexpected directory at $wt_path"',
527
+ ' rm -rf "$wt_path"',
528
+ " fi",
529
+ " git worktree prune >/dev/null 2>&1",
530
+ "fi",
531
+ 'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
532
+ ' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
533
+ ' wt_branch=""',
534
+ " finish failed 1",
535
+ " exit 1",
536
+ "fi",
537
+ 'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
538
+ ' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
539
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
540
+ ' rm -rf "$wt_path"',
541
+ ' wt_branch=""',
542
+ " finish failed 1",
543
+ " exit 1",
544
+ "fi",
545
+ 'if [ -n "$wt_sub" ]; then',
546
+ ' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
547
+ "else",
548
+ ' cd "$wt_path" || { finish failed 1; exit 1; }',
549
+ "fi"
550
+ ];
551
+ }
552
+ function worktreeEpilogueLines(options) {
553
+ const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
554
+ return [
555
+ 'if [ "$wt_enabled" -eq 1 ]; then',
556
+ ` wt_msg=${message}`,
557
+ ' git -C "$wt_path" add -A >/dev/null 2>&1',
558
+ " wt_keep=0",
559
+ ' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
560
+ ' if git -C "$wt_path" -c user.name=opencode-jobs -c user.email=jobs@opencode.invalid commit --no-gpg-sign -m "$wt_msg" >/dev/null 2>&1; then',
561
+ ' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
562
+ " else",
563
+ ' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
564
+ " wt_keep=1",
565
+ " fi",
566
+ " fi",
567
+ ' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
568
+ ' if [ "$wt_keep" -eq 0 ]; then',
569
+ ' cd "$wt_root" 2>/dev/null',
570
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
571
+ ' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
572
+ " fi",
573
+ "fi"
574
+ ];
575
+ }
306
576
  var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
307
577
  var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
308
578
  var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
579
+ function extractSessionIdLines(target, indent = "") {
580
+ return [
581
+ `${indent}${target}="$(sed -n '${SESSION_ID_SED}' "$json_out" | head -n 1)"`
582
+ ];
583
+ }
584
+ function compactSessionLines() {
585
+ return [
586
+ "compact_session() {",
587
+ ' csid="$1"',
588
+ " if ! command -v curl >/dev/null 2>&1; then",
589
+ ' echo "opencode-jobs: curl not available, skipping compaction"',
590
+ " return 0",
591
+ " fi",
592
+ ' serve_out="$(mktemp)"',
593
+ ' serve_err="$(mktemp)"',
594
+ ` OPENCODE_CONFIG_CONTENT='{"compaction":{"tail_turns":0}}' "$oc_bin" serve --port 0 >"$serve_out" 2>"$serve_err" &`,
595
+ " serve_pid=$!",
596
+ ' serve_port=""',
597
+ " tries=0",
598
+ ' while [ "$tries" -lt 100 ]; do',
599
+ 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)"`,
600
+ ' if [ -n "$serve_port" ]; then break; fi',
601
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
602
+ " sleep 0.1",
603
+ " tries=$((tries + 1))",
604
+ " done",
605
+ ' if [ -z "$serve_port" ]; then',
606
+ ' echo "opencode-jobs: compaction server failed to start"',
607
+ ' sed -n "1,10p" "$serve_err" >&2',
608
+ ' kill "$serve_pid" 2>/dev/null',
609
+ ' wait "$serve_pid" 2>/dev/null',
610
+ ' rm -f "$serve_out" "$serve_err"',
611
+ " return 0",
612
+ " fi",
613
+ " healthy=0",
614
+ " tries=0",
615
+ ' while [ "$tries" -lt 50 ]; do',
616
+ ' if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$serve_port/global/health"; then',
617
+ " healthy=1",
618
+ " break",
619
+ " fi",
620
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
621
+ " sleep 0.2",
622
+ " tries=$((tries + 1))",
623
+ " done",
624
+ ' if [ "$healthy" -ne 1 ]; then',
625
+ ' echo "opencode-jobs: compaction server never became healthy"',
626
+ ' kill "$serve_pid" 2>/dev/null',
627
+ ' wait "$serve_pid" 2>/dev/null',
628
+ ' rm -f "$serve_out" "$serve_err"',
629
+ " return 0",
630
+ " fi",
631
+ ' cs_provider=""',
632
+ ' cs_model=""',
633
+ ' case "$oc_model" in',
634
+ " */*)",
635
+ ' cs_provider="${oc_model%%/*}"',
636
+ ' cs_model="${oc_model#*/}"',
637
+ " ;;",
638
+ " esac",
639
+ ' if [ -z "$cs_provider" ]; then',
640
+ ' defaults="$(curl -s --max-time 10 "http://127.0.0.1:$serve_port/config/providers")"',
641
+ String.raw` cs_provider="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_PROVIDER_SED}' | head -n 1)"`,
642
+ String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
643
+ " fi",
644
+ ' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
645
+ ' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
646
+ ' kill "$serve_pid" 2>/dev/null',
647
+ ' wait "$serve_pid" 2>/dev/null',
648
+ ' rm -f "$serve_out" "$serve_err"',
649
+ " return 0",
650
+ " fi",
651
+ ' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
652
+ 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")"`,
653
+ ' if [ "$result" != "true" ]; then',
654
+ ' echo "opencode-jobs: compaction failed: $result"',
655
+ " fi",
656
+ ' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
657
+ String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
658
+ ` 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")"`,
659
+ ' if [ "$http" != "200" ]; then',
660
+ ' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
661
+ " fi",
662
+ " fi",
663
+ ' kill "$serve_pid" 2>/dev/null',
664
+ ' wait "$serve_pid" 2>/dev/null',
665
+ ' rm -f "$serve_out" "$serve_err"',
666
+ "}"
667
+ ];
668
+ }
669
+ function runScriptContent(job, scopeId, opencodeBin) {
670
+ const mode = job.session ?? "new";
671
+ const isTracked = mode !== "new";
672
+ const isCompact = mode === "compact" || mode === "compact+last";
673
+ const isKeepLast = mode === "compact+last";
674
+ return [
675
+ "#!/bin/sh",
676
+ "set -u",
677
+ `slug=${shQuote(job.slug)}`,
678
+ `scope=${shQuote(scopeId)}`,
679
+ `oc_bin=${shQuote(opencodeBin)}`,
680
+ 'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
681
+ 'runs="$config_root/opencode/jobs/runs/$scope"',
682
+ 'mkdir -p "$runs"',
683
+ 'record_file="$runs/$slug.jsonl"',
684
+ ...isTracked ? [
685
+ 'sessions="$config_root/opencode/jobs/sessions/$scope"',
686
+ 'mkdir -p "$sessions"',
687
+ 'state_file="$sessions/$slug.txt"',
688
+ `session_mode=${shQuote(mode)}`,
689
+ 'prev_session=""',
690
+ 'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
691
+ ] : [],
692
+ 'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
693
+ 'run_id="$(date +%s%N)-$$"',
694
+ "started=$(date +%s)",
695
+ 'new_session=""',
696
+ 'wt_branch=""',
697
+ 'wt_commit=""',
698
+ `export OPENCODE_PERMISSION='{"question":"deny"}'`,
699
+ 'export OPENCODE_JOBS_RUN_ID="$run_id"',
700
+ "finish() {",
701
+ ' status="$1"',
702
+ ' code="$2"',
703
+ " ended=$(date +%s)",
704
+ String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s","worktreeBranch":"%s","worktreeCommit":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" "$wt_branch" "$wt_commit" >> "$record_file"`,
705
+ "}",
706
+ "trap 'finish timeout 124; exit 124' TERM INT",
707
+ ...job.guard === undefined ? [] : guardScriptLines(job.guard),
708
+ 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"`,
709
+ ...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
710
+ `oc_agent=${shQuote(job.run.agent ?? "")}`,
711
+ `oc_model=${shQuote(job.run.model ?? "")}`,
712
+ "prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
713
+ `oc_command=${shQuote("command" in job.run ? job.run.command : "")}`,
714
+ `oc_args=${shQuote("command" in job.run ? job.run.arguments ?? "" : "")}`,
715
+ `oc_prompt=${shQuote("prompt" in job.run ? job.run.prompt : "")}`,
716
+ `oc_keep_last=${String(isKeepLast ? 1 : 0)}`,
717
+ "run_opencode() {",
718
+ ' sess="$1"',
719
+ ' use_json="$2"',
720
+ " set -- run",
721
+ ' if [ -n "$oc_agent" ]; then set -- "$@" --agent "$oc_agent"; fi',
722
+ ' if [ -n "$oc_model" ]; then set -- "$@" --model "$oc_model"; fi',
723
+ ' if [ -n "$sess" ]; then set -- "$@" --session "$sess"; fi',
724
+ ' if [ "$use_json" -eq 1 ]; then set -- "$@" --format json; fi',
725
+ ' if [ "$oc_command_mode" -eq 1 ]; then',
726
+ ' set -- "$@" --command "$oc_command" -- "$oc_args"',
727
+ " else",
728
+ ' set -- "$@" -- "$oc_prompt"',
729
+ " fi",
730
+ ' "$oc_bin" "$@"',
731
+ "}",
732
+ ...isCompact ? compactSessionLines() : [],
733
+ ...isTracked ? [
734
+ 'json_out="$(mktemp)"',
735
+ 'run_opencode "$prev_session" 1 >"$json_out" 2>&1',
736
+ "code=$?",
737
+ 'cat "$json_out"',
738
+ ...extractSessionIdLines("new_session"),
739
+ 'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
740
+ ' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
741
+ ' rm -f "$json_out"',
742
+ ' json_out="$(mktemp)"',
743
+ ' run_opencode "" 1 >"$json_out" 2>&1',
744
+ " code=$?",
745
+ ' cat "$json_out"',
746
+ ...extractSessionIdLines("new_session", " "),
747
+ "fi",
748
+ 'cs_text=""',
749
+ 'if [ "$oc_keep_last" -eq 1 ]; then',
750
+ ` cs_text="$(awk '`,
751
+ ' /"type":"text"/ {',
752
+ " line = $0",
753
+ String.raw` i = index(line, "\042text\042:\042")`,
754
+ " if (i == 0) next",
755
+ " i = i + 8",
756
+ ' out = ""',
757
+ " len = length(line)",
758
+ " while (i <= len) {",
759
+ " c = substr(line, i, 1)",
760
+ String.raw` if (c == "\\") {`,
761
+ " out = out substr(line, i, 2)",
762
+ " i = i + 2",
763
+ " continue",
764
+ " }",
765
+ String.raw` if (c == "\042") break`,
766
+ " out = out c",
767
+ " i = i + 1",
768
+ " }",
769
+ " result = out",
770
+ " n = n + 1",
771
+ " }",
772
+ " END {",
773
+ " if (n > 0) {",
774
+ " if (length(result) > 16000) {",
775
+ " result = substr(result, 1, 16000)",
776
+ String.raw` if (substr(result, 16000, 1) == "\\") result = substr(result, 1, 15999)`,
777
+ " }",
778
+ " print result",
779
+ " }",
780
+ " }",
781
+ ` ' "$json_out")"`,
782
+ "fi",
783
+ 'rm -f "$json_out"',
784
+ 'if [ -n "$new_session" ]; then',
785
+ String.raw` printf '%s\n' "$new_session" >"$state_file"`,
786
+ "fi"
787
+ ] : ['run_opencode "" 0', "code=$?"],
788
+ "trap - TERM INT",
789
+ ...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
790
+ 'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
791
+ ...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
792
+ "finish success 0",
793
+ "exit 0",
794
+ ""
795
+ ].join(`
796
+ `);
797
+ }
798
+ function serviceContent(job, options) {
799
+ const timeout = job.timeoutSeconds !== undefined && job.timeoutSeconds > 0 ? `TimeoutStartSec=${String(job.timeoutSeconds)}s` : "TimeoutStartSec=infinity";
800
+ const lines = [
801
+ "[Unit]",
802
+ `Description=OpenCode job: ${escapeUnitText(job.name)} (${job.slug})`,
803
+ "",
804
+ "[Service]",
805
+ "Type=oneshot",
806
+ `WorkingDirectory=${unitQuote(options.workdir)}`,
807
+ `Environment=${unitQuote(`PATH=${options.pathEnvironment}`)}`,
808
+ `ExecStart=/bin/sh ${unitQuote(options.runScript)}`,
809
+ timeout,
810
+ `StandardOutput=append:${unitQuote(options.log)}`,
811
+ `StandardError=append:${unitQuote(options.log)}`
812
+ ];
813
+ return `${lines.join(`
814
+ `)}
815
+ `;
816
+ }
817
+ function timerContent(job, onCalendars) {
818
+ return [
819
+ "[Unit]",
820
+ `Description=OpenCode job timer: ${escapeUnitText(job.name)} (${job.slug})`,
821
+ "",
822
+ "[Timer]",
823
+ ...onCalendars.map((calendar) => `OnCalendar=${calendar}`),
824
+ "Persistent=true",
825
+ "",
826
+ "[Install]",
827
+ "WantedBy=timers.target",
828
+ ""
829
+ ].join(`
830
+ `);
831
+ }
309
832
  function systemctl(systemctlArguments) {
310
833
  const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
311
834
  encoding: "utf8"
@@ -316,30 +839,188 @@ function systemctl(systemctlArguments) {
316
839
  stderr: result.stderr.trim()
317
840
  };
318
841
  }
842
+ function systemdHint(stderr) {
843
+ if (/failed to connect|not connected|dbus/i.test(stderr)) {
844
+ return `
845
+ Hint: no systemd user session is reachable. Over SSH try enabling lingering: loginctl enable-linger $USER`;
846
+ }
847
+ return "";
848
+ }
849
+ function isTimerLoaded(base) {
850
+ const result = systemctl([
851
+ "show",
852
+ timerUnit(base),
853
+ "-p",
854
+ "LoadState",
855
+ "--value"
856
+ ]);
857
+ return result.ok && result.stdout !== "not-found";
858
+ }
859
+ function timerStatus(base) {
860
+ const result = systemctl([
861
+ "show",
862
+ timerUnit(base),
863
+ "-p",
864
+ "NextElapseUSecRealtime",
865
+ "-p",
866
+ "LastTriggerUSec",
867
+ "--value"
868
+ ]);
869
+ if (!result.ok)
870
+ return { next: undefined, last: undefined };
871
+ const [next = "n/a", last = "n/a"] = result.stdout.split(`
872
+ `, 2);
873
+ return {
874
+ next: next === "n/a" ? undefined : next,
875
+ last: last === "n/a" ? undefined : last
876
+ };
877
+ }
878
+ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
879
+ const script = runScriptPath(scopeId, job.slug);
880
+ const base = unitBase(scopeId, job.slug);
881
+ mkdirSync2(logDirectory(scopeId), { recursive: true });
882
+ mkdirSync2(runsDirectory(scopeId), { recursive: true });
883
+ atomicWriteExecutable(script, runScriptContent(job, scopeId, opencodeBin));
884
+ const onCalendars = cronToOnCalendar(parseCron(job.schedule));
885
+ atomicWrite(path4.join(systemdUserDirectory(), timerUnit(base)), timerContent(job, onCalendars));
886
+ atomicWrite(path4.join(systemdUserDirectory(), serviceUnit(base)), serviceContent(job, {
887
+ workdir: path4.resolve(workdir),
888
+ runScript: script,
889
+ log: logFile(scopeId, job.slug),
890
+ pathEnvironment
891
+ }));
892
+ return base;
893
+ }
319
894
  function removeJobUnits(scopeId, slug) {
320
895
  const base = unitBase(scopeId, slug);
321
- systemctl(["disable", "--now", timerUnit(base)]);
322
- for (const file of [
323
- path3.join(systemdUserDirectory(), timerUnit(base)),
324
- path3.join(systemdUserDirectory(), serviceUnit(base))
325
- ]) {
326
- if (existsSync(file))
896
+ const files = [
897
+ path4.join(systemdUserDirectory(), timerUnit(base)),
898
+ path4.join(systemdUserDirectory(), serviceUnit(base))
899
+ ];
900
+ const script = runScriptPath(scopeId, slug);
901
+ if ([...files, script].every((file) => !existsSync2(file)) && !isTimerLoaded(base)) {
902
+ return;
903
+ }
904
+ const disable = systemctl(["disable", "--now", timerUnit(base)]);
905
+ if (!disable.ok) {
906
+ return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
907
+ }
908
+ for (const file of files) {
909
+ if (existsSync2(file))
327
910
  rmSync(file);
328
911
  }
329
- const script = runScriptPath(scopeId, slug);
330
- if (existsSync(script))
912
+ if (existsSync2(script))
331
913
  rmSync(script);
914
+ return;
915
+ }
916
+ function removeStaleUnits(scopeId, expectedSlugs) {
917
+ const prefix = `opencode-sched-${scopeId}-`;
918
+ const removed = [];
919
+ if (existsSync2(systemdUserDirectory())) {
920
+ for (const entry of readdirSync2(systemdUserDirectory())) {
921
+ if (!entry.startsWith(prefix))
922
+ continue;
923
+ if (!entry.endsWith(".service") && !entry.endsWith(".timer"))
924
+ continue;
925
+ const slug = entry.slice(prefix.length).replaceAll(/\.(service|timer)$/g, "");
926
+ if (!expectedSlugs.has(slug)) {
927
+ rmSync(path4.join(systemdUserDirectory(), entry));
928
+ removed.push(slug);
929
+ }
930
+ }
931
+ }
932
+ if (existsSync2(scopeDirectory(scopeId))) {
933
+ const entries = readdirSync2(scopeDirectory(scopeId));
934
+ for (const entry of entries) {
935
+ const match = /^run-(.+)\.sh$/.exec(entry);
936
+ if (match === null)
937
+ continue;
938
+ const [, slug = ""] = match;
939
+ if (!expectedSlugs.has(slug))
940
+ rmSync(path4.join(scopeDirectory(scopeId), entry));
941
+ }
942
+ }
943
+ return removed;
332
944
  }
333
945
 
334
946
  // src/project.ts
947
+ function enableProject(workdir) {
948
+ if (process.platform !== "linux")
949
+ throw new Error("Scheduled jobs are only supported on Linux (systemd user units)");
950
+ const { jobs, errors } = loadJobs(workdir);
951
+ if (errors.length > 0)
952
+ throw new Error(`Invalid job definitions:
953
+ ${errors.join(`
954
+ `)}`);
955
+ if (jobs.length === 0) {
956
+ throw new Error(`No job definitions found in ${jobsDirectory(workdir)}. Create one with schedule_job first.`);
957
+ }
958
+ const abs = path5.resolve(workdir);
959
+ const scopeId = deriveScopeId(abs);
960
+ const opencodeBin = findOpencode();
961
+ const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
962
+ const bases = jobs.map((job) => writeJobUnits(job, abs, scopeId, opencodeBin, pathEnvironment));
963
+ const removed = removeStaleUnits(scopeId, new Set(jobs.map((job) => job.slug)));
964
+ const reload = systemctl(["daemon-reload"]);
965
+ if (!reload.ok)
966
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
967
+ const failures = [];
968
+ for (const base of bases) {
969
+ const enable = systemctl(["enable", "--now", timerUnit(base)]);
970
+ if (!enable.ok)
971
+ failures.push(`${timerUnit(base)}: ${enable.stderr}${systemdHint(enable.stderr)}`);
972
+ }
973
+ const registry = loadRegistry();
974
+ const previous = registry.projects[abs];
975
+ registry.projects[abs] = {
976
+ scopeId,
977
+ workdir: abs,
978
+ enabledAt: previous?.enabledAt ?? nowIso(),
979
+ updatedAt: nowIso(),
980
+ jobs: jobs.map((job) => job.slug)
981
+ };
982
+ saveRegistry(registry);
983
+ const lines = [
984
+ `Enabled ${String(jobs.length)} job(s) for ${abs} (scope ${scopeId})`
985
+ ];
986
+ if (removed.length > 0)
987
+ lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
988
+ lines.push(...describeJobSchedules(jobs, scopeId));
989
+ if (failures.length > 0)
990
+ throw new Error(`Timer activation failures:
991
+ ${failures.join(`
992
+ `)}`);
993
+ return lines.join(`
994
+ `);
995
+ }
996
+ function describeJobSchedules(jobs, scopeId) {
997
+ const lines = [];
998
+ for (const job of jobs) {
999
+ const sets = parseCron(job.schedule);
1000
+ const next = timerStatus(unitBase(scopeId, job.slug)).next;
1001
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
1002
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}`);
1003
+ }
1004
+ return lines;
1005
+ }
335
1006
  function disableProject(workdir) {
336
- const abs = path4.resolve(workdir);
1007
+ const abs = path5.resolve(workdir);
337
1008
  const entry = registryEntry(abs);
338
1009
  if (entry === undefined)
339
1010
  return `Project is not enabled: ${abs}`;
340
- for (const slug of entry.jobs)
341
- removeJobUnits(entry.scopeId, slug);
342
- systemctl(["daemon-reload"]);
1011
+ const failures = entry.jobs.flatMap((slug) => {
1012
+ const failure = removeJobUnits(entry.scopeId, slug);
1013
+ return failure === undefined ? [] : [failure];
1014
+ });
1015
+ if (failures.length > 0) {
1016
+ throw new Error(`Timer removal failures:
1017
+ ${failures.join(`
1018
+ `)}`);
1019
+ }
1020
+ const reload = systemctl(["daemon-reload"]);
1021
+ if (!reload.ok) {
1022
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
1023
+ }
343
1024
  const registry = loadRegistry();
344
1025
  const { [abs]: _omitted, ...remainingProjects } = registry.projects;
345
1026
  registry.projects = remainingProjects;
@@ -352,6 +1033,82 @@ function disableProject(workdir) {
352
1033
  `);
353
1034
  }
354
1035
 
1036
+ // src/migration.ts
1037
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, renameSync as renameSync2, rmdirSync } from "node:fs";
1038
+ import path6 from "node:path";
1039
+ function legacyJobsStateDirectory() {
1040
+ return path6.join(configRoot(), "opencode", "scheduler");
1041
+ }
1042
+ function legacyRegistryPath() {
1043
+ return path6.join(legacyJobsStateDirectory(), "registry.json");
1044
+ }
1045
+ function legacyDefinitionsDirectory(workdir) {
1046
+ return path6.join(workdir, ".opencode", "scheduler", "jobs");
1047
+ }
1048
+ function migrationMoves(projects) {
1049
+ return [
1050
+ {
1051
+ from: legacyJobsStateDirectory(),
1052
+ to: jobsStateDirectory()
1053
+ },
1054
+ {
1055
+ from: path6.join(configRoot(), "opencode", "logs", "scheduler"),
1056
+ to: path6.join(configRoot(), "opencode", "logs", "jobs")
1057
+ },
1058
+ {
1059
+ from: path6.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1060
+ to: path6.join(stateRoot(), "opencode", "jobs", "worktrees")
1061
+ },
1062
+ ...[...projects].map((workdir) => ({
1063
+ from: legacyDefinitionsDirectory(workdir),
1064
+ to: jobsDirectory(workdir)
1065
+ }))
1066
+ ];
1067
+ }
1068
+ function removeLegacyProjectDirectory(workdir) {
1069
+ try {
1070
+ rmdirSync(path6.join(workdir, ".opencode", "scheduler"));
1071
+ } catch {}
1072
+ }
1073
+ function migrateStorage(projectDirectory, shouldResync) {
1074
+ const project = path6.resolve(projectDirectory);
1075
+ const legacyRegistry = legacyRegistryPath();
1076
+ const canonicalRegistry = path6.join(jobsStateDirectory(), "registry.json");
1077
+ const registryFile = existsSync3(legacyRegistry) ? legacyRegistry : canonicalRegistry;
1078
+ const registry = existsSync3(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
1079
+ const registeredProjects = new Set(Object.keys(registry.projects));
1080
+ const projects = new Set([project, ...registeredProjects]);
1081
+ const moves = migrationMoves(projects).filter(({ from }) => existsSync3(from));
1082
+ for (const { from, to } of moves) {
1083
+ if (existsSync3(to)) {
1084
+ throw new Error(`Cannot migrate legacy job storage because both paths exist: ${from} and ${to}. Reconcile or back up one path, then retry; neither path was changed.`);
1085
+ }
1086
+ }
1087
+ for (const { from, to } of moves) {
1088
+ mkdirSync3(path6.dirname(to), { recursive: true });
1089
+ renameSync2(from, to);
1090
+ }
1091
+ for (const workdir of projects)
1092
+ removeLegacyProjectDirectory(workdir);
1093
+ const result = {
1094
+ moved: moves,
1095
+ resyncedProjects: [],
1096
+ warnings: []
1097
+ };
1098
+ if (!shouldResync || moves.length === 0)
1099
+ return result;
1100
+ for (const workdir of registeredProjects) {
1101
+ try {
1102
+ enableProject(workdir);
1103
+ result.resyncedProjects.push(workdir);
1104
+ } catch (error) {
1105
+ const message = error instanceof Error ? error.message : String(error);
1106
+ result.warnings.push(`${workdir}: ${message}`);
1107
+ }
1108
+ }
1109
+ return result;
1110
+ }
1111
+
355
1112
  // src/install.ts
356
1113
  var PACKAGE_NAME = "opencode-jobs";
357
1114
  var SKILL_NAME = "opencode-jobs";
@@ -362,20 +1119,20 @@ var configSchema = z3.looseObject({
362
1119
  var CONFIG_LOCATIONS = [
363
1120
  "opencode.json",
364
1121
  "opencode.jsonc",
365
- path5.join(".opencode", "opencode.json"),
366
- path5.join(".opencode", "opencode.jsonc")
1122
+ path7.join(".opencode", "opencode.json"),
1123
+ path7.join(".opencode", "opencode.jsonc")
367
1124
  ];
368
1125
  function existingConfigPath(projectDirectory) {
369
1126
  for (const relativePath of CONFIG_LOCATIONS) {
370
- const configPath = path5.join(projectDirectory, relativePath);
371
- if (existsSync2(configPath))
1127
+ const configPath = path7.join(projectDirectory, relativePath);
1128
+ if (existsSync4(configPath))
372
1129
  return configPath;
373
1130
  }
374
1131
  return;
375
1132
  }
376
1133
  function readConfig(configPath) {
377
1134
  try {
378
- const value = JSON.parse(readFileSync2(configPath, "utf8"));
1135
+ const value = JSON.parse(readFileSync3(configPath, "utf8"));
379
1136
  const result = configSchema.safeParse(value);
380
1137
  return result.success ? result.data : undefined;
381
1138
  } catch {
@@ -392,7 +1149,7 @@ function isPackageReference(entry) {
392
1149
  }
393
1150
  function installPluginConfig(projectDirectory) {
394
1151
  const existingPath = existingConfigPath(projectDirectory);
395
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1152
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
396
1153
  if (existingPath === undefined) {
397
1154
  atomicWrite(configPath, `${JSON.stringify({
398
1155
  $schema: CONFIG_SCHEMA,
@@ -414,34 +1171,36 @@ function installPluginConfig(projectDirectory) {
414
1171
  return { status: "added", configPath };
415
1172
  }
416
1173
  function installSkill(projectDirectory, packageDirectory) {
417
- const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
418
- const skillPath = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
419
- const content = readFileSync2(sourcePath, "utf8");
420
- if (existsSync2(skillPath) && readFileSync2(skillPath, "utf8") === content) {
1174
+ const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
1175
+ const skillPath = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
1176
+ const content = readFileSync3(sourcePath, "utf8");
1177
+ if (existsSync4(skillPath) && readFileSync3(skillPath, "utf8") === content) {
421
1178
  return { status: "unchanged", skillPath };
422
1179
  }
423
1180
  atomicWrite(skillPath, content);
424
1181
  return { status: "written", skillPath };
425
1182
  }
426
1183
  function installProject(projectDirectory, packageDirectory) {
427
- const resolvedProject = path5.resolve(projectDirectory);
428
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1184
+ const resolvedProject = path7.resolve(projectDirectory);
1185
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
429
1186
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
430
1187
  }
1188
+ const migration = migrateStorage(resolvedProject, true);
431
1189
  return {
432
1190
  projectDirectory: resolvedProject,
433
1191
  plugin: installPluginConfig(resolvedProject),
434
- skill: installSkill(resolvedProject, packageDirectory)
1192
+ skill: installSkill(resolvedProject, packageDirectory),
1193
+ ...migration.moved.length > 0 && { migration }
435
1194
  };
436
1195
  }
437
1196
  function removeDirectoryIfEmpty(directory) {
438
1197
  try {
439
- rmdirSync(directory);
1198
+ rmdirSync2(directory);
440
1199
  } catch {}
441
1200
  }
442
1201
  function uninstallPluginConfig(projectDirectory) {
443
1202
  const existingPath = existingConfigPath(projectDirectory);
444
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1203
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
445
1204
  if (existingPath === undefined)
446
1205
  return { status: "absent", configPath };
447
1206
  const config = readConfig(configPath);
@@ -465,21 +1224,21 @@ function uninstallPluginConfig(projectDirectory) {
465
1224
  return { status: "removed", configPath };
466
1225
  }
467
1226
  function uninstallSkill(projectDirectory, packageDirectory) {
468
- const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
469
- const skillDirectory = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
470
- const skillPath = path5.join(skillDirectory, "SKILL.md");
471
- if (!existsSync2(skillPath))
1227
+ const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
1228
+ const skillDirectory = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
1229
+ const skillPath = path7.join(skillDirectory, "SKILL.md");
1230
+ if (!existsSync4(skillPath))
472
1231
  return { status: "absent", skillPath };
473
- if (readFileSync2(skillPath, "utf8") !== readFileSync2(sourcePath, "utf8")) {
1232
+ if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
474
1233
  return { status: "kept-modified", skillPath };
475
1234
  }
476
1235
  rmSync2(skillDirectory, { recursive: true, force: true });
477
- removeDirectoryIfEmpty(path5.dirname(skillDirectory));
478
- removeDirectoryIfEmpty(path5.join(projectDirectory, ".opencode"));
1236
+ removeDirectoryIfEmpty(path7.dirname(skillDirectory));
1237
+ removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
479
1238
  return { status: "removed", skillPath };
480
1239
  }
481
1240
  function purgeProjectData(projectDirectory) {
482
- const abs = path5.resolve(projectDirectory);
1241
+ const abs = path7.resolve(projectDirectory);
483
1242
  const scopeId = deriveScopeId(abs);
484
1243
  const targets = [
485
1244
  scopeDirectory(scopeId),
@@ -488,11 +1247,11 @@ function purgeProjectData(projectDirectory) {
488
1247
  logDirectory(scopeId),
489
1248
  locksDirectory(scopeId),
490
1249
  worktreesDirectory(scopeId),
491
- path5.join(abs, ".opencode", "scheduler")
1250
+ path7.join(abs, ".opencode", "jobs")
492
1251
  ];
493
1252
  const paths = [];
494
1253
  for (const target of targets) {
495
- if (!existsSync2(target))
1254
+ if (!existsSync4(target))
496
1255
  continue;
497
1256
  rmSync2(target, { recursive: true, force: true });
498
1257
  paths.push(target);
@@ -500,14 +1259,15 @@ function purgeProjectData(projectDirectory) {
500
1259
  if (paths.includes(worktreesDirectory(scopeId))) {
501
1260
  spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
502
1261
  }
503
- removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
1262
+ removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
504
1263
  return { paths };
505
1264
  }
506
1265
  function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
507
- const resolvedProject = path5.resolve(projectDirectory);
508
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1266
+ const resolvedProject = path7.resolve(projectDirectory);
1267
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
509
1268
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
510
1269
  }
1270
+ const migration = migrateStorage(resolvedProject, false);
511
1271
  const wasEnabled = registryEntry(resolvedProject) !== undefined;
512
1272
  if (wasEnabled)
513
1273
  disableProject(resolvedProject);
@@ -515,30 +1275,213 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
515
1275
  projectDirectory: resolvedProject,
516
1276
  disabled: wasEnabled,
517
1277
  plugin: uninstallPluginConfig(resolvedProject),
518
- skill: uninstallSkill(resolvedProject, packageDirectory)
1278
+ skill: uninstallSkill(resolvedProject, packageDirectory),
1279
+ ...migration.moved.length > 0 && { migration }
519
1280
  };
520
1281
  if (shouldPurge)
521
1282
  uninstall.purge = purgeProjectData(resolvedProject);
522
1283
  return uninstall;
523
1284
  }
524
1285
 
1286
+ // src/management.ts
1287
+ import { spawn } from "node:child_process";
1288
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync4, openSync } from "node:fs";
1289
+ import path8 from "node:path";
1290
+
1291
+ // src/runs.ts
1292
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
1293
+ import { z as z4 } from "zod";
1294
+ var optionalString = z4.string().optional().catch(undefined);
1295
+ var optionalNumber = z4.number().optional().catch(undefined);
1296
+ var runRecordSchema = z4.object({
1297
+ runId: optionalString,
1298
+ slug: optionalString,
1299
+ scopeId: optionalString,
1300
+ startedAt: optionalNumber,
1301
+ finishedAt: optionalNumber,
1302
+ durationMs: optionalNumber,
1303
+ status: optionalString,
1304
+ exitCode: optionalNumber,
1305
+ sessionId: optionalString,
1306
+ startedBy: optionalString,
1307
+ worktreeBranch: optionalString,
1308
+ worktreeCommit: optionalString
1309
+ });
1310
+ function readRunRecords(scopeId, slug, limit) {
1311
+ const file = runsFile(scopeId, slug);
1312
+ if (!existsSync5(file))
1313
+ return [];
1314
+ const records = [];
1315
+ for (const line of readFileSync4(file, "utf8").split(`
1316
+ `)) {
1317
+ if (line.trim().length === 0)
1318
+ continue;
1319
+ try {
1320
+ const value = JSON.parse(line);
1321
+ const result = runRecordSchema.safeParse(value);
1322
+ if (result.success)
1323
+ records.push(result.data);
1324
+ } catch {}
1325
+ }
1326
+ return records.slice(-limit);
1327
+ }
1328
+ function lastFinishedRun(records) {
1329
+ for (const record of records.toReversed()) {
1330
+ if (record.status !== undefined && record.status !== "running")
1331
+ return record;
1332
+ }
1333
+ return;
1334
+ }
1335
+ function timestampOf(record) {
1336
+ if (record.finishedAt !== undefined)
1337
+ return new Date(record.finishedAt * 1000).toISOString();
1338
+ if (record.startedAt !== undefined)
1339
+ return new Date(record.startedAt * 1000).toISOString();
1340
+ return "?";
1341
+ }
1342
+ function formatRunLine(record) {
1343
+ const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
1344
+ const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
1345
+ const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
1346
+ const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
1347
+ return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
1348
+ }
1349
+ function tailFile(file, lines, maxChars) {
1350
+ if (!existsSync5(file))
1351
+ return;
1352
+ const content = readFileSync4(file, "utf8").trimEnd();
1353
+ if (content.length === 0)
1354
+ return "";
1355
+ const tail = content.split(`
1356
+ `).slice(-lines).join(`
1357
+ `);
1358
+ return tail.length > maxChars ? `...${tail.slice(-maxChars)}` : tail;
1359
+ }
1360
+
1361
+ // src/management.ts
1362
+ function tryParseCron(schedule) {
1363
+ try {
1364
+ return parseCron(schedule);
1365
+ } catch {
1366
+ return;
1367
+ }
1368
+ }
1369
+ function listJobs(directory) {
1370
+ const { jobs, errors } = loadJobs(directory);
1371
+ const entry = registryEntry(directory);
1372
+ const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
1373
+ const lines = [header];
1374
+ if (jobs.length === 0)
1375
+ lines.push("No job definitions. Create one with schedule_job.");
1376
+ for (const job of jobs) {
1377
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1378
+ const sets = tryParseCron(job.schedule);
1379
+ if (sets === undefined) {
1380
+ lines.push(`- ${job.slug}: INVALID schedule "${job.schedule}"`);
1381
+ continue;
1382
+ }
1383
+ const records = readRunRecords(scopeId, job.slug, 20);
1384
+ const last = lastFinishedRun(records);
1385
+ const lastDesc = last === undefined ? ", last: never" : `, last: ${last.status ?? "?"} ${formatRunLine(last)}`;
1386
+ const next = entry === undefined ? undefined : timerStatus(unitBase(scopeId, job.slug)).next;
1387
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
1388
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
1389
+ }
1390
+ lines.push(...errors.map((error) => `! ${error}`));
1391
+ return { ok: true, output: lines.join(`
1392
+ `) };
1393
+ }
1394
+ function runJobNow(slugInput, directory) {
1395
+ const slug = slugify(slugInput);
1396
+ const file = path8.join(jobsDirectory(directory), `${slug}.json`);
1397
+ if (!existsSync6(file)) {
1398
+ return {
1399
+ ok: false,
1400
+ output: `No job "${slug}" in ${jobsDirectory(directory)}`
1401
+ };
1402
+ }
1403
+ const entry = registryEntry(directory);
1404
+ if (entry === undefined) {
1405
+ return {
1406
+ ok: false,
1407
+ output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
1408
+ };
1409
+ }
1410
+ const script = runScriptPath(entry.scopeId, slug);
1411
+ if (!existsSync6(script)) {
1412
+ return {
1413
+ ok: false,
1414
+ output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
1415
+ };
1416
+ }
1417
+ const log = logFile(entry.scopeId, slug);
1418
+ mkdirSync4(logDirectory(entry.scopeId), { recursive: true });
1419
+ const fd = openSync(log, "a");
1420
+ const child = spawn("/bin/sh", [script], {
1421
+ cwd: path8.resolve(directory),
1422
+ env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
1423
+ stdio: ["ignore", fd, fd]
1424
+ });
1425
+ child.unref();
1426
+ closeSync(fd);
1427
+ const tail = tailFile(log, 5, 2000);
1428
+ const parts = [
1429
+ `Started "${slug}" manually (pid ${String(child.pid)})`,
1430
+ `Log: ${log}`
1431
+ ];
1432
+ if (tail?.length)
1433
+ parts.push(`Log tail:
1434
+ ${tail}`);
1435
+ return { ok: true, output: parts.join(`
1436
+ `) };
1437
+ }
1438
+
525
1439
  // src/cli.ts
526
1440
  var USAGE = `Usage: opencode-jobs <command> [projectDir]
527
1441
 
528
1442
  Commands:
529
1443
  install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
530
1444
  uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
531
- --purge also deletes job definitions and scheduler data
1445
+ --purge also deletes job definitions and job data
1446
+ list [projectDir] List jobs and their enabled, next-run, and last-run state
1447
+ enable [projectDir] Enable or re-sync all jobs in a project
1448
+ disable [projectDir] Disable all jobs in a project while keeping definitions and history
1449
+ run <slug> [projectDir] Run one enabled job immediately
532
1450
  help Show this help`;
533
1451
  function packageDirectory() {
534
- return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
1452
+ return path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
535
1453
  }
536
1454
  function printError(message) {
537
- console.error(`Error: ${message}
538
-
539
- ${USAGE}`);
1455
+ console.log(JSON.stringify({ ok: false, output: `Error: ${message}` }, undefined, 2));
1456
+ console.error(USAGE);
540
1457
  process.exitCode = 1;
541
1458
  }
1459
+ function projectArgument(command, arguments_) {
1460
+ if (arguments_.some((argument) => argument.startsWith("--"))) {
1461
+ throw new Error(`${command} does not accept options`);
1462
+ }
1463
+ if (arguments_.length > 1) {
1464
+ throw new Error(`${command} accepts at most one project directory`);
1465
+ }
1466
+ return arguments_[0] ?? process.cwd();
1467
+ }
1468
+ function runArguments(arguments_) {
1469
+ if (arguments_.some((argument) => argument.startsWith("--"))) {
1470
+ throw new Error("run does not accept options");
1471
+ }
1472
+ if (arguments_.length === 0 || arguments_.length > 2) {
1473
+ throw new Error("run requires a job slug and accepts one project directory");
1474
+ }
1475
+ return {
1476
+ slug: arguments_[0] ?? "",
1477
+ project: arguments_[1] ?? process.cwd()
1478
+ };
1479
+ }
1480
+ function printManagementResult(result) {
1481
+ console.log(JSON.stringify(result, undefined, 2));
1482
+ if (!result.ok)
1483
+ process.exitCode = 1;
1484
+ }
542
1485
  function parseUninstallArguments(arguments_) {
543
1486
  let project;
544
1487
  let shouldPurge = false;
@@ -588,6 +1531,43 @@ if ([undefined, "help", "--help"].includes(command)) {
588
1531
  } catch (error) {
589
1532
  printError(errorMessage(error));
590
1533
  }
1534
+ } else if (command !== undefined && ["list", "enable", "disable", "run"].includes(command)) {
1535
+ try {
1536
+ let slug;
1537
+ let project;
1538
+ if (command === "run") {
1539
+ ({ slug, project } = runArguments(rest));
1540
+ } else {
1541
+ project = projectArgument(command, rest);
1542
+ }
1543
+ const migration = migrateStorage(project, true);
1544
+ for (const warning of migration.warnings) {
1545
+ console.error(`storage migration warning: ${warning}`);
1546
+ }
1547
+ switch (command) {
1548
+ case "list": {
1549
+ printManagementResult(listJobs(project));
1550
+ break;
1551
+ }
1552
+ case "enable": {
1553
+ printManagementResult({ ok: true, output: enableProject(project) });
1554
+ break;
1555
+ }
1556
+ case "disable": {
1557
+ printManagementResult({ ok: true, output: disableProject(project) });
1558
+ break;
1559
+ }
1560
+ case "run": {
1561
+ printManagementResult(runJobNow(slug ?? "", project));
1562
+ break;
1563
+ }
1564
+ default: {
1565
+ throw new Error(`unknown management command: ${command}`);
1566
+ }
1567
+ }
1568
+ } catch (error) {
1569
+ printError(errorMessage(error));
1570
+ }
591
1571
  } else {
592
1572
  printError(`unknown command: ${command ?? "(missing)"}`);
593
1573
  }