opencode-jobs 0.2.0 → 1.0.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 path8 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,38 @@ 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
41
  }
42
42
  function sessionStateDirectory(scopeId) {
43
- return path.join(schedulerDirectory(), "sessions", scopeId);
43
+ return path.join(jobsStateDirectory(), "sessions", scopeId);
44
44
  }
45
45
  function logDirectory(scopeId) {
46
- return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
46
+ return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
47
+ }
48
+ function logFile(scopeId, slug) {
49
+ return path.join(logDirectory(scopeId), `${slug}.log`);
47
50
  }
48
51
  function systemdUserDirectory() {
49
52
  return path.join(configRoot(), "systemd", "user");
50
53
  }
54
+ function jobsDirectory(workdir) {
55
+ return path.join(workdir, ".opencode", "jobs");
56
+ }
51
57
  function unitBase(scopeId, slug) {
52
58
  return `opencode-sched-${scopeId}-${slug}`;
53
59
  }
@@ -64,12 +70,30 @@ function slugify(input) {
64
70
  const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
65
71
  return slug.length > 0 ? slug : "job";
66
72
  }
73
+ function shQuote(value) {
74
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
75
+ }
76
+ function unitQuote(value) {
77
+ if (/^[A-Za-z0-9_@:=./-]*$/.test(value))
78
+ return value;
79
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}"`;
80
+ }
81
+ function escapeUnitText(value) {
82
+ return value.replaceAll("%", "%%").replaceAll(/\s+/g, " ").trim();
83
+ }
67
84
  function atomicWrite(file, content) {
68
85
  mkdirSync(path.dirname(file), { recursive: true });
69
86
  const temporary = `${file}.tmp`;
70
87
  writeFileSync(temporary, content);
71
88
  renameSync(temporary, file);
72
89
  }
90
+ function atomicWriteExecutable(file, content) {
91
+ atomicWrite(file, content);
92
+ chmodSync(file, 493);
93
+ }
94
+ function nowIso() {
95
+ return new Date().toISOString();
96
+ }
73
97
  function deriveScopeId(workdir) {
74
98
  const abs = path.resolve(workdir);
75
99
  const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
@@ -77,12 +101,15 @@ function deriveScopeId(workdir) {
77
101
  }
78
102
 
79
103
  // src/project.ts
80
- import path4 from "node:path";
104
+ import path5 from "node:path";
81
105
 
82
106
  // src/job.ts
107
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
108
+ import path2 from "node:path";
83
109
  import { z } from "zod";
84
110
 
85
111
  // src/cron.ts
112
+ var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
86
113
  var DOW_NAMES = {
87
114
  sun: 0,
88
115
  mon: 1,
@@ -167,6 +194,42 @@ function parseCron(expression) {
167
194
  dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
168
195
  };
169
196
  }
197
+ function pad2(value) {
198
+ return String(value).padStart(2, "0");
199
+ }
200
+ function fmtList(values) {
201
+ return values.map((v) => pad2(v)).join(",");
202
+ }
203
+ function cronToOnCalendar(sets) {
204
+ const timePart = `${fmtList(sets.hour)}:${fmtList(sets.minute)}:00`;
205
+ const months = sets.month.length === 12 ? "*" : fmtList(sets.month);
206
+ const doms = sets.dom.length === 31 ? "*" : fmtList(sets.dom);
207
+ const dows = sets.dow.length === 7 ? "" : `${sets.dow.map((d) => DOW_LABELS[d]).join(",")} `;
208
+ if (sets.dow.length === 7)
209
+ return [`*-${months}-${doms} ${timePart}`];
210
+ if (sets.dom.length === 31 && sets.month.length === 12)
211
+ return [`${dows}*-*-* ${timePart}`];
212
+ return [
213
+ `${dows}*-${months}-* ${timePart}`,
214
+ `*-${months}-${doms} ${timePart}`
215
+ ];
216
+ }
217
+ function describeCron(sets) {
218
+ 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(",")}`;
219
+ const isDowAll = sets.dow.length === 7;
220
+ const isDomAll = sets.dom.length === 31;
221
+ let dayDesc;
222
+ if (isDowAll && isDomAll)
223
+ dayDesc = "every day";
224
+ else if (isDowAll)
225
+ dayDesc = `on day ${sets.dom.join(",")} of the month`;
226
+ else if (isDomAll)
227
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")}`;
228
+ else
229
+ dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")} or day ${sets.dom.join(",")}`;
230
+ const monthDesc = sets.month.length === 12 ? "" : ` in month ${sets.month.join(",")}`;
231
+ return `${timeDesc} ${dayDesc}${monthDesc}`;
232
+ }
170
233
 
171
234
  // src/json.ts
