opencode-gateway 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.
Files changed (44) hide show
  1. package/dist/cli/doctor.js +3 -1
  2. package/dist/cli/init.js +4 -1
  3. package/dist/cli/paths.js +1 -1
  4. package/dist/cli.js +13 -4
  5. package/dist/config/gateway.d.ts +1 -0
  6. package/dist/config/gateway.js +2 -1
  7. package/dist/config/paths.d.ts +2 -0
  8. package/dist/config/paths.js +5 -1
  9. package/dist/cron/runtime.d.ts +24 -5
  10. package/dist/cron/runtime.js +178 -13
  11. package/dist/delivery/text.js +1 -1
  12. package/dist/gateway.js +41 -35
  13. package/dist/index.js +9 -5
  14. package/dist/opencode/adapter.d.ts +2 -0
  15. package/dist/opencode/adapter.js +56 -7
  16. package/dist/runtime/conversation-coordinator.d.ts +4 -0
  17. package/dist/runtime/conversation-coordinator.js +22 -0
  18. package/dist/runtime/executor.d.ts +33 -5
  19. package/dist/runtime/executor.js +229 -22
  20. package/dist/runtime/runtime-singleton.d.ts +2 -0
  21. package/dist/runtime/runtime-singleton.js +28 -0
  22. package/dist/session/context.js +6 -0
  23. package/dist/store/migrations.js +15 -1
  24. package/dist/store/sqlite.d.ts +19 -2
  25. package/dist/store/sqlite.js +81 -4
  26. package/dist/tools/channel-target.d.ts +5 -0
  27. package/dist/tools/channel-target.js +6 -0
  28. package/dist/tools/cron-run.js +1 -1
  29. package/dist/tools/cron-upsert.d.ts +2 -1
  30. package/dist/tools/cron-upsert.js +20 -6
  31. package/dist/tools/{cron-remove.d.ts → schedule-cancel.d.ts} +1 -1
  32. package/dist/tools/schedule-cancel.js +12 -0
  33. package/dist/tools/schedule-format.d.ts +4 -0
  34. package/dist/tools/schedule-format.js +48 -0
  35. package/dist/tools/{cron-list.d.ts → schedule-list.d.ts} +1 -1
  36. package/dist/tools/schedule-list.js +17 -0
  37. package/dist/tools/schedule-once.d.ts +4 -0
  38. package/dist/tools/schedule-once.js +43 -0
  39. package/dist/tools/schedule-status.d.ts +3 -0
  40. package/dist/tools/schedule-status.js +23 -0
  41. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  42. package/package.json +4 -4
  43. package/dist/tools/cron-list.js +0 -34
  44. package/dist/tools/cron-remove.js +0 -12
package/dist/index.js CHANGED
@@ -2,12 +2,14 @@ import { loadGatewayBindingModule } from "./binding";
2
2
  import { createGatewayRuntime } from "./gateway";
3
3
  import { createChannelNewSessionTool } from "./tools/channel-new-session";
4
4
  import { createChannelSendFileTool } from "./tools/channel-send-file";
5
- import { createCronListTool } from "./tools/cron-list";
6
- import { createCronRemoveTool } from "./tools/cron-remove";
7
5
  import { createCronRunTool } from "./tools/cron-run";
8
6
  import { createCronUpsertTool } from "./tools/cron-upsert";
9
7
  import { createGatewayDispatchCronTool } from "./tools/gateway-dispatch-cron";
10
8
  import { createGatewayStatusTool } from "./tools/gateway-status";
9
+ import { createScheduleCancelTool } from "./tools/schedule-cancel";
10
+ import { createScheduleListTool } from "./tools/schedule-list";
11
+ import { createScheduleOnceTool } from "./tools/schedule-once";
12
+ import { createScheduleStatusTool } from "./tools/schedule-status";
11
13
  import { createTelegramSendTestTool } from "./tools/telegram-send-test";
12
14
  import { createTelegramStatusTool } from "./tools/telegram-status";
