thepopebot 1.2.76-beta.0 → 1.2.76-beta.10

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 (48) hide show
  1. package/api/index.js +22 -8
  2. package/bin/cli.js +27 -2
  3. package/drizzle/0021_coding_agent_workspace.sql +1 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/lib/ai/index.js +27 -17
  6. package/lib/ai/sdk-adapters/CLAUDE.md +113 -0
  7. package/lib/ai/sdk-adapters/claude-code.js +100 -8
  8. package/lib/ai/system-prompt.js +16 -0
  9. package/lib/ai/tools.js +3 -0
  10. package/lib/ai/workspace-setup.js +18 -5
  11. package/lib/channels/telegram.js +78 -7
  12. package/lib/chat/actions.js +205 -10
  13. package/lib/chat/api.js +114 -15
  14. package/lib/chat/components/chat-input.js +78 -33
  15. package/lib/chat/components/chat-input.jsx +74 -23
  16. package/lib/chat/components/chat.js +27 -5
  17. package/lib/chat/components/chat.jsx +27 -3
  18. package/lib/chat/components/code-mode-toggle.js +110 -14
  19. package/lib/chat/components/code-mode-toggle.jsx +104 -13
  20. package/lib/chat/components/message.js +3 -3
  21. package/lib/chat/components/message.jsx +3 -3
  22. package/lib/chat/components/settings-secrets-page.js +274 -75
  23. package/lib/chat/components/settings-secrets-page.jsx +327 -65
  24. package/lib/chat/components/ui/combobox.js +18 -2
  25. package/lib/chat/components/ui/combobox.jsx +17 -1
  26. package/lib/code/actions.js +26 -6
  27. package/lib/code/terminal-view.js +36 -9
  28. package/lib/code/terminal-view.jsx +42 -10
  29. package/lib/config.js +11 -1
  30. package/lib/db/chats.js +9 -17
  31. package/lib/db/code-workspaces.js +6 -2
  32. package/lib/db/schema.js +1 -0
  33. package/lib/maintenance.js +31 -21
  34. package/lib/tools/docker.js +21 -8
  35. package/lib/tools/github.js +16 -0
  36. package/lib/tools/telegram.js +115 -0
  37. package/lib/utils/render-md.js +1 -1
  38. package/package.json +2 -2
  39. package/setup/lib/telegram.mjs +9 -69
  40. package/templates/.github/workflows/rebuild-event-handler.yml +1 -1
  41. package/templates/.gitignore.template +1 -1
  42. package/templates/CLAUDE.md +1 -1
  43. package/templates/CLAUDE.md.template +2 -1
  44. package/templates/agent-job/CLAUDE.md.template +1 -2
  45. package/templates/agent-job/SYSTEM.md +2 -2
  46. package/templates/coding-workspace/CLAUDE.md.template +7 -0
  47. package/templates/coding-workspace/SYSTEM.md +0 -0
  48. package/templates/agent-job/SOUL.md +0 -17
package/api/index.js CHANGED
@@ -3,7 +3,7 @@ import { createAgentJob } from '../lib/tools/create-agent-job.js';
3
3
  import { setWebhook } from '../lib/tools/telegram.js';
4
4
  import { getAgentJobStatus, fetchAgentJobLog } from '../lib/tools/github.js';
5
5
  import { getTelegramAdapter } from '../lib/channels/index.js';
6
- import { chat, summarizeAgentJob } from '../lib/ai/index.js';
6
+ import { chat, chatStream, summarizeAgentJob } from '../lib/ai/index.js';
7
7
  import { createNotification } from '../lib/db/notifications.js';
8
8
  import { loadTriggers } from '../lib/triggers.js';
9
9
  import { verifyApiKey } from '../lib/db/api-keys.js';