172
235
  function errorMessage(error) {
@@ -258,10 +321,82 @@ var jobFileSchema = z.strictObject({
258
321
  createdAt: z.string().optional(),
259
322
  updatedAt: z.string().optional()
260
323
  });
324
+ function formatValidationError(error) {
325
+ const issue = error.issues[0];
326
+ if (issue === undefined)
327
+ return "invalid job definition";
328
+ const field = issue.path.join(".");
329
+ return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
330
+ }
331
+ function loadJobFile(file, expectedSlug) {
332
+ const stem = path2.basename(file, ".json");
333
+ try {
334
+ const object = JSON.parse(readFileSync(file, "utf8"));
335
+ const result = jobFileSchema.safeParse(object);
336
+ if (!result.success) {
337
+ return {
338
+ ok: false,
339
+ error: `${stem}.json: ${formatValidationError(result.error)}`
340
+ };
341
+ }
342
+ const definition = result.data;
343
+ const slug = definition.slug ?? stem;
344
+ if (slug !== stem)
345
+ return {
346
+ ok: false,
347
+ error: `${stem}.json: "slug" ("${slug}") must match filename`
348
+ };
349
+ if (expectedSlug !== undefined && slug !== expectedSlug)
350
+ return { ok: false, error: `${stem}.json: unexpected slug` };
351
+ const timestamp = nowIso();
352
+ return {
353
+ ok: true,
354
+ job: {
355
+ slug,
356
+ name: definition.name,
357
+ schedule: definition.schedule,
358
+ run: definition.run,
359
+ ...definition.session !== "new" && { session: definition.session },
360
+ ...definition.guard !== undefined && { guard: definition.guard },
361
+ ...definition.worktree !== undefined && {
362
+ worktree: definition.worktree
363
+ },
364
+ ...definition.timeoutSeconds !== undefined && {
365
+ timeoutSeconds: definition.timeoutSeconds
366
+ },
367
+ createdAt: definition.createdAt ?? timestamp,
368
+ updatedAt: definition.updatedAt ?? timestamp
369
+ }
370
+ };
371
+ } catch (error) {
372
+ return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
373
+ }
374
+ }
375
+ function loadJobs(workdir) {
376
+ const directory = jobsDirectory(workdir);
377
+ if (!existsSync(directory))
378
+ return { jobs: [], errors: [] };
379
+ const jobs = [];
380
+ const errors = [];
381
+ const entries = readdirSync(directory, { withFileTypes: true });
382
+ for (const entry of entries) {
383
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
384
+ continue;
385
+ const result = loadJobFile(path2.join(directory, entry.name));
386
+ if (result.ok)
387
+ jobs.push(result.job);
388
+ else
389
+ errors.push(result.error);
390
+ }
391
+ return {
392
+ jobs: jobs.toSorted((a, b) => a.slug.localeCompare(b.slug)),
393
+ errors
394
+ };
395
+ }
261
396
 
262
397
  // src/registry.ts
263
- import { readFileSync } from "node:fs";
264
- import path2 from "node:path";
398
+ import { readFileSync as readFileSync2 } from "node:fs";
399
+ import path3 from "node:path";
265
400
  import { z as z2 } from "zod";
266
401
  var registryEntrySchema = z2.looseObject({
267
402
  scopeId: z2.string(),
@@ -274,19 +409,22 @@ var registryFileSchema = z2.object({
274
409
  version: z2.literal(1),
275
410
  projects: z2.record(z2.string(), z2.unknown())
276
411
  });
412
+ function readRegistryFile(file) {
413
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
414
+ const result = registryFileSchema.safeParse(parsed);
415
+ if (!result.success)
416
+ throw new Error(`Invalid job registry: ${file}`);
417
+ const projects = {};
418
+ for (const [key, value] of Object.entries(result.data.projects)) {
419
+ const entry = registryEntrySchema.safeParse(value);
420
+ if (entry.success)
421
+ projects[key] = entry.data;
422
+ }
423
+ return { version: 1, projects };
424
+ }
277
425
  function loadRegistry() {
278
426
  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 };
427
+ return readRegistryFile(registryPath());
290
428
  } catch {
291
429
  return { version: 1, projects: {} };
292
430
  }
@@ -296,16 +434,398 @@ function saveRegistry(registry) {
296
434
  `);
297
435
  }
298
436
  function registryEntry(workdir) {
299
- return loadRegistry().projects[path2.resolve(workdir)];
437
+ return loadRegistry().projects[path3.resolve(workdir)];
300
438
  }
301
439
 
302
440
  // src/systemd.ts
303
- import { existsSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "node:fs";
441
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync } from "node:fs";
304
442
  import { spawnSync } from "node:child_process";
305
- import path3 from "node:path";
443
+ import path4 from "node:path";
444
+ import { homedir as homedir2 } from "node:os";
445
+ function findOpencode() {
446
+ const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
447
+ if (override !== undefined && override.length > 0)
448
+ return override;
449
+ const which = spawnSync("sh", ["-c", "command -v opencode"], {
450
+ encoding: "utf8"
451
+ });
452
+ const onPath = which.stdout.trim();
453
+ if (which.status === 0 && onPath.length > 0)
454
+ return onPath;
455
+ const candidates = [
456
+ path4.join(homedir2(), ".opencode/bin/opencode"),
457
+ "/usr/local/bin/opencode",
458
+ "/usr/bin/opencode"
459
+ ];
460
+ for (const candidate of candidates) {
461
+ if (existsSync2(candidate))
462
+ return candidate;
463
+ }
464
+ return "opencode";
465
+ }
466
+ function guardScriptLines(guard) {
467
+ return [
468
+ `guard=${shQuote(guard)}`,
469
+ 'sh -c "$guard"',
470
+ "guard_code=$?",
471
+ 'if [ "$guard_code" -ne 0 ]; then',
472
+ ' echo "guard exited $guard_code, skipping run"',
473
+ ' finish skipped "$guard_code"',
474
+ " exit 0",
475
+ "fi"
476
+ ];
477
+ }
478
+ function worktreeDefaultRoot(scopeId) {
479
+ return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
480
+ }
481
+ function worktreePrologueLines(job, scopeId) {
482
+ const base = job.worktree?.base;
483
+ return [
484
+ "wt_enabled=1",
485
+ 'orig_pwd="$(pwd)"',
486
+ 'lock_dir="$config_root/opencode/jobs/locks/$scope"',
487
+ 'mkdir -p "$lock_dir"',
488
+ 'exec 9>"$lock_dir/$slug.lock"',
489
+ "if ! flock -n 9; then",
490
+ ' echo "opencode-jobs: another run of $slug is already active, skipping"',
491
+ " finish skipped 0",
492
+ " exit 0",
493
+ "fi",
494
+ base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
495
+ 'if ! mkdir -p "$wt_root"; then',
496
+ ' echo "opencode-jobs: cannot create worktree base $wt_root"',
497
+ " finish failed 1",
498
+ " exit 1",
499
+ "fi",
500
+ 'wt_root="$(cd "$wt_root" && pwd)"',
501
+ 'wt_path="$wt_root/$slug"',
502
+ 'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
503
+ `wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
504
+ 'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
505
+ 'wt_sub="${wt_sub%/}"',
506
+ 'if [ -d "$wt_path" ]; then',
507
+ ' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
508
+ " wt_stale_saved=1",
509
+ ' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
510
+ ' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
511
+ ' 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',
512
+ " fi",
513
+ ' if [ "$wt_stale_saved" -eq 1 ]; then',
514
+ ' git worktree remove --force "$wt_path" >/dev/null 2>&1',
515
+ ' rm -rf "$wt_path"',
516
+ " else",
517
+ ' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
518
+ ' wt_branch=""',
519
+ " finish failed 1",
520
+ " exit 1",
521
+ " fi",
522
+ " else",
523
+ ' echo "opencode-jobs: removing unexpected directory at $wt_path"',
524
+ ' rm -rf "$wt_path"',
525
+ " fi",
526
+ " git worktree prune >/dev/null 2>&1",
527
+ "fi",
528
+ 'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
529
+ ' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
530
+ ' wt_branch=""',
531
+ " finish failed 1",
532
+ " exit 1",
533
+ "fi",
534
+ 'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
535
+ ' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
536
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
537
+ ' rm -rf "$wt_path"',
538
+ ' wt_branch=""',
539
+ " finish failed 1",
540
+ " exit 1",
541
+ "fi",
542
+ 'if [ -n "$wt_sub" ]; then',
543
+ ' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
544
+ "else",
545
+ ' cd "$wt_path" || { finish failed 1; exit 1; }',
546
+ "fi"
547
+ ];
548
+ }
549
+ function worktreeEpilogueLines(options) {
550
+ const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
551
+ return [
552
+ 'if [ "$wt_enabled" -eq 1 ]; then',
553
+ ` wt_msg=${message}`,
554
+ ' git -C "$wt_path" add -A >/dev/null 2>&1',
555
+ " wt_keep=0",
556
+ ' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
557
+ ' 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',
558
+ ' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
559
+ " else",
560
+ ' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
561
+ " wt_keep=1",
562
+ " fi",
563
+ " fi",
564
+ ' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
565
+ ' if [ "$wt_keep" -eq 0 ]; then',
566
+ ' cd "$wt_root" 2>/dev/null',
567
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
568
+ ' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
569
+ " fi",
570
+ "fi"
571
+ ];
572
+ }
306
573
  var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
307
574
  var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
308
575
  var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
576
+ function extractSessionIdLines(target, indent = "") {
577
+ return [
578
+ `${indent}${target}="$(sed -n '${SESSION_ID_SED}' "$json_out" | head -n 1)"`
579
+ ];
580
+ }
581
+ function compactSessionLines() {
582
+ return [
583
+ "compact_session() {",
584
+ ' csid="$1"',
585
+ " if ! command -v curl >/dev/null 2>&1; then",
586
+ ' echo "opencode-jobs: curl not available, skipping compaction"',
587
+ " return 0",
588
+ " fi",
589
+ ' serve_out="$(mktemp)"',
590
+ ' serve_err="$(mktemp)"',
591
+ ` OPENCODE_CONFIG_CONTENT='{"compaction":{"tail_turns":0}}' "$oc_bin" serve --port 0 >"$serve_out" 2>"$serve_err" &`,
592
+ " serve_pid=$!",
593
+ ' serve_port=""',
594
+ " tries=0",
595
+ ' while [ "$tries" -lt 100 ]; do',
596
+ 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)"`,
597
+ ' if [ -n "$serve_port" ]; then break; fi',
598
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
599
+ " sleep 0.1",
600
+ " tries=$((tries + 1))",
601
+ " done",
602
+ ' if [ -z "$serve_port" ]; then',
603
+ ' echo "opencode-jobs: compaction server failed to start"',
604
+ ' sed -n "1,10p" "$serve_err" >&2',
605
+ ' kill "$serve_pid" 2>/dev/null',
606
+ ' wait "$serve_pid" 2>/dev/null',
607
+ ' rm -f "$serve_out" "$serve_err"',
608
+ " return 0",
609
+ " fi",
610
+ " healthy=0",
611
+ " tries=0",
612
+ ' while [ "$tries" -lt 50 ]; do',
613
+ ' if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$serve_port/global/health"; then',
614
+ " healthy=1",
615
+ " break",
616
+ " fi",
617
+ ' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
618
+ " sleep 0.2",
619
+ " tries=$((tries + 1))",
620
+ " done",
621
+ ' if [ "$healthy" -ne 1 ]; then',
622
+ ' echo "opencode-jobs: compaction server never became healthy"',
623
+ ' kill "$serve_pid" 2>/dev/null',
624
+ ' wait "$serve_pid" 2>/dev/null',
625
+ ' rm -f "$serve_out" "$serve_err"',
626
+ " return 0",
627
+ " fi",
628
+ ' cs_provider=""',
629
+ ' cs_model=""',
630
+ ' case "$oc_model" in',
631
+ " */*)",
632
+ ' cs_provider="${oc_model%%/*}"',
633
+ ' cs_model="${oc_model#*/}"',
634
+ " ;;",
635
+ " esac",
636
+ ' if [ -z "$cs_provider" ]; then',
637
+ ' defaults="$(curl -s --max-time 10 "http://127.0.0.1:$serve_port/config/providers")"',
638
+ String.raw` cs_provider="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_PROVIDER_SED}' | head -n 1)"`,
639
+ String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
640
+ " fi",
641
+ ' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
642
+ ' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
643
+ ' kill "$serve_pid" 2>/dev/null',
644
+ ' wait "$serve_pid" 2>/dev/null',
645
+ ' rm -f "$serve_out" "$serve_err"',
646
+ " return 0",
647
+ " fi",
648
+ ' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
649
+ 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")"`,
650
+ ' if [ "$result" != "true" ]; then',
651
+ ' echo "opencode-jobs: compaction failed: $result"',
652
+ " fi",
653
+ ' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
654
+ String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
655
+ ` 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")"`,
656
+ ' if [ "$http" != "200" ]; then',
657
+ ' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
658
+ " fi",
659
+ " fi",
660
+ ' kill "$serve_pid" 2>/dev/null',
661
+ ' wait "$serve_pid" 2>/dev/null',
662
+ ' rm -f "$serve_out" "$serve_err"',
663
+ "}"
664
+ ];
665
+ }
666
+ function runScriptContent(job, scopeId, opencodeBin) {
667
+ const mode = job.session ?? "new";
668
+ const isTracked = mode !== "new";
669
+ const isCompact = mode === "compact" || mode === "compact+last";
670
+ const isKeepLast = mode === "compact+last";
671
+ return [
672
+ "#!/bin/sh",
673
+ "set -u",
674
+ `slug=${shQuote(job.slug)}`,
675
+ `scope=${shQuote(scopeId)}`,
676
+ `oc_bin=${shQuote(opencodeBin)}`,
677
+ 'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
678
+ 'runs="$config_root/opencode/jobs/runs/$scope"',
679
+ 'mkdir -p "$runs"',
680
+ 'record_file="$runs/$slug.jsonl"',
681
+ ...isTracked ? [
682
+ 'sessions="$config_root/opencode/jobs/sessions/$scope"',
683
+ 'mkdir -p "$sessions"',
684
+ 'state_file="$sessions/$slug.txt"',
685
+ `session_mode=${shQuote(mode)}`,
686
+ 'prev_session=""',
687
+ 'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
688
+ ] : [],
689
+ 'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
690
+ 'run_id="$(date +%s%N)-$$"',
691
+ "started=$(date +%s)",
692
+ 'new_session=""',
693
+ 'wt_branch=""',
694
+ 'wt_commit=""',
695
+ `export OPENCODE_PERMISSION='{"question":"deny"}'`,
696
+ 'export OPENCODE_JOBS_RUN_ID="$run_id"',
697
+ "finish() {",
698
+ ' status="$1"',
699
+ ' code="$2"',
700
+ " ended=$(date +%s)",
701
+ 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"`,
702
+ "}",
703
+ "trap 'finish timeout 124; exit 124' TERM INT",
704
+ ...job.guard === undefined ? [] : guardScriptLines(job.guard),
705
+ 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"`,
706
+ ...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
707
+ `oc_agent=${shQuote(job.run.agent ?? "")}`,
708
+ `oc_model=${shQuote(job.run.model ?? "")}`,
709
+ "prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
710
+ `oc_command=${shQuote("command" in job.run ? job.run.command : "")}`,
711
+ `oc_args=${shQuote("command" in job.run ? job.run.arguments ?? "" : "")}`,
712
+ `oc_prompt=${shQuote("prompt" in job.run ? job.run.prompt : "")}`,
713
+ `oc_keep_last=${String(isKeepLast ? 1 : 0)}`,
714
+ "run_opencode() {",
715
+ ' sess="$1"',
716
+ ' use_json="$2"',
717
+ " set -- run",
718
+ ' if [ -n "$oc_agent" ]; then set -- "$@" --agent "$oc_agent"; fi',
719
+ ' if [ -n "$oc_model" ]; then set -- "$@" --model "$oc_model"; fi',
720
+ ' if [ -n "$sess" ]; then set -- "$@" --session "$sess"; fi',
721
+ ' if [ "$use_json" -eq 1 ]; then set -- "$@" --format json; fi',
722
+ ' if [ "$oc_command_mode" -eq 1 ]; then',
723
+ ' set -- "$@" --command "$oc_command" -- "$oc_args"',
724
+ " else",
725
+ ' set -- "$@" -- "$oc_prompt"',
726
+ " fi",
727
+ ' "$oc_bin" "$@"',
728
+ "}",
729
+ ...isCompact ? compactSessionLines() : [],
730
+ ...isTracked ? [
731
+ 'json_out="$(mktemp)"',
732
+ 'run_opencode "$prev_session" 1 >"$json_out" 2>&1',
733
+ "code=$?",
734
+ 'cat "$json_out"',
735
+ ...extractSessionIdLines("new_session"),
736
+ 'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
737
+ ' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
738
+ ' rm -f "$json_out"',
739
+ ' json_out="$(mktemp)"',
740
+ ' run_opencode "" 1 >"$json_out" 2>&1',
741
+ " code=$?",
742
+ ' cat "$json_out"',
743
+ ...extractSessionIdLines("new_session", " "),
744
+ "fi",
745
+ 'cs_text=""',
746
+ 'if [ "$oc_keep_last" -eq 1 ]; then',
747
+ ` cs_text="$(awk '`,
748
+ ' /"type":"text"/ {',
749
+ " line = $0",
750
+ String.raw` i = index(line, "\042text\042:\042")`,
751
+ " if (i == 0) next",
752
+ " i = i + 8",
753
+ ' out = ""',
754
+ " len = length(line)",
755
+ " while (i <= len) {",
756
+ " c = substr(line, i, 1)",
757
+ String.raw` if (c == "\\") {`,
758
+ " out = out substr(line, i, 2)",
759
+ " i = i + 2",
760
+ " continue",
761
+ " }",
762
+ String.raw` if (c == "\042") break`,
763
+ " out = out c",
764
+ " i = i + 1",
765
+ " }",
766
+ " result = out",
767
+ " n = n + 1",
768
+ " }",
769
+ " END {",
770
+ " if (n > 0) {",
771
+ " if (length(result) > 16000) {",
772
+ " result = substr(result, 1, 16000)",
773
+ String.raw` if (substr(result, 16000, 1) == "\\") result = substr(result, 1, 15999)`,
774
+ " }",
775
+ " print result",
776
+ " }",
777
+ " }",
778
+ ` ' "$json_out")"`,
779
+ "fi",
780
+ 'rm -f "$json_out"',
781
+ 'if [ -n "$new_session" ]; then',
782
+ String.raw` printf '%s\n' "$new_session" >"$state_file"`,
783
+ "fi"
784
+ ] : ['run_opencode "" 0', "code=$?"],
785
+ "trap - TERM INT",
786
+ ...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
787
+ 'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
788
+ ...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
789
+ "finish success 0",
790
+ "exit 0",
791
+ ""
792
+ ].join(`
793
+ `);
794
+ }
795
+ function serviceContent(job, options) {
796
+ const timeout = job.timeoutSeconds !== undefined && job.timeoutSeconds > 0 ? `TimeoutStartSec=${String(job.timeoutSeconds)}s` : "TimeoutStartSec=infinity";
797
+ const lines = [
798
+ "[Unit]",
799
+ `Description=OpenCode job: ${escapeUnitText(job.name)} (${job.slug})`,
800
+ "",
801
+ "[Service]",
802
+ "Type=oneshot",
803
+ `WorkingDirectory=${unitQuote(options.workdir)}`,
804
+ `Environment=${unitQuote(`PATH=${options.pathEnvironment}`)}`,
805
+ `ExecStart=/bin/sh ${unitQuote(options.runScript)}`,
806
+ timeout,
807
+ `StandardOutput=append:${unitQuote(options.log)}`,
808
+ `StandardError=append:${unitQuote(options.log)}`
809
+ ];
810
+ return `${lines.join(`
811
+ `)}
812
+ `;
813
+ }
814
+ function timerContent(job, onCalendars) {
815
+ return [
816
+ "[Unit]",
817
+ `Description=OpenCode job timer: ${escapeUnitText(job.name)} (${job.slug})`,
818
+ "",
819
+ "[Timer]",
820
+ ...onCalendars.map((calendar) => `OnCalendar=${calendar}`),
821
+ "Persistent=true",
822
+ "",
823
+ "[Install]",
824
+ "WantedBy=timers.target",
825
+ ""
826
+ ].join(`
827
+ `);
828
+ }
309
829
  function systemctl(systemctlArguments) {
310
830
  const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
311
831
  encoding: "utf8"
@@ -316,24 +836,154 @@ function systemctl(systemctlArguments) {
316
836
  stderr: result.stderr.trim()
317
837
  };
318
838
  }
