opencode-jobs 0.1.1 → 0.2.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/README.md CHANGED
@@ -68,7 +68,7 @@ exit. Job definitions, run history, session state, and logs are kept.
68
68
 
69
69
  Add `--purge` to also delete the project's job definitions
70
70
  (`.opencode/scheduler/`) and its scheduler data (run scripts, run history,
71
- session state, and logs):
71
+ session state, run locks, worktrees, and logs):
72
72
 
73
73
  ```sh
74
74
  opencode-jobs uninstall --purge
@@ -87,17 +87,17 @@ can also be used from setup scripts and CI.
87
87
 
88
88
  ## Tools
89
89
 
90
- | Tool | What it does |
91
- | ----------------- | ---------------------------------------------------------------------------------------------- |
92
- | `schedule_job` | Create or update a job (cron schedule, prompt or custom command, session mode, guard, timeout) |
93
- | `list_jobs` | List job definitions in the project with next/last run status |
94
- | `get_job` | Show one job: definition, timer state, last runs, recent log tail |
95
- | `run_job` | Fire a job now, through the exact script the timer would run |
96
- | `job_logs` | Tail a job's log (scheduled and manual runs both append) |
97
- | `delete_job` | Delete a job, its units, run script, and session state |
98
- | `enable_project` | Install systemd user units for all jobs in the project and register it |
99
- | `disable_project` | Stop and remove the project's units (jobs and history are kept) |
100
- | `list_projects` | List all projects that have enabled jobs |
90
+ | Tool | What it does |
91
+ | ----------------- | -------------------------------------------------------------------------------------------------------- |
92
+ | `schedule_job` | Create or update a job (cron schedule, prompt or custom command, session mode, guard, worktree, timeout) |
93
+ | `list_jobs` | List job definitions in the project with next/last run status |
94
+ | `get_job` | Show one job: definition, timer state, last runs, recent log tail |
95
+ | `run_job` | Fire a job now, through the exact script the timer would run |
96
+ | `job_logs` | Tail a job's log (scheduled and manual runs both append) |
97
+ | `delete_job` | Delete a job, its units, run script, and session state |
98
+ | `enable_project` | Install systemd user units for all jobs in the project and register it |
99
+ | `disable_project` | Stop and remove the project's units (jobs and history are kept) |
100
+ | `list_projects` | List all projects that have enabled jobs |
101
101
 
102
102
  ## Job definitions
103
103
 
@@ -114,6 +114,7 @@ them in PRs like any other code:
114
114
  },
115
115
  "session": "compact+last",
116
116
  "guard": "! git diff --quiet",
117
+ "worktree": true,
117
118
  "timeoutSeconds": 1800
118
119
  }
119
120
  ```
@@ -127,6 +128,8 @@ them in PRs like any other code:
127
128
  - `guard` — shell command run first; a non-zero exit skips the run (recorded
128
129
  as `skipped`). Example: `"! git diff --quiet"` runs only when the repo has
129
130
  changes.
131
+ - `worktree` — run in a fresh git worktree instead of the project checkout
132
+ (below).
130
133
  - `timeoutSeconds` — hard limit; systemd stops the run with SIGTERM.
131
134
 
132
135
  ### Session modes
@@ -141,6 +144,48 @@ them in PRs like any other code:
141
144
  Tracked modes (`persist`/`compact`/`compact+last`) self-heal a deleted or
142
145
  stale session by retrying once with a fresh session.
143
146
 
