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
@@ -0,0 +1,122 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, relative } from "node:path";
3
+ const MARKDOWN_GLOBS = ["**/*.md", "**/*.markdown"];
4
+ const UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
5
+ export class GatewayMemoryPromptProvider {
6
+ config;
7
+ logger;
8
+ constructor(config, logger) {
9
+ this.config = config;
10
+ this.logger = logger;
11
+ }
12
+ async buildPrompt() {
13
+ if (this.config.entries.length === 0) {
14
+ return null;
15
+ }
16
+ const sections = await Promise.all(this.config.entries.map((entry) => this.buildEntrySection(entry)));
17
+ return ["Gateway memory:", ...sections].join("\n\n");
18
+ }
19
+ async buildEntrySection(entry) {
20
+ const lines = [`Configured path: ${entry.displayPath}`, `Description: ${entry.description}`];
21
+ const injectedFiles = await collectInjectedFiles(entry, this.logger);
22
+ for (const file of injectedFiles) {
23
+ lines.push("");
24
+ lines.push(`File: ${file.displayPath}`);
25
+ lines.push(codeFence(file.infoString, file.text));
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ }
30
+ async function collectInjectedFiles(entry, logger) {
31
+ if (entry.kind === "file") {
32
+ if (!entry.injectContent) {
33
+ return [];
34
+ }
35
+ const text = await readTextFile(entry.path, logger);
36
+ if (text === null) {
37
+ return [];
38
+ }
39
+ return [
40
+ {
41
+ displayPath: entry.displayPath,
42
+ infoString: inferFenceInfoString(entry.path),
43
+ text,
44
+ },
45
+ ];
46
+ }
47
+ const filePaths = new Set();
48
+ if (entry.injectMarkdownContents) {
49
+ for (const pattern of MARKDOWN_GLOBS) {
50
+ addMatchingFiles(filePaths, entry.path, pattern);
51
+ }
52
+ }
53
+ for (const pattern of entry.globs) {
54
+ addMatchingFiles(filePaths, entry.path, pattern);
55
+ }
56
+ const injectedFiles = [];
57
+ for (const filePath of [...filePaths].sort((left, right) => left.localeCompare(right))) {
58
+ const text = await readTextFile(filePath, logger);
59
+ if (text === null) {
60
+ continue;
61
+ }
62
+ injectedFiles.push({
63
+ displayPath: relativeDisplayPath(entry.path, entry.displayPath, filePath),
64
+ infoString: inferFenceInfoString(filePath),
65
+ text,
66
+ });
67
+ }
68
+ return injectedFiles;
69
+ }
70
+ function addMatchingFiles(result, cwd, pattern) {
71
+ const glob = new Bun.Glob(pattern);
72
+ for (const match of glob.scanSync({ cwd, absolute: true, onlyFiles: true })) {
73
+ result.add(match);
74
+ }
75
+ }
76
+ async function readTextFile(path, logger) {
77
+ let bytes;
78
+ try {
79
+ bytes = await readFile(path);
80
+ }
81
+ catch (error) {
82
+ logger.log("warn", `memory file could not be read and will be skipped: ${path}: ${formatError(error)}`);
83
+ return null;
84
+ }
85
+ let text;
86
+ try {
87
+ text = UTF8_TEXT_DECODER.decode(bytes);
88
+ }
89
+ catch {
90
+ logger.log("warn", `memory file is not valid UTF-8 and will be skipped: ${path}`);
91
+ return null;
92
+ }
93
+ if (text.includes("\u0000")) {
94
+ logger.log("warn", `memory file looks binary and will be skipped: ${path}`);
95
+ return null;
96
+ }
97
+ return text;
98
+ }
99
+ function relativeDisplayPath(rootPath, rootDisplayPath, filePath) {
100
+ const suffix = relative(rootPath, filePath);
101
+ if (suffix.length === 0) {
102
+ return rootDisplayPath;
103
+ }
104
+ return `${rootDisplayPath}/${suffix.replaceAll("\\", "/")}`;
105
+ }
106
+ function inferFenceInfoString(path) {
107
+ const extension = extname(path).slice(1).toLowerCase();
108
+ if (!/^[a-z0-9_+-]+$/.test(extension)) {
109
+ return "";
110
+ }
111
+ return extension;
112
+ }
113
+ function codeFence(infoString, text) {
114
+ const language = infoString.length === 0 ? "" : infoString;
115
+ return [`\`\`\`${language}`, text, "```"].join("\n");
116
+ }
117
+ function formatError(error) {
118
+ if (error instanceof Error && error.message.trim().length > 0) {
119
+ return error.message;
120
+ }
121
+ return String(error);
122
+ }
@@ -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,43 @@ 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 waitForSessionToSettle;
33
+ private abortSessionAndWaitForSettle;
34
+ private createInternalPromptIdentity;
19
35
  private executeDriver;
20
36
  }
21
- export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "executeMailboxEntries" | "prepareInboundMessage">;
37
+ export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "dispatchScheduledJob" | "appendContextToConversation" | "executeMailboxEntries" | "prepareInboundMessage">;
38
+ export type DispatchScheduledJobInput = {
39
+ jobId: string;
40
+ jobKind: "cron" | "once";
41
+ conversationKey: string;
42
+ prompt: string;
43
+ replyTarget: BindingPreparedExecution["replyTarget"];
44
+ };
45
+ export type AppendContextToConversationInput = {
46
+ conversationKey: string;
47
+ replyTarget: BindingDeliveryTarget | null;
48
+ body: string;
49
+ recordedAtMs: number;
50
+ };
22
51
  type GatewayTextDeliveryLike = Pick<GatewayTextDelivery, "openMany">;
23
- type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
52
+ type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute" | "isSessionBusy" | "abortSession">;
24
53
  export {};
@@ -1,4 +1,8 @@
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;
5
+ const SESSION_RESIDUAL_BUSY_GRACE_POLLS = 3;
2
6
  export class GatewayExecutor {
3
7
  module;
4
8
  store;
@@ -6,13 +10,16 @@ export class GatewayExecutor {
6
10
  events;
7
11
  delivery;
8
12
  logger;
9
- constructor(module, store, opencode, events, delivery, logger) {
13
+ coordinator;
14
+ internalPromptSequence = 0;
15
+ constructor(module, store, opencode, events, delivery, logger, coordinator = new ConversationCoordinator()) {
10
16
  this.module = module;
11
17
  this.store = store;
12
18
  this.opencode = opencode;
13
19
  this.events = events;
14
20
  this.delivery = delivery;
15
21
  this.logger = logger;
22
+ this.coordinator = coordinator;
16
23
  }
17
24
  prepareInboundMessage(message) {
18
25
  return this.module.prepareInboundExecution(message);
@@ -52,19 +59,21 @@ export class GatewayExecutor {
52
59
  }
53
60
  const recordedAtMs = Date.now();
54
61
  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,
62
+ return await this.coordinator.runExclusive(conversationKey, async () => {
63
+ this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
64
+ entryIds: preparedEntries.map((entry) => entry.entry.id),
65
+ count: preparedEntries.length,
65
66
  }));
66
- }
67
- return await this.executePreparedEntries(preparedEntries, recordedAtMs);
67
+ for (const entry of preparedEntries) {
68
+ this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
69
+ deliveryTarget: entry.prepared.replyTarget,
70
+ sender: entry.message.sender,
71
+ text: entry.message.text,
72
+ attachments: entry.message.attachments,
73
+ }));
74
+ }
75
+ return await this.executePreparedBatch(preparedEntries, recordedAtMs);
76
+ });
68
77
  }
