thepopebot 1.2.75-beta.2 → 1.2.75-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 (120) hide show
  1. package/README.md +1 -1
  2. package/api/CLAUDE.md +1 -1
  3. package/api/index.js +5 -12
  4. package/bin/CLAUDE.md +1 -1
  5. package/bin/cli.js +329 -14
  6. package/bin/docker-build.js +5 -0
  7. package/bin/managed-paths.js +0 -7
  8. package/bin/sync.js +84 -0
  9. package/config/CLAUDE.md +1 -29
  10. package/config/instrumentation.js +1 -1
  11. package/lib/CLAUDE.md +3 -3
  12. package/lib/ai/CLAUDE.md +24 -3
  13. package/lib/ai/agent.js +8 -5
  14. package/lib/ai/async-channel.js +51 -0
  15. package/lib/ai/headless-stream.js +3 -0
  16. package/lib/ai/index.js +149 -173
  17. package/lib/ai/line-mappers.js +72 -9
  18. package/lib/ai/tools.js +40 -28
  19. package/lib/chat/actions.js +34 -6
  20. package/lib/chat/api.js +17 -1
  21. package/lib/chat/components/chat-header.js +4 -0
  22. package/lib/chat/components/chat-header.jsx +4 -0
  23. package/lib/chat/components/chat-input.js +1 -0
  24. package/lib/chat/components/chat-input.jsx +1 -0
  25. package/lib/chat/components/chat.js +9 -1
  26. package/lib/chat/components/chat.jsx +15 -2
  27. package/lib/chat/components/chats-page.js +3 -3
  28. package/lib/chat/components/chats-page.jsx +4 -6
  29. package/lib/chat/components/crons-page.js +1 -1
  30. package/lib/chat/components/crons-page.jsx +1 -1
  31. package/lib/chat/components/message.js +12 -4
  32. package/lib/chat/components/message.jsx +17 -4
  33. package/lib/chat/components/settings-chat-page.js +2 -1
  34. package/lib/chat/components/settings-chat-page.jsx +4 -1
  35. package/lib/chat/components/settings-coding-agents-page.js +139 -1
  36. package/lib/chat/components/settings-coding-agents-page.jsx +160 -0
  37. package/lib/chat/components/settings-jobs-page.js +13 -2
  38. package/lib/chat/components/settings-jobs-page.jsx +15 -1
  39. package/lib/chat/components/settings-secrets-layout.js +1 -1
  40. package/lib/chat/components/settings-secrets-layout.jsx +1 -1
  41. package/lib/chat/components/sidebar-history-item.js +3 -3
  42. package/lib/chat/components/sidebar-history-item.jsx +4 -6
  43. package/lib/chat/components/triggers-page.js +1 -1
  44. package/lib/chat/components/triggers-page.jsx +1 -1
  45. package/lib/cluster/actions.js +4 -4
  46. package/lib/cluster/execute.js +3 -1
  47. package/lib/code/actions.js +34 -11
  48. package/lib/code/code-page.js +40 -40
  49. package/lib/code/code-page.jsx +36 -36
  50. package/lib/code/port-forwards.js +17 -3
  51. package/lib/code/terminal-view.js +16 -0
  52. package/lib/code/terminal-view.jsx +18 -0
  53. package/lib/config.js +4 -0
  54. package/lib/cron.js +3 -3
  55. package/lib/db/api-keys.js +22 -61
  56. package/lib/db/config.js +23 -0
  57. package/lib/db/index.js +3 -1
  58. package/lib/maintenance.js +34 -11
  59. package/lib/paths.js +1 -38
  60. package/lib/tools/create-agent-job.js +0 -4
  61. package/lib/tools/docker.js +23 -16
  62. package/lib/triggers.js +4 -3
  63. package/lib/utils/render-md.js +3 -1
  64. package/package.json +2 -1
  65. package/setup/setup-ssl.mjs +414 -0
  66. package/templates/.github/workflows/rebuild-event-handler.yml +3 -0
  67. package/templates/.github/workflows/upgrade-event-handler.yml +1 -1
  68. package/templates/.gitignore.template +7 -3
  69. package/templates/.tmp/CLAUDE.md.template +5 -0
  70. package/templates/CLAUDE.md +3 -2
  71. package/templates/CLAUDE.md.template +24 -357
  72. package/templates/agent-job/CLAUDE.md.template +57 -0
  73. package/templates/agent-job/CRONS.json +16 -0
  74. package/templates/{config/agent-job → agent-job}/SOUL.md +3 -3
  75. package/templates/agent-job/SYSTEM.md +60 -0
  76. package/templates/agents/CLAUDE.md.template +54 -0
  77. package/templates/data/CLAUDE.md.template +5 -0
  78. package/templates/docker-compose.custom.yml +41 -62
  79. package/templates/docker-compose.yml +14 -21
  80. package/templates/event-handler/CLAUDE.md.template +0 -0
  81. package/templates/logs/CLAUDE.md.template +5 -0
  82. package/templates/skills/CLAUDE.md.template +57 -32
  83. package/templates/skills/active/.gitkeep +0 -0
  84. package/templates/skills/library/agent-job-secrets/SKILL.md +23 -0
  85. package/templates/skills/library/agent-job-secrets/agent-job-secrets.js +62 -0
  86. package/templates/.pi/extensions/env-sanitizer/index.ts +0 -48
  87. package/templates/.pi/extensions/env-sanitizer/package.json +0 -5
  88. package/templates/README.md +0 -75
  89. package/templates/config/CLAUDE.md.template +0 -40
  90. package/templates/config/CRONS.json +0 -56
  91. package/templates/config/agent-job/AGENT_JOB.md +0 -30
  92. package/templates/cron/CLAUDE.md.template +0 -24
  93. package/templates/docker-compose.litellm.yml +0 -82
  94. package/templates/docs/CLAUDE.md.template +0 -12
  95. package/templates/docs/CLI.md +0 -59
  96. package/templates/docs/CLUSTERS.md +0 -151
  97. package/templates/docs/CONFIGURATION.md +0 -181
  98. package/templates/docs/CRONS_AND_TRIGGERS.md +0 -132
  99. package/templates/docs/GETTING_STARTED.md +0 -64
  100. package/templates/docs/SECURITY.md +0 -61
  101. package/templates/docs/SKILLS.md +0 -113
  102. package/templates/docs/UPGRADING.md +0 -92
  103. package/templates/skills/LICENSE +0 -21
  104. package/templates/skills/README.md +0 -117
  105. package/templates/skills/agent-job-secrets/SKILL.md +0 -25
  106. package/templates/skills/agent-job-secrets/agent-job-secrets.js +0 -66
  107. package/templates/traefik-dynamic.yml.example +0 -7
  108. package/templates/triggers/CLAUDE.md.template +0 -41
  109. /package/templates/{config → agent-job}/HEARTBEAT.md +0 -0
  110. /package/templates/{cron → data}/.gitkeep +0 -0
  111. /package/templates/{logs → data/clusters}/.gitkeep +0 -0
  112. /package/templates/{triggers → data/db}/.gitkeep +0 -0
  113. /package/templates/{config/agent-job → event-handler}/SUMMARY.md +0 -0
  114. /package/templates/{config → event-handler}/TRIGGERS.json +0 -0
  115. /package/templates/{config → event-handler}/agent-chat/SYSTEM.md +0 -0
  116. /package/templates/{config/cluster → event-handler/clusters}/ROLE.md +0 -0
  117. /package/templates/{config/cluster → event-handler/clusters}/SYSTEM.md +0 -0
  118. /package/templates/{config → event-handler}/code-chat/SYSTEM.md +0 -0
  119. /package/templates/{config → event-handler}/litellm/main.yaml +0 -0
  120. /package/templates/skills/{playwright-cli → library/playwright-cli}/SKILL.md +0 -0