147
+ ### Worktree jobs
148
+
149
+ Set `"worktree": true` (or an object) to run a job in a fresh git worktree
150
+ instead of the project checkout, so scheduled runs never race your editor or
151
+ leave the main tree dirty:
152
+
153
+ ```json
154
+ "worktree": {
155
+ "base": "/srv/worktrees",
156
+ "ref": "origin/main",
157
+ "commitMessage": "nightly sweep: automated fixes"
158
+ }
159
+ ```
160
+
161
+ Each run:
162
+
163
+ 1. takes a per-job lock (so an overlapping timer and `run_job` invocation
164
+ cannot fight over the worktree — the later run is recorded as
165
+ `skipped`), then creates a worktree at `<base>/<slug>` on a new branch
166
+ `opencode-jobs/<slug>/<timestamp>-<pid>`, starting from `ref`
167
+ (default: `HEAD` of the project checkout). A leftover worktree from a
168
+ crashed or timed-out run is committed first (recovery commit on its own
169
+ branch) and then removed — unless the recovery commit fails (for
170
+ example a `pre-commit` hook rejects the changes), in which case the
171
+ stale worktree is kept and the run fails instead of discarding work;
172
+ 2. runs the job with the worktree as the working directory — if the
173
+ project is a subdirectory of a larger repository, the job runs in the
174
+ matching subdirectory of the worktree;
175
+ 3. commits everything (`git add -A`) as `opencode-jobs` with
176
+ `--no-gpg-sign`, then removes the worktree — the branch and its commits
177
+ stay in the repository, and the run record stores `worktreeBranch` and
178
+ `worktreeCommit`.
179
+
180
+ If the safety commit fails, the worktree is kept on disk rather than
181
+ discarded. The default base is
182
+ `~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>` (respecting
183
+ `XDG_STATE_HOME`); override with `base` (relative paths resolve against the
184
+ project directory, and the base should be dedicated to scheduler worktrees).
185
+ Worktree jobs require `git` and a git repository — a missing repo fails the
186
+ run with a clear record. Branches accumulate per run by design; merge or
187
+ delete them when you no longer need the work.
188
+
144
189
  ## How it works
145
190
 
146
191
  - `enable_project` generates, for each job: a frozen POSIX `run-<slug>.sh`
@@ -149,6 +194,9 @@ stale session by retrying once with a fresh session.
149
194
  - Run scripts work standalone: they append a JSONL record (status, exit
150
195
  code, duration, session id) per run, capture `--format json` output for
151
196
  tracked session modes, and never leave temp files behind.
