@grinev/opencode-telegram-bot 0.16.1 → 0.18.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 (46) hide show
  1. package/.env.example +5 -0
  2. package/README.md +72 -43
  3. package/dist/app/start-bot-app.js +0 -2
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +166 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +2 -0
  9. package/dist/bot/commands/new.js +10 -18
  10. package/dist/bot/commands/opencode-start.js +27 -24
  11. package/dist/bot/commands/opencode-stop.js +51 -23
  12. package/dist/bot/commands/projects.js +30 -6
  13. package/dist/bot/commands/sessions.js +19 -20
  14. package/dist/bot/commands/skills.js +376 -0
  15. package/dist/bot/commands/start.js +2 -0
  16. package/dist/bot/commands/status.js +21 -14
  17. package/dist/bot/commands/worktree.js +196 -0
  18. package/dist/bot/handlers/inline-menu.js +1 -0
  19. package/dist/bot/handlers/prompt.js +26 -31
  20. package/dist/bot/handlers/voice.js +8 -1
  21. package/dist/bot/index.js +90 -10
  22. package/dist/bot/middleware/interaction-guard.js +1 -1
  23. package/dist/bot/utils/busy-guard.js +3 -2
  24. package/dist/bot/utils/external-user-input.js +46 -0
  25. package/dist/bot/utils/switch-project.js +2 -0
  26. package/dist/config.js +2 -0
  27. package/dist/external-input/suppression.js +54 -0
  28. package/dist/git/worktree.js +158 -0
  29. package/dist/i18n/de.js +54 -1
  30. package/dist/i18n/en.js +54 -1
  31. package/dist/i18n/es.js +54 -1
  32. package/dist/i18n/fr.js +54 -1
  33. package/dist/i18n/ru.js +54 -1
  34. package/dist/i18n/zh.js +54 -1
  35. package/dist/interaction/guard.js +2 -1
  36. package/dist/opencode/events.js +13 -1
  37. package/dist/opencode/process.js +182 -0
  38. package/dist/pinned/manager.js +46 -61
  39. package/dist/project/manager.js +47 -32
  40. package/dist/runtime/bootstrap.js +119 -7
  41. package/dist/scheduled-task/executor.js +137 -7
  42. package/dist/settings/manager.js +9 -12
  43. package/dist/summary/aggregator.js +54 -9
  44. package/package.json +1 -1
  45. package/dist/process/manager.js +0 -273
  46. package/dist/process/types.js +0 -1
@@ -1,7 +1,6 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import path from "node:path";
3
1
  import { logger } from "../utils/logger.js";
4
2
  import { opencodeClient } from "../opencode/client.js";
3
+ import { getGitWorktreeContext } from "../git/worktree.js";
5
4
  import { getCurrentSession } from "../session/manager.js";
6
5
  import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
7
6
  import { getStoredModel } from "../model/manager.js";
