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/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { Hooks } from "@opencode-ai/plugin";
2
- declare const _default: () => Promise<Hooks>;
2
+ declare const _default: (input: import("@opencode-ai/plugin").PluginInput) => Promise<Hooks>;
3
3
  export default _default;
package/dist/index.js CHANGED
@@ -145,29 +145,35 @@ import { homedir } from "os";
145
145
  function configRoot() {
146
146
  return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
147
147
  }
148
- function schedulerDirectory() {
149
- return path.join(configRoot(), "opencode", "scheduler");
148
+ function stateRoot() {
149
+ return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
150
+ }
151
+ function worktreesDirectory(scopeId) {
152
+ return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
153
+ }
154
+ function jobsStateDirectory() {
155
+ return path.join(configRoot(), "opencode", "jobs");
150
156
  }
151
157
  function registryPath() {
152
- return path.join(schedulerDirectory(), "registry.json");
158
+ return path.join(jobsStateDirectory(), "registry.json");
153
159
  }
154
160
  function scopeDirectory(scopeId) {
155
- return path.join(schedulerDirectory(), "scopes", scopeId);
161
+ return path.join(jobsStateDirectory(), "scopes", scopeId);
156
162
  }
157
163
  function runsDirectory(scopeId) {
158
- return path.join(schedulerDirectory(), "runs", scopeId);
164
+ return path.join(jobsStateDirectory(), "runs", scopeId);
159
165
  }
160
166
  function runsFile(scopeId, slug) {
161
167
  return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
162
168
  }
163
169
  function sessionStateDirectory(scopeId) {
164
- return path.join(schedulerDirectory(), "sessions", scopeId);
170
+ return path.join(jobsStateDirectory(), "sessions", scopeId);
165
171
  }
166
172
  function sessionStateFile(scopeId, slug) {
167
173
  return path.join(sessionStateDirectory(scopeId), `${slug}.txt`);
168
174
  }
169
175
  function logDirectory(scopeId) {
170
- return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
176
+ return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
171
177
  }
172
178
  function logFile(scopeId, slug) {
173
179
  return path.join(logDirectory(scopeId), `${slug}.log`);
@@ -176,7 +182,7 @@ function systemdUserDirectory() {
176
182
  return path.join(configRoot(), "systemd", "user");
177
183
  }
178
184
  function jobsDirectory(workdir) {
179
- return path.join(workdir, ".opencode", "scheduler", "jobs");
185
+ return path.join(workdir, ".opencode", "jobs");
180
186
  }
181
187
  function unitBase(scopeId, slug) {
182
188
  return `opencode-sched-${scopeId}-${slug}`;
@@ -281,6 +287,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
281
287
  error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
282
288
  });
283
289
  var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
290
+ var worktreeSchema = z.union([
291
+ z.literal(true),
292
+ z.strictObject({
293
+ base: nonEmptyStringSchema.optional(),
294
+ ref: nonEmptyStringSchema.optional(),
295
+ commitMessage: nonEmptyStringSchema.optional()
296
+ })
297
+ ]).transform((value) => {
298
+ if (value === true)
299
+ return {};
300
+ return {
301
+ ...value.base !== undefined && { base: value.base },
302
+ ...value.ref !== undefined && { ref: value.ref },
303
+ ...value.commitMessage !== undefined && {
304
+ commitMessage: value.commitMessage
305
+ }
306
+ };
307
+ });
284
308
  var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
285
309
  var cronSchema = z.string().superRefine((schedule, context) => {
286
310
  try {
@@ -296,6 +320,7 @@ var jobFileSchema = z.strictObject({
296
320
  run: runSpecSchema,
297
321
  session: sessionSchema.default("new"),
298
322
  guard: guardSchema.optional(),
323
+ worktree: worktreeSchema.optional(),
299
324
  timeoutSeconds: timeoutSchema.optional(),
300
325
  createdAt: z.string().optional(),
301
326
  updatedAt: z.string().optional()
@@ -332,6 +357,11 @@ function validateGuard(value, context) {
332
357
  return;
333
358
  return parseWithContext(guardSchema, value, context);
334
359
  }
360
+ function validateWorktree(value, context) {
361
+ if (value === undefined)
362
+ return;
363
+ return parseWithContext(worktreeSchema, value, context);
364
+ }
335
365
  function loadJobFile(file, expectedSlug) {
336
366
  const stem = path2.basename(file, ".json");
337
367
  try {
@@ -362,6 +392,9 @@ function loadJobFile(file, expectedSlug) {
362
392
  run: definition.run,
363
393
  ...definition.session !== "new" && { session: definition.session },
364
394
  ...definition.guard !== undefined && { guard: definition.guard },
395
+ ...definition.worktree !== undefined && {
396
+ worktree: definition.worktree
397
+ },
365
398
  ...definition.timeoutSeconds !== undefined && {
366
399
  timeoutSeconds: definition.timeoutSeconds
367
400
  },
@@ -414,19 +447,22 @@ var registryFileSchema = z2.object({
414
447
  version: z2.literal(1),
415
448
  projects: z2.record(z2.string(), z2.unknown())
416
449
  });
450
+ function readRegistryFile(file) {
451
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
452
+ const result = registryFileSchema.safeParse(parsed);
453
+ if (!result.success)
454
+ throw new Error(`Invalid job registry: ${file}`);
455
+ const projects = {};
456
+ for (const [key, value] of Object.entries(result.data.projects)) {
457
+ const entry = registryEntrySchema.safeParse(value);
458
+ if (entry.success)
459
+ projects[key] = entry.data;
460
+ }
461
+ return { version: 1, projects };
462
+ }
417
463
  function loadRegistry() {
418
464
  try {
419
- const parsed = JSON.parse(readFileSync2(registryPath(), "utf8"));
420
- const result = registryFileSchema.safeParse(parsed);
421
- if (!result.success)
422
- return { version: 1, projects: {} };
423
- const projects = {};
424
- for (const [key, value] of Object.entries(result.data.projects)) {
425
- const entry = registryEntrySchema.safeParse(value);
426
- if (entry.success)
427
- projects[key] = entry.data;
428
- }
429
- return { version: 1, projects };
465
+ return readRegistryFile(registryPath());
430
466
  } catch {
431
467
  return { version: 1, projects: {} };
432
468
  }
@@ -454,7 +490,9 @@ var runRecordSchema = z3.object({
454
490
  status: optionalString,
455
491
  exitCode: optionalNumber,
456
492
  sessionId: optionalString,
457
- startedBy: optionalString
493
+ startedBy: optionalString,
494
+ worktreeBranch: optionalString,
495
+ worktreeCommit: optionalString
458
496
  });
459
497
  function readRunRecords(scopeId, slug, limit) {
460
498
  const file = runsFile(scopeId, slug);
@@ -492,7 +530,8 @@ function formatRunLine(record) {
492
530
  const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
493
531
  const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
494
532
  const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
495
- return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session} via ${record.startedBy ?? "?"}`;
533
+ const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
534
+ return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
496
535
  }
497
536
  function tailFile(file, lines, maxChars) {
498
537
  if (!existsSync2(file))
@@ -512,7 +551,7 @@ import { spawnSync } from "child_process";
512
551
  import path4 from "path";
513
552
  import { homedir as homedir2 } from "os";
514
553
  function findOpencode() {
515
- const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
554
+ const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
516
555
  if (override !== undefined && override.length > 0)
517
556
  return override;
518
557
  const which = spawnSync("sh", ["-c", "command -v opencode"], {
@@ -544,6 +583,101 @@ function guardScriptLines(guard) {
544
583
  "fi"
545
584
  ];
546
585
  }
586
+ function worktreeDefaultRoot(scopeId) {
587
+ return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
588
+ }
589
+ function worktreePrologueLines(job, scopeId) {
590
+ const base = job.worktree?.base;
591
+ return [
592
+ "wt_enabled=1",
593
+ 'orig_pwd="$(pwd)"',
594
+ 'lock_dir="$config_root/opencode/jobs/locks/$scope"',
595
+ 'mkdir -p "$lock_dir"',
596
+ 'exec 9>"$lock_dir/$slug.lock"',
597
+ "if ! flock -n 9; then",
598
+ ' echo "opencode-jobs: another run of $slug is already active, skipping"',
599
+ " finish skipped 0",
600
+ " exit 0",
601
+ "fi",
602
+ base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
603
+ 'if ! mkdir -p "$wt_root"; then',
604
+ ' echo "opencode-jobs: cannot create worktree base $wt_root"',
605
+ " finish failed 1",
606
+ " exit 1",
607
+ "fi",
608
+ 'wt_root="$(cd "$wt_root" && pwd)"',
609
+ 'wt_path="$wt_root/$slug"',
610
+ 'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
611
+ `wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
612
+ 'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
613
+ 'wt_sub="${wt_sub%/}"',
614
+ 'if [ -d "$wt_path" ]; then',
615
+ ' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
616
+ " wt_stale_saved=1",
617
+ ' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
618
+ ' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
619
+ ' 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',
620
+ " fi",
621
+ ' if [ "$wt_stale_saved" -eq 1 ]; then',
622
+ ' git worktree remove --force "$wt_path" >/dev/null 2>&1',
623
+ ' rm -rf "$wt_path"',
624
+ " else",
625
+ ' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
626
+ ' wt_branch=""',
627
+ " finish failed 1",
628
+ " exit 1",
629
+ " fi",
630
+ " else",
631
+ ' echo "opencode-jobs: removing unexpected directory at $wt_path"',
632
+ ' rm -rf "$wt_path"',
633
+ " fi",
634
+ " git worktree prune >/dev/null 2>&1",
635
+ "fi",
636
+ 'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
637
+ ' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
638
+ ' wt_branch=""',
639
+ " finish failed 1",
640
+ " exit 1",
641
+ "fi",
642
+ 'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
643
+ ' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
644
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
645
+ ' rm -rf "$wt_path"',
646
+ ' wt_branch=""',
647
+ " finish failed 1",
648
+ " exit 1",
649
+ "fi",
650
+ 'if [ -n "$wt_sub" ]; then',
651
+ ' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
652
+ "else",
653
+ ' cd "$wt_path" || { finish failed 1; exit 1; }',
654
+ "fi"
655
+ ];
656
+ }
657
+ function worktreeEpilogueLines(options) {
658
+ const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
659
+ return [
660
+ 'if [ "$wt_enabled" -eq 1 ]; then',
661
+ ` wt_msg=${message}`,
662
+ ' git -C "$wt_path" add -A >/dev/null 2>&1',
663
+ " wt_keep=0",
664
+ ' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
665
+ ' 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',
666
+ ' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
667
+ " else",
668
+ ' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
669
+ " wt_keep=1",
670
+ " fi",
671
+ " fi",
672
+ ' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
673
+ ' if [ "$wt_keep" -eq 0 ]; then',
674
+ ' cd "$wt_root" 2>/dev/null',
675
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
676
+ ' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
677
+ " fi",
678
+ "fi"
679
+ ];
680
+ }
547
681
  var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
548
682
  var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
549
683
  var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
@@ -557,7 +691,7 @@ function compactSessionLines() {
557
691
  "compact_session() {",
558
692
  ' csid="$1"',
559
693
  " if ! command -v curl >/dev/null 2>&1; then",
560
- ' echo "scheduler: curl not available, skipping compaction"',
694
+ ' echo "opencode-jobs: curl not available, skipping compaction"',
561
695
  " return 0",
562
696
  " fi",
563
697
  ' serve_out="$(mktemp)"',
@@ -574,7 +708,7 @@ function compactSessionLines() {
574
708
  " tries=$((tries + 1))",
575
709
  " done",
576
710
  ' if [ -z "$serve_port" ]; then',
577
- ' echo "scheduler: compaction server failed to start"',
711
+ ' echo "opencode-jobs: compaction server failed to start"',
578
712
  ' sed -n "1,10p" "$serve_err" >&2',
579
713
  ' kill "$serve_pid" 2>/dev/null',
580
714
  ' wait "$serve_pid" 2>/dev/null',
@@ -593,7 +727,7 @@ function compactSessionLines() {
593
727
  " tries=$((tries + 1))",
594
728
  " done",
595
729
  ' if [ "$healthy" -ne 1 ]; then',
596
- ' echo "scheduler: compaction server never became healthy"',
730
+ ' echo "opencode-jobs: compaction server never became healthy"',
597
731
  ' kill "$serve_pid" 2>/dev/null',
598
732
  ' wait "$serve_pid" 2>/dev/null',
599
733
  ' rm -f "$serve_out" "$serve_err"',
@@ -613,22 +747,22 @@ function compactSessionLines() {
613
747
  String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
614
748
  " fi",
615
749
  ' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
616
- ' echo "scheduler: could not resolve a model for compaction, skipping"',
750
+ ' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
617
751
  ' kill "$serve_pid" 2>/dev/null',
618
752
  ' wait "$serve_pid" 2>/dev/null',
619
753
  ' rm -f "$serve_out" "$serve_err"',
620
754
  " return 0",
621
755
  " fi",
622
- ' echo "scheduler: compacting session $csid (mode: $session_mode)"',
756
+ ' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
623
757
  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")"`,
624
758
  ' if [ "$result" != "true" ]; then',
625
- ' echo "scheduler: compaction failed: $result"',
759
+ ' echo "opencode-jobs: compaction failed: $result"',
626
760
  " fi",
627
761
  ' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
628
762
  String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
629
763
  ` 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")"`,
630
764
  ' if [ "$http" != "200" ]; then',
631
- ' echo "scheduler: keeping last result failed (HTTP $http)"',
765
+ ' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
632
766
  " fi",
633
767
  " fi",
634
768
  ' kill "$serve_pid" 2>/dev/null',
@@ -649,32 +783,35 @@ function runScriptContent(job, scopeId, opencodeBin) {
649
783
  `scope=${shQuote(scopeId)}`,
650
784
  `oc_bin=${shQuote(opencodeBin)}`,
651
785
  'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
652
- 'runs="$config_root/opencode/scheduler/runs/$scope"',
786
+ 'runs="$config_root/opencode/jobs/runs/$scope"',
653
787
  'mkdir -p "$runs"',
654
788
  'record_file="$runs/$slug.jsonl"',
655
789
  ...isTracked ? [
656
- 'sessions="$config_root/opencode/scheduler/sessions/$scope"',
790
+ 'sessions="$config_root/opencode/jobs/sessions/$scope"',
657
791
  'mkdir -p "$sessions"',
658
792
  'state_file="$sessions/$slug.txt"',
659
793
  `session_mode=${shQuote(mode)}`,
660
794
  'prev_session=""',
661
795
  'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
662
796
  ] : [],
663
- 'started_by="${OPENCODE_SCHEDULER_STARTED_BY:-scheduled}"',
797
+ 'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
664
798
  'run_id="$(date +%s%N)-$$"',
665
799
  "started=$(date +%s)",
666
800
  'new_session=""',
801
+ 'wt_branch=""',
802
+ 'wt_commit=""',
667
803
  `export OPENCODE_PERMISSION='{"question":"deny"}'`,
668
- 'export OPENCODE_SCHEDULER_RUN_ID="$run_id"',
804
+ 'export OPENCODE_JOBS_RUN_ID="$run_id"',
669
805
  "finish() {",
670
806
  ' status="$1"',
671
807
  ' code="$2"',
672
808
  " ended=$(date +%s)",
673
- String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" >> "$record_file"`,
809
+ 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"`,
674
810
  "}",
675
811
  "trap 'finish timeout 124; exit 124' TERM INT",
676
812
  ...job.guard === undefined ? [] : guardScriptLines(job.guard),
677
813
  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"`,
814
+ ...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
678
815
  `oc_agent=${shQuote(job.run.agent ?? "")}`,
679
816
  `oc_model=${shQuote(job.run.model ?? "")}`,
680
817
  "prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
@@ -705,7 +842,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
705
842
  'cat "$json_out"',
706
843
  ...extractSessionIdLines("new_session"),
707
844
  'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
708
- ' echo "scheduler: session $prev_session not found, retrying with a fresh session"',
845
+ ' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
709
846
  ' rm -f "$json_out"',
710
847
  ' json_out="$(mktemp)"',
711
848
  ' run_opencode "" 1 >"$json_out" 2>&1',
@@ -754,6 +891,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
754
891
  "fi"
755
892
  ] : ['run_opencode "" 0', "code=$?"],
756
893
  "trap - TERM INT",
894
+ ...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
757
895
  'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
758
896
  ...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
759
897
  "finish success 0",
@@ -1020,9 +1158,14 @@ function scheduleJobOutput(input, directory) {
1020
1158
  } catch (error) {
1021
1159
  return fail(errorMessage(error));
1022
1160
  }
1161
+ const hasWorktreeOptions = input.worktreeBase !== undefined || input.worktreeRef !== undefined || input.worktreeCommitMessage !== undefined;
1162
+ if (hasWorktreeOptions && input.worktree !== true) {
1163
+ return fail("set worktree: true to enable worktree options (worktreeBase, worktreeRef, worktreeCommitMessage)");
1164
+ }
1023
1165
  let run;
1024
1166
  let session;
1025
1167
  let guard;
1168
+ let worktree;
1026
1169
  let timeoutSeconds;
1027
1170
  try {
1028
1171
  run = validateRunSpec({
@@ -1034,6 +1177,17 @@ function scheduleJobOutput(input, directory) {
1034
1177
  }, "job");
1035
1178
  session = validateSession(input.session, "job");
1036
1179
  guard = validateGuard(input.guard, "job");
1180
+ worktree = validateWorktree(input.worktree === true ? {
1181
+ ...input.worktreeBase !== undefined && {
1182
+ base: input.worktreeBase
1183
+ },
1184
+ ...input.worktreeRef !== undefined && {
1185
+ ref: input.worktreeRef
1186
+ },
1187
+ ...input.worktreeCommitMessage !== undefined && {
1188
+ commitMessage: input.worktreeCommitMessage
1189
+ }
1190
+ } : undefined, "job");
1037
1191
  timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
1038
1192
  } catch (error) {
1039
1193
  return fail(errorMessage(error));
@@ -1046,18 +1200,21 @@ function scheduleJobOutput(input, directory) {
1046
1200
  run,
1047
1201
  ...session !== "new" && { session },
1048
1202
  ...guard !== undefined && { guard },
1203
+ ...worktree !== undefined && { worktree },
1049
1204
  ...timeoutSeconds !== undefined && { timeoutSeconds },
1050
1205
  createdAt: existing.ok ? existing.job.createdAt : nowIso(),
1051
1206
  updatedAt: nowIso()
1052
1207
  };
1053
1208
  saveJob(directory, job);
1054
- const relativePath = `.opencode/scheduler/jobs/${slug}.json`;
1209
+ const relativePath = `.opencode/jobs/${slug}.json`;
1055
1210
  const lines = [
1056
1211
  `${existing.ok ? "Updated" : "Created"} job "${job.name}" (${slug})`,
1057
1212
  `Definition: ${relativePath} (${job.schedule} \u2014 ${describeCron(sets)})`
1058
1213
  ];
1059
1214
  if (session !== "new")
1060
1215
  lines.push(`Session: ${session}`);
1216
+ if (worktree !== undefined)
1217
+ lines.push(`Worktree: yes (base ${worktree.base ?? "default"}, branch opencode-jobs/${slug}/<run>)`);
1061
1218
  const entry = registryEntry(directory);
1062
1219
  if (entry === undefined) {
1063
1220
  lines.push("Project not enabled yet. Run enable_project to install the systemd timer.");
@@ -1105,11 +1262,15 @@ function showJobOutput(slugInput, directory) {
1105
1262
  const lines = [
1106
1263
  `${job.name} (${job.slug})`,
1107
1264
  `Schedule: ${job.schedule} \u2014 ${describeCron(sets)}`,
1108
- `Definition: .opencode/scheduler/jobs/${job.slug}.json (updated ${job.updatedAt})`,
1265
+ `Definition: .opencode/jobs/${job.slug}.json (updated ${job.updatedAt})`,
1109
1266
  `Run: ${runDesc}`
1110
1267
  ];
1111
1268
  if (job.guard !== undefined)
1112
1269
  lines.push(`Guard: ${job.guard} (must exit 0 for the run to start)`);
1270
+ if (job.worktree !== undefined) {
1271
+ const base = job.worktree.base ?? worktreesDirectory(scopeId);
1272
+ lines.push(`Worktree: fresh per run at ${base} (from ${job.worktree.ref ?? "HEAD"}) \u2014 changes are committed to opencode-jobs/${job.slug}/\u2026 before the worktree is removed`);
1273
+ }
1113
1274
  if (job.session !== undefined) {
1114
1275
  const state = sessionStateFile(scopeId, job.slug);
1115
1276
  const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
@@ -1151,9 +1312,7 @@ function removeJobDefinitionOutput(slugInput, directory) {
1151
1312
  if (!existsSync4(file))
1152
1313
  return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1153
1314
  rmSync2(file);
1154
- const lines = [
1155
- `Deleted job definition .opencode/scheduler/jobs/${slug}.json`
1156
- ];
1315
+ const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
1157
1316
  const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
1158
1317
  const state = sessionStateFile(scopeId, slug);
1159
1318
  if (existsSync4(state)) {
@@ -1201,7 +1360,7 @@ function runJobNowOutput(slugInput, directory) {
1201
1360
  const fd = openSync(log, "a");
1202
1361
  const child = spawn("/bin/sh", [script], {
1203
1362
  cwd: path6.resolve(directory),
1204
- env: { ...process.env, OPENCODE_SCHEDULER_STARTED_BY: "manual" },
1363
+ env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
1205
1364
  stdio: ["ignore", fd, fd]
1206
1365
  });
1207
1366
  child.unref();
@@ -1235,7 +1394,7 @@ function listProjectsOutput() {
1235
1394
  const entries = Object.values(registry.projects).toSorted((a, b) => a.workdir.localeCompare(b.workdir));
1236
1395
  if (entries.length === 0)
1237
1396
  return ok("No projects with scheduled jobs are registered.");
1238
- const lines = ["Registry: ~/.config/opencode/scheduler/registry.json"];
1397
+ const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
1239
1398
  for (const entry of entries) {
1240
1399
  const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
1241
1400
  lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
@@ -1244,12 +1403,12 @@ function listProjectsOutput() {
1244
1403
  `));
1245
1404
  }
1246
1405
  var listJobsTool = tool({
1247
- description: "List scheduled job definitions for the current project (from .opencode/scheduler/jobs/), including enabled state, next run, and last run status.",
1406
+ description: "List scheduled job definitions for the current project (from .opencode/jobs/), including enabled state, next run, and last run status.",
1248
1407
  args: {},
1249
1408
  execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
1250
1409
  });
1251
1410
  var scheduleJobTool = tool({
1252
- description: "Create or update a scheduled job definition in the current project (.opencode/scheduler/jobs/<slug>.json, git-committable). Schedule is a 5-field cron expression. Set either prompt (natural language) or command (custom command name). If the project is enabled, systemd units are re-synced automatically.",
1411
+ description: "Create or update a scheduled job definition in the current project (.opencode/jobs/<slug>.json, git-committable). Schedule is a 5-field cron expression. Set either prompt (natural language) or command (custom command name). If the project is enabled, systemd units are re-synced automatically.",
1253
1412
  args: {
1254
1413
  name: tool.schema.string().describe("Human-readable job name"),
1255
1414
  schedule: tool.schema.string().describe('5-field cron expression, e.g. "0 9 * * *" (daily 9am), "0 */6 * * *" (every 6h), "30 8 * * 1" (Mon 8:30)'),
@@ -1258,6 +1417,10 @@ var scheduleJobTool = tool({
1258
1417
  arguments: tool.schema.string().optional().describe("Arguments passed to the custom command"),
1259
1418
  session: tool.schema.string().optional().describe(`Session continuity between runs: "new" (default, fresh session each run), "persist" (continue the same session), "compact" (continue the same session; after each run the history is compacted into a summary the next run starts from), "compact+last" (like compact, but the run's final result message is re-injected after the summary so the next run starts from summary plus last result)`),
1260
1419
  guard: tool.schema.string().optional().describe('Shell command run before the job; the run only starts if it exits 0, otherwise it is recorded as skipped (applies to run_job too). E.g. "! git diff --quiet" to run only when the repo has changes'),
1420
+ worktree: tool.schema.boolean().optional().describe("Run the job in a fresh git worktree instead of the project checkout: the worktree is created from worktreeRef (default HEAD), the job runs inside it, all changes are committed to a per-run branch opencode-jobs/<slug>/\u2026, and the worktree is removed afterwards (kept if the safety commit fails). Requires the project to be a git repository"),
1421
+ worktreeBase: tool.schema.string().optional().describe("Parent directory for the worktree (default: ~/.local/state/opencode/jobs/worktrees/<scopeId>/<slug>). Relative paths resolve against the project directory; it should be dedicated to job worktrees"),
1422
+ worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
1423
+ worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
1261
1424
  agent: tool.schema.string().optional().describe("Agent to use for the run"),
1262
1425
  model: tool.schema.string().optional().describe("Model to use for the run"),
1263
1426
  timeoutSeconds: tool.schema.number().optional().describe("Hard timeout in seconds (0 or omitted disables). systemd stops the run with SIGTERM after this"),
@@ -1295,7 +1458,7 @@ var jobLogsTool = tool({
1295
1458
  execute: (input, context) => Promise.resolve(jobLogsOutput(input.slug, input.lines, context.directory))
1296
1459
  });
1297
1460
  var enableProjectTool = tool({
1298
- description: "Enable scheduled jobs for the current project: installs a systemd user service+timer per job definition in .opencode/scheduler/jobs/, registers the project in the global registry (~/.config/opencode/scheduler/registry.json), and removes stale units for deleted jobs. Idempotent, so it also re-syncs after job definitions change. Linux only.",
1461
+ description: "Enable scheduled jobs for the current project: installs a systemd user service+timer per job definition in .opencode/jobs/, registers the project in the global registry (~/.config/opencode/jobs/registry.json), and removes stale units for deleted jobs. Idempotent, so it also re-syncs after job definitions change. Linux only.",
1299
1462
  args: {},
1300
1463
  execute: (_input, context) => Promise.resolve(enableProjectOutput(context.directory))
1301
1464
  });
@@ -1319,11 +1482,11 @@ function disableProjectOutput(directory) {
1319
1482
  }
1320
1483
  }
1321
1484
  var listProjectsTool = tool({
1322
- description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/scheduler/registry.json).",
1485
+ description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/jobs/registry.json).",
1323
1486
  args: {},
1324
1487
  execute: () => Promise.resolve(listProjectsOutput())
1325
1488
  });
1326
- var schedulerTools = {
1489
+ var jobsTools = {
1327
1490
  schedule_job: scheduleJobTool,
1328
1491
  list_jobs: listJobsTool,
1329
1492
  get_job: showJobTool,
@@ -1335,12 +1498,102 @@ var schedulerTools = {
1335
1498
  list_projects: listProjectsTool
1336
1499
  };
1337
1500
 
1501
+ // src/migration.ts
1502
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
1503
+ import path7 from "path";
1504
+ function legacyJobsStateDirectory() {
1505
+ return path7.join(configRoot(), "opencode", "scheduler");
1506
+ }
1507
+ function legacyRegistryPath() {
1508
+ return path7.join(legacyJobsStateDirectory(), "registry.json");
1509
+ }
1510
+ function legacyDefinitionsDirectory(workdir) {
1511
+ return path7.join(workdir, ".opencode", "scheduler", "jobs");
1512
+ }
1513
+ function migrationMoves(projects) {
1514
+ return [
1515
+ {
1516
+ from: legacyJobsStateDirectory(),
1517
+ to: jobsStateDirectory()
1518
+ },
1519
+ {
1520
+ from: path7.join(configRoot(), "opencode", "logs", "scheduler"),
1521
+ to: path7.join(configRoot(), "opencode", "logs", "jobs")
1522
+ },
1523
+ {
1524
+ from: path7.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1525
+ to: path7.join(stateRoot(), "opencode", "jobs", "worktrees")
1526
+ },
1527
+ ...[...projects].map((workdir) => ({
1528
+ from: legacyDefinitionsDirectory(workdir),
1529
+ to: jobsDirectory(workdir)
1530
+ }))
1531
+ ];
1532
+ }
1533
+ function removeLegacyProjectDirectory(workdir) {
1534
+ try {
1535
+ rmdirSync(path7.join(workdir, ".opencode", "scheduler"));
1536
+ } catch {}
1537
+ }
1538
+ function migrateStorage(projectDirectory, shouldResync) {
1539
+ const project = path7.resolve(projectDirectory);
1540
+ const legacyRegistry = legacyRegistryPath();
1541
+ const canonicalRegistry = path7.join(jobsStateDirectory(), "registry.json");
1542
+ const registryFile = existsSync5(legacyRegistry) ? legacyRegistry : canonicalRegistry;
1543
+ const registry = existsSync5(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
1544
+ const registeredProjects = new Set(Object.keys(registry.projects));
1545
+ const projects = new Set([project, ...registeredProjects]);
1546
+ const moves = migrationMoves(projects).filter(({ from }) => existsSync5(from));
1547
+ for (const { from, to } of moves) {
1548
+ if (existsSync5(to)) {
1549
+ 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.`);
1550
+ }
1551
+ }
1552
+ for (const { from, to } of moves) {
1553
+ mkdirSync4(path7.dirname(to), { recursive: true });
1554
+ renameSync2(from, to);
1555
+ }
1556
+ for (const workdir of projects)
1557
+ removeLegacyProjectDirectory(workdir);
1558
+ const result = {
1559
+ moved: moves,
1560
+ resyncedProjects: [],
1561
+ warnings: []
1562
+ };
1563
+ if (!shouldResync || moves.length === 0)
1564
+ return result;
1565
+ for (const workdir of registeredProjects) {
1566
+ try {
1567
+ enableProject(workdir);
1568
+ result.resyncedProjects.push(workdir);
1569
+ } catch (error) {
1570
+ const message = error instanceof Error ? error.message : String(error);
1571
+ result.warnings.push(`${workdir}: ${message}`);
1572
+ }
1573
+ }
1574
+ return result;
1575
+ }
1576
+
1338
1577
  // src/index.ts
1339
- var src_default = () => {
1578
+ var src_default = (input) => {
1579
+ if (typeof input.directory === "string") {
1580
+ try {
1581
+ const migration = migrateStorage(input.directory, true);
1582
+ if (migration.moved.length > 0) {
1583
+ console.error(`[opencode-jobs] migrated ${String(migration.moved.length)} legacy storage location(s) to jobs paths`);
1584
+ }
1585
+ for (const warning of migration.warnings) {
1586
+ console.error(`[opencode-jobs] migration warning: ${warning}`);
1587
+ }
1588
+ } catch (error) {
1589
+ const message = error instanceof Error ? error.message : String(error);
1590
+ console.error(`[opencode-jobs] storage migration failed: ${message}`);
1591
+ }
1592
+ }
1340
1593
  if (process.platform !== "linux") {
1341
1594
  console.error("[opencode-jobs] warning: this is not a Linux host, systemd user timers are unavailable \u2014 scheduling tools will not work here");
1342
1595
  }
1343
- return Promise.resolve({ tool: schedulerTools });
1596
+ return Promise.resolve({ tool: jobsTools });
1344
1597
  };
1345
1598
  export {
1346
1599
  src_default as default
package/dist/install.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type StorageMigration } from "./migration.js";
1
2
  export declare const PACKAGE_NAME = "opencode-jobs";
2
3
  export declare const SKILL_NAME = "opencode-jobs";
3
4
  export type ConfigInstall = {
@@ -15,6 +16,7 @@ export interface ProjectInstall {
15
16
  projectDirectory: string;
16
17
  plugin: ConfigInstall;
17
18
  skill: SkillInstall;
19
+ migration?: StorageMigration;
18
20
  }
19
21
  export declare function installPluginConfig(projectDirectory: string): ConfigInstall;
20
22
  export declare function installSkill(projectDirectory: string, packageDirectory: string): SkillInstall;
@@ -39,6 +41,7 @@ export interface ProjectUninstall {
39
41
  plugin: ConfigUninstall;
40
42
  skill: SkillUninstall;
41
43
  purge?: PurgeResult;
44
+ migration?: StorageMigration;
42
45
  }
43
46
  export declare function uninstallPluginConfig(projectDirectory: string): ConfigUninstall;
44
47
  export declare function uninstallSkill(projectDirectory: string, packageDirectory: string): SkillUninstall;