69
78
  async dispatchCronJob(job) {
70
79
  const prepared = this.module.prepareCronExecution(job);
@@ -80,20 +89,61 @@ export class GatewayExecutor {
80
89
  deliveryTarget: prepared.replyTarget?.target ?? null,
81
90
  deliveryTopic: prepared.replyTarget?.topic ?? null,
82
91
  }));
83
- return await this.executePreparedEntries([
84
- {
85
- entry: null,
86
- message: null,
87
- prepared,
88
- },
89
- ], recordedAtMs);
92
+ return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
93
+ return await this.executePreparedBatch([
94
+ {
95
+ entry: null,
96
+ message: null,
97
+ prepared,
98
+ },
99
+ ], recordedAtMs);
100
+ });
90
101
  }
91
- async executePreparedEntries(entries, recordedAtMs) {
102
+ async dispatchScheduledJob(input) {
103
+ const prepared = prepareTextExecution(input.conversationKey, input.prompt, input.replyTarget);
104
+ const recordedAtMs = Date.now();
105
+ this.logger.log("info", "dispatching scheduled gateway job");
106
+ return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
107
+ this.store.appendJournal(createJournalEntry("cron_dispatch", recordedAtMs, prepared.conversationKey, {
108
+ id: input.jobId,
109
+ kind: input.jobKind,
110
+ promptParts: prepared.promptParts,
111
+ deliveryChannel: prepared.replyTarget?.channel ?? null,
112
+ deliveryTarget: prepared.replyTarget?.target ?? null,
113
+ deliveryTopic: prepared.replyTarget?.topic ?? null,
114
+ }));
115
+ return await this.executePreparedBatch([
116
+ {
117
+ entry: null,
118
+ message: null,
119
+ prepared,
120
+ },
121
+ ], recordedAtMs);
122
+ });
123
+ }
124
+ async appendContextToConversation(input) {
125
+ const conversationKey = normalizeRequiredField(input.conversationKey, "conversation key");
126
+ const body = normalizeRequiredField(input.body, "context body");
127
+ await this.coordinator.runExclusive(conversationKey, async () => {
128
+ const sessionId = await this.ensureConversationSession(conversationKey, input.recordedAtMs, input.replyTarget === null ? [] : [input.replyTarget]);
129
+ const promptIdentity = this.createInternalPromptIdentity("context", input.recordedAtMs);
130
+ await this.appendPrompt(sessionId, promptIdentity.messageId, [
131
+ {
132
+ kind: "text",
133
+ partId: promptIdentity.partId,
134
+ text: body,
135
+ },
136
+ ]);
137
+ });
138
+ }
139
+ async executePreparedBatch(entries, recordedAtMs) {
92
140
  const conversationKey = entries[0].prepared.conversationKey;
93
141
  const persistedSessionId = this.store.getSessionBinding(conversationKey);
142
+ const preparedSessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
94
143
  const replyTargets = dedupeReplyTargets(entries.flatMap((entry) => (entry.prepared.replyTarget === null ? [] : [entry.prepared.replyTarget])));
95
144
  const [deliverySession] = replyTargets.length === 0 ? [null] : await this.delivery.openMany(replyTargets, "auto");
96
- const promptResult = await this.executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets);
145
+ const promptResult = await this.executeDriver(entries, recordedAtMs, preparedSessionId, deliverySession, replyTargets);
146
+ await this.cleanupResidualBusySession(promptResult.sessionId);
97
147
  this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, promptResult.sessionId, recordedAtMs);