@@ -16,8 +15,11 @@ class PinnedMessageManager {
16
15
  chatId: null,
17
16
  sessionId: null,
18
17
  sessionTitle: t("pinned.default_session_title"),
19
- projectName: "",
18
+ attachActive: false,
19
+ attachBusy: false,
20
+ projectPath: "",
20
21
  projectBranch: null,
22
+ projectWorktreePath: null,
21
23
  tokensUsed: 0,
22
24
  tokensLimit: 0,
23
25
  lastUpdated: 0,
@@ -55,6 +57,8 @@ class PinnedMessageManager {
55
57
  // Update state
56
58
  this.state.sessionId = sessionId;
57
59
  this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
60
+ this.state.attachActive = false;
61
+ this.state.attachBusy = false;
58
62
  await this.refreshProjectMetadata();
59
63
  // Fetch context limit for current model
60
64
  await this.fetchContextLimit();
@@ -83,6 +87,15 @@ class PinnedMessageManager {
83
87
  await this.updatePinnedMessage();
84
88
  }
85
89
  }
90
+ async setAttachState(active, busy) {
91
+ const nextBusy = active ? busy : false;
92
+ if (this.state.attachActive === active && this.state.attachBusy === nextBusy) {
93
+ return;
94
+ }
95
+ this.state.attachActive = active;
96
+ this.state.attachBusy = nextBusy;
97
+ await this.updatePinnedMessage();
98
+ }
86
99
  /**
87
100
  * Load context token usage from session history
88
101
  */
@@ -419,65 +432,26 @@ class PinnedMessageManager {
419
432
  */
420
433
  async refreshProjectMetadata() {
421
434
  const project = getCurrentProject();
422
- this.state.projectName =
423
- project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
424
- this.state.projectBranch = await this.getGitBranchName(project?.worktree);
425
- }
426
- /**
427
- * Resolve current git branch for a project worktree.
428
- */
429
- async getGitBranchName(worktree) {
430
- if (!worktree) {
431
- return null;
435
+ this.state.projectPath = project?.worktree || t("pinned.unknown");
436
+ this.state.projectBranch = null;
437
+ this.state.projectWorktreePath = null;
438
+ if (!project?.worktree) {
439
+ return;
432
440
  }
433
441
  try {
434
- const gitDir = await this.resolveGitDir(worktree);
435
- if (!gitDir) {
436
- return null;
442
+ const worktreeContext = await getGitWorktreeContext(project.worktree);
443
+ if (!worktreeContext) {
444
+ return;
437
445
  }
438
- const headPath = path.join(gitDir, "HEAD");
439
- const headContent = (await readFile(headPath, "utf-8")).trim();
440
- const match = headContent.match(/^ref:\s+refs\/heads\/(.+)$/);
441
- return match?.[1] || null;
446
+ this.state.projectPath = worktreeContext.mainProjectPath;
447
+ this.state.projectBranch = worktreeContext.branch;
448
+ this.state.projectWorktreePath = worktreeContext.isLinkedWorktree
449
+ ? worktreeContext.activeWorktreePath
450
+ : null;
442
451
  }
443
452
  catch (err) {
444
- logger.debug("[PinnedManager] Could not resolve git branch:", err);
445
- return null;
446
- }
447
- }
448
- /**
449
- * Resolve git directory for a normal repository or linked worktree.
450
- */
451
- async resolveGitDir(worktree) {
452
- const gitPath = path.join(worktree, ".git");
453
- try {
454
- const gitStat = await stat(gitPath);
455
- if (gitStat.isDirectory()) {
456
- return gitPath;
457
- }
458
- if (!gitStat.isFile()) {
459
- return null;
460
- }
461
- const gitPointer = (await readFile(gitPath, "utf-8")).trim();
462
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
463
- if (!match) {
464
- return null;
465
- }
466
- return path.resolve(worktree, match[1].trim());
453
+ logger.debug("[PinnedManager] Could not resolve git worktree metadata:", err);
467
454
  }
468
- catch {
469
- return null;
470
- }
471
- }
472
- /**
473
- * Extract project name from worktree path
474
- */
475
- extractProjectName(worktree) {
476
- if (!worktree)
477
- return "";
478
- // Get last part of path
479
- const parts = worktree.replace(/\\/g, "/").split("/");
480
- return parts[parts.length - 1] || "";
481
455
  }
482
456
  /**
483
457
  * Make file path relative to project worktree
@@ -540,14 +514,17 @@ class PinnedMessageManager {
540
514
  const currentModel = getStoredModel();
541
515
  const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
542
516
  const projectDisplayName = this.state.projectBranch
543
- ? `${this.state.projectName}: ${this.state.projectBranch}`
544
- : this.state.projectName;
517
+ ? `${this.state.projectPath}: ${this.state.projectBranch}`
518
+ : this.state.projectPath;
545
519
  const lines = [
546
520
  `${this.state.sessionTitle}`,
547
521
  t("pinned.line.project", { project: projectDisplayName }),
548
- t("pinned.line.model", { model: modelName }),
549
- formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
550
522
  ];
523
+ if (this.state.projectWorktreePath) {
524
+ lines.push(t("pinned.line.worktree", { worktree: this.state.projectWorktreePath }));
525
+ }
526
+ lines.push(t("pinned.line.model", { model: modelName }));
527
+ lines.push(formatContextLine(this.state.tokensUsed, this.state.tokensLimit));
551
528
  if (this.state.cost !== undefined && this.state.cost !== null) {
552
529
  lines.push(formatCostLine(this.state.cost));
553
530
  }
@@ -708,9 +685,14 @@ class PinnedMessageManager {
708
685
  // Just reset state if not initialized
709
686
  this.state.messageId = null;
710
687
  this.state.sessionId = null;
688
+ this.state.sessionTitle = t("pinned.default_session_title");
689
+ this.state.attachActive = false;
690
+ this.state.attachBusy = false;
711
691
  this.state.tokensUsed = 0;
712
692
  this.state.tokensLimit = 0;
693
+ this.state.projectPath = "";
713
694
  this.state.projectBranch = null;
695
+ this.state.projectWorktreePath = null;
714
696
  this.state.changedFiles = [];
715
697
  this.lastRenderedMessageText = null;
716
698
  this.pendingUpdate = false;
@@ -725,8 +707,11 @@ class PinnedMessageManager {
725
707
  this.state.messageId = null;
726
708
  this.state.sessionId = null;
727
709
  this.state.sessionTitle = t("pinned.default_session_title");
728
- this.state.projectName = "";
710
+ this.state.attachActive = false;
711
+ this.state.attachBusy = false;
712
+ this.state.projectPath = "";
729
713
  this.state.projectBranch = null;
714
+ this.state.projectWorktreePath = null;
730
715
  this.state.tokensUsed = 0;
731
716
  this.state.tokensLimit = 0;
732
717
  this.state.changedFiles = [];
@@ -3,35 +3,8 @@ import path from "node:path";
3
3
  import { opencodeClient } from "../opencode/client.js";
4
4
  import { getCachedSessionProjects } from "../session/cache-manager.js";
5
5
  import { logger } from "../utils/logger.js";
6
- async function isLinkedGitWorktree(worktree) {
7
- if (worktree === "/") {
8
- return false;
9
- }
10
- const gitPath = path.join(worktree, ".git");
11
- try {
12
- const gitStat = await stat(gitPath);
13
- if (!gitStat.isFile()) {
14
- return false;
15
- }
16
- const gitPointer = (await readFile(gitPath, "utf-8")).trim();
17
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
18
- if (!match) {
19
- return false;
20
- }
21
- const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
22
- return gitDir.includes("/.git/worktrees/");
23
- }
24
- catch {
25
- return false;
26
- }
27
- }
28
- function worktreeKey(worktree) {
29
- if (process.platform === "win32") {
30
- return worktree.toLowerCase();
31
- }
32
- return worktree;
33
- }
34
- export async function getProjects() {
6
+ async function getResolvedProjects(options) {
7
+ const includeLinkedWorktrees = options?.includeLinkedWorktrees === true;
35
8
  const { data: projects, error } = await opencodeClient.project.list();
36
9
  if (error || !projects) {
37
10
  throw error || new Error("No data received from server");
@@ -64,11 +37,53 @@ export async function getProjects() {
64
37
  });
65
38
  }
66
39
  const projectList = Array.from(mergedByWorktree.values()).sort((left, right) => right.lastUpdated - left.lastUpdated);
40
+ if (includeLinkedWorktrees) {
41
+ return projectList;
42
+ }
67
43
  const linkedWorktreeFlags = await Promise.all(projectList.map((project) => isLinkedGitWorktree(project.worktree)));
68
44
  const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
69
45
  const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
70
46
  logger.debug(`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`);
71
- return visibleProjects.map(({ id, worktree, name }) => ({ id, worktree, name }));
47
+ return visibleProjects;
48
+ }
49
+ async function isLinkedGitWorktree(worktree) {
50
+ if (worktree === "/") {
51
+ return false;
52
+ }
53
+ const gitPath = path.join(worktree, ".git");
54
+ try {
55
+ const gitStat = await stat(gitPath);
56
+ if (!gitStat.isFile()) {
57
+ return false;
58
+ }
59
+ const gitPointer = (await readFile(gitPath, "utf-8")).trim();
60
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
61
+ if (!match) {
62
+ return false;
63
+ }
64
+ const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
65
+ return gitDir.includes("/.git/worktrees/");
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function worktreeKey(worktree) {
72
+ const pathModule = isWindowsWorktreePath(worktree) ? path.win32 : path.posix;
73
+ const normalizedWorktree = pathModule.normalize(worktree);
74
+ const root = pathModule.parse(normalizedWorktree).root;
75
+ const trimmedWorktree = normalizedWorktree === root ? normalizedWorktree : normalizedWorktree.replace(/[\\/]+$/, "");
76
+ if (pathModule === path.win32) {
77
+ return trimmedWorktree.toLowerCase();
78
+ }
79
+ return trimmedWorktree;
80
+ }
81
+ function isWindowsWorktreePath(worktree) {
82
+ return process.platform === "win32" || /^[a-zA-Z]:[\\/]/.test(worktree) || /^\\\\/.test(worktree);
83
+ }
84
+ export async function getProjects() {
85
+ const projects = await getResolvedProjects();
86
+ return projects.map(({ id, worktree, name }) => ({ id, worktree, name }));
72
87
  }
73
88
  export async function getProjectById(id) {
74
89
  const projects = await getProjects();
@@ -79,11 +94,11 @@ export async function getProjectById(id) {
79
94
  return project;
80
95
  }
81
96
  export async function getProjectByWorktree(worktree) {
82
- const projects = await getProjects();
97
+ const projects = await getResolvedProjects({ includeLinkedWorktrees: true });
83
98
  const key = worktreeKey(worktree);
84
99
  const project = projects.find((p) => worktreeKey(p.worktree) === key);
85
100
  if (!project) {
86
101
  throw new Error(`Project with worktree ${worktree} not found`);
87
102
  }
88
- return project;
103
+ return { id: project.id, worktree: project.worktree, name: project.name };
89
104
  }
@@ -10,6 +10,16 @@ const DEFAULT_API_URL = "http://localhost:4096";
10
10
  const DEFAULT_SERVER_USERNAME = "opencode";
11
11
  const FALLBACK_MODEL_PROVIDER = "opencode";
12
12
  const FALLBACK_MODEL_ID = "big-pickle";
13
+ const WIZARD_ENV_KEYS = [
14
+ "BOT_LOCALE",
15
+ "TELEGRAM_BOT_TOKEN",
16
+ "TELEGRAM_ALLOWED_USER_ID",
17
+ "OPENCODE_API_URL",
18
+ "OPENCODE_SERVER_USERNAME",
19
+ "OPENCODE_SERVER_PASSWORD",
20
+ "OPENCODE_MODEL_PROVIDER",
21
+ "OPENCODE_MODEL_ID",
22
+ ];
13
23
  function isPositiveInteger(value) {
14
24
  return /^[1-9]\d*$/.test(value);
15
25
  }
@@ -55,10 +65,63 @@ function removeEnvKey(lines, key) {
55
65
  const regex = new RegExp(`^\\s*(?:export\\s+)?${escapeRegex(key)}\\s*=`);
56
66
  return lines.filter((line) => !regex.test(line));
57
67
  }
68
+ function parseEnvAssignmentLine(line) {
69
+ const match = /^(\s*#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
70
+ if (!match) {
71
+ return null;
72
+ }
73
+ return {
74
+ key: match[2],
75
+ rawValue: match[3],
76
+ line,
77
+ isCommented: typeof match[1] === "string",
78
+ };
79
+ }
80
+ function buildTemplateKeySet(templateContent) {
81
+ const keys = new Set();
82
+ for (const line of normalizeEnvLineEndings(templateContent)) {
83
+ const parsedLine = parseEnvAssignmentLine(line);
84
+ if (parsedLine !== null) {
85
+ keys.add(parsedLine.key);
86
+ }
87
+ }
88
+ return keys;
89
+ }
90
+ function collectActiveEnvAssignments(content) {
91
+ const assignments = new Map();
92
+ for (const line of normalizeEnvLineEndings(content)) {
93
+ const parsedLine = parseEnvAssignmentLine(line);
94
+ if (parsedLine === null || parsedLine.isCommented) {
95
+ continue;
96
+ }
97
+ assignments.set(parsedLine.key, parsedLine);
98
+ }
99
+ return assignments;
100
+ }
101
+ function collectCustomEnvAssignments(existingContent, templateKeys) {
102
+ const customAssignments = [];
103
+ const seenKeys = new Set();
104
+ const lines = normalizeEnvLineEndings(existingContent);
105
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
106
+ const parsedLine = parseEnvAssignmentLine(lines[index]);
107
+ if (parsedLine === null || parsedLine.isCommented || templateKeys.has(parsedLine.key)) {
108
+ continue;
109
+ }
110
+ if (seenKeys.has(parsedLine.key)) {
111
+ continue;
112
+ }
113
+ seenKeys.add(parsedLine.key);
114
+ customAssignments.push(parsedLine);
115
+ }
116
+ return customAssignments.reverse().map((assignment) => assignment.line);
117
+ }
118
+ function renderEnvAssignment(key, rawValue) {
119
+ return `${key}=${rawValue}`;
120
+ }
58
121
  function finalizeEnvContent(lines) {
59
122
  return `${lines.join("\n")}\n`;
60
123
  }
61
- export function buildEnvFileContent(existingContent, values) {
124
+ function buildFlatEnvFileContent(existingContent, values) {
62
125
  let lines = normalizeEnvLineEndings(existingContent);
63
126
  const orderedUpdates = [
64
127
  ["BOT_LOCALE", values.BOT_LOCALE],
@@ -78,6 +141,44 @@ export function buildEnvFileContent(existingContent, values) {
78
141
  }
79
142
  return finalizeEnvContent(lines);
80
143
  }
144
+ export function buildEnvFileContent(existingContent, values, envExampleContent) {
145
+ if (!envExampleContent) {
146
+ return buildFlatEnvFileContent(existingContent, values);
147
+ }
148
+ const templateLines = normalizeEnvLineEndings(envExampleContent);
149
+ if (templateLines.length === 0) {
150
+ return buildFlatEnvFileContent(existingContent, values);
151
+ }
152
+ const templateKeys = buildTemplateKeySet(envExampleContent);
153
+ const existingAssignments = collectActiveEnvAssignments(existingContent);
154
+ const wizardOverrides = new Map(WIZARD_ENV_KEYS.map((key) => [key, values[key]]));
155
+ const renderedLines = templateLines.map((line) => {
156
+ const parsedLine = parseEnvAssignmentLine(line);
157
+ if (parsedLine === null) {
158
+ return line;
159
+ }
160
+ if (wizardOverrides.has(parsedLine.key)) {
161
+ const overrideValue = wizardOverrides.get(parsedLine.key);
162
+ if (overrideValue && overrideValue.trim().length > 0) {
163
+ return renderEnvAssignment(parsedLine.key, overrideValue);
164
+ }
165
+ return line;
166
+ }
167
+ const existingAssignment = existingAssignments.get(parsedLine.key);
168
+ if (existingAssignment !== undefined) {
169
+ return renderEnvAssignment(parsedLine.key, existingAssignment.rawValue);
170
+ }
171
+ return line;
172
+ });
173
+ const customAssignments = collectCustomEnvAssignments(existingContent, templateKeys);
174
+ if (customAssignments.length > 0) {
175
+ if (renderedLines.length > 0 && renderedLines[renderedLines.length - 1] !== "") {
176
+ renderedLines.push("");
177
+ }
178
+ renderedLines.push(...customAssignments);
179
+ }
180
+ return finalizeEnvContent(renderedLines);
181
+ }
81
182
  async function readEnvFileIfExists(filePath) {
82
183
  try {
83
184
  return await fs.readFile(filePath, "utf-8");
@@ -112,14 +213,24 @@ function getEnvExamplePath() {
112
213
  const currentFilePath = fileURLToPath(import.meta.url);
113
214
  return path.resolve(path.dirname(currentFilePath), "..", "..", ".env.example");
114
215
  }
115
- async function loadModelDefaultsFromEnvExample() {
216
+ async function loadEnvExampleContent() {
217
+ try {
218
+ return await fs.readFile(getEnvExamplePath(), "utf-8");
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
224
+ function loadModelDefaultsFromEnvExample(envExampleContent) {
116
225
  const fallbackDefaults = {
117
226
  provider: FALLBACK_MODEL_PROVIDER,
118
227
  modelId: FALLBACK_MODEL_ID,
119
228
  };
120
229
  try {
121
- const content = await fs.readFile(getEnvExamplePath(), "utf-8");
122
- const parsed = dotenv.parse(content);
230
+ if (!envExampleContent) {
231
+ return fallbackDefaults;
232
+ }
233
+ const parsed = dotenv.parse(envExampleContent);
123
234
  const provider = parsed.OPENCODE_MODEL_PROVIDER?.trim();
124
235
  const modelId = parsed.OPENCODE_MODEL_ID?.trim();
125
236
  if (!provider || !modelId) {
@@ -305,11 +416,12 @@ async function validateExistingEnv(envFilePath) {
305
416
  }
306
417
  async function runWizardAndPersist(runtimePaths) {
307
418
  ensureInteractiveTty();
308
- const [existingContent, modelDefaults, wizardValues] = await Promise.all([
419
+ const [existingContent, envExampleContent, wizardValues] = await Promise.all([
309
420
  readEnvFileIfExists(runtimePaths.envFilePath),
310
- loadModelDefaultsFromEnvExample(),
421
+ loadEnvExampleContent(),
311
422
  collectWizardValues(),
312
423
  ]);
424
+ const modelDefaults = loadModelDefaultsFromEnvExample(envExampleContent);
313
425
  const existingParsed = existingContent ? dotenv.parse(existingContent) : {};
314
426
  const provider = existingParsed.OPENCODE_MODEL_PROVIDER || modelDefaults.provider;
315
427
  const modelId = existingParsed.OPENCODE_MODEL_ID || modelDefaults.modelId;
@@ -323,7 +435,7 @@ async function runWizardAndPersist(runtimePaths) {
323
435
  OPENCODE_MODEL_PROVIDER: provider,
324
436
  OPENCODE_MODEL_ID: modelId,
325
437
  };
326
- const envContent = buildEnvFileContent(existingContent ?? "", envValues);
438
+ const envContent = buildEnvFileContent(existingContent ?? "", envValues, envExampleContent);
327
439
  await writeFileAtomically(runtimePaths.envFilePath, envContent);
328
440
  await ensureSettingsFile(runtimePaths.settingsFilePath);
329
441
  process.stdout.write(t("runtime.wizard.saved", {
@@ -1,7 +1,12 @@
1
+ import { config } from "../config.js";
1
2
  import { opencodeClient } from "../opencode/client.js";
2
3
  import { logger } from "../utils/logger.js";
3
4
  const SCHEDULED_TASK_AGENT = "build";
4
5
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
6
+ const EXECUTION_POLL_INTERVAL_MS = 2000;
7
+ const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
8
+ const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
9
+ const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
5
10
  function collectResponseText(parts) {
6
11
  return parts
7
12
  .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
@@ -9,15 +14,143 @@ function collectResponseText(parts) {
9
14
  .join("")
10
15
  .trim();
11
16
  }
12
- function toErrorMessage(error) {
17
+ function extractErrorMessage(error) {
13
18
  if (error instanceof Error && error.message.trim()) {
14
19
  return error.message.trim();
15
20
  }
16
21
  if (typeof error === "string" && error.trim()) {
17
22
  return error.trim();
18
23
  }
24
+ if (!error || typeof error !== "object") {
25
+ return null;
26
+ }
27
+ const typedError = error;
28
+ if (typeof typedError.data?.message === "string" && typedError.data.message.trim()) {
29
+ return typedError.data.message.trim();
30
+ }
31
+ if (typeof typedError.message === "string" && typedError.message.trim()) {
32
+ return typedError.message.trim();
33
+ }
34
+ if (typeof typedError.name === "string" && typedError.name.trim()) {
35
+ return typedError.name.trim();
36
+ }
37
+ return null;
38
+ }
39
+ function isTimeoutErrorMessage(message) {
40
+ return /(timed out|timeout|time out|deadline exceeded|request aborted)/i.test(message);
41
+ }
42
+ function isBotExecutionTimeoutMessage(message) {
43
+ return message.startsWith(EXECUTION_TIMEOUT_ERROR_PREFIX);
44
+ }
45
+ function createExecutionTimeoutMessage() {
46
+ return `${EXECUTION_TIMEOUT_ERROR_PREFIX} after ${config.bot.scheduledTaskExecutionTimeoutMinutes} minutes.`;
47
+ }
48
+ function getExecutionTimeoutMs() {
49
+ return config.bot.scheduledTaskExecutionTimeoutMinutes * 60 * 1000;
50
+ }
51
+ function normalizeScheduledTaskErrorMessage(message) {
52
+ if (isBotExecutionTimeoutMessage(message) ||
53
+ !isTimeoutErrorMessage(message) ||
54
+ message.includes(MODELS_DOCS_URL)) {
55
+ return message;
56
+ }
57
+ return `${message} Check OpenCode model timeout settings: ${MODELS_DOCS_URL}`;
58
+ }
59
+ function toErrorMessage(error) {
60
+ const message = extractErrorMessage(error);
61
+ if (message) {
62
+ return normalizeScheduledTaskErrorMessage(message);
63
+ }
19
64
  return "Unknown scheduled task execution error";
20
65
  }
66
+ function sleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ function findLatestAssistantMessage(messages) {
70
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
71
+ const message = messages[index];
72
+ if (message?.info.role === "assistant") {
73
+ return message;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ function extractAssistantResult(message) {
79
+ if (!message) {
80
+ return { resultText: null, errorMessage: null, completed: false };
81
+ }
82
+ const errorMessage = extractErrorMessage(message.info.error);
83
+ if (errorMessage) {
84
+ return {
85
+ resultText: null,
86
+ errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
87
+ completed: true,
88
+ };
89
+ }
90
+ const resultText = collectResponseText(message.parts);
91
+ return {
92
+ resultText,
93
+ errorMessage: null,
94
+ completed: Boolean(message.info.time?.completed),
95
+ };
96
+ }
97
+ async function loadAssistantResult(sessionId, directory) {
98
+ const { data: messages, error: messagesError } = await opencodeClient.session.messages({
99
+ sessionID: sessionId,
100
+ directory,
101
+ });
102
+ if (messagesError || !messages) {
103
+ throw messagesError || new Error("Failed to load scheduled task messages");
104
+ }
105
+ return extractAssistantResult(findLatestAssistantMessage(messages));
106
+ }
107
+ async function waitForScheduledTaskResult(sessionId, directory) {
108
+ const startedAtMs = Date.now();
109
+ const executionTimeoutMs = getExecutionTimeoutMs();
110
+ let idlePollsWithoutResult = 0;
111
+ while (true) {
112
+ if (Date.now() - startedAtMs >= executionTimeoutMs) {
113
+ throw new Error(createExecutionTimeoutMessage());
114
+ }
115
+ const assistantResult = await loadAssistantResult(sessionId, directory);
116
+ if (assistantResult.errorMessage) {
117
+ throw new Error(assistantResult.errorMessage);
118
+ }
119
+ if (assistantResult.completed) {
120
+ if (assistantResult.resultText) {
121
+ return assistantResult.resultText;
122
+ }
123
+ throw new Error("Scheduled task returned an empty assistant response");
124
+ }
125
+ const { data: statuses, error: statusError } = await opencodeClient.session.status({
126
+ directory,
127
+ });
128
+ if (statusError || !statuses) {
129
+ throw statusError || new Error("Failed to load scheduled task status");
130
+ }
131
+ const sessionStatus = statuses[sessionId];
132
+ if (!sessionStatus || sessionStatus.type === "idle") {
133
+ const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);
134
+ if (confirmedAssistantResult.errorMessage) {
135
+ throw new Error(confirmedAssistantResult.errorMessage);
136
+ }
137
+ if (confirmedAssistantResult.completed) {
138
+ if (confirmedAssistantResult.resultText) {
139
+ return confirmedAssistantResult.resultText;
140
+ }
141
+ throw new Error("Scheduled task returned an empty assistant response");
142
+ }
143
+ idlePollsWithoutResult += 1;
144
+ if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
145
+ throw new Error("Scheduled task finished without a completed assistant response");
146
+ }
147
+ }
148
+ else {
149
+ idlePollsWithoutResult = 0;
150
+ }
151
+ await sleep(EXECUTION_POLL_INTERVAL_MS);
152
+ }
153
+ }
21
154
  export async function executeScheduledTask(task) {
22
155
  const startedAt = new Date().toISOString();
23
156
  let sessionId = null;
@@ -45,14 +178,11 @@ export async function executeScheduledTask(task) {
45
178
  if (task.model.variant) {
46
179
  promptOptions.variant = task.model.variant;
47
180
  }
48
- const { data: response, error: promptError } = await opencodeClient.session.prompt(promptOptions);
49
- if (promptError || !response) {
181
+ const { error: promptError } = await opencodeClient.session.promptAsync(promptOptions);
182
+ if (promptError) {
50
183
  throw promptError || new Error("Scheduled task prompt execution failed");
51
184
  }
52
- const resultText = collectResponseText(response.parts);
53
- if (!resultText) {
54
- throw new Error("Scheduled task returned an empty assistant response");
55
- }
185
+ const resultText = await waitForScheduledTaskResult(session.id, session.directory);
56
186
  return {
57
187
  taskId: task.id,
58
188
  status: "success",
@@ -103,17 +103,6 @@ export function clearPinnedMessageId() {
103
103
  currentSettings.pinnedMessageId = undefined;
104
104
  void writeSettingsFile(currentSettings);
105
105
  }
106
- export function getServerProcess() {
107
- return currentSettings.serverProcess;
108
- }
109
- export function setServerProcess(processInfo) {
110
- currentSettings.serverProcess = processInfo;
111
- void writeSettingsFile(currentSettings);
112
- }
113
- export function clearServerProcess() {
114
- currentSettings.serverProcess = undefined;
115
- void writeSettingsFile(currentSettings);
116
- }
117
106
  export function getSessionDirectoryCache() {
118
107
  return currentSettings.sessionDirectoryCache;
119
108
  }
@@ -138,10 +127,18 @@ export function __resetSettingsForTests() {
138
127
  }
139
128
  export async function loadSettings() {
140
129
  const loadedSettings = (await readSettingsFile());
130
+ let requiresRewrite = false;
141
131
  if ("toolMessagesIntervalSec" in loadedSettings) {
142
132
  delete loadedSettings.toolMessagesIntervalSec;
143
- void writeSettingsFile(loadedSettings);
133
+ requiresRewrite = true;
134
+ }
135
+ if ("serverProcess" in loadedSettings) {
136
+ delete loadedSettings.serverProcess;
137
+ requiresRewrite = true;
144
138
  }
145
139
  currentSettings = loadedSettings;
146
140
  currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
141
+ if (requiresRewrite) {
142
+ void writeSettingsFile(currentSettings);
143
+ }
147
144
  }