839
+ function systemdHint(stderr) {
840
+ if (/failed to connect|not connected|dbus/i.test(stderr)) {
841
+ return `
842
+ Hint: no systemd user session is reachable. Over SSH try enabling lingering: loginctl enable-linger $USER`;
843
+ }
844
+ return "";
845
+ }
846
+ function timerStatus(base) {
847
+ const result = systemctl([
848
+ "show",
849
+ timerUnit(base),
850
+ "-p",
851
+ "NextElapseUSecRealtime",
852
+ "-p",
853
+ "LastTriggerUSec",
854
+ "--value"
855
+ ]);
856
+ if (!result.ok)
857
+ return { next: undefined, last: undefined };
858
+ const [next = "n/a", last = "n/a"] = result.stdout.split(`
859
+ `, 2);
860
+ return {
861
+ next: next === "n/a" ? undefined : next,
862
+ last: last === "n/a" ? undefined : last
863
+ };
864
+ }
865
+ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
866
+ const script = runScriptPath(scopeId, job.slug);
867
+ const base = unitBase(scopeId, job.slug);
868
+ mkdirSync2(logDirectory(scopeId), { recursive: true });
869
+ mkdirSync2(runsDirectory(scopeId), { recursive: true });
870
+ atomicWriteExecutable(script, runScriptContent(job, scopeId, opencodeBin));
871
+ const onCalendars = cronToOnCalendar(parseCron(job.schedule));
872
+ atomicWrite(path4.join(systemdUserDirectory(), timerUnit(base)), timerContent(job, onCalendars));
873
+ atomicWrite(path4.join(systemdUserDirectory(), serviceUnit(base)), serviceContent(job, {
874
+ workdir: path4.resolve(workdir),
875
+ runScript: script,
876
+ log: logFile(scopeId, job.slug),
877
+ pathEnvironment
878
+ }));
879
+ return base;
880
+ }
319
881
  function removeJobUnits(scopeId, slug) {
320
882
  const base = unitBase(scopeId, slug);
321
883
  systemctl(["disable", "--now", timerUnit(base)]);
322
884
  for (const file of [
323
- path3.join(systemdUserDirectory(), timerUnit(base)),
324
- path3.join(systemdUserDirectory(), serviceUnit(base))
885
+ path4.join(systemdUserDirectory(), timerUnit(base)),
886
+ path4.join(systemdUserDirectory(), serviceUnit(base))
325
887
  ]) {
326
- if (existsSync(file))
888
+ if (existsSync2(file))
327
889
  rmSync(file);
328
890
  }
329
891
  const script = runScriptPath(scopeId, slug);
330
- if (existsSync(script))
892
+ if (existsSync2(script))
331
893
  rmSync(script);
332
894
  }