package/config/CLAUDE.md CHANGED
@@ -1,32 +1,4 @@
1
- # config/ — Configuration Files
2
-
3
- ## Directory Structure
4
-
5
- ```
6
- config/
7
- ├── agent-chat/
8
- │ └── SYSTEM.md # Agent chat system prompt (supports {{skills}}, {{datetime}})
9
- ├── code-chat/
10
- │ └── SYSTEM.md # Code workspace system prompt
11
- ├── agent-job/
12
- │ ├── SOUL.md # Agent personality/identity (used by Docker agent)
13
- │ ├── AGENT_JOB.md # Agent runtime environment docs (used by Docker agent)
14
- │ └── SUMMARY.md # Prompt for summarizing completed jobs
15
- ├── cluster/
16
- │ ├── SYSTEM.md # Cluster worker system prompt
17
- │ └── ROLE.md # Per-role prompt template for cluster workers
18
- ├── HEARTBEAT.md # Self-monitoring behavior (cron task prompt)
19
- ├── CRONS.json # Scheduled job definitions
20
- └── TRIGGERS.json # Webhook trigger definitions
21
- ```
22
-
23
- ## Markdown File Includes
24
-
25
- Markdown files in `config/` support includes and built-in variables, powered by `lib/utils/render-md.js`.
26
-
27
- - **File includes**: `{{ filepath.md }}` — resolves relative to project root, recursive with circular detection. Missing files are left as-is.
28
- - **`{{datetime}}`** — Current ISO timestamp.
29
- - **`{{skills}}`** — Dynamic bullet list of active skill descriptions from `skills/active/*/SKILL.md` frontmatter. Never hardcode skill names — this is resolved at runtime.
1
+ # config/ — Next.js Config Wrapper
30
2
 
31
3
  ## Next.js Config Wrapper (index.js)
32
4
 
