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
package/lib/ai/index.js CHANGED
@@ -1,26 +1,22 @@
1
- import { HumanMessage } from '@langchain/core/messages';
2
- import { createChannel, mergeAsyncIterables } from './async-channel.js';
1
+ import { randomUUID } from 'crypto';
3
2
  import { z } from 'zod';
4
- import { getAgentChat, getCodeChat } from './agent.js';
5
- import { createModel } from './model.js';
6
3
  import path from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { callHelperLlm, callHelperLlmStructured } from './helper-llm.js';
7
6
  import { PROJECT_ROOT } from '../paths.js';
8
7
  import { render_md } from '../utils/render-md.js';
9
- import { getChatById, createChat, saveMessage, updateChatTitle, linkChatToWorkspace } from '../db/chats.js';
8
+ import { buildCodingAgentSystemPrompt } from './system-prompt.js';
9
+ import { getChatById, createChat, updateChatTitle, linkChatToWorkspace, saveMessage } from '../db/chats.js';
10
10
  import { getConfig } from '../config.js';
11
11
  import { getSdkAdapter } from './sdk-adapters/index.js';
12
- import { ensureWorkspaceRepo, ensureSkills } from './workspace-setup.js';
12
+ import { ensureWorkspaceRepo } from './workspace-setup.js';
13
+ import { resolveAgentScope } from './scope.js';
13
14
  import { readSessionId, writeSessionId } from './session-manager.js';
14
15
  import { workspaceDir as getWorkspaceDir } from '../tools/docker.js';
15
16
 
16
17
  /**
17
18
  * Ensure a chat exists in the DB and save a message.
18
19
  * Centralized so every channel gets persistence automatically.
19
- *
20
- * @param {string} threadId - Chat/thread ID
21
- * @param {string} role - 'user' or 'assistant'
22
- * @param {string} text - Message text
23
- * @param {object} [options] - { userId, chatTitle }
24
20
  */
