thepopebot 1.2.76-beta.2 → 1.2.76-beta.21

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 (128) hide show
  1. package/README.md +3 -3
  2. package/api/CLAUDE.md +11 -4
  3. package/api/index.js +56 -18
  4. package/bin/CLAUDE.md +7 -4
  5. package/bin/cli.js +25 -45
  6. package/config/CLAUDE.md +23 -4
  7. package/drizzle/0021_coding_agent_workspace.sql +1 -0
  8. package/drizzle/0022_organic_apocalypse.sql +16 -0
  9. package/drizzle/0023_needy_ender_wiggin.sql +1 -0
  10. package/drizzle/meta/0021_snapshot.json +639 -0
  11. package/drizzle/meta/0022_snapshot.json +743 -0
  12. package/drizzle/meta/0023_snapshot.json +750 -0
  13. package/drizzle/meta/_journal.json +21 -0
  14. package/lib/CLAUDE.md +2 -2
  15. package/lib/actions.js +9 -1
  16. package/lib/ai/CLAUDE.md +72 -57
  17. package/lib/ai/helper-llm.js +108 -0
  18. package/lib/ai/index.js +308 -438
  19. package/lib/ai/line-mappers.js +42 -24
  20. package/lib/ai/scope.js +26 -0
  21. package/lib/ai/sdk-adapters/CLAUDE.md +114 -0
  22. package/lib/ai/sdk-adapters/claude-code.js +120 -8
  23. package/lib/ai/system-prompt.js +34 -0
  24. package/lib/ai/workspace-setup.js +19 -35
  25. package/lib/channels/CLAUDE.md +14 -4
  26. package/lib/channels/base.js +6 -2
  27. package/lib/channels/commands/index.js +42 -0
  28. package/lib/channels/commands/session.js +53 -0
  29. package/lib/channels/commands/verify.js +18 -0
  30. package/lib/channels/telegram.js +79 -28
  31. package/lib/chat/CLAUDE.md +4 -4
  32. package/lib/chat/actions.js +270 -49
  33. package/lib/chat/api.js +185 -31
  34. package/lib/chat/components/CLAUDE.md +6 -2
  35. package/lib/chat/components/chat-input.js +77 -47
  36. package/lib/chat/components/chat-input.jsx +77 -40
  37. package/lib/chat/components/chat-page.js +2 -0
  38. package/lib/chat/components/chat-page.jsx +3 -0
  39. package/lib/chat/components/chat.js +62 -14
  40. package/lib/chat/components/chat.jsx +68 -10
  41. package/lib/chat/components/code-mode-toggle.js +141 -22
  42. package/lib/chat/components/code-mode-toggle.jsx +129 -20
  43. package/lib/chat/components/containers-page.js +58 -40
  44. package/lib/chat/components/containers-page.jsx +64 -25
  45. package/lib/chat/components/crons-page.js +17 -3
  46. package/lib/chat/components/crons-page.jsx +34 -6
  47. package/lib/chat/components/index.js +2 -2
  48. package/lib/chat/components/message.js +18 -3
  49. package/lib/chat/components/message.jsx +18 -3
  50. package/lib/chat/components/profile-page.js +182 -4
  51. package/lib/chat/components/profile-page.jsx +196 -1
  52. package/lib/chat/components/scope-picker.js +21 -0
  53. package/lib/chat/components/scope-picker.jsx +27 -0
  54. package/lib/chat/components/settings-chat-page.js +11 -11
  55. package/lib/chat/components/settings-chat-page.jsx +14 -18
  56. package/lib/chat/components/settings-coding-agents-page.js +110 -16
  57. package/lib/chat/components/settings-coding-agents-page.jsx +87 -3
  58. package/lib/chat/components/settings-github-page.js +5 -0
  59. package/lib/chat/components/settings-github-page.jsx +5 -0
  60. package/lib/chat/components/settings-layout.js +3 -3
  61. package/lib/chat/components/settings-layout.jsx +3 -3
  62. package/lib/chat/components/settings-secrets-layout.js +1 -2
  63. package/lib/chat/components/settings-secrets-layout.jsx +1 -2
  64. package/lib/chat/components/settings-secrets-page.js +180 -75
  65. package/lib/chat/components/settings-secrets-page.jsx +212 -66
  66. package/lib/chat/components/triggers-page.js +17 -3
  67. package/lib/chat/components/triggers-page.jsx +34 -6
  68. package/lib/chat/components/ui/combobox.js +18 -2
  69. package/lib/chat/components/ui/combobox.jsx +17 -1
  70. package/lib/chat/components/ui/dropdown-menu.js +23 -2
  71. package/lib/chat/components/ui/dropdown-menu.jsx +27 -2
  72. package/lib/chat/telegram-profile.js +33 -0
  73. package/lib/cluster/CLAUDE.md +9 -3
  74. package/lib/code/CLAUDE.md +11 -3
  75. package/lib/code/actions.js +47 -8
  76. package/lib/code/terminal-view.js +31 -21
  77. package/lib/code/terminal-view.jsx +32 -23
  78. package/lib/config.js +15 -4
  79. package/lib/containers/CLAUDE.md +16 -6
  80. package/lib/db/CLAUDE.md +5 -2
  81. package/lib/db/chats.js +9 -17
  82. package/lib/db/code-workspaces.js +8 -3
  83. package/lib/db/config.js +0 -1
  84. package/lib/db/index.js +12 -0
  85. package/lib/db/schema.js +24 -1
  86. package/lib/db/user-channels.js +129 -0
  87. package/lib/llm-providers.js +8 -0
  88. package/lib/maintenance.js +31 -21
  89. package/lib/tools/CLAUDE.md +12 -3
  90. package/lib/tools/assemblyai.js +17 -0
  91. package/lib/tools/create-agent-job.js +12 -8
  92. package/lib/tools/docker.js +34 -10
  93. package/lib/tools/github.js +34 -0
  94. package/lib/tools/telegram.js +106 -0
  95. package/lib/utils/render-md.js +44 -18
  96. package/package.json +8 -8
  97. package/setup/CLAUDE.md +11 -5
  98. package/setup/lib/providers.mjs +2 -1
  99. package/setup/lib/targets.mjs +13 -16
  100. package/setup/lib/telegram.mjs +8 -69
  101. package/templates/.env.example +0 -7
  102. package/templates/.github/workflows/rebuild-event-handler.yml +1 -1
  103. package/templates/.gitignore.template +1 -3
  104. package/templates/CLAUDE.md +1 -1
  105. package/templates/CLAUDE.md.template +29 -7
  106. package/templates/agent-job/CLAUDE.md.template +5 -3
  107. package/templates/agent-job/CRONS.json +16 -0
  108. package/templates/agent-job/SYSTEM.md +16 -11
  109. package/templates/agents/CLAUDE.md.template +17 -17
  110. package/templates/coding-workspace/CLAUDE.md.template +7 -0
  111. package/templates/data/CLAUDE.md.template +1 -1
  112. package/templates/docker-compose.custom.yml +1 -0
  113. package/templates/docker-compose.yml +1 -0
  114. package/templates/event-handler/CLAUDE.md.template +79 -0
  115. package/templates/event-handler/TRIGGERS.json +18 -2
  116. package/templates/skills/CLAUDE.md.template +20 -22
  117. package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/SKILL.md +2 -2
  118. package/lib/ai/agent.js +0 -65
  119. package/lib/ai/async-channel.js +0 -51
  120. package/lib/ai/model.js +0 -130
  121. package/lib/ai/tools.js +0 -164
  122. package/lib/tools/openai.js +0 -37
  123. package/setup/lib/telegram-verify.mjs +0 -63
  124. package/setup/setup-telegram.mjs +0 -260
  125. package/templates/agent-job/SOUL.md +0 -17
  126. /package/templates/{skills/active/.gitkeep → coding-workspace/SYSTEM.md} +0 -0
  127. /package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/agent-job-secrets.js +0 -0
  128. /package/templates/skills/{library/playwright-cli → playwright-cli}/SKILL.md +0 -0