98
148
  let delivered = false;
99
149
  if (deliverySession !== null) {
@@ -112,6 +162,139 @@ export class GatewayExecutor {
112
162
  recordedAtMs: BigInt(recordedAtMs),
113
163
  };
114
164
  }
165
+ async ensureConversationSession(conversationKey, recordedAtMs, replyTargets) {
166
+ const persistedSessionId = this.store.getSessionBinding(conversationKey);
167
+ let sessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
168
+ let retryMissingSession = true;
169
+ for (;;) {
170
+ if (sessionId === null) {
171
+ sessionId = await this.createSession(conversationKey);
172
+ }
173
+ try {
174
+ await this.waitUntilIdle(sessionId);
175
+ break;
176
+ }
177
+ catch (error) {
178
+ if (retryMissingSession && error instanceof MissingSessionCommandError) {
179
+ retryMissingSession = false;
180
+ sessionId = null;
181
+ continue;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+ this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, sessionId, recordedAtMs);
187
+ this.initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs);
188
+ return sessionId;
189
+ }
190
+ initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs) {
191
+ if (replyTargets.length === 0 || this.store.listSessionReplyTargets(sessionId).length > 0) {
192
+ return;
193
+ }
194
+ this.store.replaceSessionReplyTargets({
195
+ sessionId,
196
+ conversationKey,
197
+ targets: replyTargets,
198
+ recordedAtMs,
199
+ });
200
+ }
201
+ async preparePersistedSessionForPrompt(sessionId) {
202
+ if (sessionId === null) {
203
+ return null;
204
+ }
205
+ const found = await this.lookupSession(sessionId);
206
+ if (!found) {
207
+ return null;
208
+ }
209
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
210
+ return sessionId;
211
+ }
212
+ this.logger.log("warn", `aborting busy gateway session before prompt dispatch: ${sessionId}`);
213
+ try {
214
+ await this.abortSessionAndWaitForSettle(sessionId);
215
+ return sessionId;
216
+ }
217
+ catch (error) {
218
+ this.logger.log("warn", `busy gateway session did not settle and will be replaced: ${sessionId}: ${extractErrorMessage(error)}`);
219
+ return null;
220
+ }
221
+ }
222
+ async lookupSession(sessionId) {
223
+ const result = await this.opencode.execute({
224
+ kind: "lookupSession",
225
+ sessionId,
226
+ });
227
+ const lookup = expectCommandResult(result, "lookupSession");
228
+ return lookup.found;
229
+ }
230
+ async createSession(conversationKey) {
231
+ const result = await this.opencode.execute({
232
+ kind: "createSession",
233
+ title: `Gateway ${conversationKey}`,
234
+ });
235
+ return expectCommandResult(result, "createSession").sessionId;
236
+ }
237
+ async waitUntilIdle(sessionId) {
238
+ const result = await this.opencode.execute({
239
+ kind: "waitUntilIdle",
240
+ sessionId,
241
+ });
242
+ expectCommandResult(result, "waitUntilIdle");
243
+ }
244
+ async appendPrompt(sessionId, messageId, parts) {
245
+ const result = await this.opencode.execute({
246
+ kind: "appendPrompt",
247
+ sessionId,
248
+ messageId,
249
+ parts,
250
+ });
251
+ expectCommandResult(result, "appendPrompt");
252
+ }
253
+ async cleanupResidualBusySession(sessionId) {
254
+ if (await this.waitForSessionToSettle(sessionId, SESSION_RESIDUAL_BUSY_GRACE_POLLS)) {
255
+ return;
256
+ }
257
+ this.logger.log("debug", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
258
+ try {
259
+ await this.abortSessionAndWaitForSettle(sessionId);
260
+ }
261
+ catch (error) {
262
+ this.logger.log("warn", `residual busy gateway session did not settle after abort: ${sessionId}: ${extractErrorMessage(error)}`);
263
+ }
264
+ }
265
+ async waitForSessionToSettle(sessionId, extraPolls) {
266
+ for (let attempt = 0; attempt <= extraPolls; attempt += 1) {
267
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
268
+ return true;
269
+ }
270
+ if (attempt < extraPolls) {
271
+ await Bun.sleep(SESSION_ABORT_POLL_MS);
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+ async abortSessionAndWaitForSettle(sessionId) {
277
+ await this.opencode.abortSession(sessionId);
278
+ const deadline = Date.now() + SESSION_ABORT_SETTLE_TIMEOUT_MS;
279
+ for (;;) {
280
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
281
+ return;
282
+ }
283
+ if (Date.now() >= deadline) {
284
+ throw new Error(`session remained busy after abort for ${SESSION_ABORT_SETTLE_TIMEOUT_MS}ms`);
285
+ }
286
+ await Bun.sleep(SESSION_ABORT_POLL_MS);
287
+ }
288
+ }
289
+ createInternalPromptIdentity(prefix, recordedAtMs) {
290
+ const suffix = `${recordedAtMs}_${this.internalPromptSequence}`;
291
+ this.internalPromptSequence += 1;
292
+ const normalizedPrefix = prefix.replaceAll(":", "_");
293
+ return {
294
+ messageId: `msg_gateway_${normalizedPrefix}_${suffix}`,
295
+ partId: `prt_gateway_${normalizedPrefix}_${suffix}_0`,
296
+ };
297
+ }
115
298
  async executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets) {
116
299
  return await runOpencodeDriver({
117
300
  module: this.module,
@@ -143,6 +326,18 @@ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
143
326
  payload,
144
327
  };
145
328
  }
329
+ function prepareTextExecution(conversationKey, prompt, replyTarget) {
330
+ return {
331
+ conversationKey: normalizeRequiredField(conversationKey, "conversation key"),
332
+ promptParts: [
333
+ {
334
+ kind: "text",
335
+ text: normalizeRequiredField(prompt, "schedule prompt"),
336
+ },
337
+ ],
338
+ replyTarget,
339
+ };
340
+ }
146
341
  function mailboxEntryToInboundMessage(entry) {
147
342
  return {
148
343
  deliveryTarget: {
@@ -174,6 +369,30 @@ function normalizeRequiredField(value, field) {
174
369
  }
175
370
  return trimmed;
176
371
  }
372
+ function extractErrorMessage(error) {
373
+ if (error instanceof Error && error.message.trim().length > 0) {
374
+ return error.message;
375
+ }
376
+ return String(error);
377
+ }
378
+ function expectCommandResult(result, expectedKind) {
379
+ if (result.kind === "error") {
380
+ if (result.code === "missingSession") {
381
+ throw new MissingSessionCommandError(result.message);
382
+ }
383
+ throw new Error(result.message);
384
+ }
385
+ if (result.kind !== expectedKind) {
386
+ throw new Error(`unexpected OpenCode result kind: expected ${expectedKind}, received ${result.kind}`);
387
+ }
388
+ return result;
389
+ }
390
+ class MissingSessionCommandError extends Error {
391
+ constructor(message) {
392
+ super(message);
393
+ this.name = "MissingSessionCommandError";
394
+ }
395
+ }
177
396
  function createPromptKey(entry, recordedAtMs, index) {
178
397
  if (entry.entry !== null) {
179
398
  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
+ }
@@ -6,5 +6,5 @@ export declare class GatewaySessionContext {
6
6
  replaceReplyTargets(sessionId: string, conversationKey: string, targets: BindingDeliveryTarget[], recordedAtMs: number): void;
7
7
  listReplyTargets(sessionId: string): BindingDeliveryTarget[];
8
8
  getDefaultReplyTarget(sessionId: string): BindingDeliveryTarget | null;
9
- buildSystemPrompt(sessionId: string): string | null;
9
+ isGatewaySession(sessionId: string): boolean;
10
10
  }