opencode-jobs 0.1.2 → 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,12 +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";
9
- import path5 from "node:path";
8
+ import { existsSync as existsSync4, readFileSync as readFileSync3, rmSync as rmSync2, rmdirSync as rmdirSync2, statSync } from "node:fs";
9
+ import { spawnSync as spawnSync2 } from "node:child_process";
10
+ import path7 from "node:path";
10
11
  import { z as z3 } from "zod";
11
12
 
12
13
  // src/paths.ts
@@ -17,27 +18,42 @@ import { homedir } from "node:os";
17
18
  function configRoot() {
18
19
  return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
19
20
  }
20
- function schedulerDirectory() {
21
- return path.join(configRoot(), "opencode", "scheduler");
21
+ function stateRoot() {
22
+ return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
23
+ }
24
+ function worktreesDirectory(scopeId) {
25
+ return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
26
+ }
27
+ function locksDirectory(scopeId) {
28
+ return path.join(jobsStateDirectory(), "locks", scopeId);
29
+ }
30
+ function jobsStateDirectory() {
31
+ return path.join(configRoot(), "opencode", "jobs");
22
32
  }
23
33
  function registryPath() {
24
- return path.join(schedulerDirectory(), "registry.json");
34
+ return path.join(jobsStateDirectory(), "registry.json");
25
35
  }
26
36
  function scopeDirectory(scopeId) {
27
- return path.join(schedulerDirectory(), "scopes", scopeId);
37
+ return path.join(jobsStateDirectory(), "scopes", scopeId);
28
38
  }
29
39
  function runsDirectory(scopeId) {
30
- return path.join(schedulerDirectory(), "runs", scopeId);
40
+ return path.join(jobsStateDirectory(), "runs", scopeId);
31
41
  }
32
42
  function sessionStateDirectory(scopeId) {
33
- return path.join(schedulerDirectory(), "sessions", scopeId);
43
+ return path.join(jobsStateDirectory(), "sessions", scopeId);
34
44
  }
35
45
  function logDirectory(scopeId) {
36
- 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`);
37
50
  }
38
51
  function systemdUserDirectory() {
39
52
  return path.join(configRoot(), "systemd", "user");
40
53
  }
54
+ function jobsDirectory(workdir) {
55
+ return path.join(workdir, ".opencode", "jobs");
56
+ }
41
57
  function unitBase(scopeId, slug) {
42
58
  return `opencode-sched-${scopeId}-${slug}`;
43
59
  }
@@ -54,12 +70,30 @@ function slugify(input) {
54
70
  const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
55
71
  return slug.length > 0 ? slug : "job";
56
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
+ }
57
84
  function atomicWrite(file, content) {
58
85
  mkdirSync(path.dirname(file), { recursive: true });
59
86
  const temporary = `${file}.tmp`;
60
87
  writeFileSync(temporary, content);
61
88
  renameSync(temporary, file);
62
89
  }
90
+ function atomicWriteExecutable(file, content) {
91
+ atomicWrite(file, content);
92
+ chmodSync(file, 493);
93
+ }
94
+ function nowIso() {
95
+ return new Date().toISOString();
96
+ }
63
97
  function deriveScopeId(workdir) {
64
98
  const abs = path.resolve(workdir);
65
99
  const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
@@ -67,12 +101,15 @@ function deriveScopeId(workdir) {
67
101
  }
68
102
 
69
103
  // src/project.ts
70
- import path4 from "node:path";
104
+ import path5 from "node:path";
71
105
 
72
106
  // src/job.ts
107
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
108
+ import path2 from "node:path";
73
109
  import { z } from "zod";
74
110
 
75
111
  // src/cron.ts
112
+ var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
76
113
  var DOW_NAMES = {
77
114
  sun: 0,
78
115
  mon: 1,
@@ -157,6 +194,42 @@ function parseCron(expression) {
157
194
  dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
158
195
  };
159
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
+ }
160
233
 
161
234
  // src/json.ts
162
235
  function errorMessage(error) {
@@ -210,6 +283,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
210
283
  error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
211
284
  });
212
285
  var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
286
+ var worktreeSchema = z.union([
287
+ z.literal(true),
288
+ z.strictObject({
289
+ base: nonEmptyStringSchema.optional(),
290
+ ref: nonEmptyStringSchema.optional(),
291
+ commitMessage: nonEmptyStringSchema.optional()
292
+ })
293
+ ]).transform((value) => {
294
+ if (value === true)
295
+ return {};
296
+ return {
297
+ ...value.base !== undefined && { base: value.base },
298
+ ...value.ref !== undefined && { ref: value.ref },
299
+ ...value.commitMessage !== undefined && {
300
+ commitMessage: value.commitMessage
301
+ }
302
+ };
303
+ });
213
304
  var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
214
305
  var cronSchema = z.string().superRefine((schedule, context) => {
215
306
  try {
@@ -225,14 +316,87 @@ var jobFileSchema = z.strictObject({
225
316
  run: runSpecSchema,
226
317
  session: sessionSchema.default("new"),
227
318
  guard: guardSchema.optional(),
319
+ worktree: worktreeSchema.optional(),
228
320
  timeoutSeconds: timeoutSchema.optional(),
229
321
  createdAt: z.string().optional(),
230
322
  updatedAt: z.string().optional()
231
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
+ }
232
396
 
233
397
  // src/registry.ts
234
- import { readFileSync } from "node:fs";
235
- import path2 from "node:path";
398
+ import { readFileSync as readFileSync2 } from "node:fs";
399
+ import path3 from "node:path";
236
400
  import { z as z2 } from "zod";
237
401
  var registryEntrySchema = z2.looseObject({
238
402
  scopeId: z2.string(),
@@ -245,19 +409,22 @@ var registryFileSchema = z2.object({
245
409
  version: z2.literal(1),
246
410
  projects: z2.record(z2.string(), z2.unknown())
247
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
+ }
248
425
  function loadRegistry() {
249
426
  try {
250
- const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
251
- const result = registryFileSchema.safeParse(parsed);
252
- if (!result.success)
253
- return { version: 1, projects: {} };
254
- const projects = {};
255
- for (const [key, value] of Object.entries(result.data.projects)) {
256
- const entry = registryEntrySchema.safeParse(value);
257
- if (entry.success)
258
- projects[key] = entry.data;
259
- }
260
- return { version: 1, projects };
427
+ return readRegistryFile(registryPath());
261
428
  } catch {
262
429
  return { version: 1, projects: {} };
263
430
  }
@@ -267,16 +434,398 @@ function saveRegistry(registry) {
267
434
  `);
268
435
  }
