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
@@ -29,6 +29,8 @@ export function mapLine(line, mapper = mapClaudeCodeLine) {
29
29
  try {
30
30
  parsed = JSON.parse(line);
31
31
  } catch {
32
+ // Suppress entrypoint.sh step markers ("→ Setup Git", "→ Clone", ...)
33
+ if (line.startsWith('→ ')) return [{ type: 'skip' }];
32
34
  console.warn('[line-mappers] JSON parse failed, length:', line.length, 'preview:', line.slice(0, 120));
33
35
  // Non-JSON lines (NO_CHANGES, PUSH_SUCCESS, AGENT_FAILED, etc.)
34
36
  return [{ type: 'text', text: `\n${line}\n` }];
@@ -116,15 +118,16 @@ export function mapClaudeCodeLine(parsed) {
116
118
  // ─────────────────────────────────────────────────────────────────────────────
117
119
  // Pi Coding Agent: --mode json
118
120
  //
119
- // Event types:
120
- // session, agent_start, agent_end
121
- // turn_start, turn_end
122
- // message_start, message_update, message_end
123
- // tool_execution_start, tool_execution_update, tool_execution_end
121
+ // Top-level event types:
122
+ // session, agent_start, agent_end — session/lifecycle (skip)
123
+ // turn_start, turn_end — turn lifecycle (skip)
124
+ // message_start, message_update, message_end — message lifecycle
125
+ // tool_execution_start/update/end — tool execution
124
126
  //
125
127
  // message_update.assistantMessageEvent subtypes:
126
- // text_start, text_delta, text_end
127
- // toolcall_start, toolcall_delta, toolcall_end
128
+ // text_start, text_delta, text_end — only text_delta carries new content
129
+ // thinking_start, thinking_delta, thinking_end — extended-thinking blocks
130
+ // toolcall_start, toolcall_delta, toolcall_end — only toolcall_end carries complete args
128
131
  // ─────────────────────────────────────────────────────────────────────────────
129
132
 
130
133
  /**
@@ -136,22 +139,51 @@ export function mapPiLine(parsed) {
136
139
  const events = [];
137
140
  const { type } = parsed;
138
141
 
142
+ // Lifecycle / session noise — skip silently so they don't render as Unknown Events
143
+ if (
144
+ type === 'session' ||
145
+ type === 'agent_start' ||
146
+ type === 'agent_end' ||
147
+ type === 'turn_start' ||
148
+ type === 'turn_end' ||
149
+ type === 'message_start' ||
150
+ type === 'message_end' ||
151
+ type === 'tool_execution_start' ||
152
+ type === 'tool_execution_update'
153
+ ) {
154
+ return [{ type: 'skip' }];
155
+ }
156
+
139
157
  if (type === 'message_update' && parsed.assistantMessageEvent) {
140
158
  const evt = parsed.assistantMessageEvent;
141
159
 
142
- // Text streaming
160
+ // Text streaming — deltas are authoritative; start/end are lifecycle only
143
161
  if (evt.type === 'text_delta' && evt.delta) {
144
162
  events.push({ type: 'text', text: evt.delta });
163
+ } else if (evt.type === 'text_start' || evt.type === 'text_end') {
164
+ return [{ type: 'skip' }];
165
+ }
166
+
167
+ // Extended-thinking streaming — map to UI thinking chunks (rendered as a
168
+ // collapsible ephemeral card by api.js, never persisted)
169
+ else if (evt.type === 'thinking_start') {
170
+ events.push({ type: 'thinking-start' });
171
+ } else if (evt.type === 'thinking_delta' && evt.delta) {
172
+ events.push({ type: 'thinking', delta: evt.delta });
173
+ } else if (evt.type === 'thinking_end') {
174
+ events.push({ type: 'thinking-end' });
145
175
  }
146
176
 
147
- // Tool call — emit on toolcall_end when we have complete args
148
- if (evt.type === 'toolcall_end' && evt.toolCall) {
177
+ // Tool call — emit once on toolcall_end with complete args. Start/delta are skipped.
178
+ else if (evt.type === 'toolcall_end' && evt.toolCall) {
149
179
  events.push({
150
180
  type: 'tool-call',
151
181
  toolCallId: evt.toolCall.id,
152
182
  toolName: evt.toolCall.name,
153
183
  args: evt.toolCall.arguments || {},
154
184
  });
185
+ } else if (evt.type === 'toolcall_start' || evt.type === 'toolcall_delta') {
186
+ return [{ type: 'skip' }];
155
187
  }
156
188
  }
157
189
 
@@ -167,20 +199,6 @@ export function mapPiLine(parsed) {
167
199
  });
168
200
  }
169
201
 
170
- // Final summary
171
- else if (type === 'agent_end' && parsed.messages) {
172
- const lastAssistant = [...parsed.messages].reverse().find(m => m.role === 'assistant');
173
- if (lastAssistant) {
174
- const text = (lastAssistant.content || [])
175
- .filter(b => b.type === 'text')
176
- .map(b => b.text)
177
- .join('');
178
- if (text) {
179
- events.push({ type: 'text', text });
180
- }
181
- }
182
- }
183
-
184
202
  return events;
185
203
  }
186
204
 
@@ -0,0 +1,26 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+
4
+ /**
5
+ * Resolve working directory and skills directory for a given agent scope.
6
+ *
7
+ * @param {string} repoRoot - Absolute path to the git repo root
8
+ * @param {string|null} scope - Relative subdirectory path (e.g., 'agents/gary-v') or null/empty for root
9
+ * @returns {{ workingDir: string, skillsDir: string|null }}
10
+ */
11
+ export function resolveAgentScope(repoRoot, scope) {
12
+ const workingDir = scope ? path.join(repoRoot, scope) : repoRoot;
13
+
14
+ // Skills: check scoped dir first, fall back to repo root
15
+ const scopedSkills = path.join(workingDir, 'skills');
16
+ const rootSkills = path.join(repoRoot, 'skills');
17
+
18
+ let skillsDir = null;
19
+ if (existsSync(scopedSkills)) {
20
+ skillsDir = scopedSkills;
21
+ } else if (existsSync(rootSkills)) {
22
+ skillsDir = rootSkills;
23
+ }
24
+
25
+ return { workingDir, skillsDir };
26
+ }
@@ -0,0 +1,114 @@
1
+ # lib/ai/sdk-adapters/ — SDK Adapter System
2
+
3
+ In-process SDK adapters that run a coding agent's SDK directly (no container) and yield a unified chunk stream consumed by `chatStream()` in `lib/ai/index.js`. Used when the active coding agent has a registered adapter; otherwise `chatStream()` uses the direct headless-container path.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Browser → POST /stream/chat (api.js)
9
+ → chatStream() (index.js)
10
+ → workspace setup (ensureWorkspaceRepo)
11
+ → getSdkAdapter() returns adapter function or null
12
+ → if adapter: streamViaSdk — in-process SDK call, yields normalized chunks
13
+ → if null: streamViaContainer — headless Docker container, parseHeadlessStream yields chunks
14
+ ```
15
+
16
+ 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`.
17
+
18
+ ## Existing Adapter
19
+
20
+ | File | Agent | SDK |
21
+ |------|-------|-----|
22
+ | `claude-code.js` | `claude-code` | `@anthropic-ai/claude-agent-sdk` |
23
+
24
+ ## Adding a New SDK Adapter
25
+
26
+ ### 1. Create the adapter file
27
+
28
+ Create `{agent-name}.js` in this directory. Export a single async generator function:
29
+
30
+ ```js
31
+ export async function* myAgentStream({ prompt, workspaceDir, systemPrompt, sessionId, permissionMode, attachments }) {
32
+ // ... call the SDK, yield chunks
33
+ }
34
+ ```
35
+
36
+ ### 2. Required chunk types to yield
37
+
38
+ The adapter MUST yield these chunk types for `chatStream()` and `api.js` to work correctly:
39
+
40
+ | Chunk | Shape | When | Purpose |
41
+ |-------|-------|------|---------|
42
+ | `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. |
43
+ | `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. |
44
+ | `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. |
45
+ | `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. |
46
+ | `result` | `{ type: 'result', text: string, cost?: number, duration?: number, subtype?: string }` | Stream ends | Final summary. Logged by `chatStream()`, not persisted or sent to UI. |
47
+
48
+ Optional:
49
+ | `unknown` | `{ type: 'unknown', raw: any }` | Unrecognized events | `api.js` renders these as collapsible boxes in the UI. Use for debugging unhandled SDK events. |
50
+
51
+ ### 3. Register in index.js
52
+
53
+ Add the import and mapping in `getSdkAdapter()`:
54
+
55
+ ```js
56
+ import { myAgentStream } from './my-agent.js';
57
+
58
+ export function getSdkAdapter(agentType) {
59
+ if (agentType === 'claude-code') return claudeCodeStream;
60
+ if (agentType === 'my-agent') return myAgentStream;
61
+ return null;
62
+ }
63
+ ```
64
+
65
+ The `agentType` string comes from the `CODING_AGENT` config value set in the admin UI.
66
+
67
+ ### 4. Auth resolution
68
+
69
+ 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:
70
+
71
+ ```js
72
+ import { buildAgentAuthEnv } from '../../tools/docker.js';
73
+
74
+ const env = { ...process.env };
75
+ const { env: authEnvPairs } = buildAgentAuthEnv('my-agent');
76
+ for (const pair of authEnvPairs) {
77
+ const eqIdx = pair.indexOf('=');
78
+ if (eqIdx > 0) env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
79
+ }
80
+ ```
81
+
82
+ 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.
83
+
84
+ ### 5. Function parameters
85
+
86
+ | Param | Type | Description |
87
+ |-------|------|-------------|
88
+ | `prompt` | `string` | User message text |
89
+ | `workspaceDir` | `string` | Absolute path to git repo root (the SDK should execute here) |
90
+ | `systemPrompt` | `string\|null` | System prompt for agent mode (null in code mode) |
91
+ | `sessionId` | `string\|null` | Previous session ID to resume (null on first message) |
92
+ | `permissionMode` | `string` | `'plan'` (read-only) or `'code'` (read-write). Map to the SDK's equivalent permission concept. |
93
+ | `attachments` | `Array` | Image attachments: `{ category: 'image', mimeType, dataUrl }` |
94
+
95
+ ### 6. Session continuity contract
96
+
97
+ Multi-turn conversation works via session IDs:
98
+
99
+ 1. First message: `sessionId` param is `null`. Adapter yields `{ type: 'meta', sessionId: '<new-id>' }`.
100
+ 2. `chatStream()` writes the session ID to `{workspaceBaseDir}/.claude-ttyd-sessions/7681`.
101
+ 3. Next message: `sessionId` param contains the saved ID. Adapter passes it to the SDK's resume mechanism.
102
+
103
+ If the SDK doesn't support session resume, the adapter can ignore `sessionId` — but multi-turn context will be lost between messages.
104
+
105
+ ## What the adapter does NOT handle
106
+
107
+ These are managed by `chatStream()` in `index.js` — adapters should not duplicate them:
108
+
109
+ - **Workspace git setup** — `ensureWorkspaceRepo()` clones/checkouts before the adapter is called
110
+ - **DB persistence** — `chatStream()` saves user messages, assistant text, and tool invocations
111
+ - **Chat creation** — `chatStream()` creates the chat and workspace DB records
112
+ - **Auto-titling** — `chatStream()` generates a title after the first message
113
+ - **System prompt loading** — `chatStream()` calls `buildCodingAgentSystemPrompt()` and passes the result as `systemPrompt`
114
+ - **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,72 @@ 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[/scope], but the SDK adapter uses
35
+ * cwd=/app/data/workspaces/workspace-XXX/workspace[/scope] — 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
+ * @param {string} wsBaseDir - Volume root (e.g., /app/data/workspaces/workspace-XXX)
41
+ * @param {string} sdkCwd - SDK's working directory (scoped, e.g., .../workspace/agents/gary-vee)
42
+ * @param {string} interactiveCwd - Interactive container's equivalent cwd (e.g., /home/coding-agent/workspace/agents/gary-vee)
43
+ */
44
+ function ensureSessionSymlink(wsBaseDir, sdkCwd, interactiveCwd) {
45
+ const projectsDir = path.join(wsBaseDir, '.claude', 'projects');
46
+ const sdkEncoded = encodeCwd(sdkCwd);
47
+ const interactiveEncoded = encodeCwd(interactiveCwd);
48
+
49
+ // Both point to the same dir — no symlink needed
50
+ if (sdkEncoded === interactiveEncoded) return;
51
+
52
+ fs.mkdirSync(path.join(projectsDir, sdkEncoded), { recursive: true });
53
+
54
+ const symlinkPath = path.join(projectsDir, interactiveEncoded);
55
+ try {
56
+ const existing = fs.readlinkSync(symlinkPath);
57
+ if (existing === sdkEncoded) return; // already correct
58
+ fs.unlinkSync(symlinkPath);
59
+ } catch (err) {
60
+ if (err.code !== 'ENOENT') {
61
+ // It's a real directory (not a symlink) — don't touch it
62
+ if (err.code === 'EINVAL') return;
63
+ }
64
+ }
65
+
66
+ try {
67
+ fs.symlinkSync(sdkEncoded, symlinkPath);
68
+ } catch {}
69
+ }
70
+
71
+ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, sessionId, permissionMode, attachments, workspaceId, chatMode }) {
72
+ // Point HOME at the workspace volume root (always one level above 'workspace/').
73
+ // workspaceDir may be scoped (e.g., .../workspace/agents/gary-vee), so we can't
74
+ // use path.dirname — derive from workspaceId instead.
75
+ const { workspaceDir: getWsDir } = await import('../../tools/docker.js');
76
+ const wsBaseDir = getWsDir(workspaceId);
77
+
78
+ // Build the interactive container's equivalent cwd for session symlink
79
+ const repoRoot = path.join(wsBaseDir, 'workspace');
80
+ const scope = workspaceDir.startsWith(repoRoot) ? workspaceDir.slice(repoRoot.length) : '';
81
+ const interactiveCwd = '/home/coding-agent/workspace' + scope;
82
+ ensureSessionSymlink(wsBaseDir, workspaceDir, interactiveCwd);
83
+
19
84
  // Build a local env object with auth credentials from the settings DB.
20
85
  // Passed via the SDK's `env` option — no process.env mutation needed.
21
86
  const env = { ...process.env };
87
+ env.HOME = wsBaseDir;
22
88
  try {
23
89
  const { env: authEnvPairs } = buildAgentAuthEnv('claude-code');
24
90
  for (const pair of authEnvPairs) {
@@ -41,16 +107,41 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
41
107
  // Fall through — env may already have the right vars from process.env
42
108
  }
43
109
 
110
+ // Inject agent job secrets when in agent chat mode
111
+ if (chatMode === 'agent') {
112
+ const shortId = (workspaceId || '').replace(/-/g, '').slice(0, 8);
113
+ const { key: agentJobToken } = createAgentJobApiKey(`claude-code-sdk-${shortId}`);
114
+ env.AGENT_JOB_TOKEN = agentJobToken;
115
+ const appUrl = getConfig('APP_URL');
116
+ if (appUrl) env.APP_URL = appUrl;
117
+
118
+ // Inject plain secrets as env vars (oauth types are null — agent fetches via skill)
119
+ const jobSecrets = getAllAgentJobSecrets();
120
+ for (const { key, value } of jobSecrets) {
121
+ if (value !== null && !env[key]) {
122
+ env[key] = value;
123
+ }
124
+ }
125
+ }
126
+
127
+ // Work around SDK musl/glibc detection bug — force glibc binary on Linux
128
+ let executablePath;
129
+ if (process.platform === 'linux') {
130
+ try {
131
+ executablePath = require.resolve(`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude`);
132
+ } catch {}
133
+ }
134
+
44
135
  const options = {
45
136
  cwd: workspaceDir,
46
137
  env,
47
138
  includePartialMessages: true,
139
+ model: getConfig('CODING_AGENT_CLAUDE_CODE_MODEL') || undefined,
140
+ ...(executablePath ? { pathToClaudeCodeExecutable: executablePath } : {}),
48
141
  };
49
142
 
50
- // Permission mode allowed tools
51
- if (permissionMode === 'code') {
52
- options.permissionMode = 'bypassPermissions';
53
- }
143
+ // Permission mode: plan = read-only, anything else = full write access
144
+ options.permissionMode = permissionMode === 'plan' ? 'plan' : 'bypassPermissions';
54
145
 
55
146
  if (sessionId) options.resume = sessionId;
56
147
  if (systemPrompt) {
@@ -80,6 +171,8 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
80
171
 
81
172
  // Track tool call state for mapping stream events
82
173
  const activeToolCalls = new Map(); // index → { id, name, argsJson }
174
+ const toolNamesById = new Map(); // toolCallId → toolName (persists for tool-result lookup)
175
+ const activeThinkingBlocks = new Set(); // indices of active thinking blocks
83
176
 
84
177
  try {
85
178
  for await (const message of query({ prompt: sdkPrompt, options })) {
@@ -102,9 +195,13 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
102
195
  const block = event.content_block;
103
196
  if (block.type === 'tool_use') {
104
197
  activeToolCalls.set(event.index, { id: block.id, name: block.name, argsJson: '' });
198
+ toolNamesById.set(block.id, block.name);
105
199
  yield { type: 'tool-call', toolCallId: block.id, toolName: block.name, args: {} };
200
+ } else if (block.type === 'thinking') {
201
+ activeThinkingBlocks.add(event.index);
202
+ yield { type: 'thinking-start' };
106
203
  }
107
- // Skip 'thinking', 'text' start (deltas handle text)
204
+ // Skip 'text' start (deltas handle text)
108
205
  continue;
109
206
  }
110
207
 
@@ -114,11 +211,17 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
114
211
  } else if (event.delta.type === 'input_json_delta') {
115
212
  const tc = activeToolCalls.get(event.index);
116
213
  if (tc) tc.argsJson += event.delta.partial_json;
214
+ } else if (event.delta.type === 'thinking_delta') {
215
+ yield { type: 'thinking', delta: event.delta.thinking };
117
216
  }
118
217
  continue;
119
218
  }
120
219
 
121
220
  if (event.type === 'content_block_stop') {
221
+ if (activeThinkingBlocks.has(event.index)) {
222
+ activeThinkingBlocks.delete(event.index);
223
+ yield { type: 'thinking-end' };
224
+ }
122
225
  const tc = activeToolCalls.get(event.index);
123
226
  if (tc && tc.argsJson) {
124
227
  try {
@@ -144,14 +247,23 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
144
247
  : Array.isArray(block.content)
145
248
  ? block.content.map(b => b.type === 'text' ? b.text : JSON.stringify(b)).join('\n')
146
249
  : JSON.stringify(block.content);
147
- yield { type: 'tool-result', toolCallId: block.tool_use_id, result: content };
250
+ yield { type: 'tool-result', toolCallId: block.tool_use_id, toolName: toolNamesById.get(block.tool_use_id), result: content };
148
251
  }
149
252
  }
150
253
  continue;
151
254
  }
152
255
 
153
256
  // ── assistant messages — redundant with streaming, skip ──
154
- if (message.type === 'assistant') continue;
257
+ // But extract tool names so resumed tool-results can carry them.
258
+ if (message.type === 'assistant') {
259
+ const blocks = message.message?.content || [];
260
+ for (const block of blocks) {
261
+ if (block.type === 'tool_use' && block.id && block.name) {
262
+ toolNamesById.set(block.id, block.name);
263
+ }
264
+ }
265
+ continue;
266
+ }
155
267
 
156
268
  // ── result ──
157
269
  if (message.type === 'result') {
@@ -0,0 +1,34 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { PROJECT_ROOT } from '../paths.js';
4
+ import { render_md } from '../utils/render-md.js';
5
+
6
+ /**
7
+ * Build the system prompt for a coding agent.
8
+ * @param {'agent'|'code'} mode - Chat mode
9
+ * @param {string|null} [skillsDir] - Skills directory for {{skills}} resolution.
10
+ * @param {string|null} [scope] - Agent scope (e.g., 'agents/gary-vee'). When set,
11
+ * looks for SYSTEM.md in the scoped directory first, falls back to agent-job/SYSTEM.md.
12
+ * @returns {string|null} Rendered system prompt, or null if not configured
13
+ */
14
+ export function buildCodingAgentSystemPrompt(mode, skillsDir, scope) {
15
+ let file;
16
+
17
+ if (mode === 'agent') {
18
+ // Check scoped SYSTEM.md first, fall back to agent-job/SYSTEM.md
19
+ if (scope) {
20
+ const scopedFile = path.join(PROJECT_ROOT, scope, 'SYSTEM.md');
21
+ if (existsSync(scopedFile)) {
22
+ file = scopedFile;
23
+ }
24
+ }
25
+ if (!file) {
26
+ file = path.join(PROJECT_ROOT, 'agent-job/SYSTEM.md');
27
+ }
28
+ } else {
29
+ file = path.join(PROJECT_ROOT, 'coding-workspace/SYSTEM.md');
30
+ }
31
+
32
+ const rendered = render_md(file, { skillsDir });
33
+ return rendered?.trim() || null;
34
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFile as execFileCb } from 'child_process';
2
2
  import { promisify } from 'util';
3
- import { existsSync, mkdirSync, symlinkSync, unlinkSync, lstatSync } from 'fs';
3
+ import { existsSync, mkdirSync } from 'fs';
4
4
  import path from 'path';
5
5
  import { getConfig } from '../config.js';
6
6
 
@@ -32,20 +32,25 @@ 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
+ if (!branch) throw new Error(`ensureWorkspaceRepo: branch is required (could not resolve default branch for ${repo})`);
51
+ const out = await run('git', ['clone', '--branch', branch, `https://github.com/${repo}`, '.'], execOpts);
52
+ log.push(`Cloned ${repo} (branch: ${branch})`);
53
+ if (out) log.push(out);
49
54
  }
50
55
 
51
56
  // 3. Git identity (only if not already configured)
@@ -61,6 +66,7 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
61
66
  const email = user.email || `${user.id}+${user.login}@users.noreply.github.com`;
62
67
  await run('git', ['config', 'user.name', name], execOpts);
63
68
  await run('git', ['config', 'user.email', email], execOpts);
69
+ log.push(`Git identity: ${name} <${email}>`);
64
70
  } catch (err) {
65
71
  console.error('[workspace-setup] Failed to set git identity:', err.message);
66
72
  }
@@ -68,7 +74,7 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
68
74
  }
69
75
 
70
76
  // 4. Feature branch checkout
71
- if (!featureBranch) return;
77
+ if (!featureBranch) return log.join('\n');
72
78
 
73
79
  // Already on the right branch locally?
74
80
  try {
@@ -77,8 +83,11 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
77
83
  const current = await run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], execOpts);