197
+ - Worktree jobs create a fresh worktree per run, commit all changes to a
198
+ per-run branch, and remove the worktree afterwards — see
199
+ [Worktree jobs](#worktree-jobs).
152
200
  - A global registry tracks enabled projects so `list_projects` and
153
201
  `disable_project` work from any session.
154
202
  - Schedules are cron expressions compiled to systemd `OnCalendar` and
@@ -156,15 +204,17 @@ stale session by retrying once with a fresh session.
156
204
 
157
205
  ## Storage
158
206
 
159
- | What | Where |
160
- | ------------------- | ---------------------------------------------------------------------- |
161
- | Job definitions | `<project>/.opencode/scheduler/jobs/<slug>.json` (git-committed) |
162
- | Run scripts | `~/.config/opencode/scheduler/scopes/<scopeId>/run-<slug>.sh` |
163
- | Run history (JSONL) | `~/.config/opencode/scheduler/runs/<scopeId>/<slug>.jsonl` |
164
- | Session state | `~/.config/opencode/scheduler/sessions/<scopeId>/<slug>.txt` |
165
- | Job logs | `~/.config/opencode/logs/scheduler/<scopeId>/<slug>.log` |
166
- | Project registry | `~/.config/opencode/scheduler/registry.json` |
167
- | systemd units | `~/.config/systemd/user/opencode-sched-<scope>-<slug>.{service,timer}` |
207
+ | What | Where |
208
+ | ------------------- | --------------------------------------------------------------------------------------- |
209
+ | Job definitions | `<project>/.opencode/scheduler/jobs/<slug>.json` (git-committed) |
210
+ | Run scripts | `~/.config/opencode/scheduler/scopes/<scopeId>/run-<slug>.sh` |
211
+ | Run history (JSONL) | `~/.config/opencode/scheduler/runs/<scopeId>/<slug>.jsonl` |
212
+ | Session state | `~/.config/opencode/scheduler/sessions/<scopeId>/<slug>.txt` |
213
+ | Run locks | `~/.config/opencode/scheduler/locks/<scopeId>/<slug>.lock` (worktree jobs) |
214
+ | Job worktrees | `~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>` (removed after each run) |
215
+ | Job logs | `~/.config/opencode/logs/scheduler/<scopeId>/<slug>.log` |
216
+ | Project registry | `~/.config/opencode/scheduler/registry.json` |
217
+ | systemd units | `~/.config/systemd/user/opencode-sched-<scope>-<slug>.{service,timer}` |
168
218
 
169
219
  `scopeId` is a stable hash of the project path, so multiple projects can
170
220
  define jobs without colliding. Because it is path-derived, moving or renaming
@@ -182,6 +232,7 @@ up `~/.config/opencode/scheduler/` (and the unit files) manually afterwards.
182
232
 
183
233
  - Linux with a systemd user session (jobs run via `systemctl --user`)
184
234
  - `curl` (used by `compact`/`compact+last` modes only)
235
+ - `git` and `flock` (from util-linux; used by `worktree` jobs only)
185
236
  - POSIX `sh` (generated scripts are `sh`/`dash`-verified)
186
237
  - The `opencode` CLI available to the timer environment
187
238
 
@@ -193,7 +244,8 @@ Not supported on macOS or Windows.
193
244
  npm install
194
245
  npm run check # prettier + eslint + tsc
195
246
  npm run build # Bun plugin bundle + Node CLI bundle + declarations
196
- npm run smoke # build, scheduler behavior, CLI install, and package contents
247
+ npm test # build + Node integration tests
248
+ npm run smoke # full behavioral gate, including generated scripts and CLI install
197
249
  ```
198
250
 
199
251
  Node >= 20 is required for the CLI and dev toolchain; the plugin runs on Bun
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  // src/install.ts
8
8
  import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
9
+ import { spawnSync as spawnSync2 } from "node:child_process";
9
10
  import path5 from "node:path";
10
11
  import { z as z3 } from "zod";
11
12
 
@@ -17,6 +18,15 @@ import { homedir } from "node:os";
17
18
  function configRoot() {
18
19
  return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
19
20
  }
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", "scheduler", "worktrees", scopeId);
26
+ }
27
+ function locksDirectory(scopeId) {
28
+ return path.join(schedulerDirectory(), "locks", scopeId);
29
+ }
20
30
  function schedulerDirectory() {
21
31
  return path.join(configRoot(), "opencode", "scheduler");
22
32
  }
@@ -210,6 +220,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
210
220
  error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
211
221
  });
212
222
  var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
223
+ var worktreeSchema = z.union([
224
+ z.literal(true),
225
+ z.strictObject({
226
+ base: nonEmptyStringSchema.optional(),
227
+ ref: nonEmptyStringSchema.optional(),
228
+ commitMessage: nonEmptyStringSchema.optional()
229
+ })
230
+ ]).transform((value) => {
231
+ if (value === true)
232
+ return {};
233
+ return {
234
+ ...value.base !== undefined && { base: value.base },
235
+ ...value.ref !== undefined && { ref: value.ref },
236
+ ...value.commitMessage !== undefined && {
237
+ commitMessage: value.commitMessage
238
+ }
239
+ };
240
+ });
213
241
  var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
214
242
  var cronSchema = z.string().superRefine((schedule, context) => {
215
243
  try {
@@ -225,6 +253,7 @@ var jobFileSchema = z.strictObject({
225
253
  run: runSpecSchema,
226
254
  session: sessionSchema.default("new"),
227
255
  guard: guardSchema.optional(),
256
+ worktree: worktreeSchema.optional(),
228
257
  timeoutSeconds: timeoutSchema.optional(),
229
258
  createdAt: z.string().optional(),
230
259
  updatedAt: z.string().optional()
@@ -457,6 +486,8 @@ function purgeProjectData(projectDirectory) {
457
486
  runsDirectory(scopeId),
458
487
  sessionStateDirectory(scopeId),
459
488
  logDirectory(scopeId),
489
+ locksDirectory(scopeId),
490
+ worktreesDirectory(scopeId),
460
491
  path5.join(abs, ".opencode", "scheduler")
461
492
  ];
462
493
  const paths = [];
@@ -466,6 +497,9 @@ function purgeProjectData(projectDirectory) {
466
497
  rmSync2(target, { recursive: true, force: true });
467
498
  paths.push(target);
468
499
  }
500
+ if (paths.includes(worktreesDirectory(scopeId))) {
501
+ spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
502
+ }
469
503
  removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
470
504
  return { paths };
471
505
  }
package/dist/index.js CHANGED
@@ -145,6 +145,12 @@ import { homedir } from "os";
145
145
  function configRoot() {
146
146
  return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
147
147
  }
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", "scheduler", "worktrees", scopeId);
153
+ }
148
154
  function schedulerDirectory() {
149
155
  return path.join(configRoot(), "opencode", "scheduler");
150
156
  }
@@ -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
  },
@@ -454,7 +487,9 @@ var runRecordSchema = z3.object({
454
487
  status: optionalString,
455
488
  exitCode: optionalNumber,
456
489
  sessionId: optionalString,
457
- startedBy: optionalString
490
+ startedBy: optionalString,
491
+ worktreeBranch: optionalString,
492
+ worktreeCommit: optionalString
458
493
  });
459
494
  function readRunRecords(scopeId, slug, limit) {
460
495
  const file = runsFile(scopeId, slug);
@@ -492,7 +527,8 @@ function formatRunLine(record) {
492
527
  const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
493
528
  const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
494
529
  const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
495
- return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session} via ${record.startedBy ?? "?"}`;
530
+ const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
531
+ return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
496
532
  }