13
15
  /**
@@ -18,12 +20,14 @@ export const OpencodeGatewayPlugin = async (input) => {
18
20
  const gatewayModule = await loadGatewayBindingModule();
19
21
  const runtime = await createGatewayRuntime(gatewayModule, input);
20
22
  const tools = {
21
- cron_list: createCronListTool(runtime.cron),
22
- cron_remove: createCronRemoveTool(runtime.cron),
23
23
  cron_run: createCronRunTool(runtime.cron),
24
- cron_upsert: createCronUpsertTool(runtime.cron),
24
+ cron_upsert: createCronUpsertTool(runtime.cron, runtime.sessionContext),
25
25
  gateway_status: createGatewayStatusTool(runtime),
26
26
  gateway_dispatch_cron: createGatewayDispatchCronTool(runtime.executor),
27
+ schedule_cancel: createScheduleCancelTool(runtime.cron),
28
+ schedule_list: createScheduleListTool(runtime.cron),
29
+ schedule_once: createScheduleOnceTool(runtime.cron, runtime.sessionContext),
30
+ schedule_status: createScheduleStatusTool(runtime.cron),
27
31
  };
28
32
  if (runtime.files.hasEnabledChannel()) {
29
33
  tools.channel_send_file = createChannelSendFileTool(runtime.files, runtime.sessionContext);
@@ -6,6 +6,8 @@ export declare class OpencodeSdkAdapter {
6
6
  private readonly directory;
7
7
  constructor(client: OpencodeClient, directory: string);
8
8
  createFreshSession(title: string): Promise<string>;
9
+ isSessionBusy(sessionId: string): Promise<boolean>;
10
+ abortSession(sessionId: string): Promise<void>;
9
11
  execute(command: BindingOpencodeCommand): Promise<BindingOpencodeCommandResult>;
10
12
  private lookupSession;
11
13
  private createSession;
@@ -18,6 +18,31 @@ export class OpencodeSdkAdapter {
18
18
  });
19
19
  return unwrapData(session).id;
20
20
  }
21
+ async isSessionBusy(sessionId) {
22
+ const statuses = await this.client.session.status({
23
+ query: { directory: this.directory },
24
+ responseStyle: "data",
25
+ throwOnError: true,
26
+ });
27
+ const current = unwrapData(statuses)[sessionId];
28
+ return current?.type === "busy";
29
+ }
30
+ async abortSession(sessionId) {
31
+ try {
32
+ await this.client.session.abort({
33
+ path: { id: sessionId },
34
+ query: { directory: this.directory },
35
+ responseStyle: "data",
36
+ throwOnError: true,
37
+ });
38
+ }
39
+ catch (error) {
40
+ if (isMissingSessionError(error)) {
41
+ return;
42
+ }
43
+ throw error;
44
+ }
45
+ }
21
46
  async execute(command) {
22
47
  try {
23
48
  switch (command.kind) {
@@ -125,6 +150,7 @@ export class OpencodeSdkAdapter {
125
150
  }
126
151
  async awaitPromptResponse(command) {
127
152
  const deadline = Date.now() + PROMPT_RESPONSE_TIMEOUT_MS;
153
+ let stableCandidateKey = null;
128
154
  for (;;) {
129
155
  const messages = await this.client.session.messages({
130
156
  path: { id: command.sessionId },
@@ -137,12 +163,19 @@ export class OpencodeSdkAdapter {
137
163
  });
138
164
  const response = selectAssistantResponse(unwrapData(messages), command.messageId);
139
165
  if (response !== null) {
140
- return {
141
- kind: "awaitPromptResponse",
142
- sessionId: command.sessionId,
143
- messageId: response.info.id,
144
- parts: response.parts.flatMap(toBindingMessagePart),
145
- };
166
+ const candidateKey = createAssistantCandidateKey(response);
167
+ if (stableCandidateKey === candidateKey) {
168
+ return {
169
+ kind: "awaitPromptResponse",
170
+ sessionId: command.sessionId,
171
+ messageId: response.info.id,
172
+ parts: response.parts.flatMap(toBindingMessagePart),
173
+ };
174
+ }
175
+ stableCandidateKey = candidateKey;
176
+ }
177
+ else {
178
+ stableCandidateKey = null;
146
179
  }
147
180
  if (Date.now() >= deadline) {
148
181
  throw new Error(`assistant message for prompt ${command.messageId} is unavailable after prompt completion`);
@@ -221,6 +254,19 @@ function selectAssistantResponse(messages, userMessageId) {
221
254
  }
222
255
  return null;
223
256
  }
257
+ function createAssistantCandidateKey(message) {
258
+ return JSON.stringify({
259
+ messageId: message.info.id,
260
+ finish: message.info.finish ?? null,
261
+ hasError: message.info.error !== undefined,
262
+ parts: message.parts.map((part) => ({
263
+ id: part.id ?? null,
264
+ type: part.type,
265
+ text: typeof part.text === "string" ? part.text : null,
266
+ ignored: part.ignored === true,
267
+ })),
268
+ });
269
+ }
224
270
  function isAssistantChildMessage(userMessageId) {
225
271
  return (message) => message.info?.role === "assistant" && message.info.parentID === userMessageId;
226
272
  }
@@ -239,7 +285,10 @@ function toBindingMessagePart(part) {
239
285
  ];
240
286
  }
241
287
  function hasVisibleText(message) {
242
- return message.parts.some((part) => part.type === "text" && part.ignored !== true && typeof part.text === "string" && part.text.length > 0);
288
+ return message.parts.some((part) => part.type === "text" &&
289
+ part.ignored !== true &&
290
+ typeof part.text === "string" &&
291
+ part.text.trim().length > 0);
243
292
  }
244
293
  function toBindingMessage(message) {
245
294
  if (typeof message.info?.id !== "string" ||
@@ -0,0 +1,4 @@
1
+ export declare class ConversationCoordinator {
2
+ private readonly tails;
3
+ runExclusive<T>(conversationKey: string, operation: () => Promise<T>): Promise<T>;
4
+ }
@@ -0,0 +1,22 @@
1
+ export class ConversationCoordinator {
2
+ tails = new Map();
3
+ async runExclusive(conversationKey, operation) {
4
+ const previous = this.tails.get(conversationKey) ?? Promise.resolve();
5
+ let release;
6
+ const current = new Promise((resolve) => {
7
+ release = resolve;
8
+ });
9
+ const tail = previous.then(() => current, () => current);
10
+ this.tails.set(conversationKey, tail);
11
+ await previous;
12
+ try {
13
+ return await operation();
14
+ }
15
+ finally {
16
+ release();
17
+ if (this.tails.get(conversationKey) === tail) {
18
+ this.tails.delete(conversationKey);
19
+ }
20
+ }
21
+ }
22
+ }
@@ -1,8 +1,9 @@
1
- import type { BindingCronJobSpec, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
1
+ import type { BindingCronJobSpec, BindingDeliveryTarget, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
2
2
  import type { GatewayTextDelivery } from "../delivery/text";
3
3
  import type { OpencodeSdkAdapter } from "../opencode/adapter";
4
4
  import type { OpencodeEventHub } from "../opencode/events";
5
5
  import type { MailboxEntryRecord, SqliteStore } from "../store/sqlite";
6
+ import { ConversationCoordinator } from "./conversation-coordinator";
6
7
  export declare class GatewayExecutor {
7
8
  private readonly module;
8
9
  private readonly store;
@@ -10,15 +11,42 @@ export declare class GatewayExecutor {
10
11
  private readonly events;
11
12
  private readonly delivery;
12
13
  private readonly logger;
13
- constructor(module: GatewayBindingModule, store: SqliteStore, opencode: GatewayOpencodeRuntimeLike, events: OpencodeEventHub, delivery: GatewayTextDeliveryLike, logger: BindingLoggerHost);
14
+ private readonly coordinator;
15
+ private internalPromptSequence;
16
+ constructor(module: GatewayBindingModule, store: SqliteStore, opencode: GatewayOpencodeRuntimeLike, events: OpencodeEventHub, delivery: GatewayTextDeliveryLike, logger: BindingLoggerHost, coordinator?: ConversationCoordinator);
14
17
  prepareInboundMessage(message: BindingInboundMessage): BindingPreparedExecution;
15
18
  handleInboundMessage(message: BindingInboundMessage): Promise<BindingRuntimeReport>;
16
19
  executeMailboxEntries(entries: MailboxEntryRecord[]): Promise<BindingRuntimeReport>;
17
20
  dispatchCronJob(job: BindingCronJobSpec): Promise<BindingRuntimeReport>;
18
- private executePreparedEntries;
21
+ dispatchScheduledJob(input: DispatchScheduledJobInput): Promise<BindingRuntimeReport>;
22
+ appendContextToConversation(input: AppendContextToConversationInput): Promise<void>;
23
+ private executePreparedBatch;
24
+ private ensureConversationSession;
25
+ private initializeReplyTargetsIfMissing;
26
+ private preparePersistedSessionForPrompt;
27
+ private lookupSession;
28
+ private createSession;
29
+ private waitUntilIdle;
30
+ private appendPrompt;
31
+ private cleanupResidualBusySession;
32
+ private abortSessionAndWaitForSettle;
33
+ private createInternalPromptIdentity;
19
34
  private executeDriver;
20
35
  }
21
- export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "executeMailboxEntries" | "prepareInboundMessage">;
36
+ export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "dispatchScheduledJob" | "appendContextToConversation" | "executeMailboxEntries" | "prepareInboundMessage">;
37
+ export type DispatchScheduledJobInput = {
38
+ jobId: string;
39
+ jobKind: "cron" | "once";
40
+ conversationKey: string;
41
+ prompt: string;
42
+ replyTarget: BindingPreparedExecution["replyTarget"];
43
+ };
44
+ export type AppendContextToConversationInput = {
45
+ conversationKey: string;
46
+ replyTarget: BindingDeliveryTarget | null;
47
+ body: string;
48
+ recordedAtMs: number;
49
+ };
22
50
  type GatewayTextDeliveryLike = Pick<GatewayTextDelivery, "openMany">;
23
- type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
51
+ type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute" | "isSessionBusy" | "abortSession">;
24
52
  export {};
@@ -1,4 +1,7 @@
1
+ import { ConversationCoordinator } from "./conversation-coordinator";
1
2
  import { runOpencodeDriver } from "./opencode-runner";
3
+ const SESSION_ABORT_SETTLE_TIMEOUT_MS = 5_000;
4
+ const SESSION_ABORT_POLL_MS = 250;
2
5
  export class GatewayExecutor {
3
6
  module;
4
7
  store;
@@ -6,13 +9,16 @@ export class GatewayExecutor {
6
9
  events;
7
10
  delivery;
8
11
  logger;
9
- constructor(module, store, opencode, events, delivery, logger) {
12
+ coordinator;
13
+ internalPromptSequence = 0;
14
+ constructor(module, store, opencode, events, delivery, logger, coordinator = new ConversationCoordinator()) {
10
15
  this.module = module;
11
16
  this.store = store;
12
17
  this.opencode = opencode;
13
18
  this.events = events;
14
19
  this.delivery = delivery;
15
20
  this.logger = logger;
21
+ this.coordinator = coordinator;
16
22
  }
17
23
  prepareInboundMessage(message) {
18
24
  return this.module.prepareInboundExecution(message);
@@ -52,19 +58,21 @@ export class GatewayExecutor {
52
58
  }
53
59
  const recordedAtMs = Date.now();
54
60
  this.logger.log("info", "handling inbound gateway message");
55
- this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
56
- entryIds: preparedEntries.map((entry) => entry.entry.id),
57
- count: preparedEntries.length,
58
- }));
59
- for (const entry of preparedEntries) {
60
- this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
61
- deliveryTarget: entry.prepared.replyTarget,
62
- sender: entry.message.sender,
63
- text: entry.message.text,
64
- attachments: entry.message.attachments,
61
+ return await this.coordinator.runExclusive(conversationKey, async () => {
62
+ this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
63
+ entryIds: preparedEntries.map((entry) => entry.entry.id),
64
+ count: preparedEntries.length,
65
65
  }));
66
- }
67
- return await this.executePreparedEntries(preparedEntries, recordedAtMs);
66
+ for (const entry of preparedEntries) {
67
+ this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
68
+ deliveryTarget: entry.prepared.replyTarget,
69
+ sender: entry.message.sender,
70
+ text: entry.message.text,
71
+ attachments: entry.message.attachments,
72
+ }));
73
+ }
74
+ return await this.executePreparedBatch(preparedEntries, recordedAtMs);
75
+ });
68
76
  }
69
77
  async dispatchCronJob(job) {
70
78
  const prepared = this.module.prepareCronExecution(job);
@@ -80,20 +88,61 @@ export class GatewayExecutor {
80
88
  deliveryTarget: prepared.replyTarget?.target ?? null,
81
89
  deliveryTopic: prepared.replyTarget?.topic ?? null,
82
90
  }));
83
- return await this.executePreparedEntries([
84
- {
85
- entry: null,
86
- message: null,
87
- prepared,
88
- },
89
- ], recordedAtMs);
91
+ return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
92
+ return await this.executePreparedBatch([
93
+ {
94
+ entry: null,
95
+ message: null,
96
+ prepared,
97
+ },
98
+ ], recordedAtMs);
99
+ });
90
100
  }
91
- async executePreparedEntries(entries, recordedAtMs) {
101
+ async dispatchScheduledJob(input) {
102
+ const prepared = prepareTextExecution(input.conversationKey, input.prompt, input.replyTarget);
103
+ const recordedAtMs = Date.now();
104
+ this.logger.log("info", "dispatching scheduled gateway job");
105
+ return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
106
+ this.store.appendJournal(createJournalEntry("cron_dispatch", recordedAtMs, prepared.conversationKey, {
107
+ id: input.jobId,
108
+ kind: input.jobKind,
109
+ promptParts: prepared.promptParts,
110
+ deliveryChannel: prepared.replyTarget?.channel ?? null,
111
+ deliveryTarget: prepared.replyTarget?.target ?? null,
112
+ deliveryTopic: prepared.replyTarget?.topic ?? null,
113
+ }));
114
+ return await this.executePreparedBatch([
115
+ {
116
+ entry: null,
117
+ message: null,
118
+ prepared,
119
+ },
120
+ ], recordedAtMs);
121
+ });
122
+ }
123
+ async appendContextToConversation(input) {
124
+ const conversationKey = normalizeRequiredField(input.conversationKey, "conversation key");
125
+ const body = normalizeRequiredField(input.body, "context body");
126
+ await this.coordinator.runExclusive(conversationKey, async () => {
127
+ const sessionId = await this.ensureConversationSession(conversationKey, input.recordedAtMs, input.replyTarget === null ? [] : [input.replyTarget]);
128
+ const promptIdentity = this.createInternalPromptIdentity("context", input.recordedAtMs);
129
+ await this.appendPrompt(sessionId, promptIdentity.messageId, [
130
+ {
131
+ kind: "text",
132
+ partId: promptIdentity.partId,
133
+ text: body,
134
+ },
135
+ ]);
136
+ });
137
+ }
138
+ async executePreparedBatch(entries, recordedAtMs) {
92
139
  const conversationKey = entries[0].prepared.conversationKey;
93
140
  const persistedSessionId = this.store.getSessionBinding(conversationKey);
141
+ const preparedSessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
94
142
  const replyTargets = dedupeReplyTargets(entries.flatMap((entry) => (entry.prepared.replyTarget === null ? [] : [entry.prepared.replyTarget])));
95
143
  const [deliverySession] = replyTargets.length === 0 ? [null] : await this.delivery.openMany(replyTargets, "auto");
96
- const promptResult = await this.executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets);
144
+ const promptResult = await this.executeDriver(entries, recordedAtMs, preparedSessionId, deliverySession, replyTargets);
145
+ await this.cleanupResidualBusySession(promptResult.sessionId);
97
146
  this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, promptResult.sessionId, recordedAtMs);
98
147
  let delivered = false;
99
148
  if (deliverySession !== null) {
@@ -112,6 +161,128 @@ export class GatewayExecutor {
112
161
  recordedAtMs: BigInt(recordedAtMs),
113
162
  };
114
163
  }
164
+ async ensureConversationSession(conversationKey, recordedAtMs, replyTargets) {
165
+ const persistedSessionId = this.store.getSessionBinding(conversationKey);
166
+ let sessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
167
+ let retryMissingSession = true;
168
+ for (;;) {
169
+ if (sessionId === null) {
170
+ sessionId = await this.createSession(conversationKey);
171
+ }
172
+ try {
173
+ await this.waitUntilIdle(sessionId);
174
+ break;
175
+ }
176
+ catch (error) {
177
+ if (retryMissingSession && error instanceof MissingSessionCommandError) {
178
+ retryMissingSession = false;
179
+ sessionId = null;
180
+ continue;
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, sessionId, recordedAtMs);
186
+ this.initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs);
187
+ return sessionId;
188
+ }
189
+ initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs) {
190
+ if (replyTargets.length === 0 || this.store.listSessionReplyTargets(sessionId).length > 0) {
191
+ return;
192
+ }
193
+ this.store.replaceSessionReplyTargets({
194
+ sessionId,
195
+ conversationKey,
196
+ targets: replyTargets,
197
+ recordedAtMs,
198
+ });
199
+ }
200
+ async preparePersistedSessionForPrompt(sessionId) {
201
+ if (sessionId === null) {
202
+ return null;
203
+ }
204
+ const found = await this.lookupSession(sessionId);
205
+ if (!found) {
206
+ return null;
207
+ }
208
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
209
+ return sessionId;
210
+ }
211
+ this.logger.log("warn", `aborting busy gateway session before prompt dispatch: ${sessionId}`);
212
+ try {
213
+ await this.abortSessionAndWaitForSettle(sessionId);
214
+ return sessionId;
215
+ }
216
+ catch (error) {
217
+ this.logger.log("warn", `busy gateway session did not settle and will be replaced: ${sessionId}: ${extractErrorMessage(error)}`);
218
+ return null;
219
+ }
220
+ }
221
+ async lookupSession(sessionId) {
222
+ const result = await this.opencode.execute({
223
+ kind: "lookupSession",
224
+ sessionId,
225
+ });
226
+ const lookup = expectCommandResult(result, "lookupSession");
227
+ return lookup.found;
228
+ }
229
+ async createSession(conversationKey) {
230
+ const result = await this.opencode.execute({
231
+ kind: "createSession",
232
+ title: `Gateway ${conversationKey}`,
233
+ });
234
+ return expectCommandResult(result, "createSession").sessionId;
235
+ }
236
+ async waitUntilIdle(sessionId) {
237
+ const result = await this.opencode.execute({
238
+ kind: "waitUntilIdle",
239
+ sessionId,
240
+ });
241
+ expectCommandResult(result, "waitUntilIdle");
242
+ }
243
+ async appendPrompt(sessionId, messageId, parts) {
244
+ const result = await this.opencode.execute({
245
+ kind: "appendPrompt",
246
+ sessionId,
247
+ messageId,
248
+ parts,
249
+ });
250
+ expectCommandResult(result, "appendPrompt");
251
+ }
252
+ async cleanupResidualBusySession(sessionId) {
253
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
254
+ return;
255
+ }
256
+ this.logger.log("warn", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
257
+ try {
258
+ await this.abortSessionAndWaitForSettle(sessionId);
259
+ }
260
+ catch (error) {
261
+ this.logger.log("warn", `residual busy gateway session did not settle after abort: ${sessionId}: ${extractErrorMessage(error)}`);
262
+ }
263
+ }
264
+ async abortSessionAndWaitForSettle(sessionId) {
265
+ await this.opencode.abortSession(sessionId);
266
+ const deadline = Date.now() + SESSION_ABORT_SETTLE_TIMEOUT_MS;
267
+ for (;;) {
268
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
269
+ return;
270
+ }
271
+ if (Date.now() >= deadline) {
272
+ throw new Error(`session remained busy after abort for ${SESSION_ABORT_SETTLE_TIMEOUT_MS}ms`);
273
+ }
274
+ await Bun.sleep(SESSION_ABORT_POLL_MS);
275
+ }
276
+ }
277
+ createInternalPromptIdentity(prefix, recordedAtMs) {
278
+ const suffix = `${recordedAtMs}_${this.internalPromptSequence}`;
279
+ this.internalPromptSequence += 1;
280
+ const normalizedPrefix = prefix.replaceAll(":", "_");
281
+ return {
282
+ messageId: `msg_gateway_${normalizedPrefix}_${suffix}`,
283
+ partId: `prt_gateway_${normalizedPrefix}_${suffix}_0`,
284
+ };
285
+ }
115
286
  async executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets) {
116
287
  return await runOpencodeDriver({
117
288
  module: this.module,
@@ -143,6 +314,18 @@ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
143
314
  payload,
144
315
  };
145
316
  }
317
+ function prepareTextExecution(conversationKey, prompt, replyTarget) {
318
+ return {
319
+ conversationKey: normalizeRequiredField(conversationKey, "conversation key"),
320
+ promptParts: [
321
+ {
322
+ kind: "text",
323
+ text: normalizeRequiredField(prompt, "schedule prompt"),
324
+ },
325
+ ],
326
+ replyTarget,
327
+ };
328
+ }
146
329
  function mailboxEntryToInboundMessage(entry) {
147
330
  return {
148
331
  deliveryTarget: {
@@ -174,6 +357,30 @@ function normalizeRequiredField(value, field) {
174
357
  }
175
358
  return trimmed;
176
359
  }
360
+ function extractErrorMessage(error) {
361
+ if (error instanceof Error && error.message.trim().length > 0) {
362
+ return error.message;
363
+ }
364
+ return String(error);
365
+ }
366
+ function expectCommandResult(result, expectedKind) {
367
+ if (result.kind === "error") {
368
+ if (result.code === "missingSession") {
369
+ throw new MissingSessionCommandError(result.message);
370
+ }
371
+ throw new Error(result.message);
372
+ }
373
+ if (result.kind !== expectedKind) {
374
+ throw new Error(`unexpected OpenCode result kind: expected ${expectedKind}, received ${result.kind}`);
375
+ }
376
+ return result;
377
+ }
378
+ class MissingSessionCommandError extends Error {
379
+ constructor(message) {
380
+ super(message);
381
+ this.name = "MissingSessionCommandError";
382
+ }
383
+ }
177
384
  function createPromptKey(entry, recordedAtMs, index) {
178
385
  if (entry.entry !== null) {
179
386
  return `mailbox:${entry.entry.id}:${recordedAtMs}`;
@@ -0,0 +1,2 @@
1
+ export declare function getOrCreateRuntimeSingleton<T>(key: string, factory: () => Promise<T>): Promise<T>;
2
+ export declare function clearRuntimeSingletonForTests(key: string): void;
@@ -0,0 +1,28 @@
1
+ const RUNTIME_CACHE_KEY = Symbol.for("opencode-gateway.runtime-cache");
2
+ export function getOrCreateRuntimeSingleton(key, factory) {
3
+ const cache = getRuntimeCache();
4
+ const existing = cache.get(key);
5
+ if (existing !== undefined) {
6
+ return existing;
7
+ }
8
+ const promise = factory().catch((error) => {
9
+ if (cache.get(key) === promise) {
10
+ cache.delete(key);
11
+ }
12
+ throw error;
13
+ });
14
+ cache.set(key, promise);
15
+ return promise;
16
+ }
17
+ export function clearRuntimeSingletonForTests(key) {
18
+ getRuntimeCache().delete(key);
19
+ }
20
+ function getRuntimeCache() {
21
+ const globalScope = globalThis;
22
+ let cache = globalScope[RUNTIME_CACHE_KEY];
23
+ if (cache === undefined) {
24
+ cache = new Map();
25
+ globalScope[RUNTIME_CACHE_KEY] = cache;
26
+ }
27
+ return cache;
28
+ }
@@ -31,6 +31,10 @@ export class GatewaySessionContext {
31
31
  `- Current reply topic: ${target.topic ?? "none"}`,
32
32
  "- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
33
33
  "- If the user asks to start a fresh channel session, use channel_new_session.",
34
+ "- If the user asks for a one-shot reminder or relative-time follow-up, prefer schedule_once.",
35
+ "- If the user asks for a recurring schedule, prefer cron_upsert.",
36
+ "- Use schedule_list and schedule_status to inspect existing scheduled jobs and recent run results.",
37
+ "- Scheduled results delivered to this channel are automatically appended to this session as context.",
34
38
  ].join("\n");
35
39
  }
36
40
  return [
@@ -39,6 +43,8 @@ export class GatewaySessionContext {
39
43
  ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
40
44
  "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
41
45
  "- If the user asks to start a fresh channel session for this route, use channel_new_session.",
46
+ "- Prefer schedule_once for one-shot reminders and cron_upsert for recurring schedules.",
47
+ "- Use schedule_list and schedule_status to inspect scheduled jobs and recent run results.",
42
48
  ].join("\n");
43
49
  }
44
50
  }
@@ -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;
@@ -119,6 +134,8 @@ export declare class SqliteStore {
119
134
  listDueCronJobs(nowMs: number, limit: number): CronJobRecord[];
120
135
  removeCronJob(id: string): boolean;
121
136
  updateCronJobNextRun(id: string, nextRunAtMs: number, recordedAtMs: number): void;
137
+ setCronJobEnabled(id: string, enabled: boolean, recordedAtMs: number): void;
138
+ listCronRuns(jobId: string, limit: number): CronRunRecord[];
122
139
  insertCronRun(jobId: string, scheduledForMs: number, startedAtMs: number): number;
123
140
  finishCronRun(runId: number, status: Exclude<CronRunStatus, "running">, finishedAtMs: number, responseText: string | null, errorMessage: string | null): void;
124
141
  abandonRunningCronRuns(finishedAtMs: number): number;