thepopebot 1.2.74-beta.21 → 1.2.74-beta.23

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.
package/lib/ai/agent.js CHANGED
@@ -3,12 +3,16 @@ import { SystemMessage } from '@langchain/core/messages';
3
3
  import { createModel } from './model.js';
4
4
  import { createJobTool, getJobStatusTool, getSystemTechnicalSpecsTool, getSkillBuildingGuideTool, getSkillDetailsTool, createStartHeadlessCodingTool, createGetRepositoryDetailsTool } from './tools.js';
5
5
  import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
6
- import { jobPlanningMd, codePlanningMd, codeExecutionMd, thepopebotDb } from '../paths.js';
6
+ import { jobPlanningMd, codePlanningMd, thepopebotDb } from '../paths.js';
7
7
  import { render_md } from '../utils/render-md.js';
8
8
  import { createWebSearchTool, getProvider } from './web-search.js';
9
9
 
10
10
  let _agent = null;
11
11
 
12
+ let _currentCodeModeType = 'plan';
13
+ export function setCurrentCodeModeType(mode) { _currentCodeModeType = mode; }
14
+ export function getCurrentCodeModeType() { return _currentCodeModeType; }
15
+
12
16
  /**
13
17
  * Get or create the LangGraph job agent singleton.
14
18
  * Uses createReactAgent which handles the tool loop automatically.
@@ -49,19 +53,16 @@ const _codeAgents = new Map();
49
53
  /**
50
54
  * Get or create a code agent for a specific chat/workspace.
51
55
  * Each code chat gets its own agent with unique start_coding tool bindings.
52
- * Agents are keyed by chatId+mode so switching modes creates a new agent with the right prompt.
53
56
  * @param {object} context
54
57
  * @param {string} context.repo - GitHub repo
55
58
  * @param {string} context.branch - Git branch
56
59
  * @param {string} context.workspaceId - Pre-created workspace row ID
57
60
  * @param {string} context.chatId - Chat thread ID
58
- * @param {string} [context.codeModeType='plan'] - 'plan' or 'code'
59
61
  * @returns {Promise<object>} LangGraph agent
60
62
  */