895
+ function removeStaleUnits(scopeId, expectedSlugs) {
896
+ const prefix = `opencode-sched-${scopeId}-`;
897
+ const removed = [];
898
+ if (existsSync2(systemdUserDirectory())) {
899
+ for (const entry of readdirSync2(systemdUserDirectory())) {
900
+ if (!entry.startsWith(prefix))
901
+ continue;
902
+ if (!entry.endsWith(".service") && !entry.endsWith(".timer"))
903
+ continue;
904
+ const slug = entry.slice(prefix.length).replaceAll(/\.(service|timer)$/g, "");
905
+ if (!expectedSlugs.has(slug)) {
906
+ rmSync(path4.join(systemdUserDirectory(), entry));
907
+ removed.push(slug);
908
+ }
909
+ }
910
+ }
911
+ if (existsSync2(scopeDirectory(scopeId))) {
912
+ const entries = readdirSync2(scopeDirectory(scopeId));
913
+ for (const entry of entries) {
914
+ const match = /^run-(.+)\.sh$/.exec(entry);
915
+ if (match === null)
916
+ continue;
917
+ const [, slug = ""] = match;
918
+ if (!expectedSlugs.has(slug))
919
+ rmSync(path4.join(scopeDirectory(scopeId), entry));
920
+ }
921
+ }
922
+ return removed;
923
+ }
333
924
 
