opencode-jobs 0.1.0 → 0.1.1

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
@@ -202,3 +202,8 @@ inside opencode.
202
202
  ## License
203
203
 
204
204
  MIT
205
+
206
+ ## Development
207
+
208
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the quality and release workflow and
209
+ [ROADMAP.md](ROADMAP.md) for current planning priorities.
package/dist/cli.js CHANGED
@@ -7,18 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  // src/install.ts
8
8
  import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
9
9
  import path5 from "node:path";
10
-
11
- // src/json.ts
12
- function isRecord(value) {
13
- return typeof value === "object" && value !== null;
14
- }
15
- function stringProperty(record, key) {
16
- const value = record[key];
17
- return typeof value === "string" ? value : undefined;
18
- }
19
- function errorMessage(error) {
20
- return error instanceof Error ? error.message : String(error);
21
- }
10
+ import { z as z3 } from "zod";
22
11
 
23
12
  // src/paths.ts
24
13
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
@@ -80,24 +69,193 @@ function deriveScopeId(workdir) {
80
69
  // src/project.ts
81
70
  import path4 from "node:path";
82
71
 
72
+ // src/job.ts
73
+ import { z } from "zod";
74
+
75
+ // src/cron.ts
76
+ var DOW_NAMES = {
77
+ sun: 0,
78
+ mon: 1,
79
+ tue: 2,
80
+ wed: 3,
81
+ thu: 4,
82
+ fri: 5,
83
+ sat: 6
84
+ };
85
+ var MONTH_NAMES = {
86
+ jan: 1,
87
+ feb: 2,
88
+ mar: 3,
89
+ apr: 4,
90
+ may: 5,
91
+ jun: 6,
92
+ jul: 7,
93
+ aug: 8,
94
+ sep: 9,
95
+ oct: 10,
96
+ nov: 11,
97
+ dec: 12
98
+ };
99
+ function parseBound(raw, field, min, max, names) {
100
+ const trimmed = raw.trim().toLowerCase();
101
+ const named = names?.[trimmed];
102
+ if (named !== undefined)
103
+ return named;
104
+ if (!/^\d+$/.test(trimmed))
105
+ throw new Error(`Invalid ${field} value "${raw}" in cron expression`);
106
+ const value = Number(trimmed);
107
+ if (value < min || value > max)
108
+ throw new Error(`${field} value ${String(value)} out of range ${String(min)}-${String(max)} in cron expression`);
109
+ return value;
110
+ }
111
+ function parseCronField(raw, field, min, max, names) {
112
+ if (raw.length === 0)
113
+ throw new Error(`Empty ${field} field in cron expression`);
114
+ const values = new Set;
115
+ for (const part of raw.split(",")) {
116
+ if (part.length === 0)
117
+ throw new Error(`Empty ${field} item in cron expression "${raw}"`);
118
+ const [rangePart = "", stepPart] = part.split("/", 2);
119
+ if (stepPart !== undefined && (!/^\d+$/.test(stepPart) || Number(stepPart) < 1)) {
120
+ throw new Error(`Invalid step "/${stepPart}" in ${field} field`);
121
+ }
122
+ const step = stepPart === undefined ? 1 : Number(stepPart);
123
+ let lo;
124
+ let hi;
125
+ if (rangePart === "*") {
126
+ lo = min;
127
+ hi = max;
128
+ } else if (rangePart.includes("-")) {
129
+ const pieces = rangePart.split("-");
130
+ if (pieces.length !== 2)
131
+ throw new Error(`Invalid range "${rangePart}" in ${field} field`);
132
+ const [loPart = "", hiPart = ""] = pieces;
133
+ lo = parseBound(loPart, field, min, max, names);
134
+ hi = parseBound(hiPart, field, min, max, names);
135
+ if (lo > hi)
136
+ throw new Error(`Descending range "${rangePart}" in ${field} field`);
137
+ } else {
138
+ lo = parseBound(rangePart, field, min, max, names);
139
+ hi = stepPart === undefined ? lo : max;
140
+ }
141
+ for (let v = lo;v <= hi; v += step) {
142
+ values.add(field === "dow" && v === 7 ? 0 : v);
143
+ }
144
+ }
145
+ return [...values].toSorted((a, b) => a - b);
146
+ }
147
+ function parseCron(expression) {
148
+ const fields = expression.trim().split(/\s+/);
149
+ if (fields.length !== 5)
150
+ throw new Error(`Expected 5-field cron expression, got ${String(fields.length)} fields: "${expression}"`);
151
+ const [minute = "", hour = "", dom = "", month = "", dow = ""] = fields;
152
+ return {
153
+ minute: parseCronField(minute, "minute", 0, 59),
154
+ hour: parseCronField(hour, "hour", 0, 23),
155
+ dom: parseCronField(dom, "day of month", 1, 31),
156
+ month: parseCronField(month, "month", 1, 12, MONTH_NAMES),
157
+ dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
158
+ };
159
+ }
160
+
161
+ // src/json.ts
162
+ function errorMessage(error) {
163
+ return error instanceof Error ? error.message : String(error);
164
+ }
165
+
166
+ // src/job.ts
167
+ var SESSION_MODES = [
168
+ "new",
169
+ "persist",
170
+ "compact",
171
+ "compact+last"
172
+ ];
173
+ var nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty string");
174
+ var runSpecSchema = z.strictObject({
175
+ prompt: nonEmptyStringSchema.optional(),
176
+ command: nonEmptyStringSchema.optional(),
177
+ arguments: z.string().optional(),
178
+ agent: z.string().optional(),
179
+ model: z.string().optional()
180
+ }).superRefine((run, context) => {
181
+ if (run.prompt === undefined === (run.command === undefined)) {
182
+ context.addIssue({
183
+ code: "custom",
184
+ message: 'must set exactly one of "prompt" (natural language) or "command" (custom command name)'
185
+ });
186
+ }
187
+ if (run.prompt !== undefined && run.arguments !== undefined) {
188
+ context.addIssue({
189
+ code: "custom",
190
+ path: ["arguments"],
191
+ message: "only applies to command jobs"
192
+ });
193
+ }
194
+ }).transform((run) => {
195
+ if (run.command !== undefined) {
196
+ return {
197
+ command: run.command,
198
+ ...run.arguments !== undefined && { arguments: run.arguments },
199
+ ...run.agent !== undefined && { agent: run.agent },
200
+ ...run.model !== undefined && { model: run.model }
201
+ };
202
+ }
203
+ return {
204
+ prompt: run.prompt ?? "",
205
+ ...run.agent !== undefined && { agent: run.agent },
206
+ ...run.model !== undefined && { model: run.model }
207
+ };
208
+ });
209
+ var sessionSchema = z.enum(SESSION_MODES, {
210
+ error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
211
+ });
212
+ var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
213
+ var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
214
+ var cronSchema = z.string().superRefine((schedule, context) => {
215
+ try {
216
+ parseCron(schedule);
217
+ } catch (error) {
218
+ context.addIssue({ code: "custom", message: errorMessage(error) });
219
+ }
220
+ });
221
+ var jobFileSchema = z.strictObject({
222
+ slug: z.string().optional(),
223
+ name: nonEmptyStringSchema,
224
+ schedule: cronSchema,
225
+ run: runSpecSchema,
226
+ session: sessionSchema.default("new"),
227
+ guard: guardSchema.optional(),
228
+ timeoutSeconds: timeoutSchema.optional(),
229
+ createdAt: z.string().optional(),
230
+ updatedAt: z.string().optional()
231
+ });
232
+
83
233
  // src/registry.ts
84
234
  import { readFileSync } from "node:fs";
85
235
  import path2 from "node:path";
86
- function isRegistryEntry(value) {
87
- if (!isRecord(value))
88
- return false;
89
- return stringProperty(value, "scopeId") !== undefined && stringProperty(value, "workdir") !== undefined && stringProperty(value, "enabledAt") !== undefined && stringProperty(value, "updatedAt") !== undefined && Array.isArray(value.jobs) && value.jobs.every((job) => typeof job === "string");
90
- }
236
+ import { z as z2 } from "zod";
237
+ var registryEntrySchema = z2.looseObject({
238
+ scopeId: z2.string(),
239
+ workdir: z2.string(),
240
+ enabledAt: z2.string(),
241
+ updatedAt: z2.string(),
242
+ jobs: z2.array(z2.string())
243
+ });
244
+ var registryFileSchema = z2.object({
245
+ version: z2.literal(1),
246
+ projects: z2.record(z2.string(), z2.unknown())
247
+ });
91
248
  function loadRegistry() {
92
249
  try {
93
250
  const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
94
- if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.projects)) {
251
+ const result = registryFileSchema.safeParse(parsed);
252
+ if (!result.success)
95
253
  return { version: 1, projects: {} };
96
- }
97
254
  const projects = {};
98
- for (const [key, value] of Object.entries(parsed.projects)) {
99
- if (isRegistryEntry(value))
100
- projects[key] = value;
255
+ for (const [key, value] of Object.entries(result.data.projects)) {
256
+ const entry = registryEntrySchema.safeParse(value);
257
+ if (entry.success)
258
+ projects[key] = entry.data;
101
259
  }
102
260
  return { version: 1, projects };
103
261
  } catch {
@@ -169,6 +327,9 @@ function disableProject(workdir) {
169
327
  var PACKAGE_NAME = "opencode-jobs";
170
328
  var SKILL_NAME = "opencode-jobs";
171
329
  var CONFIG_SCHEMA = "https://opencode.ai/config.json";
330
+ var configSchema = z3.looseObject({
331
+ plugin: z3.array(z3.unknown()).optional()
332
+ });
172
333
  var CONFIG_LOCATIONS = [
173
334
  "opencode.json",
174
335
  "opencode.jsonc",
@@ -186,7 +347,8 @@ function existingConfigPath(projectDirectory) {
186
347
  function readConfig(configPath) {
187
348
  try {
188
349
  const value = JSON.parse(readFileSync2(configPath, "utf8"));
189
- return isRecord(value) && !Array.isArray(value) ? value : undefined;
350
+ const result = configSchema.safeParse(value);
351
+ return result.success ? result.data : undefined;
190
352
  } catch {
191
353
  return;
192
354
  }
@@ -213,11 +375,7 @@ function installPluginConfig(projectDirectory) {
213
375
  const config = readConfig(configPath);
214
376
  if (config === undefined)
215
377
  return { status: "manual", configPath };
216
- const plugins = config.plugin;
217
- if (plugins !== undefined && !Array.isArray(plugins)) {
218
- return { status: "manual", configPath };
219
- }
220
- const entries = Array.isArray(plugins) ? plugins : [];
378
+ const entries = config.plugin ?? [];
221
379
  if (entries.some((entry) => isPackageReference(entry))) {
222
380
  return { status: "present", configPath };
223
381
  }
@@ -260,11 +418,7 @@ function uninstallPluginConfig(projectDirectory) {
260
418
  const config = readConfig(configPath);
261
419
  if (config === undefined)
262
420
  return { status: "manual", configPath };
263
- const plugins = config.plugin;
264
- if (plugins !== undefined && !Array.isArray(plugins)) {
265
- return { status: "manual", configPath };
266
- }
267
- const entries = Array.isArray(plugins) ? plugins : [];
421
+ const entries = config.plugin ?? [];
268
422
  const remaining = entries.filter((entry) => !isPackageReference(entry));
269
423
  if (remaining.length === entries.length) {
270
424
  return { status: "absent", configPath };
package/dist/index.js CHANGED
@@ -227,19 +227,9 @@ function deriveScopeId(workdir) {
227
227
  // src/job.ts
228
228
  import { existsSync, readFileSync, readdirSync } from "fs";
229
229
  import path2 from "path";
230
+ import { z } from "zod";
230
231
 
231
232
  // src/json.ts
232
- function isRecord(value) {
233
- return typeof value === "object" && value !== null;
234
- }
235
- function stringProperty(record, key) {
236
- const value = record[key];
237
- return typeof value === "string" ? value : undefined;
238
- }
239
- function numberProperty(record, key) {
240
- const value = record[key];
241
- return typeof value === "number" ? value : undefined;
242
- }
243
233
  function errorMessage(error) {
244
234
  return error instanceof Error ? error.message : String(error);
245
235
  }
@@ -251,69 +241,110 @@ var SESSION_MODES = [
251
241
  "compact",
252
242
  "compact+last"
253
243
  ];
254
- function nonEmptyString(value) {
255
- return typeof value === "string" && value.trim().length > 0 ? value : undefined;
256
- }
257
- function validateRunSpec(run, context) {
258
- if (!isRecord(run))
259
- throw new Error(`${context}: "run" must be an object`);
260
- const prompt = nonEmptyString(run.prompt);
261
- const command = nonEmptyString(run.command);
262
- if (prompt !== undefined === (command !== undefined)) {
263
- throw new Error(`${context}: "run" must set exactly one of "prompt" (natural language) or "command" (custom command name)`);
244
+ var nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty string");
245
+ var runSpecSchema = z.strictObject({
246
+ prompt: nonEmptyStringSchema.optional(),
247
+ command: nonEmptyStringSchema.optional(),
248
+ arguments: z.string().optional(),
249
+ agent: z.string().optional(),
250
+ model: z.string().optional()
251
+ }).superRefine((run, context) => {
252
+ if (run.prompt === undefined === (run.command === undefined)) {
253
+ context.addIssue({
254
+ code: "custom",
255
+ message: 'must set exactly one of "prompt" (natural language) or "command" (custom command name)'
256
+ });
264
257
  }
265
- const commandArguments = stringProperty(run, "arguments");
266
- const agent = stringProperty(run, "agent");
267
- const model = stringProperty(run, "model");
268
- if (command !== undefined) {
258
+ if (run.prompt !== undefined && run.arguments !== undefined) {
259
+ context.addIssue({
260
+ code: "custom",
261
+ path: ["arguments"],
262
+ message: "only applies to command jobs"
263
+ });
264
+ }
265
+ }).transform((run) => {
266
+ if (run.command !== undefined) {
269
267
  return {
270
- command,
271
- ...commandArguments !== undefined && { arguments: commandArguments },
272
- ...agent !== undefined && { agent },
273
- ...model !== undefined && { model }
268
+ command: run.command,
269
+ ...run.arguments !== undefined && { arguments: run.arguments },
270
+ ...run.agent !== undefined && { agent: run.agent },
271
+ ...run.model !== undefined && { model: run.model }
274
272
  };
275
273
  }
276
- if (commandArguments !== undefined) {
277
- throw new Error(`${context}: "run.arguments" only applies to command jobs`);
278
- }
279
274
  return {
280
- prompt: prompt ?? "",
281
- ...agent !== undefined && { agent },
282
- ...model !== undefined && { model }
275
+ prompt: run.prompt ?? "",
276
+ ...run.agent !== undefined && { agent: run.agent },
277
+ ...run.model !== undefined && { model: run.model }
283
278
  };
279
+ });
280
+ var sessionSchema = z.enum(SESSION_MODES, {
281
+ error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
282
+ });
283
+ var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
284
+ var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
285
+ var cronSchema = z.string().superRefine((schedule, context) => {
286
+ try {
287
+ parseCron(schedule);
288
+ } catch (error) {
289
+ context.addIssue({ code: "custom", message: errorMessage(error) });
290
+ }
291
+ });
292
+ var jobFileSchema = z.strictObject({
293
+ slug: z.string().optional(),
294
+ name: nonEmptyStringSchema,
295
+ schedule: cronSchema,
296
+ run: runSpecSchema,
297
+ session: sessionSchema.default("new"),
298
+ guard: guardSchema.optional(),
299
+ timeoutSeconds: timeoutSchema.optional(),
300
+ createdAt: z.string().optional(),
301
+ updatedAt: z.string().optional()
302
+ });
303
+ function formatValidationError(error) {
304
+ const issue = error.issues[0];
305
+ if (issue === undefined)
306
+ return "invalid job definition";
307
+ const field = issue.path.join(".");
308
+ return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
309
+ }
310
+ function parseWithContext(schema, value, context) {
311
+ const result = schema.safeParse(value);
312
+ if (!result.success) {
313
+ throw new Error(`${context}: ${formatValidationError(result.error)}`);
314
+ }
315
+ return result.data;
316
+ }
317
+ function validateRunSpec(run, context) {
318
+ return parseWithContext(runSpecSchema, run, context);
284
319
  }
285
320
  function validateSession(value, context) {
286
321
  if (value === undefined)
287
322
  return "new";
288
- const mode = typeof value === "string" ? SESSION_MODES.find((candidate) => candidate === value) : undefined;
289
- if (mode === undefined) {
290
- throw new Error(`${context}: "session" must be one of ${SESSION_MODES.map((candidate) => `"${candidate}"`).join(", ")}`);
291
- }
292
- return mode;
323
+ return parseWithContext(sessionSchema, value, context);
293
324
  }
294
325
  function validateTimeout(value, context) {
295
326
  if (value === undefined)
296
327
  return;
297
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
298
- throw new Error(`${context}: "timeoutSeconds" must be a non-negative integer`);
299
- }
300
- return value;
328
+ return parseWithContext(timeoutSchema, value, context);
301
329
  }
302
330
  function validateGuard(value, context) {
303
331
  if (value === undefined)
304
332
  return;
305
- if (typeof value !== "string" || value.trim().length === 0) {
306
- throw new Error(`${context}: "guard" must be a non-empty shell command string`);
307
- }
308
- return value;
333
+ return parseWithContext(guardSchema, value, context);
309
334
  }
310
335
  function loadJobFile(file, expectedSlug) {
311
- const stem = path2.basename(file).replaceAll(/\.json$/g, "");
336
+ const stem = path2.basename(file, ".json");
312
337
  try {
313
338
  const object = JSON.parse(readFileSync(file, "utf8"));
314
- if (!isRecord(object))
315
- return { ok: false, error: `${stem}.json: not a job object` };
316
- const slug = stringProperty(object, "slug") ?? stem;
339
+ const result = jobFileSchema.safeParse(object);
340
+ if (!result.success) {
341
+ return {
342
+ ok: false,
343
+ error: `${stem}.json: ${formatValidationError(result.error)}`
344
+ };
345
+ }
346
+ const definition = result.data;
347
+ const slug = definition.slug ?? stem;
317
348
  if (slug !== stem)
318
349
  return {
319
350
  ok: false,
@@ -321,31 +352,21 @@ function loadJobFile(file, expectedSlug) {
321
352
  };
322
353
  if (expectedSlug !== undefined && slug !== expectedSlug)
323
354
  return { ok: false, error: `${stem}.json: unexpected slug` };
324
- const name = nonEmptyString(object.name);
325
- if (name === undefined)
326
- return { ok: false, error: `${stem}.json: "name" is required` };
327
- const schedule = stringProperty(object, "schedule") ?? "";
328
- try {
329
- parseCron(schedule);
330
- } catch (error) {
331
- return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
332
- }
333
- const run = validateRunSpec(object.run, `${stem}.json`);
334
- const session = validateSession(object.session, `${stem}.json`);
335
- const guard = validateGuard(object.guard, `${stem}.json`);
336
- const timeoutSeconds = validateTimeout(object.timeoutSeconds, `${stem}.json`);
355
+ const timestamp = nowIso();
337
356
  return {
338
357
  ok: true,
339
358
  job: {
340
359
  slug,
341
- name,
342
- schedule,
343
- run,
344
- ...session !== "new" && { session },
345
- ...guard !== undefined && { guard },
346
- ...timeoutSeconds !== undefined && { timeoutSeconds },
347
- createdAt: stringProperty(object, "createdAt") ?? nowIso(),
348
- updatedAt: stringProperty(object, "updatedAt") ?? nowIso()
360
+ name: definition.name,
361
+ schedule: definition.schedule,
362
+ run: definition.run,
363
+ ...definition.session !== "new" && { session: definition.session },
364
+ ...definition.guard !== undefined && { guard: definition.guard },
365
+ ...definition.timeoutSeconds !== undefined && {
366
+ timeoutSeconds: definition.timeoutSeconds
367
+ },
368
+ createdAt: definition.createdAt ?? timestamp,
369
+ updatedAt: definition.updatedAt ?? timestamp
349
370
  }
350
371
  };
351
372
  } catch (error) {
@@ -381,21 +402,29 @@ function saveJob(workdir, job) {
381
402
  // src/registry.ts
382
403
  import { readFileSync as readFileSync2 } from "fs";
383
404
  import path3 from "path";
384
- function isRegistryEntry(value) {
385
- if (!isRecord(value))
386
- return false;
387
- return stringProperty(value, "scopeId") !== undefined && stringProperty(value, "workdir") !== undefined && stringProperty(value, "enabledAt") !== undefined && stringProperty(value, "updatedAt") !== undefined && Array.isArray(value.jobs) && value.jobs.every((job) => typeof job === "string");
388
- }
405
+ import { z as z2 } from "zod";
406
+ var registryEntrySchema = z2.looseObject({
407
+ scopeId: z2.string(),
408
+ workdir: z2.string(),
409
+ enabledAt: z2.string(),
410
+ updatedAt: z2.string(),
411
+ jobs: z2.array(z2.string())
412
+ });
413
+ var registryFileSchema = z2.object({
414
+ version: z2.literal(1),
415
+ projects: z2.record(z2.string(), z2.unknown())
416
+ });
389
417
  function loadRegistry() {
390
418
  try {
391
419
  const parsed = JSON.parse(readFileSync2(registryPath(), "utf8"));
392
- if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.projects)) {
420
+ const result = registryFileSchema.safeParse(parsed);
421
+ if (!result.success)
393
422
  return { version: 1, projects: {} };
394
- }
395
423
  const projects = {};
396
- for (const [key, value] of Object.entries(parsed.projects)) {
397
- if (isRegistryEntry(value))
398
- projects[key] = value;
424
+ for (const [key, value] of Object.entries(result.data.projects)) {
425
+ const entry = registryEntrySchema.safeParse(value);
426
+ if (entry.success)
427
+ projects[key] = entry.data;
399
428
  }
400
429
  return { version: 1, projects };
401
430
  } catch {
@@ -412,30 +441,21 @@ function registryEntry(workdir) {
412
441
 
413
442
  // src/runs.ts
414
443
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
415
- function parseRunRecord(value) {
416
- const runId = stringProperty(value, "runId");
417
- const slug = stringProperty(value, "slug");
418
- const scopeId = stringProperty(value, "scopeId");
419
- const startedAt = numberProperty(value, "startedAt");
420
- const finishedAt = numberProperty(value, "finishedAt");
421
- const durationMs = numberProperty(value, "durationMs");
422
- const status = stringProperty(value, "status");
423
- const exitCode = numberProperty(value, "exitCode");
424
- const sessionId = stringProperty(value, "sessionId");
425
- const startedBy = stringProperty(value, "startedBy");
426
- return {
427
- ...runId !== undefined && { runId },
428
- ...slug !== undefined && { slug },
429
- ...scopeId !== undefined && { scopeId },
430
- ...startedAt !== undefined && { startedAt },
431
- ...finishedAt !== undefined && { finishedAt },
432
- ...durationMs !== undefined && { durationMs },
433
- ...status !== undefined && { status },
434
- ...exitCode !== undefined && { exitCode },
435
- ...sessionId !== undefined && { sessionId },
436
- ...startedBy !== undefined && { startedBy }
437
- };
438
- }
444
+ import { z as z3 } from "zod";
445
+ var optionalString = z3.string().optional().catch(undefined);
446
+ var optionalNumber = z3.number().optional().catch(undefined);
447
+ var runRecordSchema = z3.object({
448
+ runId: optionalString,
449
+ slug: optionalString,
450
+ scopeId: optionalString,
451
+ startedAt: optionalNumber,
452
+ finishedAt: optionalNumber,
453
+ durationMs: optionalNumber,
454
+ status: optionalString,
455
+ exitCode: optionalNumber,
456
+ sessionId: optionalString,
457
+ startedBy: optionalString
458
+ });
439
459
  function readRunRecords(scopeId, slug, limit) {
440
460
  const file = runsFile(scopeId, slug);
441
461
  if (!existsSync2(file))
@@ -447,8 +467,9 @@ function readRunRecords(scopeId, slug, limit) {
447
467
  continue;
448
468
  try {
449
469
  const value = JSON.parse(line);
450
- if (isRecord(value))
451
- records.push(parseRunRecord(value));
470
+ const result = runRecordSchema.safeParse(value);
471
+ if (result.success)
472
+ records.push(result.data);
452
473
  } catch {}
453
474
  }
454
475
  return records.slice(-limit);
@@ -999,7 +1020,13 @@ function scheduleJobOutput(input, directory) {
999
1020
  } catch (error) {
1000
1021
  return fail(errorMessage(error));
1001
1022
  }
1002
- const run = validateRunSpec(input, "job");
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");
1003
1030
  const session = validateSession(input.session, "job");
1004
1031
  const guard = validateGuard(input.guard, "job");
1005
1032
  const timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
package/dist/json.d.ts CHANGED
@@ -1,4 +1 @@
1
- export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
- export declare function stringProperty(record: Record<string, unknown>, key: string): string | undefined;
3
- export declare function numberProperty(record: Record<string, unknown>, key: string): number | undefined;
4
1
  export declare function errorMessage(error: unknown): string;
package/dist/runs.d.ts CHANGED
@@ -1,16 +1,19 @@
1
- export interface RunRecord {
2
- runId?: string;
3
- slug?: string;
4
- scopeId?: string;
5
- startedAt?: number;
6
- finishedAt?: number;
7
- durationMs?: number;
8
- status?: string;
9
- exitCode?: number;
10
- sessionId?: string;
11
- startedBy?: string;
12
- }
1
+ import { z } from "zod";
2
+ declare const runRecordSchema: z.ZodObject<{
3
+ runId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
4
+ slug: z.ZodCatch<z.ZodOptional<z.ZodString>>;
5
+ scopeId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
6
+ startedAt: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
7
+ finishedAt: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
8
+ durationMs: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
9
+ status: z.ZodCatch<z.ZodOptional<z.ZodString>>;
10
+ exitCode: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
11
+ sessionId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
12
+ startedBy: z.ZodCatch<z.ZodOptional<z.ZodString>>;
13
+ }, z.core.$strip>;
14
+ export type RunRecord = z.infer<typeof runRecordSchema>;
13
15
  export declare function readRunRecords(scopeId: string, slug: string, limit: number): RunRecord[];
14
16
  export declare function lastFinishedRun(records: RunRecord[]): RunRecord | undefined;
15
17
  export declare function formatRunLine(record: RunRecord): string;
16
18
  export declare function tailFile(file: string, lines: number, maxChars: number): string | undefined;
19
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-jobs",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -24,17 +24,14 @@
24
24
  }
25
25
  },
26
26
  "scripts": {
27
- "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin && bun build src/cli.ts --outfile dist/cli.js --target node --format esm && tsc -p tsconfig.build.json",
27
+ "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin --external zod && bun build src/cli.ts --outfile dist/cli.js --target node --format esm --external zod && tsc -p tsconfig.build.json",
28
28
  "clean": "rm -rf dist",
29
29
  "prepublishOnly": "npm run clean && npm run build",
30
30
  "format": "prettier --write .",
31
31
  "lint": "eslint",
32
32
  "typecheck": "tsc --noEmit",
33
33
  "check": "prettier --check . && eslint && tsc --noEmit",
34
- "smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs",
35
- "release:patch": "npm version patch && npm publish",
36
- "release:minor": "npm version minor && npm publish",
37
- "release:major": "npm version major && npm publish"
34
+ "smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs"
38
35
  },
39
36
  "keywords": [
40
37
  "opencode",
@@ -59,7 +56,8 @@
59
56
  },
60
57
  "homepage": "https://github.com/Fraser-Grant/opencode-jobs#readme",
61
58
  "dependencies": {
62
- "@opencode-ai/plugin": "^1.18.18"
59
+ "@opencode-ai/plugin": "^1.18.18",
60
+ "zod": "4.1.8"
63
61
  },
64
62
  "peerDependencies": {
65
63
  "@opencode-ai/plugin": ">=1.0.0"