opencode-jobs 1.0.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/README.md CHANGED
@@ -79,11 +79,16 @@ opencode-jobs uninstall --purge
79
79
  ```text
80
80
  opencode-jobs install [projectDir]
81
81
  opencode-jobs uninstall [projectDir] [--purge]
82
+ opencode-jobs list [projectDir]
83
+ opencode-jobs enable [projectDir]
84
+ opencode-jobs disable [projectDir]
85
+ opencode-jobs run <slug> [projectDir]
82
86
  ```
83
87
 
84
88
  The CLI is installed as the `opencode-jobs` npm executable. `projectDir`
85
- defaults to the current directory. Both commands print a JSON result so they
86
- can also be used from setup scripts and CI.
89
+ defaults to the current directory. The management commands mirror the plugin's
90
+ list, project enable/disable, and immediate-run operations. All commands print
91
+ JSON results so they can also be used from setup scripts and CI.
87
92
 
88
93
  ## Tools
89
94
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path8 from "node:path";
4
+ import path9 from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  // src/install.ts
@@ -39,6 +39,9 @@ function scopeDirectory(scopeId) {
39
39
  function runsDirectory(scopeId) {
40
40
  return path.join(jobsStateDirectory(), "runs", scopeId);
41
41
  }
42
+ function runsFile(scopeId, slug) {
43
+ return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
44
+ }
42
45
  function sessionStateDirectory(scopeId) {
43
46
  return path.join(jobsStateDirectory(), "sessions", scopeId);
44
47
  }
@@ -843,6 +846,16 @@ Hint: no systemd user session is reachable. Over SSH try enabling lingering: log
843
846
  }
844
847
  return "";
845
848
  }