78
84
  if (current !== featureBranch) {
79
85
  await run('git', ['checkout', featureBranch], execOpts);
86
+ log.push(`Checked out ${featureBranch}`);
87
+ } else {
88
+ log.push(`Already on ${featureBranch}`);
80
89
  }
81
- return;
90
+ return log.join('\n');
82
91
  } catch {
83
92
  // Branch doesn't exist locally — check remote
84
93
  }
@@ -88,43 +97,18 @@ export async function ensureWorkspaceRepo({ workspaceDir, repo, branch, featureB
88
97
  if (remoteCheck) {
89
98
  // Remote branch exists — checkout tracking it
90
99
  await run('git', ['checkout', '-B', featureBranch, `origin/${featureBranch}`], execOpts);
100
+ log.push(`Checked out ${featureBranch} (tracking origin)`);
91
101
  } else {
92
102
  // Create new branch and push
93
103
  await run('git', ['checkout', '-b', featureBranch], execOpts);
94
- await run('git', ['push', '-u', 'origin', featureBranch], execOpts);
104
+ const pushOut = await run('git', ['push', '-u', 'origin', featureBranch], execOpts);
105
+ log.push(`Created and pushed ${featureBranch}`);
106
+ if (pushOut) log.push(pushOut);
95
107
  }
96
108
  } catch (err) {
97
109
  console.error('[workspace-setup] Feature branch error:', err.message);
98
110
  throw err;
99
111
  }
100
- }
101
-
102
- /**
103
- * Activate agent-job-secrets skill in the workspace when in agent chatMode.
104
- * Mirrors Docker setup.sh: ln -sfn ../library/agent-job-secrets skills/active/agent-job-secrets
105
- * Idempotent — skips if library skill doesn't exist.
106
- *
107
- * @param {string} workspaceDir - Absolute path to workspace (git repo root)
108
- * @param {string} chatMode - 'agent' or 'code'
109
- */
110
- export function ensureSkills(workspaceDir, chatMode) {
111
- if (chatMode !== 'agent') return;
112
-
113
- const librarySkill = path.join(workspaceDir, 'skills', 'library', 'agent-job-secrets');
114
- if (!existsSync(librarySkill)) return;
115
-
116
- const activeDir = path.join(workspaceDir, 'skills', 'active');
117
- mkdirSync(activeDir, { recursive: true });
118
-
119
- const link = path.join(activeDir, 'agent-job-secrets');
120
-
121
- // ln -sfn: remove existing symlink/file before creating (force + no-deref)
122
- try {
123
- const stat = lstatSync(link);
124
- if (stat) unlinkSync(link);
125
- } catch {
126
- // doesn't exist — fine
127
- }
128
112
 
129
- symlinkSync('../library/agent-job-secrets', link);
113
+ return log.join('\n');
130
114
  }