334
925
  // src/project.ts
926
+ function enableProject(workdir) {
927
+ if (process.platform !== "linux")
928
+ throw new Error("Scheduled jobs are only supported on Linux (systemd user units)");
929
+ const { jobs, errors } = loadJobs(workdir);
930
+ if (errors.length > 0)
931
+ throw new Error(`Invalid job definitions:
932
+ ${errors.join(`
933
+ `)}`);
934
+ if (jobs.length === 0) {
935
+ throw new Error(`No job definitions found in ${jobsDirectory(workdir)}. Create one with schedule_job first.`);
936
+ }
937
+ const abs = path5.resolve(workdir);
938
+ const scopeId = deriveScopeId(abs);
939
+ const opencodeBin = findOpencode();
940
+ const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
941
+ const bases = jobs.map((job) => writeJobUnits(job, abs, scopeId, opencodeBin, pathEnvironment));
942
+ const removed = removeStaleUnits(scopeId, new Set(jobs.map((job) => job.slug)));
943
+ const reload = systemctl(["daemon-reload"]);
944
+ if (!reload.ok)
945
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
946
+ const failures = [];
947
+ for (const base of bases) {
948
+ const enable = systemctl(["enable", "--now", timerUnit(base)]);
949
+ if (!enable.ok)
950
+ failures.push(`${timerUnit(base)}: ${enable.stderr}${systemdHint(enable.stderr)}`);
951
+ }
952
+ const registry = loadRegistry();
953
+ const previous = registry.projects[abs];
954
+ registry.projects[abs] = {
955
+ scopeId,
956
+ workdir: abs,
957
+ enabledAt: previous?.enabledAt ?? nowIso(),
958
+ updatedAt: nowIso(),
959
+ jobs: jobs.map((job) => job.slug)
960
+ };
961
+ saveRegistry(registry);
962
+ const lines = [
963
+ `Enabled ${String(jobs.length)} job(s) for ${abs} (scope ${scopeId})`
964
+ ];
965
+ if (removed.length > 0)
966
+ lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
967
+ lines.push(...describeJobSchedules(jobs, scopeId));
968
+ if (failures.length > 0)
969
+ lines.push(`Timer activation failures:
970
+ ${failures.join(`
971
+ `)}`);
972
+ return lines.join(`
973
+ `);
974
+ }
975
+ function describeJobSchedules(jobs, scopeId) {
976
+ const lines = [];
977
+ for (const job of jobs) {
978
+ const sets = parseCron(job.schedule);
979
+ const next = timerStatus(unitBase(scopeId, job.slug)).next;
980
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
981
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}`);
982
+ }
983
+ return lines;
984
+ }
335
985
  function disableProject(workdir) {
336
- const abs = path4.resolve(workdir);
986
+ const abs = path5.resolve(workdir);
337
987
  const entry = registryEntry(abs);
338
988
  if (entry === undefined)
339
989
  return `Project is not enabled: ${abs}`;
@@ -352,6 +1002,82 @@ function disableProject(workdir) {
352
1002
  `);