@@ -211,7 +211,7 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
211
211
  term.open(containerRef.current);
212
212
 
213
213
  const style = document.createElement('style');
214
- style.textContent = `.xterm { padding: 5px; background-color: ${theme.background} !important; } .xterm-viewport { background-color: ${theme.background} !important; } .xterm-rows span { pointer-events: none; }`;
214
+ style.textContent = `.xterm { padding: 5px; background-color: ${theme.background} !important; } .xterm-viewport { background-color: ${theme.background} !important; touch-action: none; } .xterm-rows span { pointer-events: none; }`;
215
215
  containerRef.current.appendChild(style);
216
216
  styleRef.current = style;
217
217
 
@@ -227,8 +227,13 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
227
227
 
228
228
  fitAddon.fit();
229
229
 
230
- // Mobile touch scroll: translate finger swipes into terminal scrollLines()
231
- const screenEl = containerRef.current.querySelector('.xterm-screen');
230
+ // Mobile touch scroll: xterm v5's .xterm-viewport is overflow-y:scroll and its
231
+ // scroll event listener syncs scrollTop → buffer position. touch-action:none on
232
+ // the viewport (injected above) prevents the compositor from natively scrolling
233
+ // it, so we manually translate finger swipes into scrollLines() calls. Capture
234
+ // phase + stopPropagation blocks xterm's own touch handlers on .xterm from
235
+ // also setting viewport.scrollTop.
236
+ const termContainer = containerRef.current;
232
237
  let lastTouchY = null;
233
238
  let touchScrollAccum = 0;
234
239
  const LINE_HEIGHT = 15; // approximate px per line at fontSize 16
@@ -238,6 +243,7 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
238
243
  lastTouchY = ev.touches[0].clientY;
239
244
  touchScrollAccum = 0;
240
245
  }
246
+ ev.stopPropagation();
241
247
  };
242
248
  const onTouchMove = (ev) => {
243
249
  if (lastTouchY === null || ev.touches.length !== 1) return;
@@ -251,14 +257,13 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
251
257
  touchScrollAccum -= lines * LINE_HEIGHT;
252
258
  }
253
259
  ev.preventDefault();
260
+ ev.stopPropagation();
254
261
  };
255
262
  const onTouchEnd = () => { lastTouchY = null; touchScrollAccum = 0; };
256
263
 
257
- if (screenEl) {
258
- screenEl.addEventListener('touchstart', onTouchStart, { passive: true });
259
- screenEl.addEventListener('touchmove', onTouchMove, { passive: false });
260
- screenEl.addEventListener('touchend', onTouchEnd, { passive: true });
261
- }
264
+ termContainer.addEventListener('touchstart', onTouchStart, { passive: false, capture: true });
265
+ termContainer.addEventListener('touchmove', onTouchMove, { passive: false, capture: true });
266
+ termContainer.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
262
267
 