@@ -70,7 +70,7 @@ export async function register() {
70
70
  const { startClusterRuntime } = await import('../lib/cluster/runtime.js');
71
71
  startClusterRuntime();
72
72
 
73
- // Start internal maintenance cron (cleanup expired agent job keys, etc.)
73
+ // Start internal maintenance cron (cleanup orphaned agent job keys, etc.)
74
74
  const { startMaintenanceCron } = await import('../lib/maintenance.js');
75
75
  startMaintenanceCron();
76
76
 
package/lib/CLAUDE.md CHANGED
@@ -12,16 +12,16 @@ If the task needs to *think*, use `agent`. If it just needs to *do*, use `comman
12
12
 
13
13
  **Agent**: Creates a Docker Agent job via `createAgentJob()`. Pushes an `agent-job/*` branch and launches a local Docker container. The `job` string is the LLM task prompt. Agent backend selected via `agent_backend` in `agent-job.config.json`.
14
14
 
15
- **Command**: Runs a shell command on the event handler. Working directory: `cron/` for crons, `triggers/` for triggers.
15
+ **Command**: Runs a shell command on the event handler. Working directory: project root.
16
16
 
17
17
  **Webhook**: Makes an HTTP request. `GET` skips the body; `POST` (default) sends `{ ...vars }` or `{ ...vars, data: <payload> }`.
18
18
 
19
19
  ## Cron Jobs
20
20
 
21
- Defined in `config/CRONS.json`, loaded by `lib/cron.js` at startup via `node-cron`. Each entry has `name`, `schedule` (cron expression), `type` (`agent`/`command`/`webhook`), and the corresponding action fields (`job`, `command`, or `url`/`method`/`headers`/`vars`). Set `enabled: false` to disable. Agent-type entries support optional `llm_provider` and `llm_model` fields to override the default LLM (passed to Docker agent via `agent-job.config.json`).
21
+ Defined in `agent-job/CRONS.json`, loaded by `lib/cron.js` at startup via `node-cron`. Each entry has `name`, `schedule` (cron expression), `type` (`agent`/`command`/`webhook`), and the corresponding action fields (`job`, `command`, or `url`/`method`/`headers`/`vars`). Set `enabled: false` to disable. Agent-type entries support optional `llm_provider` and `llm_model` fields to override the default LLM (passed to Docker agent via `agent-job.config.json`).
22
22
 
23
23
  ## Webhook Triggers
24
24
 
25
- Defined in `config/TRIGGERS.json`, loaded by `lib/triggers.js`. Each trigger watches an endpoint path (`watch_path`) and fires an array of actions (fire-and-forget, after auth, before route handler). Actions use the same `type`/`job`/`command`/`url` fields as cron jobs, including optional `llm_provider`/`llm_model` overrides.
25
+ Defined in `event-handler/TRIGGERS.json`, loaded by `lib/triggers.js`. Each trigger watches an endpoint path (`watch_path`) and fires an array of actions (fire-and-forget, after auth, before route handler). Actions use the same `type`/`job`/`command`/`url` fields as cron jobs, including optional `llm_provider`/`llm_model` overrides.
26
26
 
27
27
  Template tokens in `job` and `command` strings: `{{body}}`, `{{body.field}}`, `{{query}}`, `{{query.field}}`, `{{headers}}`, `{{headers.field}}`.
package/lib/ai/CLAUDE.md CHANGED
@@ -5,12 +5,12 @@
5
5
  Two agent singletons, both using `createReactAgent` from `@langchain/langgraph/prebuilt` with `SqliteSaver` for conversation memory:
6
6
 
7
7
  **Agent Chat** — singleton via `getAgentChat()`:
8
- - System prompt: `config/agent-chat/SYSTEM.md` (rendered fresh each invocation via `render_md()`)
8
+ - System prompt: `event-handler/agent-chat/SYSTEM.md` (rendered fresh each invocation via `render_md()`)
9
9
  - Tools: `agent_job`, `coding_agent`
10
10
  - Call `resetAgentChats()` to clear both singletons (required if hot-reloading)
11
11
 
12
12
  **Code Chat** — singleton via `getCodeChat()`:
13
- - System prompt: `config/code-chat/SYSTEM.md` (rendered fresh each invocation)
13
+ - System prompt: `event-handler/code-chat/SYSTEM.md` (rendered fresh each invocation)
14
14
  - Tools: `coding_agent` (reads repo/branch/workspace from `runtime.configurable`)
15
15
 
16
16
  ## Adding a New Tool
@@ -73,7 +73,28 @@ Three-layer parser for Claude Code agents running in headless Docker containers:
73
73
  3. **Event mapper** (`mapLine()`) — Converts each line to chat events:
74
74
  - `assistant` messages: `text` blocks → `{ type: 'text' }`, `tool_use` blocks → `{ type: 'tool-call' }`
75
75
  - `user` messages: `tool_result` blocks → `{ type: 'tool-result' }` (priority: stdout > string content > array)
76
- - `result` messages: → `{ type: 'text', _resultSummary }` (injected into LangGraph memory)
76
+ - `result` messages: → `{ type: 'text' }` (final summary from the agent)
77
77
  - Non-JSON lines (e.g. `NO_CHANGES`, `AGENT_FAILED`): wrapped as plain text events
78
78
 
79
79
  `parseHeadlessStream(dockerLogStream)` is an async generator consuming `http.IncomingMessage`. `mapLine()` is also reused by `lib/cluster/stream.js` for worker log parsing.
80
+
81
+ ### Tool Return Format
82
+
83
+ The `coding_agent` tool (in `tools.js`) returns the **full container session** as a flat JSON array. This becomes the ToolMessage in LangGraph's checkpoint, giving the LLM complete context on the current turn. The array contains:
84
+
85
+ - `{ type: 'meta', codingAgent, backendApi }` — first event, agent identity
86
+ - `{ type: 'text', text }` — agent text output
87
+ - `{ type: 'tool-call', toolCallId, toolName, args }` — agent tool invocations
88
+ - `{ type: 'tool-result', toolCallId, result }` — tool execution results
89
+ - `{ type: 'exit', exitCode }` — last event, container exit status
90
+
91
+ On error before streaming starts: `[{ type: 'error', message }]`.
92
+
93
+ ### Adding a New Agent Mapper (line-mappers.js)
94
+
95
+ Each coding agent CLI has its own mapper function (`mapClaudeCodeLine`, `mapPiLine`, `mapGeminiLine`, `mapCodexLine`, `mapOpenCodeLine`, `mapKimiLine`). When adding a new agent:
96
+
97
+ 1. Create `mapXxxLine(parsed)` in `line-mappers.js` that returns an array of `{ type, ... }` events
98
+ 2. Register it in `headless-stream.js`: add to imports, re-exports, and the `mapperMap` object
99
+ 3. Map the agent's JSON output to three event types: `{ type: 'text', text }`, `{ type: 'tool-call', toolCallId, toolName, args }`, `{ type: 'tool-result', toolCallId, result }`
100
+ 4. Return `[{ type: 'skip' }]` for noise events (session init, rate limits, etc.) to suppress them without triggering the unknown fallback
package/lib/ai/agent.js CHANGED
@@ -3,7 +3,8 @@ import { SystemMessage } from '@langchain/core/messages';
3
3
  import { createModel } from './model.js';
4
4
  import { agentJobTool, agentChatCodingTool, codeChatCodingTool } from './tools.js';
5
5
  import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
6
- import { agentJobPlanningMd, codePlanningMd, thepopebotDb } from '../paths.js';
6
+ import path from 'path';
7
+ import { PROJECT_ROOT } from '../paths.js';
7
8
  import { render_md } from '../utils/render-md.js';
8
9
 
9
10
  // Singletons on globalThis to survive Next.js webpack chunk duplication.
@@ -20,13 +21,14 @@ export async function getAgentChat() {
20
21
  const model = await createModel();
21
22
  const tools = [agentJobTool, agentChatCodingTool];
22
23
 
23
- const checkpointer = SqliteSaver.fromConnString(thepopebotDb);
24
+ const dbPath = process.env.DATABASE_PATH || path.join(PROJECT_ROOT, 'data/db/thepopebot.sqlite');
25
+ const checkpointer = SqliteSaver.fromConnString(dbPath);
24
26
 
25
27
  globalThis.__popebotAgentChat = createReactAgent({
26
28
  llm: model,
27
29
  tools,
28
30
  checkpointSaver: checkpointer,
29
- prompt: (state) => [new SystemMessage(render_md(agentJobPlanningMd)), ...state.messages],
31
+ prompt: (state) => [new SystemMessage(render_md(path.join(PROJECT_ROOT, 'event-handler/agent-chat/SYSTEM.md'))), ...state.messages],
30
32
  });
31
33
  }
32
34
  return globalThis.__popebotAgentChat;
@@ -41,13 +43,14 @@ export async function getCodeChat() {
41
43
  const model = await createModel();
42
44
  const tools = [codeChatCodingTool];
43
45
 
44
- const checkpointer = SqliteSaver.fromConnString(thepopebotDb);
46
+ const dbPath = process.env.DATABASE_PATH || path.join(PROJECT_ROOT, 'data/db/thepopebot.sqlite');
47
+ const checkpointer = SqliteSaver.fromConnString(dbPath);
45
48
 
46
49
  globalThis.__popebotCodeChat = createReactAgent({
47
50
  llm: model,
48
51
  tools,
49
52
  checkpointSaver: checkpointer,
50
- prompt: (state) => [new SystemMessage(render_md(codePlanningMd)), ...state.messages],
53
+ prompt: (state) => [new SystemMessage(render_md(path.join(PROJECT_ROOT, 'event-handler/code-chat/SYSTEM.md'))), ...state.messages],
51
54
  });
52
55
  }
53
56
  return globalThis.__popebotCodeChat;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Async push/pull queue. Producer calls push()/done(), consumer uses for-await.
3
+ */
4
+ export function createChannel() {
5
+ const queue = [];
6
+ const waiters = [];
7
+ let isDone = false;
8
+
9
+ return {
10
+ push(value) {
11
+ if (waiters.length > 0) waiters.shift()(value);
12
+ else queue.push(value);
13
+ },
14
+ done() {
15
+ isDone = true;
16
+ while (waiters.length > 0) waiters.shift()(Symbol.for('done'));
17
+ },
18
+ async *[Symbol.asyncIterator]() {
19
+ while (true) {
20
+ if (queue.length > 0) {
21
+ yield queue.shift();
22
+ } else if (isDone) {
23
+ return;
24
+ } else {
25
+ const value = await new Promise(resolve => waiters.push(resolve));
26
+ if (value === Symbol.for('done')) return;
27
+ yield value;
28
+ }
29
+ }
30
+ }
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Merge two async iterables — yields from whichever has data first.
36
+ * Completes when BOTH are exhausted.
37
+ */
38
+ export async function* mergeAsyncIterables(iter1, iter2) {
39
+ const channel = createChannel();
40
+ let active = 2;
41
+
42
+ const consume = async (iter) => {
43
+ for await (const item of iter) channel.push(item);
44
+ if (--active === 0) channel.done();
45
+ };
46
+
47
+ consume(iter1);
48
+ consume(iter2);
49
+
50
+ yield* channel;
51
+ }
@@ -10,6 +10,7 @@ export {
10
10
  mapGeminiLine,
11
11
  mapCodexLine,
12
12
  mapOpenCodeLine,
13
+ mapKimiLine,
13
14
  } from './line-mappers.js';
14
15
 
15
16
  import {
@@ -19,6 +20,7 @@ import {
19
20
  mapGeminiLine,
20
21
  mapCodexLine,
21
22
  mapOpenCodeLine,
23
+ mapKimiLine,
22
24
  } from './line-mappers.js';
23
25
 
24
26
  /**
@@ -41,6 +43,7 @@ export async function* parseHeadlessStream(dockerLogStream, codingAgent = 'claud
41
43
  'gemini-cli': mapGeminiLine,
42
44
  'codex-cli': mapCodexLine,
43
45
  'opencode': mapOpenCodeLine,
46
+ 'kimi-cli': mapKimiLine,
44
47
  };
45
48
  const mapper = mapperMap[codingAgent] || mapClaudeCodeLine;
46
49
 
package/lib/ai/index.js CHANGED
@@ -1,8 +1,10 @@
1
- import { HumanMessage, AIMessage } from '@langchain/core/messages';
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import { createChannel, mergeAsyncIterables } from './async-channel.js';
2
3
  import { z } from 'zod';
3
4
  import { getAgentChat, getCodeChat } from './agent.js';
4
5
  import { createModel } from './model.js';
5
- import { agentJobSummaryMd } from '../paths.js';
6
+ import path from 'path';
7
+ import { PROJECT_ROOT } from '../paths.js';
6
8
  import { render_md } from '../utils/render-md.js';
7
9
  import { getChatById, createChat, saveMessage, updateChatTitle, linkChatToWorkspace } from '../db/chats.js';
8
10
 
@@ -178,16 +180,21 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
178
180
  }
179
181
  }
180
182
 
183
+ // Side channel: bridges the tool's live container output to this generator
184
+ const sideChannel = createChannel();
185
+ const streamCallback = (chunk) => {
186
+ if (chunk === null) sideChannel.done();
187
+ else sideChannel.push(chunk);
188
+ };
189
+
181
190
  try {
182
191
  const stream = await agent.stream(
183
192
  { messages: [new HumanMessage({ content: messageContent })] },
184
- { configurable: { thread_id: threadId, workspaceId, repo, branch, codeModeType }, streamMode: 'messages' }
193
+ { configurable: { thread_id: threadId, workspaceId, repo, branch, codeModeType, streamCallback }, streamMode: 'messages' }
185
194
  );
186
195
 
187
- let fullText = '';
188
196
  const toolCallNames = {};
189
197
  const pendingToolCalls = new Map();
190
- let headlessContainer = null;
191
198
 
192
199
  // Accumulate raw tool call arg fragments across streaming chunks.
193
200
  // Each AIMessageChunk only carries its own delta — the first chunk
@@ -195,202 +202,170 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
195
202
  // chunks (input_json_delta) have only index with the partial JSON delta.
196
203
  const toolCallRawArgs = {}; // tool_call_id → accumulated args string
197
204
  const indexToToolCallId = {}; // chunk index → tool_call_id
205
+ const toolCallArgsEmitted = new Set(); // tool_call_ids whose complete args have been yielded
206
+
207
+ // Headless container streaming state
208
+ const headlessPendingToolCalls = new Map();
209
+ let pendingText = ''; // channel text, flushed to DB at tool boundaries
210
+ let llmTextAccum = ''; // langgraph text (direct response or LLM follow-up after container)
211
+
212
+ // Tag helper so mergeAsyncIterables can tell the two sources apart.
213
+ // The LangGraph wrapper also closes sideChannel when the agent stream
214
+ // finishes — this prevents a deadlock when no tool calls streamCallback.
215
+ async function* tagged(iter, source) {
216
+ for await (const item of iter) yield { _src: source, item };
217
+ if (source === 'lg') sideChannel.done();
218
+ }
198
219
 
199
- for await (const event of stream) {
200
- // streamMode: 'messages' yields [message, metadata] tuples
201
- const msg = Array.isArray(event) ? event[0] : event;
202
- const msgType = msg._getType?.();
203
-
204
- if (msgType === 'ai') {
205
- // Tool calls AIMessage.tool_calls is an array of { id, name, args }
206
- if (msg.tool_calls?.length > 0) {
207
- for (const tc of msg.tool_calls) {
208
- toolCallNames[tc.id] = tc.name;
209
- pendingToolCalls.set(tc.id, { toolName: tc.name, args: tc.args });
210
- yield {
211
- type: 'tool-call',
212
- toolCallId: tc.id,
213
- toolName: tc.name,
214
- args: tc.args,
215
- };
216
- }
217
- }
218
-
219
- // Accumulate raw tool call arg strings from streaming chunks
220
- if (msg.tool_call_chunks?.length > 0) {
221
- for (const c of msg.tool_call_chunks) {
222
- if (c.id) {
223
- indexToToolCallId[c.index] = c.id;
224
- toolCallRawArgs[c.id] = (toolCallRawArgs[c.id] || '') + (c.args || '');
225
- } else if (c.index != null && indexToToolCallId[c.index]) {
226
- const id = indexToToolCallId[c.index];
227
- toolCallRawArgs[id] = (toolCallRawArgs[id] || '') + (c.args || '');
220
+ try {
221
+ for await (const { _src, item } of mergeAsyncIterables(
222
+ tagged(stream, 'lg'),
223
+ tagged(sideChannel, 'ch')
224
+ )) {
225
+ if (_src === 'lg') {
226
+ // ── LangGraph agent stream ────────────────────────────────────────
227
+ const msg = Array.isArray(item) ? item[0] : item;
228
+ const msgType = msg._getType?.();
229
+
230
+ if (msgType === 'ai') {
231
+ // Tool calls — AIMessage.tool_calls is an array of { id, name, args }
232
+ if (msg.tool_calls?.length > 0) {
233
+ for (const tc of msg.tool_calls) {
234
+ toolCallNames[tc.id] = tc.name;
235
+ pendingToolCalls.set(tc.id, { toolName: tc.name, args: tc.args });
236
+ yield {
237
+ type: 'tool-call',
238
+ toolCallId: tc.id,
239
+ toolName: tc.name,
240
+ args: tc.args,
241
+ };
242
+ }
228
243
  }
229
- }
230
- }
231
-
232
- // Text content (wrapped in structured object)
233
- let text = '';
234
- if (typeof msg.content === 'string') {
235
- text = msg.content;
236
- } else if (Array.isArray(msg.content)) {
237
- text = msg.content
238
- .filter((b) => b.type === 'text' && b.text)
239
- .map((b) => b.text)
240
- .join('');
241
- }
242
244
 
243
- if (text) {
244
- fullText += text;
245
- yield { type: 'text', text };
246
- }
247
- } else if (msgType === 'tool') {
248
- // Parse complete args from accumulated raw fragments
249
- const tc = pendingToolCalls.get(msg.tool_call_id);
250
- const rawArgs = toolCallRawArgs[msg.tool_call_id];
251
- let completeArgs;
252
- try { completeArgs = rawArgs ? JSON.parse(rawArgs) : {}; } catch { completeArgs = {}; }
253
-
254
- // Tool result ToolMessage has tool_call_id and content
255
- yield {
256
- type: 'tool-result',
257
- toolCallId: msg.tool_call_id,
258
- toolName: tc?.toolName,
259
- args: completeArgs,
260
- result: msg.content,
261
- };
262
-
263
- // Save complete tool invocation as JSON
264
- if (tc) {
265
- persistMessage(threadId, 'assistant', JSON.stringify({
266
- type: 'tool-invocation',
267
- toolCallId: msg.tool_call_id,
268
- toolName: tc.toolName,
269
- state: 'output-available',
270
- input: completeArgs,
271
- output: msg.content,
272
- }), options);
273
- pendingToolCalls.delete(msg.tool_call_id);
274
- }
245
+ // Accumulate raw tool call arg strings from streaming chunks
246
+ if (msg.tool_call_chunks?.length > 0) {
247
+ for (const c of msg.tool_call_chunks) {
248
+ if (c.id) {
249
+ indexToToolCallId[c.index] = c.id;
250
+ toolCallRawArgs[c.id] = (toolCallRawArgs[c.id] || '') + (c.args || '');
251
+ } else if (c.index != null && indexToToolCallId[c.index]) {
252
+ const id = indexToToolCallId[c.index];
253
+ toolCallRawArgs[id] = (toolCallRawArgs[id] || '') + (c.args || '');
254
+ }
255
+ }
256
+ // Re-yield tool-call with complete args once the JSON is fully streamed
257
+ for (const c of msg.tool_call_chunks) {
258
+ const id = c.id || indexToToolCallId[c.index];
259
+ if (id && toolCallRawArgs[id] && !toolCallArgsEmitted.has(id)) {
260
+ try {
261
+ const parsed = JSON.parse(toolCallRawArgs[id]);
262
+ toolCallArgsEmitted.add(id);
263
+ const tc = pendingToolCalls.get(id);
264
+ if (tc) {
265
+ tc.args = parsed;
266
+ yield { type: 'tool-call', toolCallId: id, toolName: tc.toolName, args: parsed };
267
+ }
268
+ } catch {} // args not complete yet, keep accumulating
269
+ }
270
+ }
271
+ }
275
272
 
276
- // Detect headless container tool result for Phase 2 streaming
277
- const headlessToolName = toolCallNames[msg.tool_call_id];
278
- if (headlessToolName === 'coding_agent') {
279
- try {
280
- const parsed = JSON.parse(msg.content);
281
- if (parsed.status === 'started' && parsed.containerName) {
282
- headlessContainer = { ...parsed, toolName: headlessToolName };
273
+ // Text content (wrapped in structured object)
274
+ let text = '';
275
+ if (typeof msg.content === 'string') {
276
+ text = msg.content;
277
+ } else if (Array.isArray(msg.content)) {
278
+ text = msg.content
279
+ .filter((b) => b.type === 'text' && b.text)
280
+ .map((b) => b.text)
281
+ .join('');
283
282
  }
284
- } catch {}
285
- }
286
- }
287
- // Skip other message types (human, system)
288
- }
289
283
 
290
- // Save assistant response to DB (defer if headless streaming follows)
291
- if (fullText && !headlessContainer) {
292
- persistMessage(threadId, 'assistant', fullText, options);
293
- }
284
+ if (text) {
285
+ llmTextAccum += text;
286
+ yield { type: 'text', text };
287
+ }
288
+ } else if (msgType === 'tool') {
289
+ // Parse complete args from accumulated raw fragments
290
+ const tc = pendingToolCalls.get(msg.tool_call_id);
291
+ const rawArgs = toolCallRawArgs[msg.tool_call_id];
292
+ let completeArgs;
293
+ try { completeArgs = rawArgs ? JSON.parse(rawArgs) : {}; } catch { completeArgs = {}; }
294
+
295
+ // Tool result — ToolMessage has tool_call_id and content
296
+ yield {
297
+ type: 'tool-result',
298
+ toolCallId: msg.tool_call_id,
299
+ toolName: tc?.toolName,
300
+ args: completeArgs,
301
+ result: msg.content,
302
+ };
294
303
 
295
- // Phase 2: Stream headless container output live
296
- if (headlessContainer) {
297
- try {
298
- const { tailContainerLogs, waitForContainer, removeContainer } =
299
- await import('../tools/docker.js');
300
- const { parseHeadlessStream } = await import('./headless-stream.js');
301
-
302
- const logStream = await tailContainerLogs(headlessContainer.containerName);
303
-
304
- // Collect conversation parts during streaming, add to memory in one batch at the end
305
- let resultSummary = '';
306
- const memoryParts = [];
307
- const headlessPendingToolCalls = new Map();
308
- let pendingText = '';
309
-
310
- let lastEmittedText = '';
311
- for await (const chunk of parseHeadlessStream(logStream, headlessContainer.codingAgent)) {
312
- // Result summary: skip if duplicate, otherwise ensure it starts on a new line
313
- if (chunk._resultSummary && chunk.type === 'text') {
314
- resultSummary = chunk._resultSummary;
315
- if (pendingText.trim() && chunk.text.trim() === pendingText.trim()) {
316
- continue;
304
+ // Save complete tool invocation as JSON
305
+ if (tc) {
306
+ persistMessage(threadId, 'assistant', JSON.stringify({
307
+ type: 'tool-invocation',
308
+ toolCallId: msg.tool_call_id,
309
+ toolName: tc.toolName,
310
+ state: 'output-available',
311
+ input: completeArgs,
312
+ output: msg.content,
313
+ }), options);
314
+ pendingToolCalls.delete(msg.tool_call_id);
317
315
  }
318
- chunk = { ...chunk, text: '\n\n' + chunk.text };
319
316
  }
320
- yield chunk;
317
+ // Skip other message types (human, system)
318
+
319
+ } else {
320
+ // ── Side channel: headless container chunks ───────────────────────
321
+ const chunk = item;
322
+
321
323
  if (chunk.type === 'text') {
322
- fullText += chunk.text;
323
- memoryParts.push(chunk.text);
324
324
  pendingText += chunk.text;
325
+ yield chunk;
325
326
  } else if (chunk.type === 'tool-call') {
326
327
  // Flush accumulated text before tool call
327
328
  if (pendingText) {
328
329
  persistMessage(threadId, 'assistant', pendingText, options);
329
330
  pendingText = '';
330
331
  }
331
- memoryParts.push('[tool-call] ' + chunk.toolName + ': ' + JSON.stringify(chunk.args));
332
332
  headlessPendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
333
+ yield chunk;
333
334
  } else if (chunk.type === 'tool-result') {
334
- memoryParts.push('[tool-result] ' + chunk.result);
335
- const tc = headlessPendingToolCalls.get(chunk.toolCallId);
336
- if (tc) {
335
+ // Enrich with args from matching tool-call (required by api.js tool-input-available update)
336
+ const htc = headlessPendingToolCalls.get(chunk.toolCallId);
337
+ const enriched = htc ? { ...chunk, args: htc.args, toolName: htc.toolName } : chunk;
338
+ yield enriched;
339
+ if (htc) {
337
340
  persistMessage(threadId, 'assistant', JSON.stringify({
338
341
  type: 'tool-invocation',
339
342
  toolCallId: chunk.toolCallId,
340
- toolName: tc.toolName,
343
+ toolName: htc.toolName,
341
344
  state: 'output-available',
342
- input: tc.args,
345
+ input: htc.args,
343
346
  output: chunk.result,
344
347
  }), options);
345
348
  headlessPendingToolCalls.delete(chunk.toolCallId);
346
349
  }
350
+ } else {
351
+ // unknown/meta events pass through unchanged
352
+ yield chunk;
347
353
  }
348
- if (chunk._resultSummary) resultSummary = chunk._resultSummary;
349
- }
350
-
351
- // Flush remaining accumulated text
352
- if (pendingText) {
353
- persistMessage(threadId, 'assistant', pendingText, options);
354
- pendingText = '';
355
- }
356
-
357
- // Container has exited by now (tailContainerLogs follows until EOF)
358
- const exitCode = await waitForContainer(headlessContainer.containerName);
359
- await removeContainer(headlessContainer.containerName);
360
-
361
- if (exitCode === 0) {
362
- const completionMsg = codeModeType === 'plan'
363
- ? '\n\nPlanning complete.'
364
- : '\n\nCoding complete.';
365
- yield { type: 'text', text: completionMsg };
366
- fullText += completionMsg;
367
- persistMessage(threadId, 'assistant', completionMsg, options);
368
- } else {
369
- const failureMsg = '\n\nTask exited with errors.';
370
- yield { type: 'text', text: failureMsg };
371
- fullText += failureMsg;
372
- persistMessage(threadId, 'assistant', failureMsg, options);
373
354
  }
355
+ }
356
+ } finally {
357
+ // Ensure no dangling promise when tool was never called
358
+ sideChannel.done();
359
+ }
374
360
 
375
- // Inject full conversation into LangGraph memory using the correct agent
376
- if (memoryParts.length > 0) {
377
- await agent.updateState(
378
- { configurable: { thread_id: threadId } },
379
- { messages: [new AIMessage(memoryParts.join('\n'))] }
380
- );
381
- }
382
- // Also inject the summary separately for concise follow-up context
383
- if (resultSummary) {
384
- await agent.updateState(
385
- { configurable: { thread_id: threadId } },
386
- { messages: [new AIMessage(resultSummary)] }
387
- );
388
- }
361
+ // Flush remaining channel text
362
+ if (pendingText) {
363
+ persistMessage(threadId, 'assistant', pendingText, options);
364
+ }
389
365
 
390
- } catch (err) {
391
- console.error('[chatStream] headless stream error:', err);
392
- yield { type: 'text', text: '\n\nError streaming headless output: ' + err.message };
393
- }
366
+ // Persist LLM text (direct response with no tool, or LLM follow-up after container)
367
+ if (llmTextAccum) {
368
+ persistMessage(threadId, 'assistant', llmTextAccum, options);
394
369
  }
395
370
 
396
371
  } catch (err) {
@@ -410,7 +385,7 @@ async function autoTitle(threadId, firstMessage) {
410
385
 
411
386
  const model = await createModel({ maxTokens: 250 });
412
387
  const response = await model.withStructuredOutput(z.object({ title: z.string() })).invoke([
413
- ['system', 'Generate a descriptive (8-12 word) title for this chat based on the user\'s first message.'],
388
+ ['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.'],
414
389
  ['human', firstMessage],
415
390
  ]);
416
391
  if (response.title.trim()) {
@@ -434,10 +409,11 @@ async function autoTitle(threadId, firstMessage) {
434
409
  async function summarizeAgentJob(results) {
435
410
  try {
436
411
  const model = await createModel({ maxTokens: 1024 });
437
- const systemPrompt = render_md(agentJobSummaryMd);
412
+ const summaryMdPath = path.join(PROJECT_ROOT, 'event-handler/SUMMARY.md');
413
+ const systemPrompt = render_md(summaryMdPath);
438
414
 
439
415
  if (!systemPrompt) {
440
- console.error(`[summarizeAgentJob] Empty system prompt — agent-job/SUMMARY.md not found or empty at: ${agentJobSummaryMd}`);
416
+ console.error(`[summarizeAgentJob] Empty system prompt — event-handler/SUMMARY.md not found or empty at: ${summaryMdPath}`);
441
417
  }
442
418
 
443
419
  const userMessage = [