@@ -211,19 +211,33 @@ async function handleTelegramWebhook(request) {
211
211
  /**
212
212
  * Process a normalized message through the AI layer with channel UX.
213
213
  * Message persistence is handled centrally by the AI layer.
214
+ *
215
+ * Uses chatStream() for progressive tool-call rendering when the adapter
216
+ * supports it (Telegram: sends each tool call as a message, reacts on completion).
217
+ * Falls back to chat() for adapters without streamChatResponse.
214
218
  */
215
219
  async function processChannelMessage(adapter, normalized) {
216
220
  await adapter.acknowledge(normalized.metadata);
217
221
  const stopIndicator = adapter.startProcessingIndicator(normalized.metadata);
218
222
 
219
223
  try {
220
- const response = await chat(
221
- normalized.threadId,
222
- normalized.text,
223
- normalized.attachments,
224
- { userId: 'telegram', chatTitle: 'Telegram' }
225
- );
226
- await adapter.sendResponse(normalized.threadId, response, normalized.metadata);
224
+ if (adapter.streamChatResponse) {
225
+ const chunks = chatStream(
226
+ normalized.threadId,
227
+ normalized.text,
228
+ normalized.attachments,
229
+ { userId: 'telegram', chatTitle: 'Telegram' }
230
+ );
231
+ await adapter.streamChatResponse(normalized.metadata.chatId, chunks);
232
+ } else {
233
+ const response = await chat(
234
+ normalized.threadId,
235
+ normalized.text,
236
+ normalized.attachments,
237
+ { userId: 'telegram', chatTitle: 'Telegram' }
238
+ );
239
+ await adapter.sendResponse(normalized.threadId, response, normalized.metadata);
240
+ }
227
241
  } catch (err) {
228
242
  console.error('Failed to process message with AI:', err);
229
243
  await adapter
package/bin/cli.js CHANGED
@@ -305,6 +305,31 @@ async function init() {
305
305
  console.log(' Created .claude/skills → ../skills/active');
306
306
  }
307
307
 
308
+ // Create .codex/skills → ../skills/active symlink
309
+ const codexSkillsLink = path.join(cwd, '.codex', 'skills');
310
+ if (!fs.existsSync(codexSkillsLink)) {
311
+ fs.mkdirSync(path.dirname(codexSkillsLink), { recursive: true });
312
+ createDirLink('../skills/active', codexSkillsLink);
313
+ console.log(' Created .codex/skills → ../skills/active');
314
+ }
315
+
316
+ // Create .gemini/skills → ../skills/active symlink
317
+ const geminiSkillsLink = path.join(cwd, '.gemini', 'skills');
318
+ if (!fs.existsSync(geminiSkillsLink)) {
319
+ fs.mkdirSync(path.dirname(geminiSkillsLink), { recursive: true });
320
+ createDirLink('../skills/active', geminiSkillsLink);
321
+ console.log(' Created .gemini/skills → ../skills/active');
322
+ }
323
+
324
+ // Create .kimi/skills → ../skills/active symlink
325
+ const kimiSkillsLink = path.join(cwd, '.kimi', 'skills');
326
+ if (!fs.existsSync(kimiSkillsLink)) {
327
+ fs.mkdirSync(path.dirname(kimiSkillsLink), { recursive: true });
328
+ createDirLink('../skills/active', kimiSkillsLink);
329
+ console.log(' Created .kimi/skills → ../skills/active');
330
+ }
331
+
332
+
308
333
  // Report backed-up files
309
334
  if (backedUp.length > 0) {
310
335
  console.log(`\n Backed up ${backedUp.length} file(s) to ${path.relative(cwd, backupDir)}/`);
@@ -420,7 +445,7 @@ function reset(filePath) {
420
445
  console.log(` ${destPath(file)}`);
421
446
  }
422
447
  console.log('\nUsage: thepopebot reset <file>');
423
- console.log('Example: thepopebot reset agent-job/SOUL.md\n');
448
+ console.log('Example: thepopebot reset agent-job/SYSTEM.md\n');
424
449
  return;
425
450
  }
426
451
 
@@ -502,7 +527,7 @@ function diff(filePath) {
502
527
  console.log(' All files match package templates.');
503
528
  }
504
529
  console.log('\nUsage: thepopebot diff <file>');
505
- console.log('Example: thepopebot diff agent-job/SOUL.md\n');
530
+ console.log('Example: thepopebot diff agent-job/SYSTEM.md\n');
506
531
  return;
507
532
  }
508
533
 
@@ -0,0 +1 @@
1
+ ALTER TABLE `code_workspaces` ADD `coding_agent` text;
@@ -148,6 +148,13 @@
148
148
  "when": 1774327178886,
149
149
  "tag": "0020_natural_fabian_cortez",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "6",
155
+ "when": 1775865600000,
156
+ "tag": "0021_coding_agent_workspace",
157
+ "breakpoints": true
151
158
  }
152
159
  ]