849
+ function isTimerLoaded(base) {
850
+ const result = systemctl([
851
+ "show",
852
+ timerUnit(base),
853
+ "-p",
854
+ "LoadState",
855
+ "--value"
856
+ ]);
857
+ return result.ok && result.stdout !== "not-found";
858
+ }
846
859
  function timerStatus(base) {
847
860
  const result = systemctl([
848
861
  "show",
@@ -880,17 +893,25 @@ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
880
893
  }
881
894
  function removeJobUnits(scopeId, slug) {
882
895
  const base = unitBase(scopeId, slug);
883
- systemctl(["disable", "--now", timerUnit(base)]);
884
- for (const file of [
896
+ const files = [
885
897
  path4.join(systemdUserDirectory(), timerUnit(base)),
886
898
  path4.join(systemdUserDirectory(), serviceUnit(base))
887
- ]) {
899
+ ];
900
+ const script = runScriptPath(scopeId, slug);
901
+ if ([...files, script].every((file) => !existsSync2(file)) && !isTimerLoaded(base)) {
902
+ return;
903
+ }
904
+ const disable = systemctl(["disable", "--now", timerUnit(base)]);
905
+ if (!disable.ok) {
906
+ return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
907
+ }
908
+ for (const file of files) {
888
909
  if (existsSync2(file))
889
910
  rmSync(file);
890
911
  }
891
- const script = runScriptPath(scopeId, slug);
892
912
  if (existsSync2(script))
893
913
  rmSync(script);
914
+ return;
894
915
  }
895
916
  function removeStaleUnits(scopeId, expectedSlugs) {
896
917
  const prefix = `opencode-sched-${scopeId}-`;
@@ -966,7 +987,7 @@ ${errors.join(`
966
987
  lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
967
988
  lines.push(...describeJobSchedules(jobs, scopeId));
968
989
  if (failures.length > 0)
969
- lines.push(`Timer activation failures:
990
+ throw new Error(`Timer activation failures:
970
991
  ${failures.join(`
971
992
  `)}`);
972
993
  return lines.join(`
@@ -987,9 +1008,19 @@ function disableProject(workdir) {
987
1008
  const entry = registryEntry(abs);
988
1009
  if (entry === undefined)
989
1010
  return `Project is not enabled: ${abs}`;
990
- for (const slug of entry.jobs)
991
- removeJobUnits(entry.scopeId, slug);
992
- systemctl(["daemon-reload"]);
1011
+ const failures = entry.jobs.flatMap((slug) => {
1012
+ const failure = removeJobUnits(entry.scopeId, slug);
1013
+ return failure === undefined ? [] : [failure];
1014
+ });
1015
+ if (failures.length > 0) {
1016
+ throw new Error(`Timer removal failures:
1017
+ ${failures.join(`
1018
+ `)}`);
1019
+ }
1020
+ const reload = systemctl(["daemon-reload"]);
1021
+ if (!reload.ok) {
1022
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
1023
+ }
993
1024
  const registry = loadRegistry();
994
1025
  const { [abs]: _omitted, ...remainingProjects } = registry.projects;
995
1026
  registry.projects = remainingProjects;
@@ -1252,6 +1283,159 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
1252
1283
  return uninstall;
1253
1284
  }
1254
1285
 
1286
+ // src/management.ts
1287
+ import { spawn } from "node:child_process";
1288
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync4, openSync } from "node:fs";
1289
+ import path8 from "node:path";
1290
+
1291
+ // src/runs.ts
1292
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
1293
+ import { z as z4 } from "zod";
1294
+ var optionalString = z4.string().optional().catch(undefined);
1295
+ var optionalNumber = z4.number().optional().catch(undefined);
1296
+ var runRecordSchema = z4.object({
1297
+ runId: optionalString,
1298
+ slug: optionalString,
1299
+ scopeId: optionalString,
1300
+ startedAt: optionalNumber,
1301
+ finishedAt: optionalNumber,
1302
+ durationMs: optionalNumber,
1303
+ status: optionalString,
1304
+ exitCode: optionalNumber,
1305
+ sessionId: optionalString,
1306
+ startedBy: optionalString,
1307
+ worktreeBranch: optionalString,
1308
+ worktreeCommit: optionalString
1309
+ });
1310
+ function readRunRecords(scopeId, slug, limit) {
1311
+ const file = runsFile(scopeId, slug);
1312
+ if (!existsSync5(file))
1313
+ return [];
1314
+ const records = [];
1315
+ for (const line of readFileSync4(file, "utf8").split(`
1316
+ `)) {
1317
+ if (line.trim().length === 0)
1318
+ continue;
1319
+ try {
1320
+ const value = JSON.parse(line);
1321
+ const result = runRecordSchema.safeParse(value);
1322
+ if (result.success)
1323
+ records.push(result.data);
1324
+ } catch {}
1325
+ }
1326
+ return records.slice(-limit);
1327
+ }
1328
+ function lastFinishedRun(records) {
1329
+ for (const record of records.toReversed()) {
1330
+ if (record.status !== undefined && record.status !== "running")
1331
+ return record;
1332
+ }
1333
+ return;
1334
+ }
1335
+ function timestampOf(record) {
1336
+ if (record.finishedAt !== undefined)
1337
+ return new Date(record.finishedAt * 1000).toISOString();
1338
+ if (record.startedAt !== undefined)
1339
+ return new Date(record.startedAt * 1000).toISOString();
1340
+ return "?";
1341
+ }
1342
+ function formatRunLine(record) {
1343
+ const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
1344
+ const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
1345
+ const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
1346
+ const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
1347
+ return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
1348
+ }
1349
+ function tailFile(file, lines, maxChars) {
1350
+ if (!existsSync5(file))
1351
+ return;
1352
+ const content = readFileSync4(file, "utf8").trimEnd();
1353
+ if (content.length === 0)
1354
+ return "";
1355
+ const tail = content.split(`
1356
+ `).slice(-lines).join(`
1357
+ `);
1358
+ return tail.length > maxChars ? `...${tail.slice(-maxChars)}` : tail;
1359
+ }
1360
+
1361
+ // src/management.ts
1362
+ function tryParseCron(schedule) {
1363
+ try {
1364
+ return parseCron(schedule);
1365
+ } catch {
1366
+ return;
1367
+ }
1368
+ }
1369
+ function listJobs(directory) {
1370
+ const { jobs, errors } = loadJobs(directory);
1371
+ const entry = registryEntry(directory);
1372
+ const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
1373
+ const lines = [header];
1374
+ if (jobs.length === 0)
1375
+ lines.push("No job definitions. Create one with schedule_job.");
1376
+ for (const job of jobs) {
1377
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1378
+ const sets = tryParseCron(job.schedule);
1379
+ if (sets === undefined) {
1380
+ lines.push(`- ${job.slug}: INVALID schedule "${job.schedule}"`);
1381
+ continue;
1382
+ }
1383
+ const records = readRunRecords(scopeId, job.slug, 20);
1384
+ const last = lastFinishedRun(records);
1385
+ const lastDesc = last === undefined ? ", last: never" : `, last: ${last.status ?? "?"} ${formatRunLine(last)}`;
1386
+ const next = entry === undefined ? undefined : timerStatus(unitBase(scopeId, job.slug)).next;
1387
+ const nextDesc = next === undefined ? "" : `, next: ${next}`;
1388
+ lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
1389
+ }
1390
+ lines.push(...errors.map((error) => `! ${error}`));
1391
+ return { ok: true, output: lines.join(`
1392
+ `) };
1393
+ }
1394
+ function runJobNow(slugInput, directory) {
1395
+ const slug = slugify(slugInput);
1396
+ const file = path8.join(jobsDirectory(directory), `${slug}.json`);
1397
+ if (!existsSync6(file)) {
1398
+ return {
1399
+ ok: false,
1400
+ output: `No job "${slug}" in ${jobsDirectory(directory)}`
1401
+ };
1402
+ }
1403
+ const entry = registryEntry(directory);
1404
+ if (entry === undefined) {
1405
+ return {
1406
+ ok: false,
1407
+ output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
1408
+ };
1409
+ }
1410
+ const script = runScriptPath(entry.scopeId, slug);
1411
+ if (!existsSync6(script)) {
1412
+ return {
1413
+ ok: false,
1414
+ output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
1415
+ };
1416
+ }
1417
+ const log = logFile(entry.scopeId, slug);
1418
+ mkdirSync4(logDirectory(entry.scopeId), { recursive: true });
1419
+ const fd = openSync(log, "a");
1420
+ const child = spawn("/bin/sh", [script], {
1421
+ cwd: path8.resolve(directory),
1422
+ env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
1423
+ stdio: ["ignore", fd, fd]
1424
+ });
1425
+ child.unref();
1426
+ closeSync(fd);
1427
+ const tail = tailFile(log, 5, 2000);
1428
+ const parts = [
1429
+ `Started "${slug}" manually (pid ${String(child.pid)})`,
1430
+ `Log: ${log}`
1431
+ ];
1432
+ if (tail?.length)
1433
+ parts.push(`Log tail:
1434
+ ${tail}`);
1435
+ return { ok: true, output: parts.join(`
1436
+ `) };
1437
+ }
1438
+
1255
1439
  // src/cli.ts
1256
1440
  var USAGE = `Usage: opencode-jobs <command> [projectDir]
1257
1441
 
@@ -1259,16 +1443,45 @@ Commands:
1259
1443
  install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
1260
1444
  uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
1261
1445
  --purge also deletes job definitions and job data
1446
+ list [projectDir] List jobs and their enabled, next-run, and last-run state
1447
+ enable [projectDir] Enable or re-sync all jobs in a project
1448
+ disable [projectDir] Disable all jobs in a project while keeping definitions and history
1449
+ run <slug> [projectDir] Run one enabled job immediately
1262
1450
  help Show this help`;
1263
1451
  function packageDirectory() {
1264
- return path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..");
1452
+ return path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
1265
1453
  }
1266
1454
  function printError(message) {
1267
- console.error(`Error: ${message}
1268
-
1269
- ${USAGE}`);
1455
+ console.log(JSON.stringify({ ok: false, output: `Error: ${message}` }, undefined, 2));
1456
+ console.error(USAGE);
1270
1457
  process.exitCode = 1;
1271
1458
  }
1459
+ function projectArgument(command, arguments_) {
1460
+ if (arguments_.some((argument) => argument.startsWith("--"))) {
1461
+ throw new Error(`${command} does not accept options`);
1462
+ }
1463
+ if (arguments_.length > 1) {
1464
+ throw new Error(`${command} accepts at most one project directory`);
1465
+ }
1466
+ return arguments_[0] ?? process.cwd();
1467
+ }
1468
+ function runArguments(arguments_) {
1469
+ if (arguments_.some((argument) => argument.startsWith("--"))) {
1470
+ throw new Error("run does not accept options");
1471
+ }
1472
+ if (arguments_.length === 0 || arguments_.length > 2) {
1473
+ throw new Error("run requires a job slug and accepts one project directory");
1474
+ }
1475
+ return {
1476
+ slug: arguments_[0] ?? "",
1477
+ project: arguments_[1] ?? process.cwd()
1478
+ };
1479
+ }
1480
+ function printManagementResult(result) {
1481
+ console.log(JSON.stringify(result, undefined, 2));
1482
+ if (!result.ok)
1483
+ process.exitCode = 1;
1484
+ }
1272
1485
  function parseUninstallArguments(arguments_) {
1273
1486
  let project;
1274
1487
  let shouldPurge = false;
@@ -1318,6 +1531,43 @@ if ([undefined, "help", "--help"].includes(command)) {
1318
1531
  } catch (error) {
1319
1532
  printError(errorMessage(error));
1320
1533
  }
1534
+ } else if (command !== undefined && ["list", "enable", "disable", "run"].includes(command)) {
1535
+ try {
1536
+ let slug;
1537
+ let project;
1538
+ if (command === "run") {
1539
+ ({ slug, project } = runArguments(rest));
1540
+ } else {
1541
+ project = projectArgument(command, rest);
1542
+ }
1543
+ const migration = migrateStorage(project, true);
1544
+ for (const warning of migration.warnings) {
1545
+ console.error(`storage migration warning: ${warning}`);
1546
+ }
1547
+ switch (command) {
1548
+ case "list": {
1549
+ printManagementResult(listJobs(project));
1550
+ break;
1551
+ }
1552
+ case "enable": {
1553
+ printManagementResult({ ok: true, output: enableProject(project) });
1554
+ break;
1555
+ }
1556
+ case "disable": {
1557
+ printManagementResult({ ok: true, output: disableProject(project) });
1558
+ break;
1559
+ }
1560
+ case "run": {
1561
+ printManagementResult(runJobNow(slug ?? "", project));
1562
+ break;
1563
+ }
1564
+ default: {
1565
+ throw new Error(`unknown management command: ${command}`);
1566
+ }
1567
+ }
1568
+ } catch (error) {
1569
+ printError(errorMessage(error));
1570
+ }
1321
1571
  } else {
1322
1572
  printError(`unknown command: ${command ?? "(missing)"}`);
1323
1573
  }
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"];
@@ -951,6 +943,16 @@ Hint: no systemd user session is reachable. Over SSH try enabling lingering: log
951
943
  }
952
944
  return "";
953
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
+ }
954
956
  function timerStatus(base) {
955
957
  const result = systemctl([
956
958
  "show",
@@ -988,17 +990,25 @@ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
988
990
  }
989
991
  function removeJobUnits(scopeId, slug) {
990
992
  const base = unitBase(scopeId, slug);
991
- systemctl(["disable", "--now", timerUnit(base)]);
992
- for (const file of [
993
+ const files = [
993
994
  path4.join(systemdUserDirectory(), timerUnit(base)),
994
995
  path4.join(systemdUserDirectory(), serviceUnit(base))
995
- ]) {
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) {
996
1006
  if (existsSync3(file))
997
1007
  rmSync(file);
998
1008
  }
999
- const script = runScriptPath(scopeId, slug);
1000
1009
  if (existsSync3(script))
1001
1010
  rmSync(script);
1011
+ return;
1002
1012
  }
1003
1013
  function removeStaleUnits(scopeId, expectedSlugs) {
1004
1014
  const prefix = `opencode-sched-${scopeId}-`;
@@ -1075,7 +1085,7 @@ ${errors.join(`
1075
1085
  lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
1076
1086
  lines.push(...describeJobSchedules(jobs, scopeId));
1077
1087
  if (failures.length > 0)
1078
- lines.push(`Timer activation failures:
1088
+ throw new Error(`Timer activation failures:
1079
1089
  ${failures.join(`
1080
1090
  `)}`);
1081
1091
  return lines.join(`
@@ -1096,9 +1106,19 @@ function disableProject(workdir) {
1096
1106
  const entry = registryEntry(abs);
1097
1107
  if (entry === undefined)
1098
1108
  return `Project is not enabled: ${abs}`;
1099
- for (const slug of entry.jobs)
1100
- removeJobUnits(entry.scopeId, slug);
1101
- 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
+ }
1102
1122
  const registry = loadRegistry();
1103
1123
  const { [abs]: _omitted, ...remainingProjects } = registry.projects;
1104
1124
  registry.projects = remainingProjects;
@@ -1111,13 +1131,10 @@ function disableProject(workdir) {
1111
1131
  `);
1112
1132
  }
1113
1133
 
1114
- // src/tools.ts
1115
- function ok(output) {
1116
- return { output };
1117
- }
1118
- function fail(message) {
1119
- return { output: `Error: ${message}`, metadata: { error: true } };
1120
- }
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";
1121
1138
  function tryParseCron(schedule) {
1122
1139
  try {
1123
1140
  return parseCron(schedule);
@@ -1125,7 +1142,7 @@ function tryParseCron(schedule) {
1125
1142
  return;
1126
1143
  }
1127
1144
  }
1128
- function listJobsOutput(directory) {
1145
+ function listJobs(directory) {
1129
1146
  const { jobs, errors } = loadJobs(directory);
1130
1147
  const entry = registryEntry(directory);
1131
1148
  const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
@@ -1147,8 +1164,63 @@ function listJobsOutput(directory) {
1147
1164
  lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
1148
1165
  }
1149
1166
  lines.push(...errors.map((error) => `! ${error}`));
1150
- return ok(lines.join(`
1151
- `));
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);
1152
1224
  }
1153
1225
  function scheduleJobOutput(input, directory) {
1154
1226
  const slug = slugify(input.slug ?? input.name);
@@ -1192,7 +1264,7 @@ function scheduleJobOutput(input, directory) {
1192
1264
  } catch (error) {
1193
1265
  return fail(errorMessage(error));
1194
1266
  }
1195
- const existing = loadJobFile(path6.join(jobsDirectory(directory), `${slug}.json`), slug);
1267
+ const existing = loadJobFile(path7.join(jobsDirectory(directory), `${slug}.json`), slug);
1196
1268
  const job = {
1197
1269
  slug,
1198
1270
  name: input.name,
@@ -1221,7 +1293,7 @@ function scheduleJobOutput(input, directory) {
1221
1293
  return ok(lines.join(`
1222
1294
  `));
1223
1295
  }
1224
- const abs = path6.resolve(directory);
1296
+ const abs = path7.resolve(directory);
1225
1297
  const opencodeBin = findOpencode();
1226
1298
  const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1227
1299
  const base = writeJobUnits(job, abs, entry.scopeId, opencodeBin, pathEnvironment);
@@ -1247,8 +1319,8 @@ function scheduleJobOutput(input, directory) {
1247
1319
  `));
1248
1320
  }
1249
1321
  function showJobOutput(slugInput, directory) {
1250
- const file = path6.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
1251
- if (!existsSync4(file)) {
1322
+ const file = path7.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
1323
+ if (!existsSync5(file)) {
1252
1324
  return fail(`No job "${slugInput}" in ${jobsDirectory(directory)}. Use list_jobs to see definitions.`);
1253
1325
  }
1254
1326
  const result = loadJobFile(file);
@@ -1273,7 +1345,7 @@ function showJobOutput(slugInput, directory) {
1273
1345
  }
1274
1346
  if (job.session !== undefined) {
1275
1347
  const state = sessionStateFile(scopeId, job.slug);
1276
- const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
1348
+ const sessionId = existsSync5(state) ? readFileSync4(state, "utf8").trim() : "";
1277
1349
  lines.push(`Session: ${job.session}${sessionId.length > 0 ? ` \u2014 current ${sessionId}` : " \u2014 no session yet"}`);
1278
1350
  }
1279
1351
  if (job.run.agent !== undefined)
@@ -1308,22 +1380,30 @@ function showJobOutput(slugInput, directory) {
1308
1380
  }
1309
1381
  function removeJobDefinitionOutput(slugInput, directory) {
1310
1382
  const slug = slugify(slugInput);
1311
- const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1312
- if (!existsSync4(file))
1383
+ const file = path7.join(jobsDirectory(directory), `${slug}.json`);
1384
+ if (!existsSync5(file))
1313
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
+ }
1314
1398
  rmSync2(file);
1315
1399
  const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
1316
- const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
1400
+ const scopeId = entry?.scopeId ?? deriveScopeId(directory);
1317
1401
  const state = sessionStateFile(scopeId, slug);
1318
- if (existsSync4(state)) {
1402
+ if (existsSync5(state)) {
1319
1403
  rmSync2(state);
1320
1404
  lines.push(`Removed session state ${state}`);
1321
1405
  }
1322
- const abs = path6.resolve(directory);
1323
- const entry = registryEntry(directory);
1324
1406
  if (entry?.jobs.includes(slug)) {
1325
- removeJobUnits(entry.scopeId, slug);
1326
- systemctl(["daemon-reload"]);
1327
1407
  const registry = loadRegistry();
1328
1408
  const current = registry.projects[abs];
1329
1409
  if (current !== undefined) {
@@ -1342,40 +1422,6 @@ function omitProject(registry, workdir) {
1342
1422
  const { [workdir]: _omitted, ...remaining } = registry.projects;
1343
1423
  registry.projects = remaining;
1344
1424
  }
1345
- function runJobNowOutput(slugInput, directory) {
1346
- const slug = slugify(slugInput);
1347
- const file = path6.join(jobsDirectory(directory), `${slug}.json`);
1348
- if (!existsSync4(file))
1349
- return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
1350
- const entry = registryEntry(directory);
1351
- if (entry === undefined) {
1352
- return fail(`Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`);
1353
- }
1354
- const script = runScriptPath(entry.scopeId, slug);
1355
- if (!existsSync4(script)) {
1356
- return fail(`Run script missing for "${slug}". Run enable_project to (re)install units.`);
1357
- }
1358
- const log = logFile(entry.scopeId, slug);
1359
- mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
1360
- const fd = openSync(log, "a");
1361
- const child = spawn("/bin/sh", [script], {
1362
- cwd: path6.resolve(directory),
1363
- env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
1364
- stdio: ["ignore", fd, fd]
1365
- });
1366
- child.unref();
1367
- closeSync(fd);
1368
- const tail = tailFile(log, 5, 2000);
1369
- const parts = [
1370
- `Started "${slug}" manually (pid ${String(child.pid)})`,
1371
- `Log: ${log}`
1372
- ];
1373
- if (tail?.length)
1374
- parts.push(`Log tail:
1375
- ${tail}`);
1376
- return ok(parts.join(`
1377
- `));
1378
- }
1379
1425
  function jobLogsOutput(slugInput, lineCountInput, directory) {
1380
1426
  const entry = registryEntry(directory);
1381
1427
  const scopeId = entry?.scopeId ?? deriveScopeId(directory);
@@ -1396,7 +1442,7 @@ function listProjectsOutput() {
1396
1442
  return ok("No projects with scheduled jobs are registered.");
1397
1443
  const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
1398
1444
  for (const entry of entries) {
1399
- const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
1445
+ const missing = existsSync5(entry.workdir) ? "" : " [WORKDIR MISSING]";
1400
1446
  lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
1401
1447
  }
1402
1448
  return ok(lines.join(`
@@ -1405,7 +1451,7 @@ function listProjectsOutput() {
1405
1451
  var listJobsTool = tool({
1406
1452
  description: "List scheduled job definitions for the current project (from .opencode/jobs/), including enabled state, next run, and last run status.",
1407
1453
  args: {},
1408
- execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
1454
+ execute: (_input, context) => Promise.resolve(managementToolResult(listJobs(context.directory)))
1409
1455
  });
1410
1456
  var scheduleJobTool = tool({
1411
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.",
@@ -1447,7 +1493,7 @@ var runJobTool = tool({
1447
1493
  args: {
1448
1494
  slug: tool.schema.string().describe("Job slug to run now")
1449
1495
  },
1450
- execute: (input, context) => Promise.resolve(runJobNowOutput(input.slug, context.directory))
1496
+ execute: (input, context) => Promise.resolve(managementToolResult(runJobNow(input.slug, context.directory)))
1451
1497
  });
1452
1498
  var jobLogsTool = tool({
1453
1499
  description: "Show the tail of a scheduled job's log file (scheduled and manual runs both append to it).",
@@ -1499,16 +1545,16 @@ var jobsTools = {
1499
1545
  };
1500
1546
 
1501
1547
  // src/migration.ts
1502
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
1503
- import path7 from "path";
1548
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
1549
+ import path8 from "path";
1504
1550
  function legacyJobsStateDirectory() {
1505
- return path7.join(configRoot(), "opencode", "scheduler");
1551
+ return path8.join(configRoot(), "opencode", "scheduler");
1506
1552
  }
1507
1553
  function legacyRegistryPath() {
1508
- return path7.join(legacyJobsStateDirectory(), "registry.json");
1554
+ return path8.join(legacyJobsStateDirectory(), "registry.json");
1509
1555
  }
1510
1556
  function legacyDefinitionsDirectory(workdir) {
1511
- return path7.join(workdir, ".opencode", "scheduler", "jobs");
1557
+ return path8.join(workdir, ".opencode", "scheduler", "jobs");
1512
1558
  }
1513
1559
  function migrationMoves(projects) {
1514
1560
  return [
@@ -1517,12 +1563,12 @@ function migrationMoves(projects) {
1517
1563
  to: jobsStateDirectory()
1518
1564
  },
1519
1565
  {
1520
- from: path7.join(configRoot(), "opencode", "logs", "scheduler"),
1521
- to: path7.join(configRoot(), "opencode", "logs", "jobs")
1566
+ from: path8.join(configRoot(), "opencode", "logs", "scheduler"),
1567
+ to: path8.join(configRoot(), "opencode", "logs", "jobs")
1522
1568
  },
1523
1569
  {
1524
- from: path7.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1525
- to: path7.join(stateRoot(), "opencode", "jobs", "worktrees")
1570
+ from: path8.join(stateRoot(), "opencode", "scheduler", "worktrees"),
1571
+ to: path8.join(stateRoot(), "opencode", "jobs", "worktrees")
1526
1572
  },
1527
1573
  ...[...projects].map((workdir) => ({
1528
1574
  from: legacyDefinitionsDirectory(workdir),
@@ -1532,25 +1578,25 @@ function migrationMoves(projects) {
1532
1578
  }
1533
1579
  function removeLegacyProjectDirectory(workdir) {
1534
1580
  try {
1535
- rmdirSync(path7.join(workdir, ".opencode", "scheduler"));
1581
+ rmdirSync(path8.join(workdir, ".opencode", "scheduler"));
1536
1582
  } catch {}
1537
1583
  }
1538
1584
  function migrateStorage(projectDirectory, shouldResync) {
1539
- const project = path7.resolve(projectDirectory);
1585
+ const project = path8.resolve(projectDirectory);
1540
1586
  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: {} };
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: {} };
1544
1590
  const registeredProjects = new Set(Object.keys(registry.projects));
1545
1591
  const projects = new Set([project, ...registeredProjects]);
1546
- const moves = migrationMoves(projects).filter(({ from }) => existsSync5(from));
1592
+ const moves = migrationMoves(projects).filter(({ from }) => existsSync6(from));
1547
1593
  for (const { from, to } of moves) {
1548
- if (existsSync5(to)) {
1594
+ if (existsSync6(to)) {
1549
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.`);
1550
1596
  }
1551
1597
  }
1552
1598
  for (const { from, to } of moves) {
1553
- mkdirSync4(path7.dirname(to), { recursive: true });
1599
+ mkdirSync4(path8.dirname(to), { recursive: true });
1554
1600
  renameSync2(from, to);
1555
1601
  }
1556
1602
  for (const workdir of projects)
@@ -0,0 +1,9 @@
1
+ export type ManagementResult = {
2
+ ok: true;
3
+ output: string;
4
+ } | {
5
+ ok: false;
6
+ output: string;
7
+ };
8
+ export declare function listJobs(directory: string): ManagementResult;
9
+ export declare function runJobNow(slugInput: string, directory: string): ManagementResult;
package/dist/systemd.d.ts CHANGED
@@ -22,5 +22,5 @@ export interface TimerStatus {
22
22
  }
23
23
  export declare function timerStatus(base: string): TimerStatus;
24
24
  export declare function writeJobUnits(job: Job, workdir: string, scopeId: string, opencodeBin: string, pathEnvironment: string): string;
25
- export declare function removeJobUnits(scopeId: string, slug: string): void;
25
+ export declare function removeJobUnits(scopeId: string, slug: string): string | undefined;
26
26
  export declare function removeStaleUnits(scopeId: string, expectedSlugs: Set<string>): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-jobs",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": {