263
268
  term.onData((data) => {
264
269
  const ws = wsRef.current;
@@ -302,11 +307,9 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
302
307
  clearTimeout(resizeTimeout);
303
308
  clearTimeout(retryTimer.current);
304
309
  window.removeEventListener('resize', handleResize);
305
- if (screenEl) {
306
- screenEl.removeEventListener('touchstart', onTouchStart);
307
- screenEl.removeEventListener('touchmove', onTouchMove);
308
- screenEl.removeEventListener('touchend', onTouchEnd);
309
- }
310
+ termContainer.removeEventListener('touchstart', onTouchStart, { capture: true });
311
+ termContainer.removeEventListener('touchmove', onTouchMove, { capture: true });
312
+ termContainer.removeEventListener('touchend', onTouchEnd, { capture: true });
310
313
  if (wsRef.current) wsRef.current.close();
311
314
  term.dispose();
312
315
  };
@@ -839,7 +842,7 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
839
842
  );
840
843
  }
841
844
 
842
- const STORAGE_KEY = 'thepopebot-workspace-command';
845
+ const STORAGE_KEY = 'thepopebot-workspace-command:code';
843
846
 
844
847
  function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh, onShowDiff }) {
845
848
  const [selectedCommand, setSelectedCommandState] = useState(() => {
@@ -849,6 +852,21 @@ function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh,
849
852
  setSelectedCommandState(cmd);
850
853
  try { localStorage.setItem(STORAGE_KEY, cmd); } catch {}
851
854
  };
855
+
856
+ // Seed from admin default if user hasn't picked anything for code mode yet.
857
+ useEffect(() => {
858
+ let stored = null;
859
+ try { stored = localStorage.getItem(STORAGE_KEY); } catch {}
860
+ if (stored) return;
861
+ let cancelled = false;
862
+ import('../chat/actions.js').then(({ getModeGitActionDefault }) => {
863
+ getModeGitActionDefault('code').then((val) => {
864
+ if (cancelled || !val) return;
865
+ setSelectedCommandState(val);
866
+ }).catch(() => {});
867
+ }).catch(() => {});
868
+ return () => { cancelled = true; };
869
+ }, []);
852
870
  const [commandRunning, setCommandRunning] = useState(false);
853
871
  const [dialogOpen, setDialogOpen] = useState(false);
854
872
  const [commandOutput, setCommandOutput] = useState('');
@@ -869,15 +887,6 @@ function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh,
869
887
  const handleRun = useCallback(async () => {
870
888
  if (commandRunning) return;
871
889
 
872
- const fresh = await onDiffStatsRefresh?.();
873
- const stats = fresh || diffStats;
874
- if (!(stats?.insertions || 0) && !(stats?.deletions || 0)) {
875
- setDialogOpen(true);
876
- setCommandOutput('You have no changes.');
877
- setCommandExitCode(1);
878
- return;
879
- }
880
-
881
890
  setCommandRunning(true);
882
891
  setDialogOpen(true);
883
892
  setCommandOutput('');
package/lib/config.js CHANGED
@@ -37,7 +37,6 @@ const CONFIG_KEYS = new Set([
37
37
  'LLM_MAX_TOKENS',
38
38
  'AGENT_BACKEND',
39
39
  'CUSTOM_OPENAI_BASE_URL',
40
- 'TELEGRAM_CHAT_ID',
41
40
  'UPGRADE_INCLUDE_BETA',
42
41
  'CODING_AGENT',
43
42
  'CODING_AGENT_CLAUDE_CODE_ENABLED',
@@ -58,6 +57,10 @@ const CONFIG_KEYS = new Set([
58
57
  'CODING_AGENT_KIMI_CLI_ENABLED',
59
58
  'CODING_AGENT_KIMI_CLI_PROVIDER',
60
59
  'CODING_AGENT_KIMI_CLI_MODEL',
60
+ 'AGENT_MODE_BRANCH',
61
+ 'CODE_MODE_BRANCH',
62
+ 'AGENT_MODE_GIT_ACTION',
63
+ 'CODE_MODE_GIT_ACTION',
61
64
  ]);
62
65
 
63
66
  // Default values
@@ -74,6 +77,10 @@ const DEFAULTS = {
74
77
  CODING_AGENT_CODEX_CLI_AUTH: 'api-key',
75
78
  CODING_AGENT_OPENCODE_ENABLED: 'false',
76
79
  CODING_AGENT_KIMI_CLI_ENABLED: 'false',
80
+ AGENT_MODE_BRANCH: 'default',
81
+ CODE_MODE_BRANCH: 'dynamic',
82
+ AGENT_MODE_GIT_ACTION: 'push',
83
+ CODE_MODE_GIT_ACTION: 'create-pr',
77
84
  };
78
85
 
79
86
  // In-memory cache on globalThis to survive Next.js webpack chunk duplication.
@@ -120,7 +127,13 @@ export function getConfig(key) {
120
127
 
121
128
  // Infrastructure keys: fall back to .env (these live in .env, not exclusively in DB)
122
129
  if (value === undefined) {
123
- const ENV_KEYS = ['GH_OWNER', 'GH_REPO', 'GH_TOKEN', 'APP_URL', 'APP_HOSTNAME'];
130
+ const ENV_KEYS = [
131
+ 'GH_OWNER',
132
+ 'GH_REPO',
133
+ 'GH_TOKEN',
134
+ 'APP_URL',
135
+ 'APP_HOSTNAME',
136
+ ];
124
137
  if (ENV_KEYS.includes(key)) {
125
138
  value = process.env[key];
126
139
  }
@@ -147,6 +160,4 @@ export function getConfig(key) {
147
160
  */
148
161
  export function invalidateConfigCache() {
149
162
  _cache.clear();
150
- // Reset agent singletons so they pick up new config
151
- import('./ai/agent.js').then(({ resetAgentChats }) => resetAgentChats()).catch(() => {});
152
163
  }
@@ -1,9 +1,19 @@
1
1
  # lib/containers/ — Container Streaming
2
2
 
3
- `stream.js` exports `GET(request)`an SSE route handler for real-time Docker container monitoring.
3
+ Two SSE endpointsboth authenticated via `auth()` session.
4
4
 
5
- **Endpoint**: `/stream/containers` (actual SSE streaming, stays in `/stream/`)
6
- **Auth**: `auth()` session check
7
- **Events**: `containers` (every 3s with full container list + CPU/memory stats), `ping` (keepalive every 15s)
8
- **Data source**: `listNetworkContainers()` + `getContainerStats()` from `lib/tools/docker.js`
9
- **Client**: `ContainersPage` connects via `new EventSource('/stream/containers')`
5
+ ## `stream.js` Container Status
6
+
7
+ - **Endpoint**: `/stream/containers`
8
+ - **Events**: `containers` (every 3s with full container list + CPU/memory stats), `ping` (keepalive every 15s)
9
+ - **Data source**: `listNetworkContainers()` + `getContainerStats()` from `lib/tools/docker.js`. Filtered to containers on the event-handler's Docker network (auto-detected at boot).
10
+ - **Client**: `ContainersPage` connects via `new EventSource('/stream/containers')`
11
+
12
+ ## `logs.js` — Container Log Tail
13
+
14
+ - **Endpoint**: `/stream/containers/logs?name=<containerName>`
15
+ - **Events**: raw log lines as SSE `data:` frames
16
+ - **Data source**: Docker `containers/{id}/logs?follow=true&stdout=1&stderr=1` via the multiplexed-stream parser in `lib/tools/docker.js`
17
+ - **Client**: `ContainerLogsView` (used from the Containers admin page when a row's logs are expanded)
18
+
19
+ Both endpoints share the same network filter and frame-decoding logic so all SSE consumers see the same view.
package/lib/db/CLAUDE.md CHANGED
@@ -25,11 +25,12 @@ Key files: `schema.js` (source of truth), `drizzle/` (generated migrations), `dr
25
25
  | `users` | Admin accounts (email, bcrypt password hash, role) |
26
26
  | `chats` | Chat sessions (user_id, title, starred, chat_mode, code_workspace_id, timestamps) |
27
27
  | `messages` | Chat messages (chat_id, role, content) |
28
- | `code_workspaces` | Code workspace containers (user_id, container_name, repo, branch, feature_branch, title, last_interactive_commit, starred, has_changes) |
28
+ | `code_workspaces` | Code workspace containers (user_id, container_name, repo, branch, feature_branch, title, last_interactive_commit, coding_agent, scope, starred, has_changes) |
29
29
  | `notifications` | Job completion notifications (notification text, payload, read status) |
30
30
  | `subscriptions` | Channel subscriptions (platform, channel_id) |
31
+ | `user_channels` | Per-user channel linking (user_id, channel, channel_chat_id, code, code_expires_at, verified_at, active_thread_id) — Telegram verification + active thread |
31
32
  | `clusters` | Worker clusters (user_id, name, system_prompt, folders, enabled, starred) |
32
- | `cluster_roles` | Role definitions scoped to a cluster (cluster_id, role_name, role, trigger_config, max_concurrency, cleanup_worker_dir, folders) |
33
+ | `cluster_roles` | Role definitions scoped to a cluster (cluster_id, role_name, role, trigger_config, max_concurrency, plan_mode, cleanup_worker_dir, folders) |
33
34
  | `settings` | Key-value configuration store (also stores API keys and OAuth tokens via type/key/value) |
34
35
 
35
36
  ## OAuth Token Storage
@@ -64,3 +65,5 @@ OAuth tokens for coding agent backends are stored as `config_secret` with LRU ro
64
65
  - `chats.chatMode` — `'agent'` (default) or `'code'`. Determines which agent singleton and tools are used.
65
66
  - `codeWorkspaces.featureBranch` — tracks the git feature branch for the workspace session.
66
67
  - `codeWorkspaces.hasChanges` — flag set when workspace has uncommitted changes.
68
+ - `codeWorkspaces.codingAgent` — per-workspace coding-agent override. Falls back to global `CODING_AGENT` config, then `claude-code` (`lib/code/actions.js:410`).
69
+ - `codeWorkspaces.scope` — subdirectory scope within the repo (e.g., `agents/gary-vee`). Resolves the agent's working directory and skills (`lib/ai/scope.js`).
package/lib/db/chats.js CHANGED
@@ -100,23 +100,6 @@ export function deleteChat(chatId) {
100
100
  db.delete(chats).where(eq(chats.id, chatId)).run();
101
101
  }
102
102
 
103
- /**
104
- * Delete all chats and messages for a user.
105
- * @param {string} userId
106
- */
107
- export function deleteAllChatsByUser(userId) {
108
- const db = getDb();
109
- const userChats = db
110
- .select({ id: chats.id })
111
- .from(chats)
112
- .where(eq(chats.userId, userId))
113
- .all();
114
-
115
- for (const chat of userChats) {
116
- db.delete(messages).where(eq(messages.chatId, chat.id)).run();
117
- }
118
- db.delete(chats).where(eq(chats.userId, userId)).run();
119
- }
120
103
 
121
104
  /**
122
105
  * Get all messages for a chat, ordered by creation time.
@@ -146,6 +129,15 @@ export function linkChatToWorkspace(chatId, workspaceId) {
146
129
  .run();
147
130
  }
148
131
 
132
+ /**
133
+ * Touch a chat's updatedAt timestamp.
134
+ * @param {string} chatId
135
+ */
136
+ export function touchChatUpdatedAt(chatId) {
137
+ const db = getDb();
138
+ db.update(chats).set({ updatedAt: Date.now() }).where(eq(chats.id, chatId)).run();
139
+ }
140
+
149
141
  /**
150
142
  * Save a message to a chat. Also updates the chat's updatedAt timestamp.
151
143
  * @param {string} chatId
@@ -14,7 +14,7 @@ import { codeWorkspaces } from './schema.js';
14
14
  * @param {string} [options.id] - Optional ID (UUID). Generated if not provided.
15
15
  * @returns {object} The created workspace
16
16
  */
17
- export function createCodeWorkspace(userId, { containerName = null, repo = null, branch = null, title = 'Code Workspace', id = null } = {}) {
17
+ export function createCodeWorkspace(userId, { containerName = null, repo = null, branch = null, scope = null, title = 'Code Workspace', id = null } = {}) {
18
18
  const db = getDb();
19
19
  const now = Date.now();
20
20
  const workspace = {
@@ -23,6 +23,7 @@ export function createCodeWorkspace(userId, { containerName = null, repo = null,
23
23
  containerName,
24
24
  repo,
25
25
  branch,
26
+ scope,
26
27
  title,
27
28
  createdAt: now,
28
29
  updatedAt: now,
@@ -33,13 +34,17 @@ export function createCodeWorkspace(userId, { containerName = null, repo = null,
33
34
 
34
35
  /**
35
36
  * Update the container name on an existing workspace (when Docker launches).
37
+ * Also stores the coding agent used so recovery can recreate with the same agent.
36
38
  * @param {string} id - Workspace ID
37
39
  * @param {string} containerName - Docker container name
40
+ * @param {string} [codingAgent] - Coding agent identifier (e.g. 'claude-code', 'codex-cli')
38
41
  */
39
- export function updateContainerName(id, containerName) {
42
+ export function updateContainerName(id, containerName, codingAgent) {
40
43
  const db = getDb();
44
+ const set = { containerName, updatedAt: Date.now() };
45
+ if (codingAgent !== undefined) set.codingAgent = codingAgent;
41
46
  db.update(codeWorkspaces)
42
- .set({ containerName, updatedAt: Date.now() })
47
+ .set(set)
43
48
  .where(eq(codeWorkspaces.id, id))
44
49
  .run();
45
50
  }
package/lib/db/config.js CHANGED
@@ -381,7 +381,6 @@ const MIGRATE_CONFIG = [
381
381
  'LLM_MAX_TOKENS',
382
382
  'AGENT_BACKEND',
383
383
  'CUSTOM_OPENAI_BASE_URL',
384
- 'TELEGRAM_CHAT_ID',
385
384
  ];
386
385
 
387
386
  /**
package/lib/db/index.js CHANGED
@@ -50,6 +50,18 @@ export function initDatabase() {
50
50
 
51
51
  migrate(db, { migrationsFolder });
52
52
 
53
+ // One-shot cleanup: drop orphan LangGraph checkpoint tables left behind by
54
+ // @langchain/langgraph-checkpoint-sqlite. The React agent has been removed —
55
+ // these tables are never read or written again. Safe to run on every boot
56
+ // (IF EXISTS is a no-op once dropped).
57
+ sqlite.exec(`
58
+ DROP TABLE IF EXISTS checkpoints;
59
+ DROP TABLE IF EXISTS checkpoint_blobs;
60
+ DROP TABLE IF EXISTS checkpoint_writes;
61
+ DROP TABLE IF EXISTS writes;
62
+ DROP TABLE IF EXISTS checkpoint_migrations;
63
+ `);
64
+
53
65
  sqlite.close();
54
66
 
55
67
  // Force re-creation of drizzle instance on next getDb() call
package/lib/db/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
1
+ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
2
2
 
3
3
  export const users = sqliteTable('users', {
4
4
  id: text('id').primaryKey(),
@@ -52,6 +52,8 @@ export const codeWorkspaces = sqliteTable('code_workspaces', {
52
52
  featureBranch: text('feature_branch'),
53
53
  title: text('title').notNull().default('Code Workspace'),
54
54
  lastInteractiveCommit: text('last_interactive_commit'),
55
+ codingAgent: text('coding_agent'),
56
+ scope: text('scope'),
55
57
  starred: integer('starred').notNull().default(0),
56
58
  hasChanges: integer('has_changes').notNull().default(0),
57
59
  createdAt: integer('created_at').notNull(),
@@ -96,3 +98,24 @@ export const settings = sqliteTable('settings', {
96
98
  createdAt: integer('created_at').notNull(),
97
99
  updatedAt: integer('updated_at').notNull(),
98
100
  });
101
+
102
+ export const userChannels = sqliteTable(
103
+ 'user_channels',
104
+ {
105
+ id: text('id').primaryKey(),
106
+ userId: text('user_id').notNull(),
107
+ channel: text('channel').notNull(),
108
+ channelChatId: text('channel_chat_id'),
109
+ code: text('code'),
110
+ codeExpiresAt: integer('code_expires_at'),
111
+ verifiedAt: integer('verified_at'),
112
+ activeThreadId: text('active_thread_id'),
113
+ createdAt: integer('created_at').notNull(),
114
+ updatedAt: integer('updated_at').notNull(),
115
+ },
116
+ (t) => ({
117
+ userChannelUnique: uniqueIndex('user_channels_user_channel_unique').on(t.userId, t.channel),
118
+ channelChatIdUnique: uniqueIndex('user_channels_channel_chat_id_unique').on(t.channel, t.channelChatId),
119
+ codeLookup: index('user_channels_code_lookup').on(t.code),
120
+ })
121
+ );
@@ -0,0 +1,129 @@
1
+ import { randomUUID, randomBytes } from 'crypto';
2
+ import { and, eq, isNotNull } from 'drizzle-orm';
3
+ import { getDb } from './index.js';
4
+ import { userChannels } from './schema.js';
5
+
6
+ const CODE_TTL_MS = 10 * 60 * 1000;
7
+ const CODE_BYTES = 4;
8
+
9
+ function now() {
10
+ return Date.now();
11
+ }
12
+
13
+ function generateCode() {
14
+ return randomBytes(CODE_BYTES).toString('hex').toUpperCase();
15
+ }
16
+
17
+ export function getUserChannel(userId, channel) {
18
+ const db = getDb();
19
+ return db
20
+ .select()
21
+ .from(userChannels)
22
+ .where(and(eq(userChannels.userId, userId), eq(userChannels.channel, channel)))
23
+ .get();
24
+ }
25
+
26
+ export function getByChannelChatId(channel, channelChatId) {
27
+ const db = getDb();
28
+ return db
29
+ .select()
30
+ .from(userChannels)
31
+ .where(and(eq(userChannels.channel, channel), eq(userChannels.channelChatId, channelChatId)))
32
+ .get();
33
+ }
34
+
35
+ export function getByCode(code) {
36
+ const db = getDb();
37
+ return db.select().from(userChannels).where(eq(userChannels.code, code)).get();
38
+ }
39
+
40
+ /**
41
+ * Issue or re-issue a verification code for a user+channel.
42
+ * Creates the row if absent; overwrites the code if present and still pending.
43
+ * Throws if the row is already verified — caller should unlink first.
44
+ */
45
+ export function issueCode(userId, channel) {
46
+ const db = getDb();
47
+ const existing = getUserChannel(userId, channel);
48
+ const code = generateCode();
49
+ const codeExpiresAt = now() + CODE_TTL_MS;
50
+ const timestamp = now();
51
+
52
+ if (existing) {
53
+ if (existing.verifiedAt) {
54
+ throw new Error('Channel already verified — unlink before re-issuing a code');
55
+ }
56
+ db.update(userChannels)
57
+ .set({ code, codeExpiresAt, updatedAt: timestamp })
58
+ .where(eq(userChannels.id, existing.id))
59
+ .run();
60
+ return { ...existing, code, codeExpiresAt, updatedAt: timestamp };
61
+ }
62
+
63
+ const row = {
64
+ id: randomUUID(),
65
+ userId,
66
+ channel,
67
+ channelChatId: null,
68
+ code,
69
+ codeExpiresAt,
70
+ verifiedAt: null,
71
+ activeThreadId: null,
72
+ createdAt: timestamp,
73
+ updatedAt: timestamp,
74
+ };
75
+ db.insert(userChannels).values(row).run();
76
+ return row;
77
+ }
78
+
79
+ /**
80
+ * Redeem a code from an incoming channel message.
81
+ * Returns the userId on success. Throws on expired, already-consumed, or chat-taken.
82
+ */
83
+ export function redeemCode(channel, code, channelChatId) {
84
+ const db = getDb();
85
+ const row = getByCode(code);
86
+ if (!row || row.channel !== channel) throw new Error('Invalid code');
87
+ if (row.verifiedAt) throw new Error('Code already used');
88
+ if (row.codeExpiresAt && row.codeExpiresAt < now()) throw new Error('Code expired');
89
+
90
+ const chatTaken = getByChannelChatId(channel, channelChatId);
91
+ if (chatTaken && chatTaken.id !== row.id) {
92
+ throw new Error('This chat is already linked to another user');
93
+ }
94
+
95
+ const timestamp = now();
96
+ db.update(userChannels)
97
+ .set({
98
+ channelChatId,
99
+ code: null,
100
+ codeExpiresAt: null,
101
+ verifiedAt: timestamp,
102
+ updatedAt: timestamp,
103
+ })
104
+ .where(eq(userChannels.id, row.id))
105
+ .run();
106
+ return { userId: row.userId, rowId: row.id };
107
+ }
108
+
109
+ export function setActiveThread(userId, channel, threadId) {
110
+ const db = getDb();
111
+ const timestamp = now();
112
+ db.update(userChannels)
113
+ .set({ activeThreadId: threadId, updatedAt: timestamp })
114
+ .where(
115
+ and(
116
+ eq(userChannels.userId, userId),
117
+ eq(userChannels.channel, channel),
118
+ isNotNull(userChannels.verifiedAt)
119
+ )
120
+ )
121
+ .run();
122
+ }
123
+
124
+ export function unlink(userId, channel) {
125
+ const db = getDb();
126
+ db.delete(userChannels)
127
+ .where(and(eq(userChannels.userId, userId), eq(userChannels.channel, channel)))
128
+ .run();
129
+ }
@@ -17,6 +17,7 @@ export const BUILTIN_PROVIDERS = {
17
17
  ],
18
18
  models: [
19
19
  { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', default: true },
20
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
20
21
  { id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
21
22
  { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
22
23
  { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
@@ -61,6 +62,7 @@ export const BUILTIN_PROVIDERS = {
61
62
  credentials: [
62
63
  { type: 'api_key', key: 'DEEPSEEK_API_KEY', label: 'API Key' },
63
64
  ],
65
+ baseUrl: 'https://api.deepseek.com/v1',
64
66
  anthropicEndpoint: 'https://api.deepseek.com/anthropic',
65
67
  models: [
66
68
  { id: 'deepseek-chat', name: 'DeepSeek V3', default: true },
@@ -72,6 +74,7 @@ export const BUILTIN_PROVIDERS = {
72
74
  credentials: [
73
75
  { type: 'api_key', key: 'MINIMAX_API_KEY', label: 'API Key' },
74
76
  ],
77
+ baseUrl: 'https://api.minimax.io/v1',
75
78
  anthropicEndpoint: 'https://api.minimax.io/anthropic',
76
79
  models: [
77
80
  { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', default: true },
@@ -83,6 +86,7 @@ export const BUILTIN_PROVIDERS = {
83
86
  mistral: {
84
87
  name: 'Mistral',
85
88
  litellmProxy: true, litellmPrefix: 'mistral',
89
+ baseUrl: 'https://api.mistral.ai/v1',
86
90
  credentials: [
87
91
  { type: 'api_key', key: 'MISTRAL_API_KEY', label: 'API Key' },
88
92
  ],
@@ -97,6 +101,7 @@ export const BUILTIN_PROVIDERS = {
97
101
  xai: {
98
102
  name: 'xAI',
99
103
  litellmProxy: true, litellmPrefix: 'xai',
104
+ baseUrl: 'https://api.x.ai/v1',
100
105
  credentials: [
101
106
  { type: 'api_key', key: 'XAI_API_KEY', label: 'API Key' },
102
107
  ],
@@ -112,6 +117,7 @@ export const BUILTIN_PROVIDERS = {
112
117
  credentials: [
113
118
  { type: 'api_key', key: 'MOONSHOT_API_KEY', label: 'API Key' },
114
119
  ],
120
+ baseUrl: 'https://api.moonshot.cn/v1',
115
121
  anthropicEndpoint: 'https://api.moonshot.cn/anthropic',
116
122
  models: [
117
123
  { id: 'kimi-k2.5', name: 'Kimi K2.5', default: true },
@@ -124,12 +130,14 @@ export const BUILTIN_PROVIDERS = {
124
130
  credentials: [
125
131
  { type: 'api_key', key: 'OPENROUTER_API_KEY', label: 'API Key' },
126
132
  ],
133
+ baseUrl: 'https://openrouter.ai/api/v1',
127
134
  anthropicEndpoint: 'https://openrouter.ai/api',
128
135
  models: [],
129
136
  },
130
137
  nvidia: {
131
138
  name: 'NVIDIA',
132
139
  litellmProxy: true, litellmPrefix: 'nvidia_nim',
140
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
133
141
  credentials: [
134
142
  { type: 'api_key', key: 'NVIDIA_API_KEY', label: 'API Key' },
135
143
  ],
@@ -4,44 +4,54 @@ import { getDb } from './db/index.js';
4
4
  import { settings } from './db/schema.js';
5
5
 
6
6
  const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
7
+ const ONE_HOUR = 60 * 60 * 1000;
7
8
 
8
9
  async function cleanExpiredAgentJobKeys() {
9
10
  try {
10
11
  const db = getDb();
11
- const cutoff = Date.now() - TWENTY_FOUR_HOURS;
12
12
  const rows = db
13
13
  .select({ id: settings.id, key: settings.key, lastUsedAt: settings.lastUsedAt, createdAt: settings.createdAt })
14
14
  .from(settings)
15
15
  .where(eq(settings.type, 'agent_job_api_key'))
16
16
  .all();
17
17
 
18
- // Filter to candidates not used in the last 24 hours
19
- const candidates = rows.filter(r =>
20
- (r.lastUsedAt !== null ? r.lastUsedAt : r.createdAt) < cutoff
21
- );
18
+ // Split into SDK keys (no container) vs container-backed keys
19
+ const sdkRows = rows.filter(r => r.key.includes('sdk'));
20
+ const containerRows = rows.filter(r => !r.key.includes('sdk'));
22
21
 
23
- if (candidates.length === 0) {
24
- console.log(`[maintenance] No expired agent job keys (${rows.length} active)`);
25
- return;
26
- }
22
+ let deleted = 0;
27
23
 
28
- // Check if the container still exists for each candidate
29
- const { inspectContainer } = await import('./tools/docker.js');
30
- const expiredIds = [];
31
- for (const r of candidates) {
32
- const info = await inspectContainer(r.key);
33
- if (!info) {
34
- expiredIds.push(r.id);
24
+ // SDK keys: delete any older than 1 hour (no container to inspect)
25
+ const sdkCutoff = Date.now() - ONE_HOUR;
26
+ for (const r of sdkRows) {
27
+ const age = r.lastUsedAt !== null ? r.lastUsedAt : r.createdAt;
28
+ if (age < sdkCutoff) {
29
+ db.delete(settings).where(eq(settings.id, r.id)).run();
30
+ deleted++;
35
31
  }
36
32
  }
37
33
 
38
- if (expiredIds.length > 0) {
39
- for (const id of expiredIds) {
40
- db.delete(settings).where(eq(settings.id, id)).run();
34
+ // Container-backed keys: existing logic — 24h expiry + container inspect
35
+ const containerCutoff = Date.now() - TWENTY_FOUR_HOURS;
36
+ const candidates = containerRows.filter(r =>
37
+ (r.lastUsedAt !== null ? r.lastUsedAt : r.createdAt) < containerCutoff
38
+ );
39
+
40
+ if (candidates.length > 0) {
41
+ const { inspectContainer } = await import('./tools/docker.js');
42
+ for (const r of candidates) {
43
+ const info = await inspectContainer(r.key);
44
+ if (!info) {
45
+ db.delete(settings).where(eq(settings.id, r.id)).run();
46
+ deleted++;
47
+ }
41
48
  }
42
- console.log(`[maintenance] Deleted ${expiredIds.length} orphaned agent job key(s)`);
49
+ }
50
+
51
+ if (deleted > 0) {
52
+ console.log(`[maintenance] Deleted ${deleted} expired agent job key(s)`);
43
53
  } else {
44
- console.log(`[maintenance] ${candidates.length} candidate(s) checked, all containers still running`);
54
+ console.log(`[maintenance] No expired agent job keys (${rows.length} active)`);
45
55
  }
46
56
  } catch (err) {
47
57
  console.error('[maintenance] cleanExpiredAgentJobKeys failed:', err);
@@ -12,7 +12,16 @@ Calls Docker Engine API directly through `/var/run/docker.sock` using Node's `ht
12
12
 
13
13
  **Image pull on demand**: Checks if image exists locally before pulling. Avoids pre-pulling at startup.
14
14
 
15
- **`buildAgentAuthEnv(agent)`**: Resolves coding agent type → auth environment variables from the settings DB. Handles OAuth vs API key auth modes, multi-provider resolution (builtin + custom), and model overrides. Each agent type (claude-code, pi, gemini-cli, codex-cli, opencode) has its own credential resolution path. OAuth tokens use LRU rotation via `getNextOAuthToken()`. All credentials come from the DB, not `.env` or GitHub secrets.
15
+ **`buildAgentAuthEnv(agent)`**: Resolves coding agent type → auth environment variables from the settings DB. All credentials come from the DB (`getConfig`, `getCustomProvider`), never `.env` or GitHub secrets. Returns `{ env: string[], backendApi: string }`.
16
+
17
+ Per-agent resolution paths:
18
+
19
+ - **`claude-code`** — `CODING_AGENT_CLAUDE_CODE_BACKEND` selects backend. If `anthropic`, picks OAuth (`CLAUDE_CODE_OAUTH_TOKEN` via LRU rotation) or API key (`ANTHROPIC_API_KEY`). For Anthropic-compatible third parties (DeepSeek, MiniMax, Kimi, OpenRouter) sets `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_BASE_URL` to the provider's `anthropicEndpoint`. For OpenAI-only builtins or custom providers, routes through the LiteLLM sidecar at `http://litellm:4000` and prefixes the model with the provider key.
20
+ - **`pi-coding-agent`, `opencode`, `kimi-cli`** — share a multi-provider pattern. `CODING_AGENT_{AGENT}_PROVIDER` picks anthropic/openai/google/deepseek/minimax/mistral/xai/openrouter/nvidia or a custom provider. Sets the matching `*_API_KEY` (or `CUSTOM_OPENAI_BASE_URL` + `CUSTOM_API_KEY` for custom).
21
+ - **`gemini-cli`** — `GOOGLE_API_KEY` only. Backend is always `google`.
22
+ - **`codex-cli`** — OAuth (`CODEX_OAUTH_TOKEN`) or API key (`OPENAI_API_KEY`). Backend always `openai`.
23
+
24
+ OAuth tokens use LRU rotation via `getNextOAuthToken()` (in `lib/db/oauth-tokens.js`) — distributes load across multiple stored tokens and updates `lastUsedAt` on each pick. Refresh-token rotation is handled at retrieval time in `/api/get-agent-job-secret` (under a per-token lock).
16
25
 
17
26
  ## create-agent-job.js — Agent Job Creation
18
27
 
@@ -36,6 +45,6 @@ Calls Docker Engine API directly through `/var/run/docker.sock` using Node's `ht
36
45
 
37
46
  **Typing indicator with jitter**: Re-sends typing action at 5.5–8s random intervals (Telegram expires indicators after 5s). Returns a stop function.
38
47
 
39
- ## openai.js — Whisper Transcription
48
+ ## assemblyai.js — Voice Transcription
40
49
 
41
- **Feature flag via API key**: `isWhisperEnabled()` checks if `OPENAI_API_KEY` is set. Used by the Telegram adapter to conditionally offer voice transcription. `transcribeAudio()` sends binary audio via FormData to `/v1/audio/transcriptions`.
50
+ **Feature flag via API key**: `isAssemblyAIEnabled()` checks if `ASSEMBLYAI_API_KEY` is set. Used by the Telegram adapter to conditionally offer voice transcription. `transcribeAudio(buffer)` uses the official `assemblyai` SDK (`client.transcripts.transcribe`), which handles upload + polling internally. Throws when the transcript status is `'error'`.