153
160
  }
package/lib/ai/index.js CHANGED
@@ -4,8 +4,10 @@ import { z } from 'zod';
4
4
  import { getAgentChat, getCodeChat } from './agent.js';
5
5
  import { createModel } from './model.js';
6
6
  import path from 'path';
7
+ import { existsSync } from 'fs';
7
8
  import { PROJECT_ROOT } from '../paths.js';
8
9
  import { render_md } from '../utils/render-md.js';
10
+ import { buildCodingAgentSystemPrompt } from './system-prompt.js';
9
11
  import { getChatById, createChat, saveMessage, updateChatTitle, linkChatToWorkspace } from '../db/chats.js';
10
12
  import { getConfig } from '../config.js';
11
13
  import { getSdkAdapter } from './sdk-adapters/index.js';
@@ -171,35 +173,41 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
171
173
  const workspace = getCodeWorkspaceById(workspaceId);
172
174
  const featureBranch = workspace?.featureBranch;
173
175
 
176
+ const needsSetup = !existsSync(path.join(repoDir, '.git'));
174
177
  const setupToolCallId = `setup-${workspaceId.slice(0, 8)}`;
175
178
  const setupArgs = { repo, branch, featureBranch };
176
- yield { type: 'tool-call', toolCallId: setupToolCallId, toolName: 'set_up_workspace', args: setupArgs };
179
+
180
+ if (needsSetup) {
181
+ yield { type: 'tool-call', toolCallId: setupToolCallId, toolName: 'workspace', args: setupArgs };
182
+ }
177
183
 
178
184
  try {
179
- await ensureWorkspaceRepo({ workspaceDir: repoDir, repo, branch, featureBranch });
185
+ const setupOutput = await ensureWorkspaceRepo({ workspaceDir: repoDir, repo, branch, featureBranch });
180
186
  ensureSkills(repoDir, isCodeMode ? 'code' : 'agent');
181
- yield { type: 'tool-result', toolCallId: setupToolCallId, result: JSON.stringify({ result: `Workspace ready on ${featureBranch || branch}` }) };
187
+ if (needsSetup) {
188
+ const result = setupOutput || `Workspace ready on ${featureBranch || branch}`;
189
+ yield { type: 'tool-result', toolCallId: setupToolCallId, result };
190
+ persistMessage(threadId, 'assistant', JSON.stringify({
191
+ type: 'tool-invocation',
192
+ toolCallId: setupToolCallId,
193
+ toolName: 'workspace',
194
+ state: 'output-available',
195
+ input: setupArgs,
196
+ output: result,
197
+ }), options);
198
+ }
182
199
  } catch (err) {
183
- yield { type: 'tool-result', toolCallId: setupToolCallId, result: JSON.stringify({ result: `Setup failed: ${err.message}` }) };
200
+ if (needsSetup) {
201
+ yield { type: 'tool-result', toolCallId: setupToolCallId, result: `Setup failed: ${err.message}` };
202
+ }
184
203
  throw err;
185
204
  }
186
205
 
187
206
  // 2. Session continuity
188
207
  const sessionId = readSessionId(wsBaseDir);
189
208
 
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
- }
202
- }
209
+ // 3. System prompt
210
+ const systemPrompt = buildCodingAgentSystemPrompt(isCodeMode ? 'code' : 'agent');
203
211
 
204
212
  // 4. Stream from SDK adapter
205
213
  let pendingText = '';
@@ -213,6 +221,8 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
213
221
  sessionId,
214
222
  permissionMode: codeModeType,
215
223
  attachments,