@@ -17,7 +17,7 @@ Abstract interface for platform integrations. Methods:
17
17
  **Critical distinction**: Audio is preprocessed at the adapter layer. Images are passed through to the LLM.
18
18
 
19
19
  - **Images** (`message.photo`) → Downloaded, passed as `{ category: 'image', mimeType, data: Buffer }` attachment → LLM receives as vision content
20
- - **Audio** (`message.voice`/`message.audio`) → Transcribed via Whisper → merged into `text` field → **never passed as attachment**
20
+ - **Audio** (`message.voice`/`message.audio`) → Transcribed via AssemblyAI → merged into `text` field → **never passed as attachment**
21
21
  - **Documents** (`message.document`) → Downloaded as `{ category: 'document', mimeType, data: Buffer }`
22
22
 
23
23
  ## Factory (index.js) — Lazy Singleton
@@ -26,7 +26,17 @@ Abstract interface for platform integrations. Methods:
26
26
 
27
27
  ## Telegram Adapter (telegram.js)
28
28
 
29
- - **Chat ID filtering**: `TELEGRAM_CHAT_ID` required. Messages from other chats are silently dropped. If not configured, all messages rejected.
30
- - **Verification flow**: If `TELEGRAM_VERIFICATION` env is set and user sends that code, bot responds with the chat ID (for initial setup).
29
+ - **Authorization**: per-user via the `user_channels` table. Unverified chats only accept `/verify <code>`; all other messages are dropped. See `lib/db/user-channels.js` and `lib/channels/commands/verify.js`.
31
30
  - **Webhook auth**: Validates `x-telegram-bot-api-secret-token` header against `TELEGRAM_WEBHOOK_SECRET`.
