opencode-jobs 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,16 +3,8 @@
3
3
  import {
4
4
  tool
5
5
  } from "@opencode-ai/plugin";
6
- import {
7
- closeSync,
8
- existsSync as existsSync4,
9
- mkdirSync as mkdirSync3,
10
- openSync,
11
- readFileSync as readFileSync4,
12
- rmSync as rmSync2
13
- } from "fs";
14
- import { spawn } from "child_process";
15
- import path6 from "path";
6
+ import { existsSync as existsSync5, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
7
+ import path7 from "path";
16
8
 
17
9
  // src/cron.ts
18
10
  var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -149,31 +141,31 @@ function stateRoot() {
149
141
  return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
150
142
  }
151
143
  function worktreesDirectory(scopeId) {
152
- return path.join(stateRoot(), "opencode", "scheduler", "worktrees", scopeId);
144
+ return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
153
145
  }
154
- function schedulerDirectory() {
155
- return path.join(configRoot(), "opencode", "scheduler");
146
+ function jobsStateDirectory() {
147
+ return path.join(configRoot(), "opencode", "jobs");
156
148
  }
157
149
  function registryPath() {
158
- return path.join(schedulerDirectory(), "registry.json");
150
+ return path.join(jobsStateDirectory(), "registry.json");
159
151
  }
160
152
  function scopeDirectory(scopeId) {
161
- return path.join(schedulerDirectory(), "scopes", scopeId);
153
+ return path.join(jobsStateDirectory(), "scopes", scopeId);
162
154
  }
163
155
  function runsDirectory(scopeId) {
164
- return path.join(schedulerDirectory(), "runs", scopeId);
156
+ return path.join(jobsStateDirectory(), "runs", scopeId);
165
157
  }
166
158
  function runsFile(scopeId, slug) {
167
159
  return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
168
160
  }
169
161
  function sessionStateDirectory(scopeId) {
170
- return path.join(schedulerDirectory(), "sessions", scopeId);
162
+ return path.join(jobsStateDirectory(), "sessions", scopeId);
171
163
  }
172
164
  function sessionStateFile(scopeId, slug) {
173
165
  return path.join(sessionStateDirectory(scopeId), `${slug}.txt`);
174
166
  }
175
167
  function logDirectory(scopeId) {
176
- return path.join(configRoot(), "opencode", "logs", "scheduler", scopeId);
168
+ return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
177
169
  }
178
170
  function logFile(scopeId, slug) {
179
171
  return path.join(logDirectory(scopeId), `${slug}.log`);
@@ -182,7 +174,7 @@ function systemdUserDirectory() {
182
174
  return path.join(configRoot(), "systemd", "user");
183
175
  }
184
176
  function jobsDirectory(workdir) {
185
- return path.join(workdir, ".opencode", "scheduler", "jobs");
177
+ return path.join(workdir, ".opencode", "jobs");
186
178
  }
187
179
  function unitBase(scopeId, slug) {
188
180
  return `opencode-sched-${scopeId}-${slug}`;
@@ -447,19 +439,22 @@ var registryFileSchema = z2.object({
447
439
  version: z2.literal(1),
448
440
  projects: z2.record(z2.string(), z2.unknown())
449
441
  });
442
+ function readRegistryFile(file) {
443
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
444
+ const result = registryFileSchema.safeParse(parsed);
445
+ if (!result.success)
446
+ throw new Error(`Invalid job registry: ${file}`);
447
+ const projects = {};
448
+ for (const [key, value] of Object.entries(result.data.projects)) {
449
+ const entry = registryEntrySchema.safeParse(value);
450
+ if (entry.success)
451
+ projects[key] = entry.data;
452
+ }
453
+ return { version: 1, projects };
454
+ }
450
455
  function loadRegistry() {
451
456
  try {
452
- const parsed = JSON.parse(readFileSync2(registryPath(), "utf8"));
453
- const result = registryFileSchema.safeParse(parsed);
454
- if (!result.success)
455
- return { version: 1, projects: {} };
456
- const projects = {};
457
- for (const [key, value] of Object.entries(result.data.projects)) {
458
- const entry = registryEntrySchema.safeParse(value);
459
- if (entry.success)
460
- projects[key] = entry.data;
461
- }
462
- return { version: 1, projects };
457
+ return readRegistryFile(registryPath());
463
458
  } catch {
464
459
  return { version: 1, projects: {} };
465
460
  }
@@ -548,7 +543,7 @@ import { spawnSync } from "child_process";
548
543
  import path4 from "path";
549
544
  import { homedir as homedir2 } from "os";
550
545
  function findOpencode() {
551
- const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
546
+ const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
552
547
  if (override !== undefined && override.length > 0)
553
548
  return override;
554
549
  const which = spawnSync("sh", ["-c", "command -v opencode"], {
@@ -581,24 +576,24 @@ function guardScriptLines(guard) {
581
576
  ];
582
577
  }
583
578
  function worktreeDefaultRoot(scopeId) {
584
- return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/scheduler/worktrees/" + scopeId;
579
+ return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
585
580
  }
586
581
  function worktreePrologueLines(job, scopeId) {
587
582
  const base = job.worktree?.base;
588
583
  return [
589
584
  "wt_enabled=1",
590
585
  'orig_pwd="$(pwd)"',
591
- 'lock_dir="$config_root/opencode/scheduler/locks/$scope"',
586
+ 'lock_dir="$config_root/opencode/jobs/locks/$scope"',
592
587
  'mkdir -p "$lock_dir"',
593
588
  'exec 9>"$lock_dir/$slug.lock"',
594
589
  "if ! flock -n 9; then",
595
- ' echo "scheduler: another run of $slug is already active, skipping"',
590
+ ' echo "opencode-jobs: another run of $slug is already active, skipping"',
596
591
  " finish skipped 0",
597
592
  " exit 0",
598
593
  "fi",
599
594
  base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
600
595
  'if ! mkdir -p "$wt_root"; then',
601
- ' echo "scheduler: cannot create worktree base $wt_root"',
596
+ ' echo "opencode-jobs: cannot create worktree base $wt_root"',
602
597
  " finish failed 1",
603
598
  " exit 1",
604
599
  "fi",
@@ -613,31 +608,31 @@ function worktreePrologueLines(job, scopeId) {
613
608
  " wt_stale_saved=1",
614
609
  ' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
615
610
  ' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
616
- ' git -C "$wt_path" -c user.name=opencode-jobs -c user.email=scheduler@opencode.invalid commit --no-gpg-sign -m "opencode-jobs: $slug recovery (stale worktree)" >/dev/null 2>&1 || wt_stale_saved=0',
611
+ ' 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',
617
612
  " fi",
618
613
  ' if [ "$wt_stale_saved" -eq 1 ]; then',
619
614
  ' git worktree remove --force "$wt_path" >/dev/null 2>&1',
620
615
  ' rm -rf "$wt_path"',
621
616
  " else",
622
- ' echo "scheduler: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
617
+ ' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
623
618
  ' wt_branch=""',
624
619
  " finish failed 1",
625
620
  " exit 1",
626
621
  " fi",
627
622
  " else",
628
- ' echo "scheduler: removing unexpected directory at $wt_path"',
623
+ ' echo "opencode-jobs: removing unexpected directory at $wt_path"',
629
624
  ' rm -rf "$wt_path"',
630
625
  " fi",
631
626
  " git worktree prune >/dev/null 2>&1",
632
627
  "fi",
633
628
  'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
634
- ' echo "scheduler: failed to create worktree $wt_path (worktree jobs require a git repository)"',
629
+ ' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
635
630
  ' wt_branch=""',
636
631
  " finish failed 1",
637
632
  " exit 1",
638
633
  "fi",
639
634
  'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
640
- ' echo "scheduler: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
635
+ ' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
641
636
  ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
642
637
  ' rm -rf "$wt_path"',
643
638
  ' wt_branch=""',
@@ -659,10 +654,10 @@ function worktreeEpilogueLines(options) {
659
654
  ' git -C "$wt_path" add -A >/dev/null 2>&1',
660
655
  " wt_keep=0",
661
656
  ' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
662
- ' if git -C "$wt_path" -c user.name=opencode-jobs -c user.email=scheduler@opencode.invalid commit --no-gpg-sign -m "$wt_msg" >/dev/null 2>&1; then',
663
- ' echo "scheduler: committed worktree changes to branch $wt_branch"',
657
+ ' 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',
658
+ ' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
664
659
  " else",
665
- ' echo "scheduler: worktree commit failed, keeping worktree at $wt_path"',
660
+ ' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
666
661
  " wt_keep=1",
667
662
  " fi",
668
663
  " fi",
@@ -688,7 +683,7 @@ function compactSessionLines() {
688
683
  "compact_session() {",
689
684
  ' csid="$1"',
690
685
  " if ! command -v curl >/dev/null 2>&1; then",
691
- ' echo "scheduler: curl not available, skipping compaction"',
686
+ ' echo "opencode-jobs: curl not available, skipping compaction"',
692
687
  " return 0",
693
688
  " fi",
694
689
  ' serve_out="$(mktemp)"',
@@ -705,7 +700,7 @@ function compactSessionLines() {
705
700
  " tries=$((tries + 1))",
706
701
  " done",
707
702
  ' if [ -z "$serve_port" ]; then',
708
- ' echo "scheduler: compaction server failed to start"',
703
+ ' echo "opencode-jobs: compaction server failed to start"',
709
704
  ' sed -n "1,10p" "$serve_err" >&2',
710
705
  ' kill "$serve_pid" 2>/dev/null',
711
706
  ' wait "$serve_pid" 2>/dev/null',
@@ -724,7 +719,7 @@ function compactSessionLines() {
724
719
  " tries=$((tries + 1))",
725
720
  " done",
726
721
  ' if [ "$healthy" -ne 1 ]; then',
727
- ' echo "scheduler: compaction server never became healthy"',
722
+ ' echo "opencode-jobs: compaction server never became healthy"',
728
723
  ' kill "$serve_pid" 2>/dev/null',
729
724
  ' wait "$serve_pid" 2>/dev/null',
730
725
  ' rm -f "$serve_out" "$serve_err"',
@@ -744,22 +739,22 @@ function compactSessionLines() {
744
739
  String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
745
740
  " fi",
746
741
  ' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
747
- ' echo "scheduler: could not resolve a model for compaction, skipping"',
742
+ ' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
748
743
  ' kill "$serve_pid" 2>/dev/null',
749
744
  ' wait "$serve_pid" 2>/dev/null',
750
745
  ' rm -f "$serve_out" "$serve_err"',
751
746
  " return 0",
752
747
  " fi",
753
- ' echo "scheduler: compacting session $csid (mode: $session_mode)"',
748
+ ' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
754
749
  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")"`,
755
750
  ' if [ "$result" != "true" ]; then',
756
- ' echo "scheduler: compaction failed: $result"',
751
+ ' echo "opencode-jobs: compaction failed: $result"',
757
752
  " fi",
758
753
  ' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
759
754
  String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
760
755
  ` 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")"`,
761
756
  ' if [ "$http" != "200" ]; then',
762
- ' echo "scheduler: keeping last result failed (HTTP $http)"',
757
+ ' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
763
758
  " fi",
764
759
  " fi",
765
760
  ' kill "$serve_pid" 2>/dev/null',
@@ -780,25 +775,25 @@ function runScriptContent(job, scopeId, opencodeBin) {
780
775
  `scope=${shQuote(scopeId)}`,
781
776
  `oc_bin=${shQuote(opencodeBin)}`,
782
777
  'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
783
- 'runs="$config_root/opencode/scheduler/runs/$scope"',
778
+ 'runs="$config_root/opencode/jobs/runs/$scope"',
784
779
  'mkdir -p "$runs"',
785
780
  'record_file="$runs/$slug.jsonl"',
786
781
  ...isTracked ? [
787
- 'sessions="$config_root/opencode/scheduler/sessions/$scope"',
782
+ 'sessions="$config_root/opencode/jobs/sessions/$scope"',
788
783
  'mkdir -p "$sessions"',
789
784
  'state_file="$sessions/$slug.txt"',
790
785
  `session_mode=${shQuote(mode)}`,
791
786
  'prev_session=""',
792
787
  'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
793
788
  ] : [],
794
- 'started_by="${OPENCODE_SCHEDULER_STARTED_BY:-scheduled}"',
789
+ 'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
795
790
  'run_id="$(date +%s%N)-$$"',
796
791
  "started=$(date +%s)",
797
792
  'new_session=""',
798
793
  'wt_branch=""',
799
794
  'wt_commit=""',
800
795
  `export OPENCODE_PERMISSION='{"question":"deny"}'`,
801
- 'export OPENCODE_SCHEDULER_RUN_ID="$run_id"',
796
+ 'export OPENCODE_JOBS_RUN_ID="$run_id"',
802
797
  "finish() {",
803
798
  ' status="$1"',
804
799
  ' code="$2"',
@@ -839,7 +834,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
839
834
  'cat "$json_out"',
840
835
  ...extractSessionIdLines("new_session"),
841
836
  'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
842
- ' echo "scheduler: session $prev_session not found, retrying with a fresh session"',
837
+ ' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
843
838
  ' rm -f "$json_out"',
844
839
  ' json_out="$(mktemp)"',
845
840
  ' run_opencode "" 1 >"$json_out" 2>&1',
@@ -948,6 +943,16 @@ Hint: no systemd user session is reachable. Over SSH try enabling lingering: log
948
943
  }
949
944
  return "";
950
945
  }
946
+ function isTimerLoaded(base) {
947
+ const result = systemctl([
948
+ "show",
949
+ timerUnit(base),
950
+ "-p",
951
+ "LoadState",
952
+ "--value"
953
+ ]);
954
+ return result.ok && result.stdout !== "not-found";
955
+ }
951
956
  function timerStatus(base) {
952
957
  const result = systemctl([
953
958
  "show",
@@ -985,17 +990,25 @@ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
985
990
  }
986
991
  function removeJobUnits(scopeId, slug) {
987
992
  const base = unitBase(scopeId, slug);
988
- systemctl(["disable", "--now", timerUnit(base)]);
989
- for (const file of [
993
+ const files = [
990
994
  path4.join(systemdUserDirectory(), timerUnit(base)),
991
995
  path4.join(systemdUserDirectory(), serviceUnit(base))
992
- ]) {
996
+ ];
997
+ const script = runScriptPath(scopeId, slug);
998
+ if ([...files, script].every((file) => !existsSync3(file)) && !isTimerLoaded(base)) {
999
+ return;
1000
+ }
1001
+ const disable = systemctl(["disable", "--now", timerUnit(base)]);
1002
+ if (!disable.ok) {
1003
+ return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
1004
+ }
1005
+ for (const file of files) {
993
1006
  if (existsSync3(file))
994
1007
  rmSync(file);
995
1008
  }
996
- const script = runScriptPath(scopeId, slug);
997
1009
  if (existsSync3(script))
998
1010
  rmSync(script);
1011
+ return;
999
1012
  }
1000
1013
  function removeStaleUnits(scopeId, expectedSlugs) {
1001
1014
  const prefix = `opencode-sched-${scopeId}-`;
@@ -1072,7 +1085,7 @@ ${errors.join(`
1072
1085
  lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
1073
1086
  lines.push(...describeJobSchedules(jobs, scopeId));
1074
1087
  if (failures.length > 0)
1075
- lines.push(`Timer activation failures:
1088
+ throw new Error(`Timer activation failures:
1076
1089
  ${failures.join(`
1077
1090
  `)}`);
1078
1091
  return lines.join(`
@@ -1093,9 +1106,19 @@ function disableProject(workdir) {
1093
1106
  const entry = registryEntry(abs);
1094
1107
  if (entry === undefined)
1095
1108
  return `Project is not enabled: ${abs}`;
1096
- for (const slug of entry.jobs)
1097
- removeJobUnits(entry.scopeId, slug);
1098
- systemctl(["daemon-reload"]);
1109
+ const failures = entry.jobs.flatMap((slug) => {
1110
+ const failure = removeJobUnits(entry.scopeId, slug);
1111
+ return failure === undefined ? [] : [failure];
1112
+ });
1113
+ if (failures.length > 0) {
1114
+ throw new Error(`Timer removal failures:
1115
+ ${failures.join(`
1116
+ `)}`);
1117
+ }
1118
+ const reload = systemctl(["daemon-reload"]);
1119
+ if (!reload.ok) {
1120
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
1121
+ }
1099
1122
  const registry = loadRegistry();
1100
1123
  const { [abs]: _omitted, ...remainingProjects } = registry.projects;
1101
1124
  registry.projects = remainingProjects;
@@ -1108,13 +1131,10 @@ function disableProject(workdir) {
1108
1131
  `);
1109
1132
  }
1110
1133
 
1111
- // src/tools.ts
1112
- function ok(output) {
1113
- return { output };
1114
- }
1115
- function fail(message) {
1116
- return { output: `Error: ${message}`, metadata: { error: true } };
1117
- }
1134
+ // src/management.ts
1135
+ import { spawn } from "child_process";
1136
+ import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync } from "fs";
1137
+ import path6 from "path";
1118
1138
  function tryParseCron(schedule) {
1119
1139
  try {
1120
1140
  return parseCron(schedule);
@@ -1122,7 +1142,7 @@ function tryParseCron(schedule) {
1122
1142
  return;
1123
1143
  }
1124
1144
  }
1125
- function listJobsOutput(directory) {
1145
+ function listJobs(directory) {
1126
1146
  const { jobs, errors } = loadJobs(directory);
1127
1147
  const entry = registryEntry(directory);
1128
1148
  const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
@@ -1144,8 +1164,63 @@ function listJobsOutput(directory) {
1144
1164
  lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
1145
1165
  }
1146
1166
  lines.push(...errors.map((error) => `! ${error}`));
1147
- return ok(lines.join(`
1148
- `));
1167
+ return { ok: true, output: lines.join(`
1168
+ `) };
1169
+ }
1170
+ function runJobNow(slugInput, directory) {
1171
+ const slug = slugify(slugInput);
1172
+ const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1173
+ if (!existsSync4(file)) {
1174
+ return {
1175
+ ok: false,
1176
+ output: `No job "${slug}" in ${jobsDirectory(directory)}`
1177
+ };
1178
+ }
1179
+ const entry = registryEntry(directory);
1180
+ if (entry === undefined) {
1181
+ return {
1182
+ ok: false,
1183
+ output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
1184
+ };
1185
+ }
1186
+ const script = runScriptPath(entry.scopeId, slug);
1187
+ if (!existsSync4(script)) {
1188
+ return {
1189
+ ok: false,
1190
+ output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
1191
+ };
1192
+ }
1193
+ const log = logFile(entry.scopeId, slug);
1194
+ mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
1195
+ const fd = openSync(log, "a");
1196
+ const child = spawn("/bin/sh", [script], {
1197
+ cwd: path6.resolve(directory),
1198
+ env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
1199
+ stdio: ["ignore", fd, fd]
1200
+ });
1201
+ child.unref();
1202
+ closeSync(fd);
1203
+ const tail = tailFile(log, 5, 2000);
1204
+ const parts = [
1205
+ `Started "${slug}" manually (pid ${String(child.pid)})`,
1206
+ `Log: ${log}`
1207
+ ];
1208
+ if (tail?.length)
1209
+ parts.push(`Log tail:
1210
+ ${tail}`);
1211
+ return { ok: true, output: parts.join(`
1212
+ `) };
1213
+ }
1214
+
1215
+ // src/tools.ts
1216
+ function ok(output) {
1217
+ return { output };
1218
+ }
1219
+ function fail(message) {
1220
+ return { output: `Error: ${message}`, metadata: { error: true } };
1221
+ }
1222
+ function managementToolResult(result) {
1223
+ return result.ok ? ok(result.output) : fail(result.output);
1149
1224
  }
1150
1225
  function scheduleJobOutput(input, directory) {
1151
1226
  const slug = slugify(input.slug ?? input.name);
@@ -1189,7 +1264,7 @@ function scheduleJobOutput(input, directory) {
1189
1264
  } catch (error) {
1190
1265
  return fail(errorMessage(error));
1191
1266
  }
1192
- const existing = loadJobFile(path6.join(jobsDirectory(directory), `${slug}.json`), slug);
1267
+ const existing = loadJobFile(path7.join(jobsDirectory(directory), `${slug}.json`), slug);
1193
1268
  const job = {
1194
1269
  slug,
1195
1270
  name: input.name,
@@ -1203,7 +1278,7 @@ function scheduleJobOutput(input, directory) {
1203
1278
  updatedAt: nowIso()
1204
1279
  };
1205
1280
  saveJob(directory, job);
1206
- const relativePath = `.opencode/scheduler/jobs/${slug}.json`;
1281
+ const relativePath = `.opencode/jobs/${slug}.json`;
1207
1282
  const lines = [
1208
1283
  `${existing.ok ? "Updated" : "Created"} job "${job.name}" (${slug})`,
1209
1284
  `Definition: ${relativePath} (${job.schedule} \u2014 ${describeCron(sets)})`
@@ -1218,7 +1293,7 @@ function scheduleJobOutput(input, directory) {
1218
1293
  return ok(lines.join(`
1219
1294
  `));
1220
1295
  }
1221
- const abs = path6.resolve(directory);
1296
+ const abs = path7.resolve(directory);
1222
1297
  const opencodeBin = findOpencode();
1223
1298
  const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1224
1299
  const base = writeJobUnits(job, abs, entry.scopeId, opencodeBin, pathEnvironment);
@@ -1244,8 +1319,8 @@ function scheduleJobOutput(input, directory) {
1244
1319
  `));
1245
1320
  }
1246
1321
  function showJobOutput(slugInput, directory) {
1247
- const file = path6.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
1248
- if (!existsSync4(file)) {
1322
+ const file = path7.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
1323
+ if (!existsSync5(file)) {
1249
1324
  return fail(`No job "${slugInput}" in ${jobsDirectory(directory)}. Use list_jobs to see definitions.`);
1250
1325
  }
1251
1326
  const result = loadJobFile(file);
@@ -1259,7 +1334,7 @@ function showJobOutput(slugInput, directory) {
1259
1334
  const lines = [
1260
1335
  `${job.name} (${job.slug})`,
1261
1336
  `Schedule: ${job.schedule} \u2014 ${describeCron(sets)}`,
1262
- `Definition: .opencode/scheduler/jobs/${job.slug}.json (updated ${job.updatedAt})`,
1337
+ `Definition: .opencode/jobs/${job.slug}.json (updated ${job.updatedAt})`,
1263
1338
  `Run: ${runDesc}`
1264
1339
  ];
1265
1340
  if (job.guard !== undefined)
@@ -1270,7 +1345,7 @@ function showJobOutput(slugInput, directory) {
1270
1345
  }
1271
1346
  if (job.session !== undefined) {
1272
1347
  const state = sessionStateFile(scopeId, job.slug);
1273
- const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
1348
+ const sessionId = existsSync5(state) ? readFileSync4(state, "utf8").trim() : "";
1274
1349
  lines.push(`Session: ${job.session}${sessionId.length > 0 ? ` \u2014 current ${sessionId}` : " \u2014 no session yet"}`);
1275
1350
  }
1276
1351
  if (job.run.agent !== undefined)
@@ -1305,24 +1380,30 @@ function showJobOutput(slugInput, directory) {
1305
1380
  }
1306
1381
  function removeJobDefinitionOutput(slugInput, directory) {
1307
1382
  const slug = slugify(slugInput);
1308
- const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1309
- if (!existsSync4(file))
1383
+ const file = path7.join(jobsDirectory(directory), `${slug}.json`);
1384
+ if (!existsSync5(file))
1310
1385
  return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1386
+ const abs = path7.resolve(directory);
1387
+ const entry = registryEntry(directory);
1388
+ if (entry?.jobs.includes(slug)) {
1389
+ const removalFailure = removeJobUnits(entry.scopeId, slug);
1390
+ if (removalFailure !== undefined) {
1391
+ return fail(`Failed to remove systemd units: ${removalFailure}`);
1392
+ }
1393
+ const reload = systemctl(["daemon-reload"]);
1394
+ if (!reload.ok) {
1395
+ return fail(`Removed the units but systemd reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
1396
+ }
1397
+ }
1311
1398
  rmSync2(file);
1312
- const lines = [
1313
- `Deleted job definition .opencode/scheduler/jobs/${slug}.json`
1314
- ];
1315
- const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
1399
+ const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
1400
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1316
1401
  const state = sessionStateFile(scopeId, slug);
1317
- if (existsSync4(state)) {
1402
+ if (existsSync5(state)) {
1318
1403
  rmSync2(state);
1319
1404
  lines.push(`Removed session state ${state}`);
1320
1405
  }
1321
- const abs = path6.resolve(directory);
1322
- const entry = registryEntry(directory);
1323
1406
  if (entry?.jobs.includes(slug)) {
1324
- removeJobUnits(entry.scopeId, slug);
1325
- systemctl(["daemon-reload"]);
1326
1407
  const registry = loadRegistry();
1327
1408
  const current = registry.projects[abs];
1328
1409
  if (current !== undefined) {
@@ -1341,40 +1422,6 @@ function omitProject(registry, workdir) {
1341
1422
  const { [workdir]: _omitted, ...remaining } = registry.projects;
1342
1423
  registry.projects = remaining;
1343
1424
  }
1344
- function runJobNowOutput(slugInput, directory) {
1345
- const slug = slugify(slugInput);
1346
- const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1347
- if (!existsSync4(file))
1348
- return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1349
- const entry = registryEntry(directory);
1350
- if (entry === undefined) {
1351
- return fail(`Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`);
1352
- }
1353
- const script = runScriptPath(entry.scopeId, slug);
1354
- if (!existsSync4(script)) {
1355
- return fail(`Run script missing for "${slug}". Run enable_project to (re)install units.`);
1356
- }
1357
- const log = logFile(entry.scopeId, slug);
1358
- mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
1359
- const fd = openSync(log, "a");
1360
- const child = spawn("/bin/sh", [script], {
1361
- cwd: path6.resolve(directory),
1362
- env: { ...process.env, OPENCODE_SCHEDULER_STARTED_BY: "manual" },
1363
- stdio: ["ignore", fd, fd]
1364
- });
1365
- child.unref();
1366
- closeSync(fd);
1367
- const tail = tailFile(log, 5, 2000);
1368
- const parts = [
1369
- `Started "${slug}" manually (pid ${String(child.pid)})`,
1370
- `Log: ${log}`
1371
- ];
1372
- if (tail?.length)
1373
- parts.push(`Log tail:
1374
- ${tail}`);
1375
- return ok(parts.join(`
1376
- `));
1377
- }
1378
1425
  function jobLogsOutput(slugInput, lineCountInput, directory) {
1379
1426
  const entry = registryEntry(directory);
1380
1427
  const scopeId = entry?.scopeId ?? deriveScopeId(directory);
@@ -1393,21 +1440,21 @@ function listProjectsOutput() {
1393
1440
  const entries = Object.values(registry.projects).toSorted((a, b) => a.workdir.localeCompare(b.workdir));
1394
1441
  if (entries.length === 0)
1395
1442
  return ok("No projects with scheduled jobs are registered.");
1396
- const lines = ["Registry: ~/.config/opencode/scheduler/registry.json"];
1443
+ const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
1397
1444
  for (const entry of entries) {
1398
- const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
1445
+ const missing = existsSync5(entry.workdir) ? "" : " [WORKDIR MISSING]";
1399
1446
  lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
1400
1447
  }
1401
1448
  return ok(lines.join(`
1402
1449
  `));
1403
1450
  }
1404
1451
  var listJobsTool = tool({
1405
- description: "List scheduled job definitions for the current project (from .opencode/scheduler/jobs/), including enabled state, next run, and last run status.",
1452
+ description: "List scheduled job definitions for the current project (from .opencode/jobs/), including enabled state, next run, and last run status.",
1406
1453
  args: {},
1407
- execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
1454
+ execute: (_input, context) => Promise.resolve(managementToolResult(listJobs(context.directory)))
1408
1455
  });
1409
1456
  var scheduleJobTool = tool({
1410
- 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.",
1457
+ 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.",
1411
1458
  args: {
1412
1459
  name: tool.schema.string().describe("Human-readable job name"),
1413
1460
  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)'),
@@ -1417,7 +1464,7 @@ var scheduleJobTool = tool({
1417
1464
  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)`),
1418
1465
  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'),
1419
1466
  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"),
1420
- worktreeBase: tool.schema.string().optional().describe("Parent directory for the worktree (default: ~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>). Relative paths resolve against the project directory; it should be dedicated to scheduler worktrees"),
1467
+ 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"),
1421
1468
  worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
1422
1469
  worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
1423
1470
  agent: tool.schema.string().optional().describe("Agent to use for the run"),
@@ -1446,7 +1493,7 @@ var runJobTool = tool({
1446
1493
  args: {
1447
1494
  slug: tool.schema.string().describe("Job slug to run now")
1448
1495
  },
1449
- execute: (input, context) => Promise.resolve(runJobNowOutput(input.slug, context.directory))
1496
+ execute: (input, context) => Promise.resolve(managementToolResult(runJobNow(input.slug, context.directory)))
1450
1497
  });
1451
1498
  var jobLogsTool = tool({
1452
1499
  description: "Show the tail of a scheduled job's log file (scheduled and manual runs both append to it).",
@@ -1457,7 +1504,7 @@ var jobLogsTool = tool({
1457
1504
  execute: (input, context) => Promise.resolve(jobLogsOutput(input.slug, input.lines, context.directory))
1458
1505
  });
1459
1506
  var enableProjectTool = tool({
1460
- 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.",
1507
+ 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.",
1461
1508
  args: {},
1462
1509
  execute: (_input, context) => Promise.resolve(enableProjectOutput(context.directory))
1463
1510
  });
@@ -1481,11 +1528,11 @@ function disableProjectOutput(directory) {
1481
1528
  }
1482
1529
  }
1483
1530
  var listProjectsTool = tool({
1484
- description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/scheduler/registry.json).",
1531
+ description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/jobs/registry.json).",
1485
1532
  args: {},
1486
1533
  execute: () => Promise.resolve(listProjectsOutput())
1487
1534
  });
1488
- var schedulerTools = {
1535
+ var jobsTools = {
1489
1536
  schedule_job: scheduleJobTool,
1490
1537
  list_jobs: listJobsTool,
1491
1538
  get_job: showJobTool,
@@ -1497,12 +1544,102 @@ var schedulerTools = {
1497
1544
  list_projects: listProjectsTool
1498
1545
  };
1499
1546
 
1547
+ // src/migration.ts
1548
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
1549
+ import path8 from "path";
1550
+ function legacyJobsStateDirectory() {
1551
+ return path8.join(configRoot(), "opencode", "scheduler");
1552
+ }
1553
+ function legacyRegistryPath() {
1554
+ return path8.join(legacyJobsStateDirectory(), "registry.json");
1555
+ }
1556
+ function legacyDefinitionsDirectory(workdir) {
1557
+ return path8.join(workdir, ".opencode", "scheduler", "jobs");
1558
+ }
1559
+ function migrationMoves(projects) {
1560
+ return [
1561
+ {
1562
+ from: legacyJobsStateDirectory(),
1563
+ to: jobsStateDirectory()
1564
+ },
1565
+ {
1566
+ from: path8.join(configRoot(), "opencode", "logs", "scheduler"),
1567
+ to: path8.join(configRoot(), "opencode", "logs", "jobs")
1568
+ },
1569
+ {
1570
+ from: path8.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1571
+ to: path8.join(stateRoot(), "opencode", "jobs", "worktrees")
1572
+ },
1573
+ ...[...projects].map((workdir) => ({
1574
+ from: legacyDefinitionsDirectory(workdir),
1575
+ to: jobsDirectory(workdir)
1576
+ }))
1577
+ ];
1578
+ }
1579
+ function removeLegacyProjectDirectory(workdir) {
1580
+ try {
1581
+ rmdirSync(path8.join(workdir, ".opencode", "scheduler"));
1582
+ } catch {}
1583
+ }
1584
+ function migrateStorage(projectDirectory, shouldResync) {
1585
+ const project = path8.resolve(projectDirectory);
1586
+ const legacyRegistry = legacyRegistryPath();
1587
+ const canonicalRegistry = path8.join(jobsStateDirectory(), "registry.json");
1588
+ const registryFile = existsSync6(legacyRegistry) ? legacyRegistry : canonicalRegistry;
1589
+ const registry = existsSync6(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
1590
+ const registeredProjects = new Set(Object.keys(registry.projects));
1591
+ const projects = new Set([project, ...registeredProjects]);
1592
+ const moves = migrationMoves(projects).filter(({ from }) => existsSync6(from));
1593
+ for (const { from, to } of moves) {
1594
+ if (existsSync6(to)) {
1595
+ 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.`);
1596
+ }
1597
+ }
1598
+ for (const { from, to } of moves) {
1599
+ mkdirSync4(path8.dirname(to), { recursive: true });
1600
+ renameSync2(from, to);
1601
+ }
1602
+ for (const workdir of projects)
1603
+ removeLegacyProjectDirectory(workdir);
1604
+ const result = {
1605
+ moved: moves,
1606
+ resyncedProjects: [],
1607
+ warnings: []
1608
+ };
1609
+ if (!shouldResync || moves.length === 0)
1610
+ return result;
1611
+ for (const workdir of registeredProjects) {
1612
+ try {
1613
+ enableProject(workdir);
1614
+ result.resyncedProjects.push(workdir);
1615
+ } catch (error) {
1616
+ const message = error instanceof Error ? error.message : String(error);
1617
+ result.warnings.push(`${workdir}: ${message}`);
1618
+ }
1619
+ }
1620
+ return result;
1621
+ }
1622
+
1500
1623
  // src/index.ts
1501
- var src_default = () => {
1624
+ var src_default = (input) => {
1625
+ if (typeof input.directory === "string") {
1626
+ try {
1627
+ const migration = migrateStorage(input.directory, true);
1628
+ if (migration.moved.length > 0) {
1629
+ console.error(`[opencode-jobs] migrated ${String(migration.moved.length)} legacy storage location(s) to jobs paths`);
1630
+ }
1631
+ for (const warning of migration.warnings) {
1632
+ console.error(`[opencode-jobs] migration warning: ${warning}`);
1633
+ }
1634
+ } catch (error) {
1635
+ const message = error instanceof Error ? error.message : String(error);
1636
+ console.error(`[opencode-jobs] storage migration failed: ${message}`);
1637
+ }
1638
+ }
1502
1639
  if (process.platform !== "linux") {
1503
1640
  console.error("[opencode-jobs] warning: this is not a Linux host, systemd user timers are unavailable \u2014 scheduling tools will not work here");
1504
1641
  }
1505
- return Promise.resolve({ tool: schedulerTools });
1642
+ return Promise.resolve({ tool: jobsTools });
1506
1643
  };
1507
1644
  export {
1508
1645
  src_default as default