224
+ workspaceId,
225
+ chatMode: isCodeMode ? 'code' : 'agent',
216
226
  })) {
217
227
  // Write session ID on first meta chunk
218
228
  if (chunk.type === 'meta' && chunk.sessionId) {
@@ -0,0 +1,113 @@
1
+ # lib/ai/sdk-adapters/ — SDK Adapter System
2
+
3
+ In-process SDK adapters that replace the legacy LangGraph + Docker path for chat. Each adapter wraps a coding agent's SDK and yields a unified chunk stream consumed by `chatStream()` in `lib/ai/index.js`.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Browser → POST /stream/chat (api.js)
9
+ → chatStream() (index.js)
10
+ → getSdkAdapter() returns adapter function or null
11
+ → if adapter: workspace setup → SDK adapter streaming → DB persistence
12
+ → if null: falls back to legacy LangGraph/Docker path
13
+ ```
14
+
15
+ The adapter is a pure stream translator — it receives a prompt and options, calls the SDK, and yields normalized chunks. Everything else (workspace setup, DB persistence, session continuity, system prompts) is handled by `chatStream()` in `index.js`.
16
+
17
+ ## Existing Adapter
18
+
19
+ | File | Agent | SDK |
20
+ |------|-------|-----|
21
+ | `claude-code.js` | `claude-code` | `@anthropic-ai/claude-agent-sdk` |
22
+
23
+ ## Adding a New SDK Adapter
24
+
25
+ ### 1. Create the adapter file
26
+
27
+ Create `{agent-name}.js` in this directory. Export a single async generator function:
28
+
29
+ ```js
30
+ export async function* myAgentStream({ prompt, workspaceDir, systemPrompt, sessionId, permissionMode, attachments }) {
31
+ // ... call the SDK, yield chunks
32
+ }
33
+ ```
34
+
35
+ ### 2. Required chunk types to yield
36
+
37
+ The adapter MUST yield these chunk types for `chatStream()` and `api.js` to work correctly:
38
+
39
+ | Chunk | Shape | When | Purpose |
40
+ |-------|-------|------|---------|
41
+ | `meta` | `{ type: 'meta', sessionId: string }` | First event | Session ID for continuity across messages. `chatStream()` writes this to disk via `writeSessionId()` so subsequent messages resume the session. |
42
+ | `text` | `{ type: 'text', text: string }` | Text output | Streamed to UI as deltas. Accumulated by `chatStream()` and flushed to DB as assistant messages at tool boundaries and stream end. |
43
+ | `tool-call` | `{ type: 'tool-call', toolCallId: string, toolName: string, args: object }` | Tool invocation starts | Triggers tool UI in the browser. May be yielded twice: once at start with `args: {}`, once at `content_block_stop` with complete args. `chatStream()` tracks these in `pendingToolCalls` for pairing with results. |
44
+ | `tool-result` | `{ type: 'tool-result', toolCallId: string, result: string }` | Tool completes | Paired with the matching `tool-call` by `toolCallId`. `chatStream()` persists the pair as a `tool-invocation` JSON message in the DB. |
45
+ | `result` | `{ type: 'result', text: string, cost?: number, duration?: number, subtype?: string }` | Stream ends | Final summary. Logged by `chatStream()`, not persisted or sent to UI. |
46
+
47
+ Optional:
48
+ | `unknown` | `{ type: 'unknown', raw: any }` | Unrecognized events | `api.js` renders these as collapsible boxes in the UI. Use for debugging unhandled SDK events. |
49
+
50
+ ### 3. Register in index.js
51
+
52
+ Add the import and mapping in `getSdkAdapter()`:
53
+
54
+ ```js
55
+ import { myAgentStream } from './my-agent.js';
56
+
57
+ export function getSdkAdapter(agentType) {
58
+ if (agentType === 'claude-code') return claudeCodeStream;
59
+ if (agentType === 'my-agent') return myAgentStream;
60
+ return null;
61
+ }
62
+ ```
63
+
64
+ The `agentType` string comes from the `CODING_AGENT` config value set in the admin UI.
65
+
66
+ ### 4. Auth resolution
67
+
68
+ Use `buildAgentAuthEnv(agentType)` from `lib/tools/docker.js` to get credentials from the settings DB. This returns `{ env: string[], backendApi: string }` where `env` is an array of `KEY=value` strings. Parse them into an env object:
69
+
70
+ ```js
71
+ import { buildAgentAuthEnv } from '../../tools/docker.js';
72
+
73
+ const env = { ...process.env };
74
+ const { env: authEnvPairs } = buildAgentAuthEnv('my-agent');
75
+ for (const pair of authEnvPairs) {
76
+ const eqIdx = pair.indexOf('=');
77
+ if (eqIdx > 0) env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
78
+ }
79
+ ```
80
+
81
+ The agent's auth config (API keys, OAuth tokens, provider selection) is managed in the admin UI at `/admin/event-handler/coding-agents` and stored in the settings DB. `buildAgentAuthEnv()` reads it — you don't need to access the settings DB directly.
82
+
83
+ ### 5. Function parameters
84
+
85
+ | Param | Type | Description |
86
+ |-------|------|-------------|
87
+ | `prompt` | `string` | User message text |
88
+ | `workspaceDir` | `string` | Absolute path to git repo root (the SDK should execute here) |
89
+ | `systemPrompt` | `string\|null` | System prompt for agent mode (null in code mode) |
90
+ | `sessionId` | `string\|null` | Previous session ID to resume (null on first message) |
91
+ | `permissionMode` | `string` | `'plan'` (read-only) or `'code'` (read-write). Map to the SDK's equivalent permission concept. |
92
+ | `attachments` | `Array` | Image attachments: `{ category: 'image', mimeType, dataUrl }` |
93
+
94
+ ### 6. Session continuity contract
95
+
96
+ Multi-turn conversation works via session IDs:
97
+
98
+ 1. First message: `sessionId` param is `null`. Adapter yields `{ type: 'meta', sessionId: '<new-id>' }`.
99
+ 2. `chatStream()` writes the session ID to `{workspaceBaseDir}/.claude-ttyd-sessions/7681`.
100
+ 3. Next message: `sessionId` param contains the saved ID. Adapter passes it to the SDK's resume mechanism.
101
+
102
+ If the SDK doesn't support session resume, the adapter can ignore `sessionId` — but multi-turn context will be lost between messages.
103
+
104
+ ## What the adapter does NOT handle
105
+
106
+ These are managed by `chatStream()` in `index.js` — adapters should not duplicate them:
107
+
108
+ - **Workspace git setup** — `ensureWorkspaceRepo()` clones/checkouts before the adapter is called
109
+ - **DB persistence** — `chatStream()` saves user messages, assistant text, and tool invocations
110
+ - **Chat creation** — `chatStream()` creates the chat and workspace DB records
111
+ - **Auto-titling** — `chatStream()` generates a title after the first message
112
+ - **System prompt loading** — `chatStream()` calls `buildCodingAgentSystemPrompt()` and passes the result as `systemPrompt`
113
+ - **Skill activation** — `ensureSkills()` runs before the adapter is called
@@ -1,6 +1,10 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
1
3
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
4
  import { getConfig } from '../../config.js';
3
5
  import { buildAgentAuthEnv } from '../../tools/docker.js';
6
+ import { createAgentJobApiKey } from '../../db/api-keys.js';
7
+ import { getAllAgentJobSecrets } from '../../db/config.js';
4
8
 
5
9
  /**
6
10
  * Claude Agent SDK adapter. Wraps the SDK's query() and yields
@@ -15,10 +19,61 @@ import { buildAgentAuthEnv } from '../../tools/docker.js';
15
19
  * @param {Array} [opts.attachments] - Image attachments
16
20
  * @yields {{ type: 'text'|'tool-call'|'tool-result'|'meta'|'result'|'unknown', ... }}
17
21
  */
18
- export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, sessionId, permissionMode, attachments }) {
22
+ /**
23
+ * Encode an absolute path the same way Claude Code encodes cwd for session storage.
24
+ * Non-alphanumeric characters are replaced with '-'.
25
+ */
26
+ function encodeCwd(absolutePath) {
27
+ return absolutePath.replace(/[^a-zA-Z0-9]/g, '-');
28
+ }
29
+
30
+ /**
31
+ * Ensure the interactive container can find SDK-created sessions on the shared volume.
32
+ *
33
+ * The SDK stores sessions at $HOME/.claude/projects/<encoded-cwd>/. The interactive
34
+ * container uses cwd=/home/coding-agent/workspace, but the SDK adapter uses
35
+ * cwd=/app/data/workspaces/workspace-XXX/workspace — different encoded paths.
36
+ *
37
+ * Creates a symlink so the interactive container's encoded path resolves to the
38
+ * SDK adapter's encoded path, both on the same volume.
39
+ */
40
+ function ensureSessionSymlink(wsBaseDir, workspaceDir) {
41
+ const projectsDir = path.join(wsBaseDir, '.claude', 'projects');
42
+ const sdkEncoded = encodeCwd(workspaceDir);
43
+ const interactiveEncoded = encodeCwd('/home/coding-agent/workspace');
44
+
45
+ // Both point to the same dir — no symlink needed
46
+ if (sdkEncoded === interactiveEncoded) return;
47
+
48
+ fs.mkdirSync(path.join(projectsDir, sdkEncoded), { recursive: true });
49
+
50
+ const symlinkPath = path.join(projectsDir, interactiveEncoded);
51
+ try {
52
+ const existing = fs.readlinkSync(symlinkPath);
53
+ if (existing === sdkEncoded) return; // already correct
54
+ fs.unlinkSync(symlinkPath);
55
+ } catch (err) {
56
+ if (err.code !== 'ENOENT') {
57
+ // It's a real directory (not a symlink) — don't touch it
58
+ if (err.code === 'EINVAL') return;
59
+ }
60
+ }
61
+
62
+ try {
63
+ fs.symlinkSync(sdkEncoded, symlinkPath);
64
+ } catch {}
65
+ }
66
+
67
+ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, sessionId, permissionMode, attachments, workspaceId, chatMode }) {
68
+ // Point HOME at the workspace volume so the SDK stores session data on the
69
+ // shared volume (not the EH container's ephemeral filesystem).
70
+ const wsBaseDir = path.dirname(workspaceDir);
71
+ ensureSessionSymlink(wsBaseDir, workspaceDir);
72
+
19
73
  // Build a local env object with auth credentials from the settings DB.
20
74
  // Passed via the SDK's `env` option — no process.env mutation needed.
21
75
  const env = { ...process.env };
76
+ env.HOME = wsBaseDir;
22
77
  try {
23
78
  const { env: authEnvPairs } = buildAgentAuthEnv('claude-code');
24
79
  for (const pair of authEnvPairs) {
@@ -41,16 +96,32 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
41
96
  // Fall through — env may already have the right vars from process.env
42
97
  }
43
98
 
99
+ // Inject agent job secrets when in agent chat mode
100
+ if (chatMode === 'agent') {
101
+ const shortId = (workspaceId || '').replace(/-/g, '').slice(0, 8);
102
+ const { key: agentJobToken } = createAgentJobApiKey(`claude-code-sdk-${shortId}`);
103
+ env.AGENT_JOB_TOKEN = agentJobToken;
104
+ const appUrl = getConfig('APP_URL');
105
+ if (appUrl) env.APP_URL = appUrl;
106
+
107
+ // Inject plain secrets as env vars (oauth types are null — agent fetches via skill)
108
+ const jobSecrets = getAllAgentJobSecrets();
109
+ for (const { key, value } of jobSecrets) {
110
+ if (value !== null && !env[key]) {
111
+ env[key] = value;
112
+ }
113
+ }
114
+ }
115
+
44
116
  const options = {
45
117
  cwd: workspaceDir,
46
118
  env,
47
119
  includePartialMessages: true,
120
+ model: getConfig('CODING_AGENT_CLAUDE_CODE_MODEL') || undefined,
48
121
  };
49
122
 
50
- // Permission mode allowed tools
51
- if (permissionMode === 'code') {
52
- options.permissionMode = 'bypassPermissions';
53
- }
123
+ // Permission mode: plan = read-only, anything else = full write access
124
+ options.permissionMode = permissionMode === 'plan' ? 'plan' : 'bypassPermissions';
54
125
 
55
126
  if (sessionId) options.resume = sessionId;
56
127
  if (systemPrompt) {
@@ -80,6 +151,8 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
80
151
 
81
152
  // Track tool call state for mapping stream events
82
153
  const activeToolCalls = new Map(); // index → { id, name, argsJson }
154
+ const toolNamesById = new Map(); // toolCallId → toolName (persists for tool-result lookup)
155
+ const activeThinkingBlocks = new Set(); // indices of active thinking blocks
83
156
 
84
157
  try {
85
158
  for await (const message of query({ prompt: sdkPrompt, options })) {
@@ -102,9 +175,13 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
102
175
  const block = event.content_block;
103
176
  if (block.type === 'tool_use') {
104
177
  activeToolCalls.set(event.index, { id: block.id, name: block.name, argsJson: '' });
178
+ toolNamesById.set(block.id, block.name);
105
179
  yield { type: 'tool-call', toolCallId: block.id, toolName: block.name, args: {} };
180
+ } else if (block.type === 'thinking') {
181
+ activeThinkingBlocks.add(event.index);
182
+ yield { type: 'thinking-start' };
106
183
  }
107
- // Skip 'thinking', 'text' start (deltas handle text)
184
+ // Skip 'text' start (deltas handle text)
108
185
  continue;
109
186
  }
110
187
 
@@ -114,11 +191,17 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
114
191
  } else if (event.delta.type === 'input_json_delta') {
115
192
  const tc = activeToolCalls.get(event.index);
116
193
  if (tc) tc.argsJson += event.delta.partial_json;
194
+ } else if (event.delta.type === 'thinking_delta') {
195
+ yield { type: 'thinking', delta: event.delta.thinking };
117
196
  }
118
197
  continue;
119
198
  }
120
199
 
121
200
  if (event.type === 'content_block_stop') {
201
+ if (activeThinkingBlocks.has(event.index)) {
202
+ activeThinkingBlocks.delete(event.index);
203
+ yield { type: 'thinking-end' };
204
+ }
122
205
  const tc = activeToolCalls.get(event.index);
123
206
  if (tc && tc.argsJson) {
124
207
  try {
@@ -144,14 +227,23 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
144
227
  : Array.isArray(block.content)
145
228
  ? block.content.map(b => b.type === 'text' ? b.text : JSON.stringify(b)).join('\n')
146
229
  : JSON.stringify(block.content);
147
- yield { type: 'tool-result', toolCallId: block.tool_use_id, result: content };
230
+ yield { type: 'tool-result', toolCallId: block.tool_use_id, toolName: toolNamesById.get(block.tool_use_id), result: content };
148
231
  }
149
232
  }
150
233
  continue;
151
234
  }
152
235
 
153
236
  // ── assistant messages — redundant with streaming, skip ──
154
- if (message.type === 'assistant') continue;
237
+ // But extract tool names so resumed tool-results can carry them.
238
+ if (message.type === 'assistant') {
239
+ const blocks = message.message?.content || [];
240
+ for (const block of blocks) {
241
+ if (block.type === 'tool_use' && block.id && block.name) {
242
+ toolNamesById.set(block.id, block.name);
243
+ }
244
+ }
245
+ continue;
246
+ }
155
247
 
156
248
  // ── result ──
157
249
  if (message.type === 'result') {
@@ -0,0 +1,16 @@
1
+ import path from 'path';
2
+ import { PROJECT_ROOT } from '../paths.js';
3
+ import { render_md } from '../utils/render-md.js';
4
+
5
+ /**
6
+ * Build the system prompt for a coding agent.
7
+ * @param {'agent'|'code'} mode - Chat mode
8
+ * @returns {string|null} Rendered system prompt, or null if not configured
9
+ */
10
+ export function buildCodingAgentSystemPrompt(mode) {
11
+ const file = mode === 'agent'
12
+ ? path.join(PROJECT_ROOT, 'agent-job/SYSTEM.md')
13
+ : path.join(PROJECT_ROOT, 'coding-workspace/SYSTEM.md');
14
+ const rendered = render_md(file);
15
+ return rendered?.trim() || null;
16
+ }
package/lib/ai/tools.js CHANGED
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { createAgentJob } from '../tools/create-agent-job.js';
4
4
 
5
5
  import { getConfig } from '../config.js';
6
+ import { buildCodingAgentSystemPrompt } from './system-prompt.js';
6
7
 
7
8
  const agentJobTool = tool(
8
9
  async ({ prompt }) => {
@@ -63,6 +64,7 @@ const agentChatCodingTool = tool(
63
64
  workspaceId,
64
65
  taskPrompt: prompt,
65
66
  mode,
67
+ systemPrompt: buildCodingAgentSystemPrompt('agent'),
66
68
  injectSecrets: true,
67
69
  });
68
70
 
@@ -124,6 +126,7 @@ const codeChatCodingTool = tool(
124
126
  containerName, repo, branch, featureBranch, workspaceId,
125
127
  taskPrompt: prompt,
126
128
  mode,
129
+ systemPrompt: buildCodingAgentSystemPrompt('code'),
127
130
  });
128
131
 
129
132
  const chunks = [{ type: 'meta', codingAgent, backendApi }];
@@ -32,20 +32,24 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
32
32
  if (ghToken) env.GH_TOKEN = ghToken;
33
33
 
34
34
  const execOpts = { cwd: workspaceDir, env };
35
+ const log = [];
35
36
 
36
37
  // 1. Create workspace directory
37
38
  mkdirSync(workspaceDir, { recursive: true });
38
39
 
39
40
  // 2. Configure git to use GH_TOKEN for GitHub HTTPS URLs (mirrors setup-git.sh)
40
41
  if (ghToken) {
41
- await run('gh', ['auth', 'setup-git'], execOpts);
42
+ const out = await run('gh', ['auth', 'setup-git'], execOpts);
43
+ if (out) log.push(out);
42
44
  }
43
45
 
44
46
  // 3. Clone if not already a git repo
45
47
  const hasGit = existsSync(path.join(workspaceDir, '.git'));
46
48
  if (!hasGit) {
47
49
  if (!repo) throw new Error('ensureWorkspaceRepo: repo is required for initial clone');
48
- await run('git', ['clone', '--branch', branch || 'main', `https://github.com/${repo}`, '.'], execOpts);
50
+ const out = await run('git', ['clone', '--branch', branch || 'main', `https://github.com/${repo}`, '.'], execOpts);
51
+ log.push(`Cloned ${repo} (branch: ${branch || 'main'})`);
52
+ if (out) log.push(out);
49
53
  }
50
54
 
51
55
  // 3. Git identity (only if not already configured)
@@ -61,6 +65,7 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
61
65
  const email = user.email || `${user.id}+${user.login}@users.noreply.github.com`;
62
66
  await run('git', ['config', 'user.name', name], execOpts);
63
67
  await run('git', ['config', 'user.email', email], execOpts);
68
+ log.push(`Git identity: ${name} <${email}>`);
64
69
  } catch (err) {
65
70
  console.error('[workspace-setup] Failed to set git identity:', err.message);
66
71
  }