32
- - **Streaming**: `supportsStreaming` returns `false` — sends complete responses only.
31
+ - **Streaming**: `supportsStreaming` returns `false` — text + tool calls accumulate during streaming and are sent as complete messages once the turn ends. Progressive tool-call rendering (commit 740c734 / d9bf19a) inserts intermediate "→ used X" lines as tool calls land.
32
+
33
+ ## Slash Commands (`lib/channels/commands/`)
34
+
35
+ Post-auth messages starting with `/` are dispatched here before reaching the LLM. Resolution chat.id → userId → activeThreadId happens in `api/index.js` `processChannelMessage`.
36
+
37
+ | Command | Purpose | Source |
38
+ |---------|---------|--------|
39
+ | `/verify <code>` | Verify a Telegram account against a one-time code generated in the web UI (`/profile/telegram`). Code expires in 10 minutes. Sets `verifiedAt`. | `commands/verify.js` |
40
+ | `/session` | List the user's recent chat threads (active thread marked) | `commands/session.js` |
41
+ | `/session list` | Same as `/session` | `commands/session.js` |
42
+ | `/session <id>` | Switch the user's `activeThreadId` so subsequent messages route to that chat | `commands/session.js` |
@@ -8,7 +8,10 @@ class ChannelAdapter {
8
8
  * Returns normalized message data or null if no action needed.
9
9
  *
10
10
  * @param {Request} request - Incoming HTTP request
11
- * @returns {Promise<{ threadId: string, text: string, attachments: Array, metadata: object } | null>}
11
+ * @returns {Promise<{ channel: string, channelChatId: string, text: string, attachments: Array, metadata: object } | null>}
12
+ *
13
+ * `channelChatId` is the channel-native identifier (e.g. Telegram chat.id). It is
14
+ * NOT the DB chat/thread id — that is resolved downstream via user_channels.
12
15
  *
13
16
  * Attachments array (may be empty) — only non-text content that the LLM needs to see:
14
17
  * { category: "image", mimeType: "image/png", data: Buffer } — send to LLM as vision
@@ -39,8 +42,9 @@ class ChannelAdapter {
39
42
 
40
43
  /**
41
44
  * Send a complete (non-streaming) response back to the channel.
45
+ * `channelChatId` is the channel-native chat identifier.
42
46
  */
43
- async sendResponse(threadId, text, metadata) {
47
+ async sendResponse(channelChatId, text, metadata) {
44
48
  throw new Error('Not implemented');
45
49
  }
46
50