61
- export async function getCodeAgent({ repo, branch, workspaceId, chatId, codeModeType = 'plan' }) {
62
- const cacheKey = `${chatId}:${codeModeType}`;
63
- if (_codeAgents.has(cacheKey)) {
64
- return _codeAgents.get(cacheKey);
63
+ export async function getCodeAgent({ repo, branch, workspaceId, chatId }) {
64
+ if (_codeAgents.has(chatId)) {
65
+ return _codeAgents.get(chatId);
65
66
  }
66
67
 
67
68
  const model = await createModel();
@@ -78,15 +79,13 @@ export async function getCodeAgent({ repo, branch, workspaceId, chatId, codeMode
78
79
 
79
80
  const checkpointer = SqliteSaver.fromConnString(thepopebotDb);
80
81
 
81
- const promptMd = codeModeType === 'code' ? codeExecutionMd : codePlanningMd;
82
-
83
82
  const agent = createReactAgent({
84
83
  llm: model,
85
84
  tools,
86
85
  checkpointSaver: checkpointer,
87
- prompt: (state) => [new SystemMessage(render_md(promptMd)), ...state.messages],
86
+ prompt: (state) => [new SystemMessage(render_md(codePlanningMd)), ...state.messages],
88
87
  });
89
88
 
90
- _codeAgents.set(cacheKey, agent);
89
+ _codeAgents.set(chatId, agent);
91
90
  return agent;
92
91
  }
@@ -1,55 +1,43 @@
1
+ import { Transform } from 'stream';
2
+ import split2 from 'split2';
3
+
1
4
  /**
2
5
  * Parse Docker container logs from a headless Claude Code container
3
6
  * running with --output-format stream-json.
4
7
  *
5
8
  * Three layers:
6
- * 1. Docker multiplexed frame parser (binary)
7
- * 2. NDJSON line splitter
9
+ * 1. Docker multiplexed frame decoder (Transform stream)
10
+ * 2. split2 for reliable NDJSON line splitting
8
11
  * 3. Claude Code stream-json → chat event mapper
9
12
  *
10
13
  * @param {import('http').IncomingMessage} dockerLogStream - Raw Docker log stream
11
14
  * @yields {{ type: string, text?: string, toolCallId?: string, toolName?: string, args?: object, result?: string }}
12
15
  */
13
16
  export async function* parseHeadlessStream(dockerLogStream) {
14
- let frameBuf = Buffer.alloc(0);
15
- let lineBuf = '';
16
-
17
- for await (const chunk of dockerLogStream) {
18
- // Layer 1: Docker multiplexed frame parser
19
- frameBuf = Buffer.concat([frameBuf, chunk]);
20
-
21
- let decoded = '';
22
- while (frameBuf.length >= 8) {
23
- const size = frameBuf.readUInt32BE(4);
24
- if (frameBuf.length < 8 + size) break; // incomplete frame
25
- const streamType = frameBuf[0];
26
- if (streamType === 1) { // stdout only
27
- decoded += frameBuf.slice(8, 8 + size).toString('utf8');
17
+ // Layer 1: Docker frame decoder
18
+ const frameDecoder = new Transform({
19
+ transform(chunk, encoding, callback) {
20
+ this._buf = this._buf ? Buffer.concat([this._buf, chunk]) : chunk;
21
+ while (this._buf.length >= 8) {
22
+ const size = this._buf.readUInt32BE(4);
23
+ if (this._buf.length < 8 + size) break;
24
+ if (this._buf[0] === 1) { // stdout only
25
+ this.push(this._buf.slice(8, 8 + size));
26
+ }
27
+ this._buf = this._buf.slice(8 + size);
28
28
  }
29
- frameBuf = frameBuf.slice(8 + size);
29
+ callback();
30
30
  }
31
+ });
31
32
 
32
- if (!decoded) continue;
33
-
34
- // Layer 2: NDJSON line splitter
35
- lineBuf += decoded;
36
- const lines = lineBuf.split('\n');
37
- lineBuf = lines.pop(); // keep incomplete last piece
38
-
39
- for (const line of lines) {
40
- const trimmed = line.trim();
41
- if (!trimmed) continue;
42
-
43
- // Layer 3: Event mapper
44
- for (const event of mapLine(trimmed)) {
45
- yield event;
46
- }
47
- }
48
- }
33
+ // Layer 2: split2 for reliable line splitting
34
+ const lines = dockerLogStream.pipe(frameDecoder).pipe(split2());
49
35
 
50
- // Process any remaining partial line
51
- if (lineBuf.trim()) {
52
- for (const event of mapLine(lineBuf.trim())) {
36
+ // Layer 3: map each complete line to chat events
37
+ for await (const line of lines) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed) continue;
40
+ for (const event of mapLine(trimmed)) {
53
41
  yield event;
54
42
  }
55
43
  }
@@ -65,6 +53,7 @@ export function mapLine(line) {
65
53
  try {
66
54
  parsed = JSON.parse(line);
67
55
  } catch {
56
+ console.warn('[headless-stream] JSON parse failed, length:', line.length, 'preview:', line.slice(0, 120));
68
57
  // Non-JSON lines (NO_CHANGES, MERGE_SUCCESS, AGENT_FAILED, etc.)
69
58
  return [{ type: 'text', text: `\n${line}\n` }];
70
59
  }
package/lib/ai/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HumanMessage, AIMessage } from '@langchain/core/messages';
2
2
  import { z } from 'zod';
3
- import { getJobAgent, getCodeAgent } from './agent.js';
3
+ import { getJobAgent, getCodeAgent, setCurrentCodeModeType } from './agent.js';
4
4
  import { createModel } from './model.js';
5
5
  import { jobSummaryMd } from '../paths.js';
6
6
  import { render_md } from '../utils/render-md.js';
@@ -134,12 +134,12 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
134
134
  workspaceId = existingChat.codeWorkspaceId;
135
135
  }
136
136
 
137
+ setCurrentCodeModeType(options.codeModeType || 'plan');
137
138
  agent = await getCodeAgent({
138
139
  repo: options.repo,
139
140
  branch: options.branch,
140
141
  workspaceId,
141
142
  chatId: threadId,
142
- codeModeType: options.codeModeType || 'plan',
143
143
  });
144
144
  } else {
145
145
  agent = await getJobAgent();
@@ -194,14 +194,6 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
194
194
 
195
195
  if (msgType === 'ai') {
196
196
  // Debug: log web search content blocks to see actual shape
197
- if (Array.isArray(msg.content)) {
198
- for (const block of msg.content) {
199
- if (block.type === 'server_tool_use' || block.type === 'server_tool_call' || block.type === 'web_search_tool_result') {
200
- console.log(`[chatStream] ${block.type}:`, JSON.stringify(block));
201
- }
202
- }
203
- }
204
-
205
197
  // Tool calls — AIMessage.tool_calls is an array of { id, name, args }
206
198
  if (msg.tool_calls?.length > 0) {
207
199
  for (const tc of msg.tool_calls) {
@@ -274,7 +266,7 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
274
266
  // Phase 2: Stream headless container output live
275
267
  if (headlessContainer) {
276
268
  try {
277
- const { tailContainerLogs, waitForContainer, removeContainer, removeCodeWorkspaceVolume } =
269
+ const { tailContainerLogs, waitForContainer, removeContainer } =
278
270
  await import('../tools/docker.js');
279
271
  const { parseHeadlessStream } = await import('./headless-stream.js');
280
272
 
@@ -317,11 +309,6 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
317
309
  await removeContainer(headlessContainer.containerName);
318
310
 
319
311
  if (exitCode === 0) {
320
- const existingChat = getChatById(threadId);
321
- const wsId = existingChat?.codeWorkspaceId;
322
- if (wsId) {
323
- await removeCodeWorkspaceVolume(wsId);
324
- }
325
312
  const completionMsg = '\n\nTask completed and merged back to base branch.';
326
313
  yield { type: 'text', text: completionMsg };
327
314
  fullText += completionMsg;
package/lib/ai/tools.js CHANGED
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
  import { createJob } from '../tools/create-job.js';
6
6
  import { getJobStatus } from '../tools/github.js';
7
7
  import { claudeMd, skillGuidePath, skillsDir } from '../paths.js';
8
+ import { getCurrentCodeModeType } from './agent.js';
8
9
 
9
10
  const createJobTool = tool(
10
11
  async ({ job_description }) => {
@@ -276,7 +277,7 @@ function createGetRepositoryDetailsTool({ repo, branch }) {
276
277
  */
277
278
  function createStartHeadlessCodingTool({ repo, branch, workspaceId }) {
278
279
  return tool(
279
- async ({ task_description, plan = true }) => {
280
+ async ({ task_description }) => {
280
281
  try {
281
282
  const { randomUUID } = await import('crypto');
282
283
  const containerName = `code-headless-${randomUUID().slice(0, 8)}`;
@@ -285,6 +286,8 @@ function createStartHeadlessCodingTool({ repo, branch, workspaceId }) {
285
286
  const workspace = getCodeWorkspaceById(workspaceId);
286
287
  const featureBranch = workspace?.featureBranch || `thepopebot/new-chat-${workspaceId.replace(/-/g, '').slice(0, 8)}`;
287
288
 
289
+ const plan = getCurrentCodeModeType() === 'plan';
290
+
288
291
  const { runHeadlessCodeContainer } = await import('../tools/docker.js');
289
292
 
290
293
  await runHeadlessCodeContainer({
@@ -315,9 +318,6 @@ function createStartHeadlessCodingTool({ repo, branch, workspaceId }) {
315
318
  task_description: z.string().describe(
316
319
  'Detailed description of the coding task. Include context, requirements, and any specific instructions.'
317
320
  ),
318
- plan: z.boolean().default(true).describe(
319
- 'Defaults to true (plan permission mode — read-only, no writes). Set to false to run in dangerous mode with full write access for code execution.'
320
- ),
321
321
  }),
322
322
  }
323
323
  );
@@ -131,7 +131,7 @@ function CodeModeToggle({
131
131
  }
132
132
  ),
133
133
  enabled && /* @__PURE__ */ jsxs(Fragment, { children: [
134
- /* @__PURE__ */ jsx("div", { className: "w-full sm:w-auto sm:min-w-[220px]", children: /* @__PURE__ */ jsx(
134
+ /* @__PURE__ */ jsx("div", { className: "w-full sm:w-auto sm:min-w-[240px] sm:max-w-[240px]", children: /* @__PURE__ */ jsx(
135
135
  Combobox,
136
136
  {
137
137
  options: repoOptions,
@@ -142,7 +142,7 @@ function CodeModeToggle({
142
142
  highlight: !repo && !loadingRepos
143
143
  }
144
144
  ) }),
145
- /* @__PURE__ */ jsx("div", { className: cn("w-full sm:w-auto sm:min-w-[180px]", !repo && "opacity-50 pointer-events-none"), children: /* @__PURE__ */ jsx(
145
+ /* @__PURE__ */ jsx("div", { className: cn("w-full sm:w-auto sm:min-w-[200px] sm:max-w-[200px]", !repo && "opacity-50 pointer-events-none"), children: /* @__PURE__ */ jsx(
146
146
  Combobox,
147
147
  {
148
148
  options: branchOptions,
@@ -173,7 +173,7 @@ export function CodeModeToggle({
173
173
  {/* Repo/branch pickers — inline, both always visible */}
174
174
  {enabled && (
175
175
  <>
176
- <div className="w-full sm:w-auto sm:min-w-[220px]">
176
+ <div className="w-full sm:w-auto sm:min-w-[240px] sm:max-w-[240px]">
177
177
  <Combobox
178
178
  options={repoOptions}
179
179
  value={repo}
@@ -183,7 +183,7 @@ export function CodeModeToggle({
183
183
  highlight={!repo && !loadingRepos}
184
184
  />
185
185
  </div>
186
- <div className={cn("w-full sm:w-auto sm:min-w-[180px]", !repo && "opacity-50 pointer-events-none")}>
186
+ <div className={cn("w-full sm:w-auto sm:min-w-[200px] sm:max-w-[200px]", !repo && "opacity-50 pointer-events-none")}>
187
187
  <Combobox
188
188
  options={branchOptions}
189
189
  value={branch}
@@ -280,7 +280,9 @@ function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
280
280
  text ? /* @__PURE__ */ jsx("div", { className: "whitespace-pre-wrap break-words", children: text }) : null
281
281
  ] }) : /* @__PURE__ */ jsx(Fragment, { children: message.parts?.length > 0 ? message.parts.map((part, i) => {
282
282
  if (part.type === "text") {
283
- return /* @__PURE__ */ jsx(Streamdown, { mode: isLoading ? "streaming" : "static", linkSafety, children: part.text }, i);
283
+ const prevPart = message.parts[i - 1];
284
+ const afterTool = prevPart?.type?.startsWith("tool-");
285
+ return /* @__PURE__ */ jsx(Streamdown, { className: afterTool ? "mt-3" : void 0, mode: isLoading ? "streaming" : "static", linkSafety, children: part.text }, i);
284
286
  }
285
287
  if (part.type === "file") {
286
288
  if (part.mediaType?.startsWith("image/")) {
@@ -331,7 +331,9 @@ export function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
331
331
  {message.parts?.length > 0 ? (
332
332
  message.parts.map((part, i) => {
333
333
  if (part.type === 'text') {
334
- return <Streamdown key={i} mode={isLoading ? 'streaming' : 'static'} linkSafety={linkSafety}>{part.text}</Streamdown>;
334
+ const prevPart = message.parts[i - 1];
335
+ const afterTool = prevPart?.type?.startsWith('tool-');
336
+ return <Streamdown key={i} className={afterTool ? 'mt-3' : undefined} mode={isLoading ? 'streaming' : 'static'} linkSafety={linkSafety}>{part.text}</Streamdown>;
335
337
  }
336
338
  if (part.type === 'file') {
337
339
  if (part.mediaType?.startsWith('image/')) {
@@ -322,7 +322,7 @@ export async function closeInteractiveMode(id, isClean) {
322
322
  }
323
323
 
324
324
  try {
325
- const { execInContainer, removeContainer, removeCodeWorkspaceVolume } = await import('../tools/docker.js');
325
+ const { execInContainer, removeContainer } = await import('../tools/docker.js');
326
326
 
327
327
  // Only fetch git data and inject context when the session is clean
328
328
  let commits = '';
@@ -344,9 +344,8 @@ export async function closeInteractiveMode(id, isClean) {
344
344
  }
345
345
  }
346
346
 
347
- // Destroy container and volume
347
+ // Destroy container (volume preserved for reuse)
348
348
  await removeContainer(workspace.containerName);
349
- await removeCodeWorkspaceVolume(id);
350
349
  clearWorkspaceSessions(id);
351
350
  updateContainerName(id, null);
352
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.74-beta.21",
3
+ "version": "1.2.74-beta.23",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -90,6 +90,7 @@
90
90
  "lucide-react": "^0.400.0",
91
91
  "node-cron": "^3.0.3",
92
92
  "open": "^10.0.0",
93
+ "split2": "^4.2.0",
93
94
  "streamdown": "^2.2.0",
94
95
  "tailwind-merge": "^3.0.0",
95
96
  "uuid": "^9.0.0",