269
436
  function registryEntry(workdir) {
270
- return loadRegistry().projects[path2.resolve(workdir)];
437
+ return loadRegistry().projects[path3.resolve(workdir)];
271
438
  }
272
439
 
273
440
  // src/systemd.ts
274
- 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";
275
442
  import { spawnSync } from "node:child_process";
276
- 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
+ }
277
573
  var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
278
574
  var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
279
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
+ }
280
829
  function systemctl(systemctlArguments) {
281
830
  const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
282
831
  encoding: "utf8"
@@ -287,24 +836,154 @@ function systemctl(systemctlArguments) {
287
836
  stderr: result.stderr.trim()
288
837
  };
289
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
+ }
290
881
  function removeJobUnits(scopeId, slug) {
291
882
  const base = unitBase(scopeId, slug);
292
883
  systemctl(["disable", "--now", timerUnit(base)]);
293
884
  for (const file of [
294
- path3.join(systemdUserDirectory(), timerUnit(base)),
295
- path3.join(systemdUserDirectory(), serviceUnit(base))
885
+ path4.join(systemdUserDirectory(), timerUnit(base)),
886
+ path4.join(systemdUserDirectory(), serviceUnit(base))
296
887
  ]) {
297
- if (existsSync(file))
888
+ if (existsSync2(file))
298
889
  rmSync(file);
299
890
  }
300
891
  const script = runScriptPath(scopeId, slug);
301
- if (existsSync(script))
892
+ if (existsSync2(script))
302
893
  rmSync(script);
303
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
+ }
304
924
 