25
21
  function persistMessage(threadId, role, text, options = {}) {
26
22
  try {
@@ -34,98 +30,26 @@ function persistMessage(threadId, role, text, options = {}) {
34
30
  }
35
31
 
36
32
  /**
37
- * Process a chat message through the LangGraph agent.
38
- * Saves user and assistant messages to the DB automatically.
39
- *
40
- * @param {string} threadId - Conversation thread ID (from channel adapter)
41
- * @param {string} message - User's message text
42
- * @param {Array} [attachments=[]] - Normalized attachments from adapter
43
- * @param {object} [options] - { userId, chatTitle } for DB persistence
44
- * @returns {Promise<string>} AI response text
33
+ * Collect streamed text for channels that don't stream (e.g. Telegram one-shot).
34
+ * Delegates to chatStream single source of truth.
45
35
  */
46
36
  async function chat(threadId, message, attachments = [], options = {}) {
47
- // SDK path: delegate to chatStream and collect text
48
- const sdkAdapter = getSdkAdapter(getConfig('CODING_AGENT'));
49
-
50
- if (sdkAdapter) {
51
- let fullText = '';
52
- for await (const chunk of chatStream(threadId, message, attachments, options)) {
53
- if (chunk.type === 'text') fullText += chunk.text;
54
- }
55
- return fullText;
56
- }
57
-
58
- // Legacy LangGraph path
59
- const agent = await getAgentChat();
60
-
61
- // Save user message to DB
62
- persistMessage(threadId, 'user', message || '[attachment]', options);
63
-
64
- // Build content blocks: text + any image attachments as base64 vision
65
- const content = [];
66
-
67
- if (message) {
68
- content.push({ type: 'text', text: message });
69
- }
70
-
71
- for (const att of attachments) {
72
- if (att.category === 'image') {
73
- content.push({
74
- type: 'image_url',
75
- image_url: {
76
- url: `data:${att.mimeType};base64,${att.data.toString('base64')}`,
77
- },
78
- });
79
- }
80
- // Documents: future handling
81
- }
82
-
83
- // If only text and no attachments, simplify to a string
84
- const messageContent = content.length === 1 && content[0].type === 'text'
85
- ? content[0].text
86
- : content;
87
-
88
- const result = await agent.invoke(
89
- { messages: [new HumanMessage({ content: messageContent })] },
90
- { configurable: { thread_id: threadId } }
91
- );
92
-
93
- const lastMessage = result.messages[result.messages.length - 1];
94
-
95
- // LangChain message content can be a string or an array of content blocks
96
- let response;
97
- if (typeof lastMessage.content === 'string') {
98
- response = lastMessage.content;
99
- } else {
100
- response = lastMessage.content
101
- .filter((block) => block.type === 'text')
102
- .map((block) => block.text)
103
- .join('\n');
104
- }
105
-
106
- // Save assistant response to DB
107
- persistMessage(threadId, 'assistant', response, options);
108
-
109
- // Auto-generate title for new chats
110
- if (options.userId && message) {
111
- autoTitle(threadId, message).catch(() => {});
37
+ let fullText = '';
38
+ for await (const chunk of chatStream(threadId, message, attachments, options)) {
39
+ if (chunk.type === 'text') fullText += chunk.text;
112
40
  }
113
-
114
- return response;
41
+ return fullText;
115
42
  }
116
43
 
117
44
  /**
118
- * Process a chat message with streaming (for channels that support it).
45
+ * Process a chat message with streaming.
119
46
  * Saves user and assistant messages to the DB automatically.
120
47
  *
121
- * @param {string} threadId - Conversation thread ID
122
- * @param {string} message - User's message text
123
- * @param {Array} [attachments=[]] - Image/PDF attachments: { category, mimeType, dataUrl }
124
- * @param {object} [options] - { userId, chatTitle, skipUserPersist } for DB persistence
125
- * @returns {AsyncIterableIterator<string>} Stream of text chunks
48
+ * Two paths share identical chunk shape and persistence patterns:
49
+ * - SDK path: in-process @anthropic-ai/claude-agent-sdk (claude-code only)
50
+ * - Direct path: headless Docker container running the configured coding agent
126
51
  */
127
52
  async function* chatStream(threadId, message, attachments = [], options = {}) {
128
- // Resolve agent and workspace context
129
53
  const isCodeMode = !!options.codeMode;
130
54
  const existingChat = getChatById(threadId);
131
55
  let workspaceId = options.workspaceId;
@@ -133,369 +57,329 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
133
57
  const branch = options.branch;
134
58
  const codeModeType = options.codeModeType || 'plan';
135
59
 
60
+ // Resolve workspace — for existing chats, read scope from DB (client may not resend it after refresh)
61
+ let resolvedScope = options.scope || null;
62
+
136
63
  if (!existingChat) {
137
- // Create workspace if not already provided
138
64
  if (!workspaceId) {
65
+ // Resolve repo + branch on the server. Agent mode uses configured GH_OWNER/GH_REPO
66
+ // and always detects the default branch. Code mode uses user-picked repo and falls
67
+ // back to detection only if no branch was provided.
68
+ let resolvedRepo = repo;
69
+ if (!isCodeMode) {
70
+ const ghOwner = getConfig('GH_OWNER');
71
+ const ghRepo = getConfig('GH_REPO');
72
+ if (ghOwner && ghRepo) resolvedRepo = `${ghOwner}/${ghRepo}`;
73
+ }
74
+ let resolvedBranch = branch;
75
+ if (resolvedRepo && (!isCodeMode || !resolvedBranch)) {
76
+ try {
77
+ const { getDefaultBranch } = await import('../tools/github.js');
78
+ const detected = await getDefaultBranch(resolvedRepo);
79
+ if (detected) resolvedBranch = detected;
80
+ } catch {}
81
+ }
82
+
139
83
  const { createCodeWorkspace, updateFeatureBranch } = await import('../db/code-workspaces.js');
140
84
  const workspace = createCodeWorkspace(options.userId || 'unknown', {
141
- repo: repo,
142
- branch: branch,
85
+ repo: resolvedRepo,
86
+ branch: resolvedBranch,
87
+ scope: resolvedScope,
143
88
  });
144
89
  workspaceId = workspace.id;
145
- const { generateRandomName } = await import('../utils/random-name.js');
146
- const shortId = workspaceId.replace(/-/g, '').slice(0, 8);
147
- const featureBranch = `thepopebot/${generateRandomName()}-${shortId}`;
148
- updateFeatureBranch(workspaceId, featureBranch);
90
+ const branchMode = getConfig(isCodeMode ? 'CODE_MODE_BRANCH' : 'AGENT_MODE_BRANCH');
91
+ if (branchMode === 'dynamic') {
92
+ const { generateRandomName } = await import('../utils/random-name.js');
93
+ const shortId = workspaceId.replace(/-/g, '').slice(0, 8);
94
+ const featureBranch = `thepopebot/${generateRandomName()}-${shortId}`;
95
+ updateFeatureBranch(workspaceId, featureBranch);
96
+ } else {
97
+ // Default branch mode — featureBranch mirrors the working branch so
98
+ // downstream prompts never see an empty value.
99
+ if (resolvedBranch) updateFeatureBranch(workspaceId, resolvedBranch);
100
+ }
149
101
  }
150
102
  createChat(options.userId || 'unknown', 'New Chat', threadId, { chatMode: isCodeMode ? 'code' : 'agent' });
151
103
  linkChatToWorkspace(threadId, workspaceId);
152
104
  } else {
153
105
  workspaceId = workspaceId || existingChat.codeWorkspaceId;
106
+ if (!resolvedScope && workspaceId) {
107
+ const { getCodeWorkspaceById } = await import('../db/code-workspaces.js');
108
+ const ws = getCodeWorkspaceById(workspaceId);
109
+ if (ws?.scope) resolvedScope = ws.scope;
110
+ }
154
111
  }
155
112
 
156
- // ── SDK path: direct in-process SDK call (no LangGraph, no Docker) ──
157
- const sdkAdapter = getSdkAdapter(getConfig('CODING_AGENT'));
158
- const useSDK = !!sdkAdapter;
159
-
160
- // Save user message to DB (skip on regeneration — message already exists)
113
+ // Save user message (skip on regeneration message already exists)
161
114
  if (!options.skipUserPersist) {
162
115
  persistMessage(threadId, 'user', message || '[attachment]', options);
163
116
  }
164
117
 
165
- if (useSDK) {
166
- // 1. Workspace setup (idempotent — skips if .git exists)
167
- const wsBaseDir = getWorkspaceDir(workspaceId);
168
- const repoDir = path.join(wsBaseDir, 'workspace');
169
-
170
- const { getCodeWorkspaceById } = await import('../db/code-workspaces.js');
171
- const workspace = getCodeWorkspaceById(workspaceId);
172
- const featureBranch = workspace?.featureBranch;
173
-
174
- const setupToolCallId = `setup-${workspaceId.slice(0, 8)}`;
175
- const setupArgs = { repo, branch, featureBranch };
176
- yield { type: 'tool-call', toolCallId: setupToolCallId, toolName: 'set_up_workspace', args: setupArgs };
177
-
178
- try {
179
- await ensureWorkspaceRepo({ workspaceDir: repoDir, repo, branch, featureBranch });
180
- ensureSkills(repoDir, isCodeMode ? 'code' : 'agent');
181
- yield { type: 'tool-result', toolCallId: setupToolCallId, result: JSON.stringify({ result: `Workspace ready on ${featureBranch || branch}` }) };
182
- } catch (err) {
183
- yield { type: 'tool-result', toolCallId: setupToolCallId, result: JSON.stringify({ result: `Setup failed: ${err.message}` }) };
184
- throw err;
185
- }
186
-
187
- // 2. Session continuity
188
- const sessionId = readSessionId(wsBaseDir);
189
-
190
- // 3. System prompt (agent mode: SOUL.md + SYSTEM.md, code mode: none)
191
- let systemPrompt = null;
192
- if (!isCodeMode) {
193
- const soulPath = path.join(PROJECT_ROOT, 'agent-job/SOUL.md');
194
- const systemPath = path.join(PROJECT_ROOT, 'agent-job/SYSTEM.md');
195
- try {
196
- const soul = render_md(soulPath) || '';
197
- const system = render_md(systemPath) || '';
198
- systemPrompt = [soul, system].filter(Boolean).join('\n\n') || null;
199
- } catch {
200
- // Files may not exist — proceed without system prompt
201
- }
118
+ const wsBaseDir = getWorkspaceDir(workspaceId);
119
+ const repoDir = path.join(wsBaseDir, 'workspace');
120
+ const codingAgent = getConfig('CODING_AGENT') || 'claude-code';
121
+ const sdkAdapter = getSdkAdapter(codingAgent);
122
+
123
+ // Read the resolved repo + branch from the workspace record (set at creation time).
124
+ const { getCodeWorkspaceById } = await import('../db/code-workspaces.js');
125
+ const workspace = getCodeWorkspaceById(workspaceId);
126
+ const featureBranch = workspace?.featureBranch;
127
+
128
+ // Resolve agent-mode-specific vs code-mode-specific values used by both paths
129
+ let effectiveRepo, effectiveBranch, systemPrompt, injectSecrets;
130
+ if (isCodeMode) {
131
+ effectiveRepo = workspace?.repo || repo;
132
+ effectiveBranch = workspace?.branch || branch;
133
+ systemPrompt = buildCodingAgentSystemPrompt('code');
134
+ injectSecrets = false;
135
+ } else {
136
+ const ghOwner = getConfig('GH_OWNER');
137
+ const ghRepo = getConfig('GH_REPO');
138
+ if (!ghOwner || !ghRepo) {
139
+ const msg = 'GH_OWNER or GH_REPO not configured';
140
+ yield { type: 'error', message: msg };
141
+ persistMessage(threadId, 'assistant', JSON.stringify({ type: 'error', message: msg }), options);
142
+ return;
202
143
  }
144
+ effectiveRepo = `${ghOwner}/${ghRepo}`;
145
+ effectiveBranch = workspace?.branch || null;
146
+ const { skillsDir } = resolveAgentScope(repoDir, resolvedScope || null);
147
+ systemPrompt = buildCodingAgentSystemPrompt('agent', skillsDir, resolvedScope || null);
148
+ injectSecrets = true;
149
+ }
203
150
 
204
- // 4. Stream from SDK adapter
205
- let pendingText = '';
206
- const pendingToolCalls = new Map();
207
-
208
- try {
209
- for await (const chunk of sdkAdapter({
210
- prompt: message,
211
- workspaceDir: repoDir,
212
- systemPrompt,
213
- sessionId,
214
- permissionMode: codeModeType,
215
- attachments,
216
- })) {
217
- // Write session ID on first meta chunk
218
- if (chunk.type === 'meta' && chunk.sessionId) {
219
- writeSessionId(wsBaseDir, chunk.sessionId);
220
- }
151
+ // Workspace setup visible to user as a `workspace` tool-call the first time
152
+ const needsSetup = !existsSync(path.join(repoDir, '.git'));
153
+ const setupToolCallId = `setup-${workspaceId.slice(0, 8)}`;
154
+ const setupArgs = { repo: effectiveRepo, branch: effectiveBranch, featureBranch };
221
155
 
222
- // DB persistence
223
- if (chunk.type === 'text') {
224
- pendingText += chunk.text;
225
- } else if (chunk.type === 'tool-call') {
226
- // Flush accumulated text before tool call
227
- if (pendingText) {
228
- persistMessage(threadId, 'assistant', pendingText, options);
229
- pendingText = '';
230
- }
231
- pendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
232
- } else if (chunk.type === 'tool-result') {
233
- const tc = pendingToolCalls.get(chunk.toolCallId);
234
- if (tc) {
235
- persistMessage(threadId, 'assistant', JSON.stringify({
236
- type: 'tool-invocation',
237
- toolCallId: chunk.toolCallId,
238
- toolName: tc.toolName,
239
- state: 'output-available',
240
- input: tc.args,
241
- output: chunk.result,
242
- }), options);
243
- pendingToolCalls.delete(chunk.toolCallId);
244
- }
245
- }
156
+ if (needsSetup) {
157
+ yield { type: 'tool-call', toolCallId: setupToolCallId, toolName: 'workspace', args: setupArgs };
158
+ }
246
159
 
247
- yield chunk;
248
- }
249
- } catch (err) {
250
- console.error('[chatStream] error:', err);
251
- throw err;
252
- } finally {
253
- // Flush remaining text
254
- if (pendingText) {
255
- persistMessage(threadId, 'assistant', pendingText, options);
256
- }
160
+ try {
161
+ const setupOutput = await ensureWorkspaceRepo({ workspaceDir: repoDir, repo: effectiveRepo, branch: effectiveBranch, featureBranch });
162
+ if (needsSetup) {
163
+ const result = setupOutput || `Workspace ready on ${featureBranch || effectiveBranch}`;
164
+ yield { type: 'tool-result', toolCallId: setupToolCallId, result };
165
+ persistMessage(threadId, 'assistant', JSON.stringify({
166
+ type: 'tool-invocation',
167
+ toolCallId: setupToolCallId,
168
+ toolName: 'workspace',
169
+ state: 'output-available',
170
+ input: setupArgs,
171
+ output: result,
172
+ }), options);
257
173
  }
258
-
259
- // Auto-generate title for new chats
260
- if (options.userId && message) {
261
- autoTitle(threadId, message).catch(() => {});
174
+ } catch (err) {
175
+ const msg = err.message || 'Workspace setup failed';
176
+ if (needsSetup) {
177
+ yield { type: 'tool-result', toolCallId: setupToolCallId, result: `Setup failed: ${msg}` };
262
178
  }
263
-
179
+ yield { type: 'error', message: msg };
180
+ persistMessage(threadId, 'assistant', JSON.stringify({ type: 'error', message: msg }), options);
264
181
  return;
265
182
  }
266
183
 
267
- // ── Legacy path: LangGraph + Docker (non-claude-code agents + job mode) ──
268
-
269
- const agent = isCodeMode
270
- ? await getCodeChat()
271
- : await getAgentChat();
272
-
273
- // Build content blocks: text + any image/PDF attachments as vision
274
- const content = [];
275
-
276
- if (message) {
277
- content.push({ type: 'text', text: message });
184
+ // Fork on SDK availability
185
+ if (sdkAdapter) {
186
+ yield* streamViaSdk({ threadId, message, attachments, options, wsBaseDir, repoDir, resolvedScope, isCodeMode, codeModeType, systemPrompt, workspaceId, sdkAdapter });
187
+ } else {
188
+ yield* streamViaContainer({ threadId, message, options, codingAgent, workspaceId, featureBranch, effectiveRepo, effectiveBranch, systemPrompt, injectSecrets, codeModeType, resolvedScope });
278
189
  }
279
190
 
280
- for (const att of attachments) {
281
- if (att.category === 'image') {
282
- // Support both dataUrl (web) and Buffer (Telegram) formats
283
- const url = att.dataUrl
284
- ? att.dataUrl
285
- : `data:${att.mimeType};base64,${att.data.toString('base64')}`;
286
- content.push({
287
- type: 'image_url',
288
- image_url: { url },
289
- });
290
- }
191
+ // Auto-generate title for new chats (runs once after either path completes)
192
+ if (options.userId && message) {
193
+ autoTitle(threadId, message).catch(() => {});
291
194
  }
195
+ }
292
196
 
293
- // If only text and no attachments, simplify to a string
294
- let messageContent = content.length === 1 && content[0].type === 'text'
295
- ? content[0].text
296
- : content;
297
-
298
- // Append chat mode for agent chats so the LLM sees the user's selected mode
299
- if (!isCodeMode) {
300
- if (typeof messageContent === 'string') {
301
- messageContent += `\n\n[chat mode: ${codeModeType}]`;
302
- } else if (Array.isArray(messageContent)) {
303
- const textBlock = messageContent.find(b => b.type === 'text');
304
- if (textBlock) textBlock.text += `\n\n[chat mode: ${codeModeType}]`;
305
- }
306
- }
197
+ /**
198
+ * SDK path in-process @anthropic-ai/claude-agent-sdk.
199
+ * Used only when a registered SDK adapter exists for the active coding agent.
200
+ */
201
+ async function* streamViaSdk({ threadId, message, attachments, options, wsBaseDir, repoDir, resolvedScope, isCodeMode, codeModeType, systemPrompt, workspaceId, sdkAdapter }) {
202
+ const chatMode = isCodeMode ? 'code' : 'agent';
203
+ const { workingDir } = resolveAgentScope(repoDir, resolvedScope);
204
+ const sessionId = readSessionId(wsBaseDir);
307
205
 
308
- // Side channel: bridges the tool's live container output to this generator
309
- const sideChannel = createChannel();
310
- const streamCallback = (chunk) => {
311
- if (chunk === null) sideChannel.done();
312
- else sideChannel.push(chunk);
313
- };
206
+ let pendingText = '';
207
+ const pendingToolCalls = new Map();
314
208
 
315
209
  try {
316
- const stream = await agent.stream(
317
- { messages: [new HumanMessage({ content: messageContent })] },
318
- { configurable: { thread_id: threadId, workspaceId, repo, branch, codeModeType, streamCallback }, streamMode: 'messages' }
319
- );
320
-
321
- const toolCallNames = {};
322
- const pendingToolCalls = new Map();
323
-
324
- // Accumulate raw tool call arg fragments across streaming chunks.
325
- // Each AIMessageChunk only carries its own delta — the first chunk
326
- // (content_block_start) has id+index+name with args "", subsequent
327
- // chunks (input_json_delta) have only index with the partial JSON delta.
328
- const toolCallRawArgs = {}; // tool_call_id → accumulated args string
329
- const indexToToolCallId = {}; // chunk index → tool_call_id
330
- const toolCallArgsEmitted = new Set(); // tool_call_ids whose complete args have been yielded
331
-
332
- // Headless container streaming state
333
- const headlessPendingToolCalls = new Map();
334
- let pendingText = ''; // channel text, flushed to DB at tool boundaries
335
- let llmTextAccum = ''; // langgraph text (direct response or LLM follow-up after container)
336
-
337
- // Tag helper so mergeAsyncIterables can tell the two sources apart.
338
- // The LangGraph wrapper also closes sideChannel when the agent stream
339
- // finishes — this prevents a deadlock when no tool calls streamCallback.
340
- async function* tagged(iter, source) {
341
- for await (const item of iter) yield { _src: source, item };
342
- if (source === 'lg') sideChannel.done();
343
- }
210
+ for await (const chunk of sdkAdapter({
211
+ prompt: message,
212
+ workspaceDir: workingDir,
213
+ systemPrompt,
214
+ sessionId,
215
+ permissionMode: codeModeType,
216
+ attachments,
217
+ workspaceId,
218
+ chatMode,
219
+ })) {
220
+ if (chunk.type === 'meta' && chunk.sessionId) {
221
+ writeSessionId(wsBaseDir, chunk.sessionId);
222
+ }
344
223
 
345
- try {
346
- for await (const { _src, item } of mergeAsyncIterables(
347
- tagged(stream, 'lg'),
348
- tagged(sideChannel, 'ch')
349
- )) {
350
- if (_src === 'lg') {
351
- // ── LangGraph agent stream ────────────────────────────────────────
352
- const msg = Array.isArray(item) ? item[0] : item;
353
- const msgType = msg._getType?.();
354
-
355
- if (msgType === 'ai') {
356
- // Tool calls — AIMessage.tool_calls is an array of { id, name, args }
357
- if (msg.tool_calls?.length > 0) {
358
- for (const tc of msg.tool_calls) {
359
- toolCallNames[tc.id] = tc.name;
360
- pendingToolCalls.set(tc.id, { toolName: tc.name, args: tc.args });
361
- yield {
362
- type: 'tool-call',
363
- toolCallId: tc.id,
364
- toolName: tc.name,
365
- args: tc.args,
366
- };
367
- }
368
- }
369
-
370
- // Accumulate raw tool call arg strings from streaming chunks
371
- if (msg.tool_call_chunks?.length > 0) {
372
- for (const c of msg.tool_call_chunks) {
373
- if (c.id) {
374
- indexToToolCallId[c.index] = c.id;
375
- toolCallRawArgs[c.id] = (toolCallRawArgs[c.id] || '') + (c.args || '');
376
- } else if (c.index != null && indexToToolCallId[c.index]) {
377
- const id = indexToToolCallId[c.index];
378
- toolCallRawArgs[id] = (toolCallRawArgs[id] || '') + (c.args || '');
379
- }
380
- }
381
- // Re-yield tool-call with complete args once the JSON is fully streamed
382
- for (const c of msg.tool_call_chunks) {
383
- const id = c.id || indexToToolCallId[c.index];
384
- if (id && toolCallRawArgs[id] && !toolCallArgsEmitted.has(id)) {
385
- try {
386
- const parsed = JSON.parse(toolCallRawArgs[id]);
387
- toolCallArgsEmitted.add(id);
388
- const tc = pendingToolCalls.get(id);
389
- if (tc) {
390
- tc.args = parsed;
391
- yield { type: 'tool-call', toolCallId: id, toolName: tc.toolName, args: parsed };
392
- }
393
- } catch {} // args not complete yet, keep accumulating
394
- }
395
- }
396
- }
397
-
398
- // Text content (wrapped in structured object)
399
- let text = '';
400
- if (typeof msg.content === 'string') {
401
- text = msg.content;
402
- } else if (Array.isArray(msg.content)) {
403
- text = msg.content
404
- .filter((b) => b.type === 'text' && b.text)
405
- .map((b) => b.text)
406
- .join('');
407
- }
408
-
409
- if (text) {
410
- llmTextAccum += text;
411
- yield { type: 'text', text };
412
- }
413
- } else if (msgType === 'tool') {
414
- // Parse complete args from accumulated raw fragments
415
- const tc = pendingToolCalls.get(msg.tool_call_id);
416
- const rawArgs = toolCallRawArgs[msg.tool_call_id];
417
- let completeArgs;
418
- try { completeArgs = rawArgs ? JSON.parse(rawArgs) : {}; } catch { completeArgs = {}; }
419
-
420
- // Tool result — ToolMessage has tool_call_id and content
421
- yield {
422
- type: 'tool-result',
423
- toolCallId: msg.tool_call_id,
424
- toolName: tc?.toolName,
425
- args: completeArgs,
426
- result: msg.content,
427
- };
428
-
429
- // Save complete tool invocation as JSON
430
- if (tc) {
431
- persistMessage(threadId, 'assistant', JSON.stringify({
432
- type: 'tool-invocation',
433
- toolCallId: msg.tool_call_id,
434
- toolName: tc.toolName,
435
- state: 'output-available',
436
- input: completeArgs,
437
- output: msg.content,
438
- }), options);
439
- pendingToolCalls.delete(msg.tool_call_id);
440
- }
441
- }
442
- // Skip other message types (human, system)
443
-
444
- } else {
445
- // ── Side channel: headless container chunks ───────────────────────
446
- const chunk = item;
447
-
448
- if (chunk.type === 'text') {
449
- pendingText += chunk.text;
450
- yield chunk;
451
- } else if (chunk.type === 'tool-call') {
452
- // Flush accumulated text before tool call
453
- if (pendingText) {
454
- persistMessage(threadId, 'assistant', pendingText, options);
455
- pendingText = '';
456
- }
457
- headlessPendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
458
- yield chunk;
459
- } else if (chunk.type === 'tool-result') {
460
- // Enrich with args from matching tool-call (required by api.js tool-input-available update)
461
- const htc = headlessPendingToolCalls.get(chunk.toolCallId);
462
- const enriched = htc ? { ...chunk, args: htc.args, toolName: htc.toolName } : chunk;
463
- yield enriched;
464
- if (htc) {
465
- persistMessage(threadId, 'assistant', JSON.stringify({
466
- type: 'tool-invocation',
467
- toolCallId: chunk.toolCallId,
468
- toolName: htc.toolName,
469
- state: 'output-available',
470
- input: htc.args,
471
- output: chunk.result,
472
- }), options);
473
- headlessPendingToolCalls.delete(chunk.toolCallId);
474
- }
475
- } else {
476
- // unknown/meta events pass through unchanged
477
- yield chunk;
478
- }
224
+ if (chunk.type === 'text') {
225
+ pendingText += chunk.text;
226
+ } else if (chunk.type === 'tool-call') {
227
+ if (pendingText) {
228
+ persistMessage(threadId, 'assistant', pendingText, options);
229
+ pendingText = '';
230
+ }
231
+ pendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
232
+ } else if (chunk.type === 'tool-result') {
233
+ const tc = pendingToolCalls.get(chunk.toolCallId);
234
+ if (tc) {
235
+ persistMessage(threadId, 'assistant', JSON.stringify({
236
+ type: 'tool-invocation',
237
+ toolCallId: chunk.toolCallId,
238
+ toolName: tc.toolName,
239
+ state: 'output-available',
240
+ input: tc.args,
241
+ output: chunk.result,
242
+ }), options);
243
+ pendingToolCalls.delete(chunk.toolCallId);
479
244
  }
480
245
  }
481
- } finally {
482
- // Ensure no dangling promise when tool was never called
483
- sideChannel.done();
484
- }
485
246
 
486
- // Flush remaining channel text
247
+ yield chunk;
248
+ }
249
+ } catch (err) {
250
+ const msg = err.message || 'SDK stream failed';
251
+ console.error('[streamViaSdk] error:', err);
252
+ yield { type: 'error', message: msg };
253
+ persistMessage(threadId, 'assistant', JSON.stringify({ type: 'error', message: msg }), options);
254
+ } finally {
487
255
  if (pendingText) {
488
256
  persistMessage(threadId, 'assistant', pendingText, options);
489
257
  }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Direct headless path — spawn the coding agent in an ephemeral Docker container,
263
+ * stream its output via parseHeadlessStream, and yield normalized chunks.
264
+ *
265
+ * Replaces the former LangGraph React agent + `coding_agent` tool — there is no
266
+ * LLM layer between the user's message and the container. Multi-turn memory
267
+ * lives in the agent's own session files inside the volume-mounted workspace
268
+ * (see docker/coding-agent/CLAUDE.md § Session Tracking).
269
+ */
270
+ async function* streamViaContainer({ threadId, message, options, codingAgent, workspaceId, featureBranch, effectiveRepo, effectiveBranch, systemPrompt, injectSecrets, codeModeType, resolvedScope }) {
271
+ const containerName = `${codingAgent}-headless-${randomUUID().slice(0, 8)}`;
272
+ const mode = codeModeType === 'code' ? 'dangerous' : 'plan';
273
+ const { runHeadlessContainer, tailContainerLogs, waitForContainer, removeContainer } = await import('../tools/docker.js');
274
+ const { parseHeadlessStream } = await import('./headless-stream.js');
275
+
276
+ // Synthetic coding_agent wrapper — bracketing tool-call/tool-result pair so the
277
+ // user can see that the direct-container path ran (the SDK path has no wrapper).
278
+ // Inner container chunks stream as top-level parts alongside it; AI SDK parts
279
+ // are flat, so this is a visual anchor, not a container for nested content.
280
+ const wrapperId = `coding-agent-${randomUUID().slice(0, 8)}`;
281
+ const providerKeys = {
282
+ 'claude-code': 'CODING_AGENT_CLAUDE_CODE_BACKEND',
283
+ 'pi-coding-agent': 'CODING_AGENT_PI_PROVIDER',
284
+ 'gemini-cli': 'CODING_AGENT_GEMINI_CLI_PROVIDER',
285
+ 'codex-cli': 'CODING_AGENT_CODEX_CLI_PROVIDER',
286
+ 'opencode': 'CODING_AGENT_OPENCODE_PROVIDER',
287
+ 'kimi-cli': 'CODING_AGENT_KIMI_CLI_PROVIDER',
288
+ };
289
+ const backendApi = getConfig(providerKeys[codingAgent]) || 'anthropic';
290
+ const wrapperArgs = { prompt: message, codingAgent, backendApi };
291
+
292
+ yield { type: 'tool-call', toolCallId: wrapperId, toolName: 'coding_agent', args: wrapperArgs };
293
+
294
+ let pendingText = '';
295
+ const pendingToolCalls = new Map();
296
+ let started = false;
297
+ let wrapperClosed = false;
298
+
299
+ const closeWrapper = (result) => {
300
+ if (wrapperClosed) return null;
301
+ wrapperClosed = true;
302
+ persistMessage(threadId, 'assistant', JSON.stringify({
303
+ type: 'tool-invocation',
304
+ toolCallId: wrapperId,
305
+ toolName: 'coding_agent',
306
+ state: 'output-available',
307
+ input: wrapperArgs,
308
+ output: result,
309
+ }), options);
310
+ return { type: 'tool-result', toolCallId: wrapperId, result };
311
+ };
490
312
 
491
- // Persist LLM text (direct response with no tool, or LLM follow-up after container)
492
- if (llmTextAccum) {
493
- persistMessage(threadId, 'assistant', llmTextAccum, options);
313
+ try {
314
+ await runHeadlessContainer({
315
+ containerName,
316
+ repo: effectiveRepo,
317
+ branch: effectiveBranch,
318
+ featureBranch,
319
+ workspaceId,
320
+ taskPrompt: message,
321
+ mode,
322
+ systemPrompt,
323
+ injectSecrets,
324
+ scope: resolvedScope || undefined,
325
+ });
326
+ started = true;
327
+
328
+ const logStream = await tailContainerLogs(containerName);
329
+
330
+ for await (const chunk of parseHeadlessStream(logStream, codingAgent)) {
331
+ if (chunk.type === 'text') {
332
+ pendingText += chunk.text;
333
+ } else if (chunk.type === 'tool-call') {
334
+ if (pendingText) {
335
+ persistMessage(threadId, 'assistant', pendingText, options);
336
+ pendingText = '';
337
+ }
338
+ pendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
339
+ } else if (chunk.type === 'tool-result') {
340
+ const tc = pendingToolCalls.get(chunk.toolCallId);
341
+ if (tc) {
342
+ persistMessage(threadId, 'assistant', JSON.stringify({
343
+ type: 'tool-invocation',
344
+ toolCallId: chunk.toolCallId,
345
+ toolName: tc.toolName,
346
+ state: 'output-available',
347
+ input: tc.args,
348
+ output: chunk.result,
349
+ }), options);
350
+ pendingToolCalls.delete(chunk.toolCallId);
351
+ }
352
+ }
353
+
354
+ yield chunk;
494
355
  }
495
356
 
357
+ const exitCode = await waitForContainer(containerName);
358
+ await removeContainer(containerName);
359
+
360
+ const closeResult = exitCode === 0 ? 'Completed' : `Exited with code ${exitCode}`;
361
+ const closeChunk = closeWrapper(closeResult);
362
+ if (closeChunk) yield closeChunk;
363
+
364
+ if (exitCode !== 0) {
365
+ const msg = `Coding agent exited with code ${exitCode}`;
366
+ yield { type: 'error', message: msg };
367
+ persistMessage(threadId, 'assistant', JSON.stringify({ type: 'error', message: msg }), options);
368
+ }
496
369
  } catch (err) {
497
- console.error('[chatStream] error:', err);
498
- throw err;
370
+ const msg = err.message || 'Coding agent failed';
371
+ console.error('[streamViaContainer] error:', err);
372
+ const closeChunk = closeWrapper(`Error: ${msg}`);
373
+ if (closeChunk) yield closeChunk;
374
+ yield { type: 'error', message: msg };
375
+ persistMessage(threadId, 'assistant', JSON.stringify({ type: 'error', message: msg }), options);
376
+ if (started) {
377
+ try { await removeContainer(containerName); } catch {}
378
+ }
379
+ } finally {
380
+ if (pendingText) {
381
+ persistMessage(threadId, 'assistant', pendingText, options);
382
+ }
499
383
  }
500
384
  }
501
385
 
@@ -508,15 +392,16 @@ async function autoTitle(threadId, firstMessage) {
508
392
  const chat = getChatById(threadId);
509
393
  if (!chat || chat.title !== 'New Chat') return;
510
394
 
511
- const model = await createModel({ maxTokens: 250 });
512
- const response = await model.withStructuredOutput(z.object({ title: z.string() })).invoke([
513
- ['system', 'Title this chat in 2-5 words. Name the subject matter only. Never start with "User". Never describe what the user is doing — just the topic. Always produce a title, even for vague messages — infer the likely topic.'],
514
- ['human', firstMessage],
515
- ]);
516
- if (response.title.trim()) {
517
- updateChatTitle(threadId, response.title.trim());
518
-
519
- return response.title.trim();
395
+ const result = await callHelperLlmStructured({
396
+ system: 'Title this chat in 2-5 words. Name the subject matter only. Never start with "User". Never describe what the user is doing — just the topic. Always produce a title, even for vague messages — infer the likely topic.',
397
+ user: firstMessage,
398
+ schema: z.object({ title: z.string() }),
399
+ maxTokens: 250,
400
+ });
401
+ const title = result?.title?.trim();
402
+ if (title) {
403
+ updateChatTitle(threadId, title);
404
+ return title;
520
405
  }
521
406
  } catch (err) {
522
407
  console.error('[autoTitle] Failed to generate title:', err.message);
@@ -527,13 +412,9 @@ async function autoTitle(threadId, firstMessage) {
527
412
  /**
528
413
  * One-shot summarization with a different system prompt and no memory.
529
414
  * Used for agent job completion summaries sent via GitHub webhook.
530
- *
531
- * @param {object} results - Agent job results from webhook payload
532
- * @returns {Promise<string>} Summary text
533
415
  */
534
416
  async function summarizeAgentJob(results) {
535
417
  try {
536
- const model = await createModel({ maxTokens: 1024 });
537
418
  const summaryMdPath = path.join(PROJECT_ROOT, 'event-handler/SUMMARY.md');
538
419
  const systemPrompt = render_md(summaryMdPath);
539
420
 
@@ -556,22 +437,11 @@ async function summarizeAgentJob(results) {
556
437
 
557
438
  console.log(`[summarizeAgentJob] System prompt: ${systemPrompt.length} chars, user message: ${userMessage.length} chars`);
558
439
 
559
- const response = await model.invoke([
560
- ['system', systemPrompt],
561
- ['human', userMessage],
562
- ]);
563
-
564
- const text =
565
- typeof response.content === 'string'
566
- ? response.content
567
- : response.content
568
- .filter((block) => block.type === 'text')
569
- .map((block) => block.text)
570
- .join('\n');
440
+ const text = await callHelperLlm({ system: systemPrompt, user: userMessage, maxTokens: 1024 });
571
441
 
572
442
  console.log(`[summarizeAgentJob] Result: ${text.length} chars — ${text.slice(0, 200)}`);
573
443
 
574
- return text.trim() || 'Agent job finished.';
444
+ return text || 'Agent job finished.';
575
445
  } catch (err) {
576
446
  console.error('[summarizeAgentJob] Failed to summarize agent job:', err);
577
447
  return 'Agent job finished.';