opencode-gateway 0.1.0 → 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.
Files changed (60) hide show
  1. package/README.md +26 -0
  2. package/dist/binding/gateway.d.ts +2 -1
  3. package/dist/binding/index.d.ts +1 -1
  4. package/dist/cli/doctor.js +3 -1
  5. package/dist/cli/init.js +4 -1
  6. package/dist/cli/paths.js +1 -1
  7. package/dist/cli/templates.js +15 -0
  8. package/dist/cli.js +28 -4
  9. package/dist/config/gateway.d.ts +5 -0
  10. package/dist/config/gateway.js +6 -1
  11. package/dist/config/memory.d.ts +18 -0
  12. package/dist/config/memory.js +105 -0
  13. package/dist/config/paths.d.ts +2 -0
  14. package/dist/config/paths.js +5 -1
  15. package/dist/cron/runtime.d.ts +24 -5
  16. package/dist/cron/runtime.js +178 -13
  17. package/dist/delivery/text.js +1 -1
  18. package/dist/gateway.d.ts +3 -1
  19. package/dist/gateway.js +49 -37
  20. package/dist/host/logger.d.ts +8 -0
  21. package/dist/host/logger.js +53 -0
  22. package/dist/index.js +11 -7
  23. package/dist/memory/prompt.d.ts +9 -0
  24. package/dist/memory/prompt.js +122 -0
  25. package/dist/opencode/adapter.d.ts +2 -0
  26. package/dist/opencode/adapter.js +56 -7
  27. package/dist/runtime/conversation-coordinator.d.ts +4 -0
  28. package/dist/runtime/conversation-coordinator.js +22 -0
  29. package/dist/runtime/executor.d.ts +34 -5
  30. package/dist/runtime/executor.js +241 -22
  31. package/dist/runtime/runtime-singleton.d.ts +2 -0
  32. package/dist/runtime/runtime-singleton.js +28 -0
  33. package/dist/session/context.d.ts +1 -1
  34. package/dist/session/context.js +2 -23
  35. package/dist/session/system-prompt.d.ts +8 -0
  36. package/dist/session/system-prompt.js +52 -0
  37. package/dist/store/migrations.js +15 -1
  38. package/dist/store/sqlite.d.ts +20 -2
  39. package/dist/store/sqlite.js +103 -4
  40. package/dist/tools/channel-target.d.ts +5 -0
  41. package/dist/tools/channel-target.js +6 -0
  42. package/dist/tools/cron-run.js +1 -1
  43. package/dist/tools/cron-upsert.d.ts +2 -1
  44. package/dist/tools/cron-upsert.js +20 -6
  45. package/dist/tools/{cron-list.d.ts → schedule-cancel.d.ts} +1 -1
  46. package/dist/tools/schedule-cancel.js +12 -0
  47. package/dist/tools/schedule-format.d.ts +4 -0
  48. package/dist/tools/schedule-format.js +48 -0
  49. package/dist/tools/{cron-remove.d.ts → schedule-list.d.ts} +1 -1
  50. package/dist/tools/schedule-list.js +17 -0
  51. package/dist/tools/schedule-once.d.ts +4 -0
  52. package/dist/tools/schedule-once.js +43 -0
  53. package/dist/tools/schedule-status.d.ts +3 -0
  54. package/dist/tools/schedule-status.js +23 -0
  55. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  56. package/package.json +4 -4
  57. package/dist/host/noop.d.ts +0 -4
  58. package/dist/host/noop.js +0 -14
  59. package/dist/tools/cron-list.js +0 -34
  60. package/dist/tools/cron-remove.js +0 -12