305
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
+ }
306
985
  function disableProject(workdir) {
307
- const abs = path4.resolve(workdir);
986
+ const abs = path5.resolve(workdir);
308
987
  const entry = registryEntry(abs);
309
988
  if (entry === undefined)
310
989
  return `Project is not enabled: ${abs}`;
@@ -323,6 +1002,82 @@ function disableProject(workdir) {
323
1002
  `);
324
1003
  }
325
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
+
326
1081
  // src/install.ts
327
1082
  var PACKAGE_NAME = "opencode-jobs";
328
1083
  var SKILL_NAME = "opencode-jobs";
@@ -333,20 +1088,20 @@ var configSchema = z3.looseObject({
333
1088
  var CONFIG_LOCATIONS = [
334
1089
  "opencode.json",
335
1090
  "opencode.jsonc",
336
- path5.join(".opencode", "opencode.json"),
337
- path5.join(".opencode", "opencode.jsonc")
1091
+ path7.join(".opencode", "opencode.json"),
1092
+ path7.join(".opencode", "opencode.jsonc")
338
1093
  ];
339
1094
  function existingConfigPath(projectDirectory) {
340
1095
  for (const relativePath of CONFIG_LOCATIONS) {
341
- const configPath = path5.join(projectDirectory, relativePath);
342
- if (existsSync2(configPath))
1096
+ const configPath = path7.join(projectDirectory, relativePath);
1097
+ if (existsSync4(configPath))
343
1098
  return configPath;
344
1099
  }
345
1100
  return;
346
1101
  }
347
1102
  function readConfig(configPath) {
348
1103
  try {
349
- const value = JSON.parse(readFileSync2(configPath, "utf8"));
1104
+ const value = JSON.parse(readFileSync3(configPath, "utf8"));
350
1105
  const result = configSchema.safeParse(value);
351
1106
  return result.success ? result.data : undefined;
352
1107
  } catch {
@@ -363,7 +1118,7 @@ function isPackageReference(entry) {
363
1118
  }
364
1119
  function installPluginConfig(projectDirectory) {
365
1120
  const existingPath = existingConfigPath(projectDirectory);
366
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1121
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
367
1122
  if (existingPath === undefined) {
368
1123
  atomicWrite(configPath, `${JSON.stringify({
369
1124
  $schema: CONFIG_SCHEMA,
@@ -385,34 +1140,36 @@ function installPluginConfig(projectDirectory) {
385
1140
  return { status: "added", configPath };
386
1141
  }
387
1142
  function installSkill(projectDirectory, packageDirectory) {
388
- const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
389
- const skillPath = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
390
- const content = readFileSync2(sourcePath, "utf8");
391
- 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) {
392
1147
  return { status: "unchanged", skillPath };
393
1148
  }
394
1149
  atomicWrite(skillPath, content);
395
1150
  return { status: "written", skillPath };
396
1151
  }
397
1152
  function installProject(projectDirectory, packageDirectory) {
398
- const resolvedProject = path5.resolve(projectDirectory);
399
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1153
+ const resolvedProject = path7.resolve(projectDirectory);
1154
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
400
1155
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
401
1156
  }
1157
+ const migration = migrateStorage(resolvedProject, true);
402
1158
  return {
403
1159
  projectDirectory: resolvedProject,
404
1160
  plugin: installPluginConfig(resolvedProject),
405
- skill: installSkill(resolvedProject, packageDirectory)
1161
+ skill: installSkill(resolvedProject, packageDirectory),
1162
+ ...migration.moved.length > 0 && { migration }
406
1163
  };
407
1164
  }
408
1165
  function removeDirectoryIfEmpty(directory) {
409
1166
  try {
410
- rmdirSync(directory);
1167
+ rmdirSync2(directory);
411
1168
  } catch {}
412
1169
  }
413
1170
  function uninstallPluginConfig(projectDirectory) {
414
1171
  const existingPath = existingConfigPath(projectDirectory);
415
- const configPath = existingPath ?? path5.join(projectDirectory, "opencode.json");
1172
+ const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
416
1173
  if (existingPath === undefined)
417
1174
  return { status: "absent", configPath };
418
1175
  const config = readConfig(configPath);
@@ -436,44 +1193,50 @@ function uninstallPluginConfig(projectDirectory) {
436
1193
  return { status: "removed", configPath };
437
1194
  }
438
1195
  function uninstallSkill(projectDirectory, packageDirectory) {
439
- const sourcePath = path5.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
440
- const skillDirectory = path5.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
441
- const skillPath = path5.join(skillDirectory, "SKILL.md");
442
- 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))
443
1200
  return { status: "absent", skillPath };
444
- if (readFileSync2(skillPath, "utf8") !== readFileSync2(sourcePath, "utf8")) {
1201
+ if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
445
1202
  return { status: "kept-modified", skillPath };
446
1203
  }
447
1204
  rmSync2(skillDirectory, { recursive: true, force: true });
448
- removeDirectoryIfEmpty(path5.dirname(skillDirectory));
449
- removeDirectoryIfEmpty(path5.join(projectDirectory, ".opencode"));
1205
+ removeDirectoryIfEmpty(path7.dirname(skillDirectory));
1206
+ removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
450
1207
  return { status: "removed", skillPath };
451
1208
  }
452
1209
  function purgeProjectData(projectDirectory) {
453
- const abs = path5.resolve(projectDirectory);
1210
+ const abs = path7.resolve(projectDirectory);
454
1211
  const scopeId = deriveScopeId(abs);
455
1212
  const targets = [
456
1213
  scopeDirectory(scopeId),
457
1214
  runsDirectory(scopeId),
458
1215
  sessionStateDirectory(scopeId),
459
1216
  logDirectory(scopeId),
460
- path5.join(abs, ".opencode", "scheduler")
1217
+ locksDirectory(scopeId),
1218
+ worktreesDirectory(scopeId),
1219
+ path7.join(abs, ".opencode", "jobs")
461
1220
  ];
462
1221
  const paths = [];
463
1222
  for (const target of targets) {
464
- if (!existsSync2(target))
1223
+ if (!existsSync4(target))
465
1224
  continue;
466
1225
  rmSync2(target, { recursive: true, force: true });
467
1226
  paths.push(target);
468
1227
  }
469
- removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
1228
+ if (paths.includes(worktreesDirectory(scopeId))) {
1229
+ spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
1230
+ }
1231
+ removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
470
1232
  return { paths };
471
1233
  }
472
1234
  function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
473
- const resolvedProject = path5.resolve(projectDirectory);
474
- if (!existsSync2(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
1235
+ const resolvedProject = path7.resolve(projectDirectory);
1236
+ if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
475
1237
  throw new Error(`Project directory does not exist: ${resolvedProject}`);
476
1238
  }
1239
+ const migration = migrateStorage(resolvedProject, false);
477
1240
  const wasEnabled = registryEntry(resolvedProject) !== undefined;
478
1241
  if (wasEnabled)
479
1242
  disableProject(resolvedProject);
@@ -481,7 +1244,8 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
481
1244
  projectDirectory: resolvedProject,
482
1245
  disabled: wasEnabled,
483
1246
  plugin: uninstallPluginConfig(resolvedProject),
484
- skill: uninstallSkill(resolvedProject, packageDirectory)
1247
+ skill: uninstallSkill(resolvedProject, packageDirectory),
1248
+ ...migration.moved.length > 0 && { migration }
485
1249
  };
486
1250
  if (shouldPurge)
487
1251
  uninstall.purge = purgeProjectData(resolvedProject);
@@ -494,10 +1258,10 @@ var USAGE = `Usage: opencode-jobs <command> [projectDir]
494
1258
  Commands:
495
1259
  install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
496
1260
  uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
497
- --purge also deletes job definitions and scheduler data
1261
+ --purge also deletes job definitions and job data
498
1262
  help Show this help`;
499
1263
  function packageDirectory() {
500
- return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
1264
+ return path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..");
501
1265
  }
502
1266
  function printError(message) {
503
1267
  console.error(`Error: ${message}