@@ -68,7 +73,7 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
68
73
  }
69
74
 
70
75
  // 4. Feature branch checkout
71
- if (!featureBranch) return;
76
+ if (!featureBranch) return log.join('\n');
72
77
 
73
78
  // Already on the right branch locally?
74
79
  try {
@@ -77,8 +82,11 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
77
82
  const current = await run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], execOpts);
78
83
  if (current !== featureBranch) {
79
84
  await run('git', ['checkout', featureBranch], execOpts);
85
+ log.push(`Checked out ${featureBranch}`);
86
+ } else {
87
+ log.push(`Already on ${featureBranch}`);
80
88
  }
81
- return;
89
+ return log.join('\n');
82
90
  } catch {
83
91
  // Branch doesn't exist locally — check remote
84
92
  }
@@ -88,15 +96,20 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
88
96
  if (remoteCheck) {
89
97
  // Remote branch exists — checkout tracking it
90
98
  await run('git', ['checkout', '-B', featureBranch, `origin/${featureBranch}`], execOpts);
99
+ log.push(`Checked out ${featureBranch} (tracking origin)`);
91
100
  } else {
92
101
  // Create new branch and push
93
102
  await run('git', ['checkout', '-b', featureBranch], execOpts);
94
- await run('git', ['push', '-u', 'origin', featureBranch], execOpts);
103
+ const pushOut = await run('git', ['push', '-u', 'origin', featureBranch], execOpts);
104
+ log.push(`Created and pushed ${featureBranch}`);
105
+ if (pushOut) log.push(pushOut);
95
106
  }
96
107
  } catch (err) {
97
108
  console.error('[workspace-setup] Feature branch error:', err.message);
98
109
  throw err;
99
110
  }
111
+
112
+ return log.join('\n');
100
113
  }
101
114
 
102
115
  /**