@@ -17,28 +17,7 @@ export class GatewaySessionContext {
17
17
  getDefaultReplyTarget(sessionId) {
18
18
  return this.store.getDefaultSessionReplyTarget(sessionId);
19
19
  }
20
- buildSystemPrompt(sessionId) {
21
- const targets = this.listReplyTargets(sessionId);
22
- if (targets.length === 0) {
23
- return null;
24
- }
25
- if (targets.length === 1) {
26
- const target = targets[0];
27
- return [
28
- "Gateway context:",
29
- `- Current message source channel: ${target.channel}`,
30
- `- Current reply target id: ${target.target}`,
31
- `- Current reply topic: ${target.topic ?? "none"}`,
32
- "- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
33
- "- If the user asks to start a fresh channel session, use channel_new_session.",
34
- ].join("\n");
35
- }
36
- return [
37
- "Gateway context:",
38
- `- This session currently fans out to ${targets.length} reply targets.`,
39
- ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
40
- "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
41
- "- If the user asks to start a fresh channel session for this route, use channel_new_session.",
42
- ].join("\n");
20
+ isGatewaySession(sessionId) {
21
+ return this.store.hasGatewaySession(sessionId);
43
22
  }
44
23
  }
@@ -0,0 +1,8 @@
1
+ import type { GatewayMemoryPromptProvider } from "../memory/prompt";
2
+ import type { GatewaySessionContext } from "./context";
3
+ export declare class GatewaySystemPromptBuilder {
4
+ private readonly sessions;
5
+ private readonly memory;
6
+ constructor(sessions: GatewaySessionContext, memory: GatewayMemoryPromptProvider);
7
+ buildPrompts(sessionId: string): Promise<string[]>;
8
+ }
@@ -0,0 +1,52 @@
1
+ export class GatewaySystemPromptBuilder {
2
+ sessions;
3
+ memory;
4
+ constructor(sessions, memory) {
5
+ this.sessions = sessions;
6
+ this.memory = memory;
7
+ }
8
+ async buildPrompts(sessionId) {
9
+ if (!this.sessions.isGatewaySession(sessionId)) {
10
+ return [];
11
+ }
12
+ const prompts = [];
13
+ const gatewayPrompt = buildGatewayContextPrompt(this.sessions.listReplyTargets(sessionId));
14
+ if (gatewayPrompt !== null) {
15
+ prompts.push(gatewayPrompt);
16
+ }
17
+ const memoryPrompt = await this.memory.buildPrompt();
18
+ if (memoryPrompt !== null) {
19
+ prompts.push(memoryPrompt);
20
+ }
21
+ return prompts;
22
+ }
23
+ }
24
+ function buildGatewayContextPrompt(targets) {
25
+ if (targets.length === 0) {
26
+ return null;
27
+ }
28
+ if (targets.length === 1) {
29
+ const target = targets[0];
30
+ return [
31
+ "Gateway context:",
32
+ `- Current message source channel: ${target.channel}`,
33
+ `- Current reply target id: ${target.target}`,
34
+ `- Current reply topic: ${target.topic ?? "none"}`,
35
+ "- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
36
+ "- If the user asks to start a fresh channel session, use channel_new_session.",
37
+ "- If the user asks for a one-shot reminder or relative-time follow-up, prefer schedule_once.",
38
+ "- If the user asks for a recurring schedule, prefer cron_upsert.",
39
+ "- Use schedule_list and schedule_status to inspect existing scheduled jobs and recent run results.",
40
+ "- Scheduled results delivered to this channel are automatically appended to this session as context.",
41
+ ].join("\n");
42
+ }
43
+ return [
44
+ "Gateway context:",
45
+ `- This session currently fans out to ${targets.length} reply targets.`,
46
+ ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
47
+ "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
48
+ "- If the user asks to start a fresh channel session for this route, use channel_new_session.",
49
+ "- Prefer schedule_once for one-shot reminders and cron_upsert for recurring schedules.",
50
+ "- Use schedule_list and schedule_status to inspect scheduled jobs and recent run results.",
51
+ ].join("\n");
52
+ }
@@ -1,4 +1,4 @@
1
- const LATEST_SCHEMA_VERSION = 6;
1
+ const LATEST_SCHEMA_VERSION = 7;
2
2
  export function migrateGatewayDatabase(db) {
3
3
  db.exec("PRAGMA journal_mode = WAL;");
4
4
  db.exec("PRAGMA foreign_keys = ON;");
@@ -28,6 +28,10 @@ export function migrateGatewayDatabase(db) {
28
28
  }
29
29
  if (currentVersion === 5) {
30
30
  migrateToV6(db);
31
+ currentVersion = 6;
32
+ }
33
+ if (currentVersion === 6) {
34
+ migrateToV7(db);
31
35
  }
32
36
  }
33
37
  function readUserVersion(db) {
@@ -179,5 +183,15 @@ function migrateToV6(db) {
179
183
  CREATE INDEX pending_questions_session_id_created_at_ms_idx
180
184
  ON pending_questions (session_id, created_at_ms);
181
185
  `);
186
+ db.exec("PRAGMA user_version = 6;");
187
+ }
188
+ function migrateToV7(db) {
189
+ db.exec(`
190
+ ALTER TABLE cron_jobs
191
+ ADD COLUMN kind TEXT NOT NULL DEFAULT 'cron';
192
+
193
+ ALTER TABLE cron_jobs
194
+ ADD COLUMN run_at_ms INTEGER;
195
+ `);
182
196
  db.exec(`PRAGMA user_version = ${LATEST_SCHEMA_VERSION};`);
183
197
  }
@@ -3,6 +3,17 @@ import type { BindingDeliveryTarget } from "../binding";
3
3
  import type { GatewayQuestionInfo, PendingQuestionRecord } from "../questions/types";
4
4
  export type RuntimeJournalKind = "inbound_message" | "cron_dispatch" | "delivery" | "mailbox_enqueue" | "mailbox_flush";
5
5
  export type CronRunStatus = "running" | "succeeded" | "failed" | "abandoned";
6
+ export type ScheduleJobKind = "cron" | "once";
7
+ export type CronRunRecord = {
8
+ id: number;
9
+ jobId: string;
10
+ scheduledForMs: number;
11
+ startedAtMs: number;
12
+ finishedAtMs: number | null;
13
+ status: CronRunStatus;
14
+ responseText: string | null;
15
+ errorMessage: string | null;
16
+ };
6
17
  export type RuntimeJournalEntry = {
7
18
  kind: RuntimeJournalKind;
8
19
  recordedAtMs: number;
@@ -11,7 +22,9 @@ export type RuntimeJournalEntry = {
11
22
  };
12
23
  export type CronJobRecord = {
13
24
  id: string;
14
- schedule: string;
25
+ kind: ScheduleJobKind;
26
+ schedule: string | null;
27
+ runAtMs: number | null;
15
28
  prompt: string;
16
29
  deliveryChannel: string | null;
17
30
  deliveryTarget: string | null;
@@ -43,7 +56,9 @@ export type MailboxEntryAttachmentRecord = {
43
56
  };
44
57
  export type PersistCronJobInput = {
45
58
  id: string;
46
- schedule: string;
59
+ kind: ScheduleJobKind;
60
+ schedule: string | null;
61
+ runAtMs: number | null;
47
62
  prompt: string;
48
63
  deliveryChannel: string | null;
49
64
  deliveryTarget: string | null;
@@ -97,6 +112,7 @@ export declare class SqliteStore {
97
112
  replaceSessionReplyTargets(input: PersistSessionReplyTargetsInput): void;
98
113
  listSessionReplyTargets(sessionId: string): BindingDeliveryTarget[];
99
114
  getDefaultSessionReplyTarget(sessionId: string): BindingDeliveryTarget | null;
115
+ hasGatewaySession(sessionId: string): boolean;
100
116
  appendJournal(entry: RuntimeJournalEntry): void;
101
117
  replacePendingQuestion(input: PersistPendingQuestionInput): void;
102
118
  deletePendingQuestion(requestId: string): void;
@@ -119,6 +135,8 @@ export declare class SqliteStore {
119
135
  listDueCronJobs(nowMs: number, limit: number): CronJobRecord[];
120
136
  removeCronJob(id: string): boolean;
121
137
  updateCronJobNextRun(id: string, nextRunAtMs: number, recordedAtMs: number): void;
138
+ setCronJobEnabled(id: string, enabled: boolean, recordedAtMs: number): void;
139
+ listCronRuns(jobId: string, limit: number): CronRunRecord[];
122
140
  insertCronRun(jobId: string, scheduledForMs: number, startedAtMs: number): number;
123
141
  finishCronRun(runId: number, status: Exclude<CronRunStatus, "running">, finishedAtMs: number, responseText: string | null, errorMessage: string | null): void;
124
142
  abandonRunningCronRuns(finishedAtMs: number): number;
@@ -109,6 +109,28 @@ export class SqliteStore {
109
109
  .get(sessionId);
110
110
  return row ? mapSessionReplyTargetRow(row) : null;
111
111
  }
112
+ hasGatewaySession(sessionId) {
113
+ const binding = this.db
114
+ .query(`
115
+ SELECT 1 AS present
116
+ FROM session_bindings
117
+ WHERE session_id = ?1
118
+ LIMIT 1;
119
+ `)
120
+ .get(sessionId);
121
+ if (binding?.present === 1) {
122
+ return true;
123
+ }
124
+ const replyTarget = this.db
125
+ .query(`
126
+ SELECT 1 AS present
127
+ FROM session_reply_targets
128
+ WHERE session_id = ?1
129
+ LIMIT 1;
130
+ `)
131
+ .get(sessionId);
132
+ return replyTarget?.present === 1;
133
+ }
112
134
  appendJournal(entry) {
113
135
  this.db
114
136
  .query(`
@@ -315,11 +337,16 @@ export class SqliteStore {
315
337
  upsertCronJob(input) {
316
338
  assertSafeInteger(input.nextRunAtMs, "cron next_run_at_ms");
317
339
  assertSafeInteger(input.recordedAtMs, "cron recordedAtMs");
340
+ if (input.runAtMs !== null) {
341
+ assertSafeInteger(input.runAtMs, "cron run_at_ms");
342
+ }
318
343
  this.db
319
344
  .query(`
320
345
  INSERT INTO cron_jobs (
321
346
  id,
347
+ kind,
322
348
  schedule,
349
+ run_at_ms,
323
350
  prompt,
324
351
  delivery_channel,
325
352
  delivery_target,
@@ -329,9 +356,11 @@ export class SqliteStore {
329
356
  created_at_ms,
330
357
  updated_at_ms
331
358
  )
332
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
359
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
333
360
  ON CONFLICT(id) DO UPDATE SET
361
+ kind = excluded.kind,
334
362
  schedule = excluded.schedule,
363
+ run_at_ms = excluded.run_at_ms,
335
364
  prompt = excluded.prompt,
336
365
  delivery_channel = excluded.delivery_channel,
337
366
  delivery_target = excluded.delivery_target,
@@ -340,14 +369,16 @@ export class SqliteStore {
340
369
  next_run_at_ms = excluded.next_run_at_ms,
341
370
  updated_at_ms = excluded.updated_at_ms;
342
371
  `)
343
- .run(input.id, input.schedule, input.prompt, input.deliveryChannel, input.deliveryTarget, input.deliveryTopic, input.enabled ? 1 : 0, input.nextRunAtMs, input.recordedAtMs);
372
+ .run(input.id, input.kind, encodeStoredSchedule(input.kind, input.schedule), input.runAtMs, input.prompt, input.deliveryChannel, input.deliveryTarget, input.deliveryTopic, input.enabled ? 1 : 0, input.nextRunAtMs, input.recordedAtMs);
344
373
  }
345
374
  getCronJob(id) {
346
375
  const row = this.db
347
376
  .query(`
348
377
  SELECT
349
378
  id,
379
+ kind,
350
380
  schedule,
381
+ run_at_ms,
351
382
  prompt,
352
383
  delivery_channel,
353
384
  delivery_target,
@@ -367,7 +398,9 @@ export class SqliteStore {
367
398
  .query(`
368
399
  SELECT
369
400
  id,
401
+ kind,
370
402
  schedule,
403
+ run_at_ms,
371
404
  prompt,
372
405
  delivery_channel,
373
406
  delivery_target,
@@ -388,7 +421,9 @@ export class SqliteStore {
388
421
  .query(`
389
422
  SELECT
390
423
  id,
424
+ kind,
391
425
  schedule,
426
+ run_at_ms,
392
427
  prompt,
393
428
  delivery_channel,
394
429
  delivery_target,
@@ -398,7 +433,7 @@ export class SqliteStore {
398
433
  created_at_ms,
399
434
  updated_at_ms
400
435
  FROM cron_jobs
401
- WHERE enabled = 1 AND next_run_at_ms <= ?1
436
+ WHERE kind = 'cron' AND enabled = 1 AND next_run_at_ms <= ?1
402
437
  ORDER BY next_run_at_ms ASC, id ASC;
403
438
  `)
404
439
  .all(nowMs);
@@ -411,7 +446,9 @@ export class SqliteStore {
411
446
  .query(`
412
447
  SELECT
413
448
  id,
449
+ kind,
414
450
  schedule,
451
+ run_at_ms,
415
452
  prompt,
416
453
  delivery_channel,
417
454
  delivery_target,
@@ -443,6 +480,47 @@ export class SqliteStore {
443
480
  `)
444
481
  .run(id, nextRunAtMs, recordedAtMs);
445
482
  }
483
+ setCronJobEnabled(id, enabled, recordedAtMs) {
484
+ assertSafeInteger(recordedAtMs, "cron recordedAtMs");
485
+ this.db
486
+ .query(`
487
+ UPDATE cron_jobs
488
+ SET enabled = ?2,
489
+ updated_at_ms = ?3
490
+ WHERE id = ?1;
491
+ `)
492
+ .run(id, enabled ? 1 : 0, recordedAtMs);
493
+ }
494
+ listCronRuns(jobId, limit) {
495
+ assertSafeInteger(limit, "cron run limit");
496
+ const rows = this.db
497
+ .query(`
498
+ SELECT
499
+ id,
500
+ job_id,
501
+ scheduled_for_ms,
502
+ started_at_ms,
503
+ finished_at_ms,
504
+ status,
505
+ response_text,
506
+ error_message
507
+ FROM cron_runs
508
+ WHERE job_id = ?1
509
+ ORDER BY started_at_ms DESC, id DESC
510
+ LIMIT ?2;
511
+ `)
512
+ .all(jobId, limit);
513
+ return rows.map((row) => ({
514
+ id: row.id,
515
+ jobId: row.job_id,
516
+ scheduledForMs: row.scheduled_for_ms,
517
+ startedAtMs: row.started_at_ms,
518
+ finishedAtMs: row.finished_at_ms,
519
+ status: row.status,
520
+ responseText: row.response_text,
521
+ errorMessage: row.error_message,
522
+ }));
523
+ }
446
524
  insertCronRun(jobId, scheduledForMs, startedAtMs) {
447
525
  assertSafeInteger(scheduledForMs, "cron scheduled_for_ms");
448
526
  assertSafeInteger(startedAtMs, "cron started_at_ms");
@@ -496,9 +574,12 @@ export class SqliteStore {
496
574
  }
497
575
  }
498
576
  function mapCronJobRow(row) {
577
+ const kind = parseScheduleJobKind(row.kind);
499
578
  return {
500
579
  id: row.id,
501
- schedule: row.schedule,
580
+ kind,
581
+ schedule: kind === "cron" ? row.schedule : null,
582
+ runAtMs: row.run_at_ms,
502
583
  prompt: row.prompt,
503
584
  deliveryChannel: row.delivery_channel,
504
585
  deliveryTarget: row.delivery_target,
@@ -509,6 +590,24 @@ function mapCronJobRow(row) {
509
590
  updatedAtMs: row.updated_at_ms,
510
591
  };
511
592
  }
593
+ function parseScheduleJobKind(value) {
594
+ switch (value) {
595
+ case "cron":
596
+ case "once":
597
+ return value;
598
+ default:
599
+ throw new Error(`stored schedule job kind is invalid: ${value}`);
600
+ }
601
+ }
602
+ function encodeStoredSchedule(kind, schedule) {
603
+ if (kind === "once") {
604
+ return "@once";
605
+ }
606
+ if (schedule === null) {
607
+ throw new Error("cron schedule must not be null");
608
+ }
609
+ return schedule;
610
+ }
512
611
  function mapMailboxEntryRow(row, attachments) {
513
612
  return {
514
613
  id: row.id,
@@ -5,3 +5,8 @@ export declare function resolveToolDeliveryTarget(args: {
5
5
  target?: string;
6
6
  topic?: string;
7
7
  }, sessionId: string | null | undefined, sessions: GatewaySessionContext): BindingDeliveryTarget;
8
+ export declare function resolveOptionalToolDeliveryTarget(args: {
9
+ channel?: string;
10
+ target?: string;
11
+ topic?: string;
12
+ }, sessionId: string | null | undefined, sessions: GatewaySessionContext): BindingDeliveryTarget | null;
@@ -9,6 +9,12 @@ export function resolveToolDeliveryTarget(args, sessionId, sessions) {
9
9
  topic,
10
10
  };
11
11
  }
12
+ export function resolveOptionalToolDeliveryTarget(args, sessionId, sessions) {
13
+ if (args.channel === undefined && args.target === undefined && args.topic === undefined) {
14
+ return sessionId ? sessions.getDefaultReplyTarget(sessionId) : null;
15
+ }
16
+ return resolveToolDeliveryTarget(args, sessionId, sessions);
17
+ }
12
18
  function normalizeRequired(value, field) {
13
19
  if (value === null) {
14
20
  throw new Error(`${field} is required when the current session has no default reply target`);
@@ -1,7 +1,7 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  export function createCronRunTool(runtime) {
3
3
  return tool({
4
- description: "Run one persisted gateway cron job immediately without changing its schedule",
4
+ description: "Run one persisted gateway schedule job immediately without changing its schedule metadata.",
5
5
  args: {
6
6
  id: tool.schema.string().min(1),
7
7
  },
@@ -1,3 +1,4 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
2
  import type { GatewayCronRuntime } from "../cron/runtime";
3
- export declare function createCronUpsertTool(runtime: GatewayCronRuntime): ToolDefinition;
3
+ import type { GatewaySessionContext } from "../session/context";
4
+ export declare function createCronUpsertTool(runtime: GatewayCronRuntime, sessions: GatewaySessionContext): ToolDefinition;
@@ -1,8 +1,9 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
+ import { resolveOptionalToolDeliveryTarget } from "./channel-target";
2
3
  import { formatUnixMsAsUtc, formatUnixMsInTimeZone } from "./time";
3
- export function createCronUpsertTool(runtime) {
4
+ export function createCronUpsertTool(runtime, sessions) {
4
5
  return tool({
5
- description: "Create or replace a persisted gateway cron job. The schedule uses cron.timezone or the runtime local time zone.",
6
+ description: "Create or replace a recurring gateway cron job. When called from a channel-backed session, delivery defaults to the current reply target.",
6
7
  args: {
7
8
  id: tool.schema.string().min(1),
8
9
  schedule: tool.schema.string().min(1),
@@ -12,26 +13,39 @@ export function createCronUpsertTool(runtime) {
12
13
  delivery_target: tool.schema.string().optional(),
13
14
  delivery_topic: tool.schema.string().optional(),
14
15
  },
15
- async execute(args) {
16
+ async execute(args, context) {
17
+ const deliveryTarget = resolveOptionalToolDeliveryTarget({
18
+ channel: args.delivery_channel,
19
+ target: args.delivery_target,
20
+ topic: args.delivery_topic,
21
+ }, context.sessionID, sessions);
16
22
  const timeZone = runtime.timeZone();
17
23
  const job = runtime.upsertJob({
18
24
  id: args.id,
19
25
  schedule: args.schedule,
20
26
  prompt: args.prompt,
21
27
  enabled: args.enabled ?? true,
22
- deliveryChannel: args.delivery_channel ?? null,
23
- deliveryTarget: args.delivery_target ?? null,
24
- deliveryTopic: args.delivery_topic ?? null,
28
+ deliveryChannel: deliveryTarget?.channel ?? null,
29
+ deliveryTarget: deliveryTarget?.target ?? null,
30
+ deliveryTopic: deliveryTarget?.topic ?? null,
25
31
  });
26
32
  return [
27
33
  `id=${job.id}`,
34
+ `kind=${job.kind}`,
28
35
  `enabled=${job.enabled}`,
29
36
  `schedule=${job.schedule}`,
30
37
  `timezone=${timeZone}`,
31
38
  `next_run_at_ms=${job.nextRunAtMs}`,
32
39
  `next_run_at_local=${formatUnixMsInTimeZone(job.nextRunAtMs, timeZone)}`,
33
40
  `next_run_at_utc=${formatUnixMsAsUtc(job.nextRunAtMs)}`,
41
+ `delivery=${formatDelivery(job.deliveryChannel, job.deliveryTarget, job.deliveryTopic)}`,
34
42
  ].join("\n");
35
43
  },
36
44
  });
37
45
  }
46
+ function formatDelivery(channel, target, topic) {
47
+ if (channel === null || target === null) {
48
+ return "none";
49
+ }
50
+ return topic === null ? `${channel}:${target}` : `${channel}:${target}:topic:${topic}`;
51
+ }
@@ -1,3 +1,3 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
2
  import type { GatewayCronRuntime } from "../cron/runtime";
3
- export declare function createCronListTool(runtime: GatewayCronRuntime): ToolDefinition;
3
+ export declare function createScheduleCancelTool(runtime: GatewayCronRuntime): ToolDefinition;
@@ -0,0 +1,12 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ export function createScheduleCancelTool(runtime) {
3
+ return tool({
4
+ description: "Cancel a persisted gateway schedule job without deleting its run history.",
5
+ args: {
6
+ id: tool.schema.string().min(1),
7
+ },
8
+ async execute(args) {
9
+ return runtime.cancelJob(args.id) ? `canceled=${args.id}` : `inactive=${args.id}`;
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,4 @@
1
+ import type { ScheduleJobStatus } from "../cron/runtime";
2
+ import type { CronJobRecord } from "../store/sqlite";
3
+ export declare function formatScheduleJob(job: CronJobRecord, timeZone: string): string;
4
+ export declare function formatScheduleStatus(status: ScheduleJobStatus, timeZone: string): string;
@@ -0,0 +1,48 @@
1
+ import { formatUnixMsAsUtc, formatUnixMsInTimeZone } from "./time";
2
+ export function formatScheduleJob(job, timeZone) {
3
+ const lines = [`id=${job.id}`, `kind=${job.kind}`, `enabled=${job.enabled}`];
4
+ if (job.kind === "cron") {
5
+ lines.push(`schedule=${job.schedule}`);
6
+ lines.push(`timezone=${timeZone}`);
7
+ lines.push(`next_run_at_ms=${job.nextRunAtMs}`);
8
+ lines.push(`next_run_at_local=${formatUnixMsInTimeZone(job.nextRunAtMs, timeZone)}`);
9
+ lines.push(`next_run_at_utc=${formatUnixMsAsUtc(job.nextRunAtMs)}`);
10
+ }
11
+ else {
12
+ lines.push(`run_at_ms=${job.runAtMs ?? "none"}`);
13
+ if (job.runAtMs !== null) {
14
+ lines.push(`run_at_local=${formatUnixMsInTimeZone(job.runAtMs, timeZone)}`);
15
+ lines.push(`run_at_utc=${formatUnixMsAsUtc(job.runAtMs)}`);
16
+ }
17
+ }
18
+ lines.push(`delivery=${formatDelivery(job.deliveryChannel, job.deliveryTarget, job.deliveryTopic)}`);
19
+ lines.push(`prompt=${job.prompt}`);
20
+ return lines.join("\n");
21
+ }
22
+ export function formatScheduleStatus(status, timeZone) {
23
+ const lines = [formatScheduleJob(status.job, timeZone), `state=${status.state}`];
24
+ if (status.runs.length === 0) {
25
+ lines.push("runs=none");
26
+ return lines.join("\n");
27
+ }
28
+ lines.push("");
29
+ lines.push(...status.runs.map((run, index) => formatRun(run, index + 1)));
30
+ return lines.join("\n");
31
+ }
32
+ function formatRun(run, ordinal) {
33
+ return [
34
+ `run[${ordinal}].id=${run.id}`,
35
+ `run[${ordinal}].status=${run.status}`,
36
+ `run[${ordinal}].scheduled_for_ms=${run.scheduledForMs}`,
37
+ `run[${ordinal}].started_at_ms=${run.startedAtMs}`,
38
+ `run[${ordinal}].finished_at_ms=${run.finishedAtMs ?? "none"}`,
39
+ `run[${ordinal}].response_text=${run.responseText ?? "none"}`,
40
+ `run[${ordinal}].error_message=${run.errorMessage ?? "none"}`,
41
+ ].join("\n");
42
+ }
43
+ function formatDelivery(channel, target, topic) {
44
+ if (channel === null || target === null) {
45
+ return "none";
46
+ }
47
+ return topic === null ? `${channel}:${target}` : `${channel}:${target}:topic:${topic}`;
48
+ }
@@ -1,3 +1,3 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
2
  import type { GatewayCronRuntime } from "../cron/runtime";
3
- export declare function createCronRemoveTool(runtime: GatewayCronRuntime): ToolDefinition;
3
+ export declare function createScheduleListTool(runtime: GatewayCronRuntime): ToolDefinition;
@@ -0,0 +1,17 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { formatScheduleJob } from "./schedule-format";
3
+ export function createScheduleListTool(runtime) {
4
+ return tool({
5
+ description: "List persisted gateway schedule jobs, including recurring cron jobs and one-shot timers.",
6
+ args: {
7
+ include_terminal: tool.schema.boolean().optional(),
8
+ },
9
+ async execute(args) {
10
+ const jobs = runtime.listJobs(args.include_terminal ?? false);
11
+ if (jobs.length === 0) {
12
+ return "no scheduled jobs";
13
+ }
14
+ return jobs.map((job) => formatScheduleJob(job, runtime.timeZone())).join("\n\n");
15
+ },
16
+ });
17
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolDefinition } from "@opencode-ai/plugin";
2
+ import type { GatewayCronRuntime } from "../cron/runtime";
3
+ import type { GatewaySessionContext } from "../session/context";
4
+ export declare function createScheduleOnceTool(runtime: GatewayCronRuntime, sessions: GatewaySessionContext): ToolDefinition;
@@ -0,0 +1,43 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { resolveOptionalToolDeliveryTarget } from "./channel-target";
3
+ import { formatScheduleJob } from "./schedule-format";
4
+ export function createScheduleOnceTool(runtime, sessions) {
5
+ return tool({
6
+ description: "Schedule a one-shot gateway job. When called from a channel-backed session, delivery defaults to the current reply target.",
7
+ args: {
8
+ id: tool.schema.string().min(1),
9
+ prompt: tool.schema.string().min(1),
10
+ delay_seconds: tool.schema.number().optional(),
11
+ run_at_ms: tool.schema.number().optional(),
12
+ delivery_channel: tool.schema.string().optional(),
13
+ delivery_target: tool.schema.string().optional(),
14
+ delivery_topic: tool.schema.string().optional(),
15
+ },
16
+ async execute(args, context) {
17
+ const deliveryTarget = resolveOptionalToolDeliveryTarget({
18
+ channel: args.delivery_channel,
19
+ target: args.delivery_target,
20
+ topic: args.delivery_topic,
21
+ }, context.sessionID, sessions);
22
+ const job = runtime.scheduleOnce({
23
+ id: args.id,
24
+ prompt: args.prompt,
25
+ delaySeconds: normalizeOptionalInteger(args.delay_seconds, "delay_seconds"),
26
+ runAtMs: normalizeOptionalInteger(args.run_at_ms, "run_at_ms"),
27
+ deliveryChannel: deliveryTarget?.channel ?? null,
28
+ deliveryTarget: deliveryTarget?.target ?? null,
29
+ deliveryTopic: deliveryTarget?.topic ?? null,
30
+ });
31
+ return formatScheduleJob(job, runtime.timeZone());
32
+ },
33
+ });
34
+ }
35
+ function normalizeOptionalInteger(value, field) {
36
+ if (value === undefined) {
37
+ return null;
38
+ }
39
+ if (!Number.isSafeInteger(value)) {
40
+ throw new Error(`${field} must be an integer`);
41
+ }
42
+ return value;
43
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from "@opencode-ai/plugin";
2
+ import type { GatewayCronRuntime } from "../cron/runtime";
3
+ export declare function createScheduleStatusTool(runtime: GatewayCronRuntime): ToolDefinition;
@@ -0,0 +1,23 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { formatScheduleStatus } from "./schedule-format";
3
+ export function createScheduleStatusTool(runtime) {
4
+ return tool({
5
+ description: "Inspect one persisted gateway schedule job and its recent run history.",
6
+ args: {
7
+ id: tool.schema.string().min(1),
8
+ limit: tool.schema.number().optional(),
9
+ },
10
+ async execute(args) {
11
+ return formatScheduleStatus(runtime.getJobStatus(args.id, normalizeOptionalLimit(args.limit)), runtime.timeZone());
12
+ },
13
+ });
14
+ }
15
+ function normalizeOptionalLimit(value) {
16
+ if (value === undefined) {
17
+ return undefined;
18
+ }
19
+ if (!Number.isSafeInteger(value)) {
20
+ throw new Error("limit must be an integer");
21
+ }
22
+ return value;
23
+ }