353
1003
  }
354
1004
 
1005
+ // src/migration.ts
1006
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, renameSync as renameSync2, rmdirSync } from "node:fs";
1007
+ import path6 from "node:path";
1008
+ function legacyJobsStateDirectory() {
1009
+ return path6.join(configRoot(), "opencode", "scheduler");
1010
+ }
1011
+ function legacyRegistryPath() {
1012
+ return path6.join(legacyJobsStateDirectory(), "registry.json");
1013
+ }
1014
+ function legacyDefinitionsDirectory(workdir) {
1015
+ return path6.join(workdir, ".opencode", "scheduler", "jobs");
1016
+ }
1017
+ function migrationMoves(projects) {
1018
+ return [
1019
+ {
1020
+ from: legacyJobsStateDirectory(),
1021
+ to: jobsStateDirectory()
1022
+ },
1023
+ {
1024
+ from: path6.join(configRoot(), "opencode", "logs", "scheduler"),
1025
+ to: path6.join(configRoot(), "opencode", "logs", "jobs")
1026
+ },
1027
+ {
1028
+ from: path6.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1029
+ to: path6.join(stateRoot(), "opencode", "jobs", "worktrees")
1030
+ },
1031
+ ...[...projects].map((workdir) => ({
1032
+ from: legacyDefinitionsDirectory(workdir),
1033
+ to: jobsDirectory(workdir)
1034
+ }))
1035
+ ];
1036
+ }
1037
+ function removeLegacyProjectDirectory(workdir) {
1038
+ try {
1039
+ rmdirSync(path6.join(workdir, ".opencode", "scheduler"));
1040
+ } catch {}
1041
+ }
1042
+ function migrateStorage(projectDirectory, shouldResync) {
1043
+ const project = path6.resolve(projectDirectory);
1044
+ const legacyRegistry = legacyRegistryPath();
1045
+ const canonicalRegistry = path6.join(jobsStateDirectory(), "registry.json");
1046
+ const registryFile = existsSync3(legacyRegistry) ? legacyRegistry : canonicalRegistry;
1047
+ const registry = existsSync3(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
1048
+ const registeredProjects = new Set(Object.keys(registry.projects));
1049
+ const projects = new Set([project, ...registeredProjects]);
1050
+ const moves = migrationMoves(projects).filter(({ from }) => existsSync3(from));
1051
+ for (const { from, to } of moves) {
1052
+ if (existsSync3(to)) {
1053
+ 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.`);
1054
+ }
1055
+ }
1056
+ for (const { from, to } of moves) {
1057
+ mkdirSync3(path6.dirname(to), { recursive: true });
1058
+ renameSync2(from, to);
1059
+ }
1060
+ for (const workdir of projects)
1061
+ removeLegacyProjectDirectory(workdir);
1062
+ const result = {
1063
+ moved: moves,
1064
+ resyncedProjects: [],
1065
+ warnings: []
1066
+ };
1067
+ if (!shouldResync || moves.length === 0)
1068
+ return result;
1069
+ for (const workdir of registeredProjects) {
1070
+ try {
1071
+ enableProject(workdir);
1072
+ result.resyncedProjects.push(workdir);
1073
+ } catch (error) {
1074
+ const message = error instanceof Error ? error.message : String(error);
1075
+ result.warnings.push(`${workdir}: ${message}`);
1076
+ }
1077
+ }
1078
+ return result;
1079
+ }
1080
+
355
1081
  // src/install.ts
356
1082
  var PACKAGE_NAME = "opencode-jobs";
357
1083
  var SKILL_NAME = "opencode-jobs";
@@ -362,20 +1088,20 @@ var configSchema = z3.looseObject({
362
1088
  var CONFIG_LOCATIONS = [
363
1089
  "opencode.json",
364
1090
  "opencode.jsonc",
365
- path5.join(".opencode", "opencode.json"),
366
- path5.join(".opencode", "opencode.jsonc")
1091
+ path7.join(".opencode", "opencode.json"),
1092
+ path7.join(".opencode", "opencode.jsonc")
367
1093
  ];
368
1094
  function existingConfigPath(projectDirectory) {
369
1095
  for (const relativePath of CONFIG_LOCATIONS) {
370
- const configPath = path5.join(projectDirectory, relativePath);
371
- if (existsSync2(configPath))
1096
+ const configPath = path7.join(projectDirectory, relativePath);
1097
+ if (existsSync4(configPath))
372
1098
  return configPath;
373
1099
  }
374
1100
  return;
375
1101
  }
376
1102
  function readConfig(configPath) {
377
1103
  try {
378
- const value = JSON.parse(readFileSync2(configPath, "utf8"));
1104
+ const value = JSON.parse(readFileSync3(configPath, "utf8"));
379
1105
  const result = configSchema.safeParse(value);
380
1106
  return result.success ? result.data : undefined;
381
1107
  } catch {
@@ -392,7 +1118,7 @@ function isPackageReference(entry) {
392
1118
  }
393
1119
  function installPluginConfig(projectDirectory) {
394
1120
  const existingPath = existingConfigPath(projectDirectory);
395
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1121
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
396
1122
  if (existingPath === undefined) {
397
1123
  atomicWrite(configPath, `${JSON.stringify({
398
1124
  $schema: CONFIG_SCHEMA,
@@ -414,34 +1140,36 @@ function installPluginConfig(projectDirectory) {
414
1140
  return { status: "added", configPath };
415
1141
  }
416
1142
  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) {
1143
+ const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
1144
+ const skillPath = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
1145
+ const content = readFileSync3(sourcePath, "utf8");
1146
+ if (existsSync4(skillPath) && readFileSync3(skillPath, "utf8") === content) {
421
1147
  return { status: "unchanged", skillPath };
422
1148
  }
423
1149
  atomicWrite(skillPath, content);
424
1150
  return { status: "written", skillPath };
425
1151
  }
426
1152
  function installProject(projectDirectory, packageDirectory) {
427
- const resolvedProject = path5.resolve(projectDirectory);
428
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1153
+ const resolvedProject = path7.resolve(projectDirectory);
1154
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
429
1155
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
430
1156
  }
1157
+ const migration = migrateStorage(resolvedProject, true);
431
1158
  return {
432
1159
  projectDirectory: resolvedProject,
433
1160
  plugin: installPluginConfig(resolvedProject),
434
- skill: installSkill(resolvedProject, packageDirectory)
1161
+ skill: installSkill(resolvedProject, packageDirectory),
1162
+ ...migration.moved.length > 0 && { migration }
435
1163
  };
436
1164
  }
437
1165
  function removeDirectoryIfEmpty(directory) {
438
1166
  try {
439
- rmdirSync(directory);
1167
+ rmdirSync2(directory);
440
1168
  } catch {}
441
1169
  }
442
1170
  function uninstallPluginConfig(projectDirectory) {
443
1171
  const existingPath = existingConfigPath(projectDirectory);
444
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1172
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
445
1173
  if (existingPath === undefined)
446
1174
  return { status: "absent", configPath };
447
1175
  const config = readConfig(configPath);
@@ -465,21 +1193,21 @@ function uninstallPluginConfig(projectDirectory) {
465
1193
  return { status: "removed", configPath };
466
1194
  }
467
1195
  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))
1196
+ const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
1197
+ const skillDirectory = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
1198
+ const skillPath = path7.join(skillDirectory, "SKILL.md");
1199
+ if (!existsSync4(skillPath))
472
1200
  return { status: "absent", skillPath };
473
- if (readFileSync2(skillPath, "utf8") !== readFileSync2(sourcePath, "utf8")) {
1201
+ if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
474
1202
  return { status: "kept-modified", skillPath };
475
1203
  }
476
1204
  rmSync2(skillDirectory, { recursive: true, force: true });
477
- removeDirectoryIfEmpty(path5.dirname(skillDirectory));
478
- removeDirectoryIfEmpty(path5.join(projectDirectory, ".opencode"));
1205
+ removeDirectoryIfEmpty(path7.dirname(skillDirectory));
1206
+ removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
479
1207
  return { status: "removed", skillPath };
480
1208
  }
481
1209
  function purgeProjectData(projectDirectory) {
482
- const abs = path5.resolve(projectDirectory);
1210
+ const abs = path7.resolve(projectDirectory);
483
1211
  const scopeId = deriveScopeId(abs);
484
1212
  const targets = [
485
1213
  scopeDirectory(scopeId),
@@ -488,11 +1216,11 @@ function purgeProjectData(projectDirectory) {
488
1216
  logDirectory(scopeId),
489
1217
  locksDirectory(scopeId),
490
1218
  worktreesDirectory(scopeId),
491
- path5.join(abs, ".opencode", "scheduler")
1219
+ path7.join(abs, ".opencode", "jobs")
492
1220
  ];
493
1221
  const paths = [];
494
1222
  for (const target of targets) {
495
- if (!existsSync2(target))
1223
+ if (!existsSync4(target))
496
1224
  continue;
497
1225
  rmSync2(target, { recursive: true, force: true });
498
1226
  paths.push(target);
@@ -500,14 +1228,15 @@ function purgeProjectData(projectDirectory) {
500
1228
  if (paths.includes(worktreesDirectory(scopeId))) {
501
1229
  spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
502
1230
  }
503
- removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
1231
+ removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
504
1232
  return { paths };
505
1233
  }
506
1234
  function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
507
- const resolvedProject = path5.resolve(projectDirectory);
508
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1235
+ const resolvedProject = path7.resolve(projectDirectory);
1236
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
509
1237
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
510
1238
  }
1239
+ const migration = migrateStorage(resolvedProject, false);
511
1240
  const wasEnabled = registryEntry(resolvedProject) !== undefined;
512
1241
  if (wasEnabled)
513
1242
  disableProject(resolvedProject);
@@ -515,7 +1244,8 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
515
1244
  projectDirectory: resolvedProject,
516
1245
  disabled: wasEnabled,
517
1246
  plugin: uninstallPluginConfig(resolvedProject),
518
- skill: uninstallSkill(resolvedProject, packageDirectory)
1247
+ skill: uninstallSkill(resolvedProject, packageDirectory),
1248
+ ...migration.moved.length > 0 && { migration }
519
1249
  };
520
1250
  if (shouldPurge)
521
1251
  uninstall.purge = purgeProjectData(resolvedProject);
@@ -528,10 +1258,10 @@ var USAGE = `Usage: opencode-jobs <command> [projectDir]
528
1258
  Commands:
529
1259
  install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
530
1260
  uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
531
- --purge also deletes job definitions and scheduler data
1261
+ --purge also deletes job definitions and job data
532
1262
  help Show this help`;
533
1263
  function packageDirectory() {
534
- return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
1264
+ return path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..");
535
1265
  }
536
1266
  function printError(message) {
537
1267
  console.error(`Error: ${message}