alvin-bot 4.9.4 β 4.10.0
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/CHANGELOG.md +65 -0
- package/dist/handlers/async-agent-chunk-handler.js +33 -0
- package/dist/handlers/message.js +34 -6
- package/dist/index.js +7 -0
- package/dist/paths.js +4 -0
- package/dist/providers/claude-sdk-provider.js +43 -0
- package/dist/services/async-agent-parser.js +152 -0
- package/dist/services/async-agent-watcher.js +206 -0
- package/dist/services/personality.js +55 -0
- package/package.json +1 -1
- package/test/async-agent-chunk-flow.test.ts +131 -0
- package/test/async-agent-parser.test.ts +322 -0
- package/test/async-agent-watcher.test.ts +229 -0
- package/test/system-prompt-background-hint.test.ts +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.10.0] β 2026-04-13
|
|
6
|
+
|
|
7
|
+
### π Async sub-agents β main session no longer blocks during long tasks
|
|
8
|
+
|
|
9
|
+
The big architecture upgrade: Claude can now delegate long-running work (SEO audits, multi-page research, full-repo analyses) to **background** sub-agents. The main Telegram session ends quickly, the user can keep chatting, and the sub-agent's final report arrives as a separate message when ready.
|
|
10
|
+
|
|
11
|
+
A colleague flagged the underlying problem on 2026-04-13 via WhatsApp voice note: *"It's weird that the main routine crashes when the sub-agents are still running. It should just run in the background, and that should have zero impact on the main routine."* He was right. OpenClaw had this years ago because back then the SDK didn't support async; today's `@anthropic-ai/claude-agent-sdk@0.2.97` already ships `run_in_background: true` on the Agent tool β Alvin just wasn't using it.
|
|
12
|
+
|
|
13
|
+
This release closes that gap in two complementary stages, both bundled into the same v4.10.0:
|
|
14
|
+
|
|
15
|
+
#### Stage 1 β System prompt teaches Claude when to use `run_in_background`
|
|
16
|
+
|
|
17
|
+
- New `BACKGROUND_SUBAGENT_HINT` constant in `src/services/personality.ts`, injected only into SDK sessions (non-SDK providers don't have an Agent tool).
|
|
18
|
+
- The hint tells Claude: for audits / multi-page research / >2 min tasks β ALWAYS set `run_in_background: true`. After launching, end the turn promptly. The bot delivers the result automatically when done.
|
|
19
|
+
- Net effect: Claude's main turn ends in ~5 s instead of 10+ minutes. `session.isProcessing` flips to `false` quickly so the user can keep chatting.
|
|
20
|
+
|
|
21
|
+
#### Stage 2 β Async-agent watcher polls and delivers
|
|
22
|
+
|
|
23
|
+
The hard part. Three new pure modules + one new wired-up service:
|
|
24
|
+
|
|
25
|
+
- **`src/services/async-agent-parser.ts`** (NEW, pure) β two helpers:
|
|
26
|
+
- `parseAsyncLaunchedToolResult(text)` extracts `agentId` + `output_file` from the SDK's plain-text `Async agent launched successfullyβ¦` tool-result. **Important**: the `.d.ts` type in the SDK package claims this is a JSON object with `outputFile: string`. The runtime actually emits plain text with `output_file` (snake_case). Captured live via probe β see the parser test fixtures.
|
|
27
|
+
- `parseOutputFileStatus(path)` tail-reads (64 KB) the JSONL `output_file` and detects completion by finding the most-recent `assistant` message with `stop_reason: "end_turn"`. Concatenates `content[].text` blocks for the final answer. Token usage extracted from the `usage` field. Survives partial last lines, garbage lines, and tail-cuts on huge files. **19 unit tests** including a 200 KB tail-test.
|
|
28
|
+
- **`src/services/async-agent-watcher.ts`** (NEW) β the polling service. `Map<agentId, PendingAsyncAgent>` in memory, persisted to `~/.alvin-bot/state/async-agents.json` for restart catch-up (same pattern as v4.9.0 cron scheduler). Public API: `startWatcher` / `stopWatcher` / `registerPendingAgent` / `pollOnce` / `listPendingAgents`. Polls every 15 s, gives up after 12 h per-agent (timeout banner). On completion β builds a `SubAgentInfo + SubAgentResult` and hands off to the existing `subagent-delivery.ts` from v4.9.x. **7 integration tests** including bot-restart catch-up.
|
|
29
|
+
- **`src/handlers/async-agent-chunk-handler.ts`** (NEW) β bridge between provider stream chunks and the watcher. Inspects `tool_result` chunks for the async_launched payload, extracts the `description` from the immediately preceding `tool_use` chunk, registers with the watcher. **4 unit tests**.
|
|
30
|
+
- **`src/providers/claude-sdk-provider.ts`** β extended to surface `tool_result` blocks from SDK `user` messages as a new `tool_result` chunk type. Previously the provider only emitted `text` and `tool_use` chunks.
|
|
31
|
+
- **`src/providers/types.ts`** β `StreamChunk` gets two new optional fields: `toolUseId` and `toolResultContent`.
|
|
32
|
+
- **`src/handlers/message.ts`** β captures `lastAgentToolUseInput` from each `tool_use` chunk and consumes it on the immediately-following `tool_result` chunk. Tool-name match also extended from `"Task"` β `"Task" | "Agent"` (the SDK renamed it in v2.1.63).
|
|
33
|
+
- **`src/index.ts`** β `startAsyncAgentWatcher()` after the cron scheduler, `stopAsyncAgentWatcher()` in the shutdown handler.
|
|
34
|
+
- **`src/paths.ts`** β new `ASYNC_AGENTS_STATE_FILE` constant under `~/.alvin-bot/state/`.
|
|
35
|
+
|
|
36
|
+
#### Investigation artifacts (gitignored, maintainer-local)
|
|
37
|
+
|
|
38
|
+
- `docs/superpowers/plans/2026-04-13-async-subagents.md` β full TDD plan
|
|
39
|
+
- `docs/superpowers/specs/sdk-async-agent-outputfile-format.md` β live-captured SDK format spec; documents the `.d.ts` mismatch that ate ~30 minutes of debugging time
|
|
40
|
+
|
|
41
|
+
#### Testing
|
|
42
|
+
|
|
43
|
+
**237 tests total** (201 baseline + 36 new). All green. TSC clean.
|
|
44
|
+
|
|
45
|
+
- 6 system-prompt-hint tests (Stage 1)
|
|
46
|
+
- 19 parser tests (8 plain-text format + 11 JSONL format including 200 KB tail-test)
|
|
47
|
+
- 7 watcher integration tests (register, deliver, persistence, restart catch-up, timeout, concurrent agents)
|
|
48
|
+
- 4 chunk-handler unit tests
|
|
49
|
+
|
|
50
|
+
Live-verified via isolated SDK probe (`node sdk-probe.mjs` inside the repo) which confirmed the real `output_file` path and JSONL format match the parser's expectations.
|
|
51
|
+
|
|
52
|
+
#### What you'll see as a user
|
|
53
|
+
|
|
54
|
+
Send: *"Make a SEO audit of gethomes.io and alev-b.com in parallel"*
|
|
55
|
+
|
|
56
|
+
- **0 s** β Claude responds: *"Starting both audits in the background β I'll send the reports when done."* Main session **unlocks**.
|
|
57
|
+
- **1β10 min later** β You can chat about anything else. The bot answers immediately.
|
|
58
|
+
- **~13 min** (when each agent finishes) β Two separate banner messages arrive: *"β
SEO audit gethomes.io completed Β· 13m 17s Β· 2.6M in / 28k out"* + the full report body, delivered via the v4.9.3 Markdownβplain-text fallback path.
|
|
59
|
+
|
|
60
|
+
#### Non-goals
|
|
61
|
+
|
|
62
|
+
- No session-mutex refactor (Stage 3 from the analysis, out of scope here)
|
|
63
|
+
- No replacement for Alvin's existing cron `spawnSubAgent` system (different use case)
|
|
64
|
+
- No SDK upgrade beyond `0.2.97`
|
|
65
|
+
|
|
66
|
+
#### Compatibility
|
|
67
|
+
|
|
68
|
+
- `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1` in `.env` disables background mode at the SDK level β Stage 1 hint becomes inert, watcher idles; foreground behavior is restored
|
|
69
|
+
|
|
5
70
|
## [4.9.4] β 2026-04-13
|
|
6
71
|
|
|
7
72
|
### π Web UI fully decoupled from main bot β port conflicts no longer crash anything
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parseAsyncLaunchedToolResult } from "../services/async-agent-parser.js";
|
|
2
|
+
import { registerPendingAgent } from "../services/async-agent-watcher.js";
|
|
3
|
+
/**
|
|
4
|
+
* Inspect a stream chunk; if it's an Agent async_launched tool_result,
|
|
5
|
+
* register the pending agent with the watcher.
|
|
6
|
+
*
|
|
7
|
+
* Safe to call on any chunk type β non-tool_result chunks are ignored.
|
|
8
|
+
*/
|
|
9
|
+
export function handleToolResultChunk(chunk, ctx) {
|
|
10
|
+
if (chunk.type !== "tool_result")
|
|
11
|
+
return;
|
|
12
|
+
if (!chunk.toolResultContent)
|
|
13
|
+
return;
|
|
14
|
+
const info = parseAsyncLaunchedToolResult(chunk.toolResultContent);
|
|
15
|
+
if (!info)
|
|
16
|
+
return;
|
|
17
|
+
// The description and prompt come from the original tool_use input,
|
|
18
|
+
// not the tool_result text. If we don't have them (e.g. test setup
|
|
19
|
+
// forgot to pass lastToolUseInput), fall back to a generic label so
|
|
20
|
+
// the user still sees something meaningful in the delivery banner.
|
|
21
|
+
const description = ctx.lastToolUseInput?.description?.trim() ||
|
|
22
|
+
`Background agent ${info.agentId.slice(0, 8)}`;
|
|
23
|
+
const prompt = ctx.lastToolUseInput?.prompt?.trim() || "";
|
|
24
|
+
registerPendingAgent({
|
|
25
|
+
agentId: info.agentId,
|
|
26
|
+
outputFile: info.outputFile,
|
|
27
|
+
description,
|
|
28
|
+
prompt,
|
|
29
|
+
chatId: ctx.chatId,
|
|
30
|
+
userId: ctx.userId,
|
|
31
|
+
toolUseId: chunk.toolUseId ?? null,
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/handlers/message.js
CHANGED
|
@@ -15,6 +15,7 @@ import { trackUsage } from "../services/usage-tracker.js";
|
|
|
15
15
|
import { emitUserMessage as broadcastUserMessage, emitResponseStart as broadcastResponseStart, emitResponseDelta as broadcastResponseDelta, emitResponseDone as broadcastResponseDone, } from "../services/broadcast.js";
|
|
16
16
|
import { t } from "../i18n.js";
|
|
17
17
|
import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
|
|
18
|
+
import { handleToolResultChunk } from "./async-agent-chunk-handler.js";
|
|
18
19
|
/**
|
|
19
20
|
* Stuck-only timeout β NO absolute cap.
|
|
20
21
|
*
|
|
@@ -279,6 +280,11 @@ export async function handleMessage(ctx) {
|
|
|
279
280
|
};
|
|
280
281
|
// Stream response from provider (with fallback)
|
|
281
282
|
let lastBroadcastLen = 0;
|
|
283
|
+
// Captured during tool_use chunks; consumed by tool_result chunks so
|
|
284
|
+
// the async-agent watcher can label pending agents with their human-
|
|
285
|
+
// readable description (which only appears in the tool_use input,
|
|
286
|
+
// not in the tool_result text). See Fix #17 Stage 2.
|
|
287
|
+
let lastAgentToolUseInput;
|
|
282
288
|
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
283
289
|
// Any chunk is progress β reset the stuck timer.
|
|
284
290
|
resetStuckTimer();
|
|
@@ -309,13 +315,14 @@ export async function handleMessage(ctx) {
|
|
|
309
315
|
if (chunk.toolName) {
|
|
310
316
|
session.toolUseCount++;
|
|
311
317
|
const icon = TOOL_ICONS[chunk.toolName] || "π§";
|
|
312
|
-
// Special treatment for Claude's SDK-internal Task tool:
|
|
318
|
+
// Special treatment for Claude's SDK-internal Task/Agent tool:
|
|
313
319
|
// track how many sub-tasks Claude delegated and surface the
|
|
314
320
|
// task description in the status line so the user sees WHAT
|
|
315
|
-
// is being delegated, not just "Taskβ¦".
|
|
316
|
-
|
|
321
|
+
// is being delegated, not just "Taskβ¦". The tool was renamed
|
|
322
|
+
// from "Task" to "Agent" in Claude Code v2.1.63 β match both.
|
|
323
|
+
if (chunk.toolName === "Task" || chunk.toolName === "Agent") {
|
|
317
324
|
session.sdkSubTaskCount++;
|
|
318
|
-
let label =
|
|
325
|
+
let label = chunk.toolName;
|
|
319
326
|
if (chunk.toolInput) {
|
|
320
327
|
try {
|
|
321
328
|
const parsed = JSON.parse(chunk.toolInput);
|
|
@@ -324,11 +331,18 @@ export async function handleMessage(ctx) {
|
|
|
324
331
|
const desc = parsed.description.length > 80
|
|
325
332
|
? parsed.description.slice(0, 80) + "β¦"
|
|
326
333
|
: parsed.description;
|
|
327
|
-
label =
|
|
334
|
+
label = `${chunk.toolName}: ${desc}`;
|
|
328
335
|
}
|
|
329
336
|
else if (parsed.subagent_type) {
|
|
330
|
-
label =
|
|
337
|
+
label = `${chunk.toolName} (${parsed.subagent_type})`;
|
|
331
338
|
}
|
|
339
|
+
// Capture the description+prompt for the upcoming
|
|
340
|
+
// tool_result. Used by Fix #17 Stage 2 to label
|
|
341
|
+
// background agents in the watcher's delivery banner.
|
|
342
|
+
lastAgentToolUseInput = {
|
|
343
|
+
description: parsed.description,
|
|
344
|
+
prompt: parsed.prompt,
|
|
345
|
+
};
|
|
332
346
|
}
|
|
333
347
|
catch {
|
|
334
348
|
// not JSON β keep generic label
|
|
@@ -341,6 +355,20 @@ export async function handleMessage(ctx) {
|
|
|
341
355
|
}
|
|
342
356
|
}
|
|
343
357
|
break;
|
|
358
|
+
case "tool_result":
|
|
359
|
+
// Fix #17 Stage 2: detect Agent async_launched payloads and
|
|
360
|
+
// hand them off to the async-agent watcher. The watcher will
|
|
361
|
+
// poll the outputFile and deliver the result as a separate
|
|
362
|
+
// Telegram message when the background agent finishes.
|
|
363
|
+
handleToolResultChunk(chunk, {
|
|
364
|
+
chatId: ctx.chat.id,
|
|
365
|
+
userId,
|
|
366
|
+
lastToolUseInput: lastAgentToolUseInput,
|
|
367
|
+
});
|
|
368
|
+
// Reset the captured input β only the immediately following
|
|
369
|
+
// tool_result should consume it.
|
|
370
|
+
lastAgentToolUseInput = undefined;
|
|
371
|
+
break;
|
|
344
372
|
case "done":
|
|
345
373
|
if (chunk.sessionId)
|
|
346
374
|
session.sessionId = chunk.sessionId;
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,7 @@ import { loadPlugins, registerPluginCommands, unloadPlugins } from "./services/p
|
|
|
78
78
|
import { initMCP, disconnectMCP, hasMCPConfig } from "./services/mcp.js";
|
|
79
79
|
import { startWebServer, stopWebServer } from "./web/server.js";
|
|
80
80
|
import { startScheduler, stopScheduler, setNotifyCallback } from "./services/cron.js";
|
|
81
|
+
import { startWatcher as startAsyncAgentWatcher, stopWatcher as stopAsyncAgentWatcher } from "./services/async-agent-watcher.js";
|
|
81
82
|
import { startSessionCleanup, stopSessionCleanup } from "./services/session.js";
|
|
82
83
|
import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
|
|
83
84
|
import { discoverTools } from "./services/tool-discovery.js";
|
|
@@ -254,6 +255,7 @@ const shutdown = async () => {
|
|
|
254
255
|
await cancelAllSubAgents(true);
|
|
255
256
|
stopWatchdog();
|
|
256
257
|
stopScheduler();
|
|
258
|
+
stopAsyncAgentWatcher();
|
|
257
259
|
stopSessionCleanup();
|
|
258
260
|
if (queueInterval)
|
|
259
261
|
clearInterval(queueInterval);
|
|
@@ -420,6 +422,11 @@ setNotifyCallback(async (target, text) => {
|
|
|
420
422
|
enqueue(target.platform, String(target.chatId), text);
|
|
421
423
|
});
|
|
422
424
|
startScheduler();
|
|
425
|
+
// Start the async-agent watcher (Fix #17 Stage 2). Polls outputFiles
|
|
426
|
+
// of background sub-agents Claude launched with run_in_background and
|
|
427
|
+
// delivers their completed reports as separate Telegram messages.
|
|
428
|
+
// Loads any persisted pending agents from disk on boot.
|
|
429
|
+
startAsyncAgentWatcher();
|
|
423
430
|
// Session memory hygiene: purge sessions idle > 7 days (configurable via
|
|
424
431
|
// ALVIN_SESSION_TTL_DAYS). Never touches active sessions β see session.ts.
|
|
425
432
|
startSessionCleanup();
|
package/dist/paths.js
CHANGED
|
@@ -62,6 +62,10 @@ export const SUDO_ENC_FILE = resolve(DATA_DIR, "data", ".sudo-enc");
|
|
|
62
62
|
export const SUDO_KEY_FILE = resolve(DATA_DIR, "data", ".sudo-key");
|
|
63
63
|
/** backups/ β Config snapshots */
|
|
64
64
|
export const BACKUP_DIR = resolve(DATA_DIR, "backups");
|
|
65
|
+
/** state/async-agents.json β Pending background SDK agents (Fix #17 Stage 2).
|
|
66
|
+
* See src/services/async-agent-watcher.ts for the watcher that polls and
|
|
67
|
+
* delivers these. Survives bot restarts. */
|
|
68
|
+
export const ASYNC_AGENTS_STATE_FILE = resolve(DATA_DIR, "state", "async-agents.json");
|
|
65
69
|
/** soul.md β Bot personality */
|
|
66
70
|
export const SOUL_FILE = resolve(DATA_DIR, "soul.md");
|
|
67
71
|
/** tools.md β Custom tool definitions (Markdown) */
|
|
@@ -186,6 +186,49 @@ export class ClaudeSDKProvider {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
+
// User message β tool_results from the Claude API arrive as user
|
|
190
|
+
// messages in the SDK protocol. We surface tool_result blocks as
|
|
191
|
+
// chunks so the message handler can detect Agent async_launched
|
|
192
|
+
// payloads and register them with the watcher (Fix #17 Stage 2).
|
|
193
|
+
if (message.type === "user") {
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
195
|
+
const userMsg = message;
|
|
196
|
+
const content = userMsg.message?.content;
|
|
197
|
+
if (Array.isArray(content)) {
|
|
198
|
+
for (const block of content) {
|
|
199
|
+
if (block &&
|
|
200
|
+
typeof block === "object" &&
|
|
201
|
+
block.type === "tool_result" &&
|
|
202
|
+
typeof block.tool_use_id === "string") {
|
|
203
|
+
// The `content` field on a tool_result block can be a
|
|
204
|
+
// plain string OR an array of content blocks. Normalize
|
|
205
|
+
// to a single string so the chunk consumer doesn't need
|
|
206
|
+
// to know about the SDK shape.
|
|
207
|
+
let contentText = "";
|
|
208
|
+
if (typeof block.content === "string") {
|
|
209
|
+
contentText = block.content;
|
|
210
|
+
}
|
|
211
|
+
else if (Array.isArray(block.content)) {
|
|
212
|
+
contentText = block.content
|
|
213
|
+
.map((c) => {
|
|
214
|
+
if (c && typeof c === "object" && "text" in c) {
|
|
215
|
+
const t = c.text;
|
|
216
|
+
return typeof t === "string" ? t : "";
|
|
217
|
+
}
|
|
218
|
+
return "";
|
|
219
|
+
})
|
|
220
|
+
.join("");
|
|
221
|
+
}
|
|
222
|
+
yield {
|
|
223
|
+
type: "tool_result",
|
|
224
|
+
toolUseId: block.tool_use_id,
|
|
225
|
+
toolResultContent: contentText,
|
|
226
|
+
sessionId: capturedSessionId,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
189
232
|
// Result β done (extract full usage including cache tokens)
|
|
190
233
|
if (message.type === "result") {
|
|
191
234
|
const resultMsg = message;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the async-agent watcher (Fix #17 Stage 2).
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, both pure (the file read in parseOutputFileStatus
|
|
5
|
+
* is pure-by-input β same path returns the same shape at that moment in
|
|
6
|
+
* time, no mutation, no side effects):
|
|
7
|
+
*
|
|
8
|
+
* 1. Parse the SDK's plain-text "Async agent launched successfully" tool
|
|
9
|
+
* result into a structured AsyncLaunchedInfo.
|
|
10
|
+
* 2. Read the tail of an outputFile JSONL stream and decide whether the
|
|
11
|
+
* sub-agent is still running, completed, or failed.
|
|
12
|
+
*
|
|
13
|
+
* Format details captured live from @anthropic-ai/claude-agent-sdk@0.2.97
|
|
14
|
+
* on 2026-04-13. See docs/superpowers/specs/sdk-async-agent-outputfile-format.md
|
|
15
|
+
* for the full investigation notes β the SDK's .d.ts shape DOES NOT match
|
|
16
|
+
* what the runtime actually emits, which is why the contract is pinned by
|
|
17
|
+
* tests against real fixtures.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from "fs";
|
|
20
|
+
// ββ Tool-result text parser ββββββββββββββββββββββββββββββββββββββββββ
|
|
21
|
+
/**
|
|
22
|
+
* Parse the plain-text SDK tool-result content for an `Agent` call with
|
|
23
|
+
* `run_in_background: true`. The format is documented in the spec doc
|
|
24
|
+
* β it's NOT JSON, and the field is `output_file` (snake_case).
|
|
25
|
+
*
|
|
26
|
+
* Accepts:
|
|
27
|
+
* - the raw text string
|
|
28
|
+
* - an Anthropic SDK content array `[{type: "text", text: "..."}]`
|
|
29
|
+
* - null/undefined/non-string β returns null
|
|
30
|
+
*/
|
|
31
|
+
export function parseAsyncLaunchedToolResult(raw) {
|
|
32
|
+
// Normalize to a string
|
|
33
|
+
let text;
|
|
34
|
+
if (raw == null)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof raw === "string") {
|
|
37
|
+
text = raw;
|
|
38
|
+
}
|
|
39
|
+
else if (Array.isArray(raw)) {
|
|
40
|
+
// SDK content blocks shape
|
|
41
|
+
text = raw
|
|
42
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
43
|
+
.join("");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!text || text.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
// Quick gate: avoid expensive matching on non-async tool results
|
|
51
|
+
if (!text.includes("Async agent launched successfully"))
|
|
52
|
+
return null;
|
|
53
|
+
// agentId line: "agentId: <id> (...)" β capture everything up to first space/paren
|
|
54
|
+
const agentMatch = text.match(/agentId:\s*(\S+)/);
|
|
55
|
+
if (!agentMatch)
|
|
56
|
+
return null;
|
|
57
|
+
const agentId = agentMatch[1].trim();
|
|
58
|
+
if (!agentId)
|
|
59
|
+
return null;
|
|
60
|
+
// output_file line: "output_file: <path>" β path may contain spaces, capture
|
|
61
|
+
// until end of line (the path is always on its own line in real output).
|
|
62
|
+
const outFileMatch = text.match(/output_file:\s*(.+?)\s*(?:\n|$)/);
|
|
63
|
+
if (!outFileMatch)
|
|
64
|
+
return null;
|
|
65
|
+
const outputFile = outFileMatch[1].trim();
|
|
66
|
+
if (!outputFile)
|
|
67
|
+
return null;
|
|
68
|
+
return { agentId, outputFile };
|
|
69
|
+
}
|
|
70
|
+
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
71
|
+
/**
|
|
72
|
+
* Read the tail of an SDK background-agent outputFile and decide what
|
|
73
|
+
* state the sub-agent is in. See spec doc for the JSONL format. We only
|
|
74
|
+
* read the last `maxTailBytes` of the file because long-running agents
|
|
75
|
+
* (SEO audits etc.) can produce hundreds of KB of intermediate JSONL.
|
|
76
|
+
*/
|
|
77
|
+
export async function parseOutputFileStatus(path, opts = {}) {
|
|
78
|
+
const maxTailBytes = opts.maxTailBytes ?? DEFAULT_TAIL_BYTES;
|
|
79
|
+
let stat;
|
|
80
|
+
try {
|
|
81
|
+
stat = await fs.stat(path);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { state: "missing" };
|
|
85
|
+
}
|
|
86
|
+
if (stat.size === 0) {
|
|
87
|
+
// Empty file is functionally the same as missing β we keep polling.
|
|
88
|
+
return { state: "missing" };
|
|
89
|
+
}
|
|
90
|
+
// Tail-read the last maxTailBytes
|
|
91
|
+
let buf;
|
|
92
|
+
let fh;
|
|
93
|
+
try {
|
|
94
|
+
fh = await fs.open(path, "r");
|
|
95
|
+
const readSize = Math.min(stat.size, maxTailBytes);
|
|
96
|
+
buf = Buffer.alloc(readSize);
|
|
97
|
+
await fh.read(buf, 0, readSize, stat.size - readSize);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { state: "missing" };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
try {
|
|
104
|
+
await fh?.close();
|
|
105
|
+
}
|
|
106
|
+
catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
const text = buf.toString("utf-8");
|
|
109
|
+
// Split into lines. If we tail-read into the middle of a line (size >
|
|
110
|
+
// maxTailBytes), drop the first line because it's almost certainly
|
|
111
|
+
// truncated. The trailing line is dropped if there's no newline β it's
|
|
112
|
+
// the line being written right now.
|
|
113
|
+
const lines = text.split("\n");
|
|
114
|
+
const tailIsMidLine = stat.size > maxTailBytes;
|
|
115
|
+
const headIncomplete = tailIsMidLine ? 1 : 0;
|
|
116
|
+
const trailIncomplete = text.endsWith("\n") ? 0 : 1;
|
|
117
|
+
const usable = lines
|
|
118
|
+
.slice(headIncomplete, lines.length - (trailIncomplete > 0 ? trailIncomplete : 0))
|
|
119
|
+
.filter((l) => l.length > 0);
|
|
120
|
+
// Walk backwards to find the most-recent assistant message with end_turn
|
|
121
|
+
for (let i = usable.length - 1; i >= 0; i--) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(usable[i]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Garbage line β skip
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (parsed.type === "assistant" &&
|
|
131
|
+
parsed.message?.stop_reason === "end_turn" &&
|
|
132
|
+
Array.isArray(parsed.message.content)) {
|
|
133
|
+
const finalText = parsed.message.content
|
|
134
|
+
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
|
135
|
+
.map((c) => c.text)
|
|
136
|
+
.join("\n\n");
|
|
137
|
+
const usage = parsed.message.usage;
|
|
138
|
+
return {
|
|
139
|
+
state: "completed",
|
|
140
|
+
output: finalText,
|
|
141
|
+
tokensUsed: usage
|
|
142
|
+
? {
|
|
143
|
+
input: usage.input_tokens ?? 0,
|
|
144
|
+
output: usage.output_tokens ?? 0,
|
|
145
|
+
}
|
|
146
|
+
: undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// No completion marker found β still running.
|
|
151
|
+
return { state: "running", size: stat.size };
|
|
152
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async Sub-Agent Watcher (Fix #17 Stage 2)
|
|
3
|
+
*
|
|
4
|
+
* Tracks pending background sub-agents that Claude launched with
|
|
5
|
+
* `run_in_background: true`. Polls each agent's outputFile every
|
|
6
|
+
* POLL_INTERVAL_MS, detects completion (success/failure/timeout),
|
|
7
|
+
* and delivers the final result as a separate Telegram message via
|
|
8
|
+
* the existing subagent-delivery.ts pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Persistence: pending agents survive bot restarts via
|
|
11
|
+
* ~/.alvin-bot/state/async-agents.json. On boot, startWatcher() loads
|
|
12
|
+
* the file and resumes polling β same catchup pattern as the v4.9.0
|
|
13
|
+
* cron scheduler.
|
|
14
|
+
*
|
|
15
|
+
* Why this exists: Claude's Agent tool defaults to synchronous, which
|
|
16
|
+
* blocks the main Telegram session for 10+ minutes during long audits.
|
|
17
|
+
* Stage 1 of the fix tells Claude to use run_in_background; Stage 2
|
|
18
|
+
* (this file) catches the resulting outputFile and delivers the result
|
|
19
|
+
* when ready, so the user can keep chatting while the agent works.
|
|
20
|
+
*
|
|
21
|
+
* See docs/superpowers/plans/2026-04-13-async-subagents.md for the
|
|
22
|
+
* full plan and docs/superpowers/specs/sdk-async-agent-outputfile-format.md
|
|
23
|
+
* for the JSONL format details.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import { dirname } from "path";
|
|
27
|
+
import { parseOutputFileStatus } from "./async-agent-parser.js";
|
|
28
|
+
import { ASYNC_AGENTS_STATE_FILE } from "../paths.js";
|
|
29
|
+
/** How often the polling loop runs against each pending agent. */
|
|
30
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
31
|
+
/** Hard ceiling per agent β 12h. After this, give up and deliver
|
|
32
|
+
* a timeout banner. SEO audits historically take ~13 min, so 12h
|
|
33
|
+
* is absurdly generous and protects against state-file growth. */
|
|
34
|
+
const MAX_AGENT_AGE_MS = 12 * 60 * 60 * 1000;
|
|
35
|
+
// ββ Module state ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
let pollTimer = null;
|
|
38
|
+
let started = false;
|
|
39
|
+
// ββ Persistence βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
40
|
+
function loadFromDisk() {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(ASYNC_AGENTS_STATE_FILE, "utf-8");
|
|
43
|
+
const arr = JSON.parse(raw);
|
|
44
|
+
if (!Array.isArray(arr))
|
|
45
|
+
return;
|
|
46
|
+
for (const entry of arr) {
|
|
47
|
+
if (typeof entry?.agentId === "string" && typeof entry?.outputFile === "string") {
|
|
48
|
+
pending.set(entry.agentId, entry);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No state file yet β fresh start. Not an error.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function saveToDisk() {
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(dirname(ASYNC_AGENTS_STATE_FILE), { recursive: true });
|
|
59
|
+
fs.writeFileSync(ASYNC_AGENTS_STATE_FILE, JSON.stringify([...pending.values()], null, 2), "utf-8");
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error("[async-watcher] failed to persist state:", err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
66
|
+
/**
|
|
67
|
+
* Register a new async agent that Claude just launched. Persists
|
|
68
|
+
* immediately so a crash right after registration still delivers
|
|
69
|
+
* the result on the next boot.
|
|
70
|
+
*/
|
|
71
|
+
export function registerPendingAgent(input) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const entry = {
|
|
74
|
+
agentId: input.agentId,
|
|
75
|
+
outputFile: input.outputFile,
|
|
76
|
+
description: input.description,
|
|
77
|
+
prompt: input.prompt,
|
|
78
|
+
chatId: input.chatId,
|
|
79
|
+
userId: input.userId,
|
|
80
|
+
startedAt: now,
|
|
81
|
+
lastCheckedAt: 0,
|
|
82
|
+
giveUpAt: input.giveUpAt ?? now + MAX_AGENT_AGE_MS,
|
|
83
|
+
toolUseId: input.toolUseId,
|
|
84
|
+
};
|
|
85
|
+
pending.set(input.agentId, entry);
|
|
86
|
+
saveToDisk();
|
|
87
|
+
}
|
|
88
|
+
/** Returns a snapshot of in-memory pending agents (for /subagents + diagnostics). */
|
|
89
|
+
export function listPendingAgents() {
|
|
90
|
+
return [...pending.values()];
|
|
91
|
+
}
|
|
92
|
+
/** Start the polling loop. Idempotent. Loads any persisted state from disk. */
|
|
93
|
+
export function startWatcher() {
|
|
94
|
+
if (started)
|
|
95
|
+
return;
|
|
96
|
+
started = true;
|
|
97
|
+
loadFromDisk();
|
|
98
|
+
pollTimer = setInterval(() => {
|
|
99
|
+
pollOnce().catch((err) => console.error("[async-watcher] poll cycle failed:", err));
|
|
100
|
+
}, POLL_INTERVAL_MS);
|
|
101
|
+
console.log(`β³ Async-agent watcher started (${pending.size} pending, ${POLL_INTERVAL_MS / 1000}s interval)`);
|
|
102
|
+
}
|
|
103
|
+
/** Stop the polling loop. Idempotent. */
|
|
104
|
+
export function stopWatcher() {
|
|
105
|
+
if (pollTimer)
|
|
106
|
+
clearInterval(pollTimer);
|
|
107
|
+
pollTimer = null;
|
|
108
|
+
started = false;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run one poll cycle: check every pending agent, deliver the completed
|
|
112
|
+
* ones, drop them from the in-memory + on-disk state. Exported for
|
|
113
|
+
* tests; production uses the setInterval from startWatcher().
|
|
114
|
+
*/
|
|
115
|
+
export async function pollOnce() {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const toRemove = [];
|
|
118
|
+
for (const entry of pending.values()) {
|
|
119
|
+
entry.lastCheckedAt = now;
|
|
120
|
+
// Timeout check first β if the agent is past its giveUpAt, give up
|
|
121
|
+
// regardless of whether the file shows progress.
|
|
122
|
+
if (now >= entry.giveUpAt) {
|
|
123
|
+
await deliverAsFailure(entry, "timeout", "Agent ran longer than 12h β giving up");
|
|
124
|
+
toRemove.push(entry.agentId);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const status = await parseOutputFileStatus(entry.outputFile);
|
|
128
|
+
if (status.state === "completed") {
|
|
129
|
+
await deliverAsCompleted(entry, status.output, status.tokensUsed);
|
|
130
|
+
toRemove.push(entry.agentId);
|
|
131
|
+
}
|
|
132
|
+
else if (status.state === "failed") {
|
|
133
|
+
await deliverAsFailure(entry, "error", status.error);
|
|
134
|
+
toRemove.push(entry.agentId);
|
|
135
|
+
}
|
|
136
|
+
// running / missing β keep polling next cycle
|
|
137
|
+
}
|
|
138
|
+
if (toRemove.length > 0) {
|
|
139
|
+
for (const id of toRemove)
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
saveToDisk();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ββ Delivery helpers ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
145
|
+
async function deliverAsCompleted(entry, output, tokensUsed) {
|
|
146
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
147
|
+
const info = {
|
|
148
|
+
id: entry.agentId,
|
|
149
|
+
name: entry.description,
|
|
150
|
+
status: "completed",
|
|
151
|
+
startedAt: entry.startedAt,
|
|
152
|
+
source: "cron", // Reuse cron banner format β fits async background agents.
|
|
153
|
+
depth: 0,
|
|
154
|
+
parentChatId: entry.chatId,
|
|
155
|
+
};
|
|
156
|
+
const result = {
|
|
157
|
+
id: entry.agentId,
|
|
158
|
+
name: entry.description,
|
|
159
|
+
status: "completed",
|
|
160
|
+
output,
|
|
161
|
+
tokensUsed: tokensUsed ?? { input: 0, output: 0 },
|
|
162
|
+
duration: Date.now() - entry.startedAt,
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
await deliverSubAgentResult(info, result);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.error(`[async-watcher] delivery failed for ${entry.agentId}:`, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function deliverAsFailure(entry, status, error) {
|
|
172
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
173
|
+
const info = {
|
|
174
|
+
id: entry.agentId,
|
|
175
|
+
name: entry.description,
|
|
176
|
+
status,
|
|
177
|
+
startedAt: entry.startedAt,
|
|
178
|
+
source: "cron",
|
|
179
|
+
depth: 0,
|
|
180
|
+
parentChatId: entry.chatId,
|
|
181
|
+
};
|
|
182
|
+
const result = {
|
|
183
|
+
id: entry.agentId,
|
|
184
|
+
name: entry.description,
|
|
185
|
+
status,
|
|
186
|
+
output: "",
|
|
187
|
+
tokensUsed: { input: 0, output: 0 },
|
|
188
|
+
duration: Date.now() - entry.startedAt,
|
|
189
|
+
error,
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
await deliverSubAgentResult(info, result);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error(`[async-watcher] failure delivery failed for ${entry.agentId}:`, err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ββ Test helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
199
|
+
/** Test-only: drop in-memory state. Doesn't touch disk. */
|
|
200
|
+
export function __resetForTest() {
|
|
201
|
+
pending.clear();
|
|
202
|
+
if (pollTimer)
|
|
203
|
+
clearInterval(pollTimer);
|
|
204
|
+
pollTimer = null;
|
|
205
|
+
started = false;
|
|
206
|
+
}
|