497
533
  function tailFile(file, lines, maxChars) {
498
534
  if (!existsSync2(file))
@@ -544,6 +580,101 @@ function guardScriptLines(guard) {
544
580
  "fi"
545
581
  ];
546
582
  }
583
+ function worktreeDefaultRoot(scopeId) {
584
+ return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/scheduler/worktrees/" + scopeId;
585
+ }
586
+ function worktreePrologueLines(job, scopeId) {
587
+ const base = job.worktree?.base;
588
+ return [
589
+ "wt_enabled=1",
590
+ 'orig_pwd="$(pwd)"',
591
+ 'lock_dir="$config_root/opencode/scheduler/locks/$scope"',
592
+ 'mkdir -p "$lock_dir"',
593
+ 'exec 9>"$lock_dir/$slug.lock"',
594
+ "if ! flock -n 9; then",
595
+ ' echo "scheduler: another run of $slug is already active, skipping"',
596
+ " finish skipped 0",
597
+ " exit 0",
598
+ "fi",
599
+ base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
600
+ 'if ! mkdir -p "$wt_root"; then',
601
+ ' echo "scheduler: cannot create worktree base $wt_root"',
602
+ " finish failed 1",
603
+ " exit 1",
604
+ "fi",
605
+ 'wt_root="$(cd "$wt_root" && pwd)"',
606
+ 'wt_path="$wt_root/$slug"',
607
+ 'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
608
+ `wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
609
+ 'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
610
+ 'wt_sub="${wt_sub%/}"',
611
+ 'if [ -d "$wt_path" ]; then',
612
+ ' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
613
+ " wt_stale_saved=1",
614
+ ' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
615
+ ' 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',
617
+ " fi",
618
+ ' if [ "$wt_stale_saved" -eq 1 ]; then',
619
+ ' git worktree remove --force "$wt_path" >/dev/null 2>&1',
620
+ ' rm -rf "$wt_path"',
621
+ " else",
622
+ ' echo "scheduler: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
623
+ ' wt_branch=""',
624
+ " finish failed 1",
625
+ " exit 1",
626
+ " fi",
627
+ " else",
628
+ ' echo "scheduler: removing unexpected directory at $wt_path"',
629
+ ' rm -rf "$wt_path"',
630
+ " fi",
631
+ " git worktree prune >/dev/null 2>&1",
632
+ "fi",
633
+ '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)"',
635
+ ' wt_branch=""',
636
+ " finish failed 1",
637
+ " exit 1",
638
+ "fi",
639
+ '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"',
641
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
642
+ ' rm -rf "$wt_path"',
643
+ ' wt_branch=""',
644
+ " finish failed 1",
645
+ " exit 1",
646
+ "fi",
647
+ 'if [ -n "$wt_sub" ]; then',
648
+ ' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
649
+ "else",
650
+ ' cd "$wt_path" || { finish failed 1; exit 1; }',
651
+ "fi"
652
+ ];
653
+ }
654
+ function worktreeEpilogueLines(options) {
655
+ const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
656
+ return [
657
+ 'if [ "$wt_enabled" -eq 1 ]; then',
658
+ ` wt_msg=${message}`,
659
+ ' git -C "$wt_path" add -A >/dev/null 2>&1',
660
+ " wt_keep=0",
661
+ ' 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"',
664
+ " else",
665
+ ' echo "scheduler: worktree commit failed, keeping worktree at $wt_path"',
666
+ " wt_keep=1",
667
+ " fi",
668
+ " fi",
669
+ ' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
670
+ ' if [ "$wt_keep" -eq 0 ]; then',
671
+ ' cd "$wt_root" 2>/dev/null',
672
+ ' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
673
+ ' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
674
+ " fi",
675
+ "fi"
676
+ ];
677
+ }
547
678
  var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
548
679
  var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
549
680
  var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
@@ -664,17 +795,20 @@ function runScriptContent(job, scopeId, opencodeBin) {
664
795
  'run_id="$(date +%s%N)-$$"',
665
796
  "started=$(date +%s)",
666
797
  'new_session=""',
798
+ 'wt_branch=""',
799
+ 'wt_commit=""',
667
800
  `export OPENCODE_PERMISSION='{"question":"deny"}'`,
668
801
  'export OPENCODE_SCHEDULER_RUN_ID="$run_id"',
669
802
  "finish() {",
670
803
  ' status="$1"',
671
804
  ' code="$2"',
672
805
  " 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"`,
806
+ 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
807
  "}",
675
808
  "trap 'finish timeout 124; exit 124' TERM INT",
676
809
  ...job.guard === undefined ? [] : guardScriptLines(job.guard),
677
810
  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"`,
811
+ ...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
678
812
  `oc_agent=${shQuote(job.run.agent ?? "")}`,
679
813
  `oc_model=${shQuote(job.run.model ?? "")}`,
680
814
  "prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
@@ -754,6 +888,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
754
888
  "fi"
755
889
  ] : ['run_opencode "" 0', "code=$?"],
756
890
  "trap - TERM INT",
891
+ ...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
757
892
  'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
758
893
  ...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
759
894
  "finish success 0",
@@ -1020,16 +1155,40 @@ function scheduleJobOutput(input, directory) {
1020
1155
  } catch (error) {
1021
1156
  return fail(errorMessage(error));
1022
1157
  }
1023
- const run = validateRunSpec({
1024
- prompt: input.prompt,
1025
- command: input.command,
1026
- arguments: input.arguments,
1027
- agent: input.agent,
1028
- model: input.model
1029
- }, "job");
1030
- const session = validateSession(input.session, "job");
1031
- const guard = validateGuard(input.guard, "job");
1032
- const timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
1158
+ const hasWorktreeOptions = input.worktreeBase !== undefined || input.worktreeRef !== undefined || input.worktreeCommitMessage !== undefined;
1159
+ if (hasWorktreeOptions && input.worktree !== true) {
1160
+ return fail("set worktree: true to enable worktree options (worktreeBase, worktreeRef, worktreeCommitMessage)");
1161
+ }
1162
+ let run;
1163
+ let session;
1164
+ let guard;
1165
+ let worktree;
1166
+ let timeoutSeconds;
1167
+ try {
1168
+ run = validateRunSpec({
1169
+ prompt: input.prompt,
1170
+ command: input.command,
1171
+ arguments: input.arguments,
1172
+ agent: input.agent,
1173
+ model: input.model
1174
+ }, "job");
1175
+ session = validateSession(input.session, "job");
1176
+ guard = validateGuard(input.guard, "job");
1177
+ worktree = validateWorktree(input.worktree === true ? {
1178
+ ...input.worktreeBase !== undefined && {
1179
+ base: input.worktreeBase
1180
+ },
1181
+ ...input.worktreeRef !== undefined && {
1182
+ ref: input.worktreeRef
1183
+ },
1184
+ ...input.worktreeCommitMessage !== undefined && {
1185
+ commitMessage: input.worktreeCommitMessage
1186
+ }
1187
+ } : undefined, "job");
1188
+ timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
1189
+ } catch (error) {
1190
+ return fail(errorMessage(error));
1191
+ }
1033
1192
  const existing = loadJobFile(path6.join(jobsDirectory(directory), `${slug}.json`), slug);
1034
1193
  const job = {
1035
1194
  slug,
@@ -1038,6 +1197,7 @@ function scheduleJobOutput(input, directory) {
1038
1197
  run,
1039
1198
  ...session !== "new" && { session },
1040
1199
  ...guard !== undefined && { guard },
1200
+ ...worktree !== undefined && { worktree },
1041
1201
  ...timeoutSeconds !== undefined && { timeoutSeconds },
1042
1202
  createdAt: existing.ok ? existing.job.createdAt : nowIso(),
1043
1203
  updatedAt: nowIso()
@@ -1050,6 +1210,8 @@ function scheduleJobOutput(input, directory) {
1050
1210
  ];
1051
1211
  if (session !== "new")
1052
1212
  lines.push(`Session: ${session}`);
1213
+ if (worktree !== undefined)
1214
+ lines.push(`Worktree: yes (base ${worktree.base ?? "default"}, branch opencode-jobs/${slug}/<run>)`);
1053
1215
  const entry = registryEntry(directory);
1054
1216
  if (entry === undefined) {
1055
1217
  lines.push("Project not enabled yet. Run enable_project to install the systemd timer.");
@@ -1102,6 +1264,10 @@ function showJobOutput(slugInput, directory) {
1102
1264
  ];
1103
1265
  if (job.guard !== undefined)
1104
1266
  lines.push(`Guard: ${job.guard} (must exit 0 for the run to start)`);
1267
+ if (job.worktree !== undefined) {
1268
+ const base = job.worktree.base ?? worktreesDirectory(scopeId);
1269
+ 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`);
1270
+ }
1105
1271
  if (job.session !== undefined) {
1106
1272
  const state = sessionStateFile(scopeId, job.slug);
1107
1273
  const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
@@ -1250,6 +1416,10 @@ var scheduleJobTool = tool({
1250
1416
  arguments: tool.schema.string().optional().describe("Arguments passed to the custom command"),
1251
1417
  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)`),
1252
1418
  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
+ 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"),
1421
+ worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
1422
+ worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
1253
1423
  agent: tool.schema.string().optional().describe("Agent to use for the run"),
1254
1424
  model: tool.schema.string().optional().describe("Model to use for the run"),
1255
1425
  timeoutSeconds: tool.schema.number().optional().describe("Hard timeout in seconds (0 or omitted disables). systemd stops the run with SIGTERM after this"),
@@ -1,6 +1,6 @@
1
1
  import { deriveScopeId, shQuote, slugify } from "./paths.js";
2
2
  import { cronToOnCalendar, describeCron, parseCron } from "./cron.js";
3
- import { validateGuard, validateRunSpec, validateSession } from "./job.js";
3
+ import { validateGuard, validateRunSpec, validateSession, validateWorktree } from "./job.js";
4
4
  import { buildOpencodeArguments, runScriptContent, serviceContent, timerContent } from "./systemd.js";
5
5
  export declare const internals: {
6
6
  slugify: typeof slugify;
@@ -12,6 +12,7 @@ export declare const internals: {
12
12
  validateRunSpec: typeof validateRunSpec;
13
13
  validateGuard: typeof validateGuard;
14
14
  validateSession: typeof validateSession;
15
+ validateWorktree: typeof validateWorktree;
15
16
  buildOpencodeArguments: typeof buildOpencodeArguments;
16
17
  runScriptContent: typeof runScriptContent;
17
18
  serviceContent: typeof serviceContent;
package/dist/job.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface CommandRun extends RunOptions {
12
12
  export type RunSpec = PromptRun | CommandRun;
13
13
  export declare const SESSION_MODES: readonly ["new", "persist", "compact", "compact+last"];
14
14
  export type SessionMode = (typeof SESSION_MODES)[number];
15
+ export interface WorktreeOptions {
16
+ base?: string;
17
+ ref?: string;
18
+ commitMessage?: string;
19
+ }
15
20
  export interface Job {
16
21
  slug: string;
17
22
  name: string;
@@ -19,6 +24,7 @@ export interface Job {
19
24
  run: RunSpec;
20
25
  session?: SessionMode;
21
26
  guard?: string;
27
+ worktree?: WorktreeOptions;
22
28
  timeoutSeconds?: number;
23
29
  createdAt: string;
24
30
  updatedAt: string;
@@ -34,6 +40,7 @@ export declare function validateRunSpec(run: unknown, context: string): RunSpec;
34
40
  export declare function validateSession(value: unknown, context: string): SessionMode;
35
41
  export declare function validateTimeout(value: unknown, context: string): number | undefined;
36
42
  export declare function validateGuard(value: unknown, context: string): string | undefined;
43
+ export declare function validateWorktree(value: unknown, context: string): WorktreeOptions | undefined;
37
44
  export declare function loadJobFile(file: string, expectedSlug?: string): JobResult;
38
45
  export declare function loadJobs(workdir: string): {
39
46
  jobs: Job[];
package/dist/paths.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export declare function configRoot(): string;
2
+ export declare function stateRoot(): string;
3
+ export declare function worktreesDirectory(scopeId: string): string;
4
+ export declare function locksDirectory(scopeId: string): string;
2
5
  export declare function schedulerDirectory(): string;
3
6
  export declare function registryPath(): string;
4
7
  export declare function scopeDirectory(scopeId: string): string;
package/dist/runs.d.ts CHANGED
@@ -10,6 +10,8 @@ declare const runRecordSchema: z.ZodObject<{
10
10
  exitCode: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
11
11
  sessionId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
12
12
  startedBy: z.ZodCatch<z.ZodOptional<z.ZodString>>;
13
+ worktreeBranch: z.ZodCatch<z.ZodOptional<z.ZodString>>;
14
+ worktreeCommit: z.ZodCatch<z.ZodOptional<z.ZodString>>;
13
15
  }, z.core.$strip>;
14
16
  export type RunRecord = z.infer<typeof runRecordSchema>;
15
17
  export declare function readRunRecords(scopeId: string, slug: string, limit: number): RunRecord[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-jobs",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "opencode plugin that schedules recurring agent jobs as systemd user timers, with git-committable job definitions, run history, and session continuity",
5
5
  "type": "module",
6
6
  "engines": {
@@ -29,9 +29,11 @@
29
29
  "prepublishOnly": "npm run clean && npm run build",
30
30
  "format": "prettier --write .",
31
31
  "lint": "eslint",
32
+ "test": "npm run build && npm run test:integration",
33
+ "test:integration": "node --test test/integration/*.test.mjs",
32
34
  "typecheck": "tsc --noEmit",
33
35
  "check": "prettier --check . && eslint && tsc --noEmit",
34
- "smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs"
36
+ "smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs && npm run test:integration"
35
37
  },
36
38
  "keywords": [
37
39
  "opencode",
@@ -34,6 +34,13 @@ scheduler state directly.
34
34
  `compact` for summary continuity, or `compact+last` when the previous final
35
35
  result must also remain visible.
36
36
  - A `guard` is a shell command that must exit zero for the job to run.
37
+ - Set `worktree: true` when the job should not touch the user's checkout:
38
+ each run gets a fresh git worktree (default base
39
+ `~/.local/state/opencode/scheduler/worktrees/…`, override with
40
+ `worktree.base`), all changes are committed to a per-run branch
41
+ `opencode-jobs/<slug>/…`, and the worktree is removed. Overlapping runs
42
+ of the same job are skipped via a lock; subdirectory projects run in
43
+ the matching worktree subdirectory. Requires a git repository.
37
44
  - Scheduled jobs require Linux, a systemd user session, and an `opencode`
38
45
  executable available to the timer environment.
39
46