pikiclaw 0.3.49 → 0.3.50
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/dist/agent/drivers/claude-tui.js +315 -6
- package/dist/agent/drivers/claude.js +226 -18
- package/dist/agent/index.js +1 -1
- package/dist/agent/utils.js +40 -0
- package/dist/bot/bot.js +78 -5
- package/dist/bot/human-loop.js +45 -0
- package/dist/bot/render-shared.js +50 -14
- package/dist/bot/streaming.js +92 -5
- package/dist/channels/feishu/bot.js +130 -30
- package/dist/channels/feishu/channel.js +18 -3
- package/dist/channels/feishu/render.js +23 -1
- package/dist/channels/telegram/bot.js +159 -37
- package/dist/channels/telegram/channel.js +6 -1
- package/dist/channels/telegram/render.js +26 -1
- package/dist/channels/weixin/bot.js +64 -2
- package/dist/core/config/user-config.js +36 -0
- package/dist/core/utils.js +35 -0
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ import { createInterface } from 'node:readline';
|
|
|
8
8
|
import { registerDriver } from '../driver.js';
|
|
9
9
|
import {
|
|
10
10
|
// shared helpers
|
|
11
|
-
Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, emitSessionIdUpdate, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, attachAgentImage, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, collapseSkillPrompt, } from '../index.js';
|
|
11
|
+
Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, detectClaudeApiError, isRetryableClaudeApiError, emitSessionIdUpdate, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, attachAgentImage, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, collapseSkillPrompt, } from '../index.js';
|
|
12
12
|
import { AGENT_STREAM_HARD_KILL_GRACE_MS, AGENT_GRACEFUL_ABORT_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
|
|
13
13
|
import { terminateProcessTree } from '../../core/process-control.js';
|
|
14
14
|
import { getHome, IS_MAC, encodePathAsDirName } from '../../core/platform.js';
|
|
@@ -270,6 +270,48 @@ function accumulateClaudeImagesFromContent(content, s) {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Read the server-assigned task id from a TaskCreate tool_result. Claude
|
|
275
|
+
* surfaces it via the structured `ev.toolUseResult.task.id` companion field,
|
|
276
|
+
* with a textual fallback ("Task #N created successfully: …") that we parse
|
|
277
|
+
* if the structured form is missing.
|
|
278
|
+
*/
|
|
279
|
+
function readClaudeTaskCreateId(ev, block) {
|
|
280
|
+
const structured = ev?.toolUseResult?.task?.id;
|
|
281
|
+
if (structured != null && String(structured).trim())
|
|
282
|
+
return String(structured).trim();
|
|
283
|
+
const content = block?.content;
|
|
284
|
+
if (typeof content === 'string') {
|
|
285
|
+
const match = content.match(/Task #(\d+)/);
|
|
286
|
+
if (match)
|
|
287
|
+
return match[1];
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Rebuild s.plan from the accumulated TaskCreate / TaskUpdate state so the
|
|
293
|
+
* dashboard + IM plan card show the canonical Claude Code 2.x task progress.
|
|
294
|
+
* Order follows insertion order (matches the on-screen Claude task list).
|
|
295
|
+
*/
|
|
296
|
+
function rebuildClaudePlanFromTasks(s) {
|
|
297
|
+
if (!s.claudeTaskOrder?.length) {
|
|
298
|
+
// Nothing to render — leave s.plan alone so TodoWrite-era data (if any)
|
|
299
|
+
// doesn't get clobbered by an empty rebuild.
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const steps = [];
|
|
303
|
+
for (const id of s.claudeTaskOrder) {
|
|
304
|
+
const task = s.claudeTaskList.get(id);
|
|
305
|
+
if (!task)
|
|
306
|
+
continue;
|
|
307
|
+
const lowered = String(task.status || '').toLowerCase();
|
|
308
|
+
const status = lowered === 'completed' ? 'completed'
|
|
309
|
+
: lowered === 'in_progress' || lowered === 'inprogress' ? 'inProgress'
|
|
310
|
+
: 'pending';
|
|
311
|
+
steps.push({ step: task.subject, status });
|
|
312
|
+
}
|
|
313
|
+
s.plan = { explanation: null, steps };
|
|
314
|
+
}
|
|
273
315
|
export function claudeParse(ev, s) {
|
|
274
316
|
const t = ev.type || '';
|
|
275
317
|
// Sub-agent events (Task tool spawns a child agent) carry parent_tool_use_id
|
|
@@ -374,7 +416,7 @@ export function claudeParse(ev, s) {
|
|
|
374
416
|
if (!toolId || s.seenClaudeToolIds.has(toolId))
|
|
375
417
|
continue;
|
|
376
418
|
const toolName = String(block?.name || 'Tool').trim() || 'Tool';
|
|
377
|
-
// TodoWrite → update plan instead of adding activity noise
|
|
419
|
+
// TodoWrite → update plan instead of adding activity noise (Claude Code 1.x)
|
|
378
420
|
if (toolName === 'TodoWrite') {
|
|
379
421
|
const plan = parseTodoWriteAsPlan(block?.input);
|
|
380
422
|
if (plan)
|
|
@@ -383,6 +425,38 @@ export function claudeParse(ev, s) {
|
|
|
383
425
|
s.claudeToolsById.set(toolId, { name: toolName, summary: 'Update plan' });
|
|
384
426
|
continue;
|
|
385
427
|
}
|
|
428
|
+
// TaskCreate / TaskUpdate → 2.x plan tools. Same intent as TodoWrite, but
|
|
429
|
+
// emitted one task at a time. Buffer TaskCreate inputs until the matching
|
|
430
|
+
// tool_result arrives with the server-assigned id; apply TaskUpdate status
|
|
431
|
+
// changes against the running map. Both rebuild s.plan so the dashboard /
|
|
432
|
+
// IM plan card keeps surfacing total + current progress.
|
|
433
|
+
if (toolName === 'TaskCreate') {
|
|
434
|
+
const subject = typeof block?.input?.subject === 'string' ? block.input.subject.trim() : '';
|
|
435
|
+
if (subject)
|
|
436
|
+
s.pendingClaudeTaskCreates.set(toolId, { subject });
|
|
437
|
+
s.seenClaudeToolIds.add(toolId);
|
|
438
|
+
s.claudeToolsById.set(toolId, { name: toolName, summary: subject ? `Create task: ${subject}` : 'Create task' });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (toolName === 'TaskUpdate') {
|
|
442
|
+
const taskId = String(block?.input?.taskId ?? '').trim();
|
|
443
|
+
const rawStatus = String(block?.input?.status ?? '').trim().toLowerCase();
|
|
444
|
+
if (taskId) {
|
|
445
|
+
if (rawStatus === 'deleted') {
|
|
446
|
+
s.claudeTaskList.delete(taskId);
|
|
447
|
+
s.claudeTaskOrder = s.claudeTaskOrder.filter((id) => id !== taskId);
|
|
448
|
+
}
|
|
449
|
+
else if (rawStatus) {
|
|
450
|
+
const existing = s.claudeTaskList.get(taskId);
|
|
451
|
+
if (existing)
|
|
452
|
+
existing.status = rawStatus;
|
|
453
|
+
}
|
|
454
|
+
rebuildClaudePlanFromTasks(s);
|
|
455
|
+
}
|
|
456
|
+
s.seenClaudeToolIds.add(toolId);
|
|
457
|
+
s.claudeToolsById.set(toolId, { name: toolName, summary: `Update task ${taskId || '?'} → ${rawStatus || 'unknown'}` });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
386
460
|
// Task → represents a sub-agent invocation. Carve it out as its own
|
|
387
461
|
// streamed unit so the child's tool stream and model don't bleed into
|
|
388
462
|
// the parent's activity card.
|
|
@@ -418,10 +492,37 @@ export function claudeParse(ev, s) {
|
|
|
418
492
|
const toolResults = contents.filter((b) => b?.type === 'tool_result');
|
|
419
493
|
for (const block of toolResults) {
|
|
420
494
|
const toolId = String(block?.tool_use_id || '').trim();
|
|
495
|
+
// Dedup against tool_results already pushed by the TUI hook stream —
|
|
496
|
+
// PreToolUse / PostToolUse arrive in real time, JSONL eventually
|
|
497
|
+
// delivers the same events at end-of-turn and would otherwise re-push
|
|
498
|
+
// each summary into activity / re-process TaskCreate's plan entry.
|
|
499
|
+
if (toolId && s.seenClaudeToolResultIds?.has(toolId))
|
|
500
|
+
continue;
|
|
501
|
+
if (toolId) {
|
|
502
|
+
if (!s.seenClaudeToolResultIds)
|
|
503
|
+
s.seenClaudeToolResultIds = new Set();
|
|
504
|
+
s.seenClaudeToolResultIds.add(toolId);
|
|
505
|
+
}
|
|
421
506
|
const tool = toolId ? s.claudeToolsById.get(toolId) : undefined;
|
|
422
|
-
// Skip TodoWrite results from activity — plan
|
|
507
|
+
// Skip TodoWrite / TaskCreate / TaskUpdate results from activity — plan
|
|
508
|
+
// card handles them. TaskCreate's tool_result carries the assigned task
|
|
509
|
+
// id, which we splice into the running task list before skipping.
|
|
423
510
|
if (tool?.name === 'TodoWrite')
|
|
424
511
|
continue;
|
|
512
|
+
if (tool?.name === 'TaskCreate') {
|
|
513
|
+
const pending = toolId ? s.pendingClaudeTaskCreates.get(toolId) : undefined;
|
|
514
|
+
const assignedId = readClaudeTaskCreateId(ev, block);
|
|
515
|
+
if (pending && assignedId) {
|
|
516
|
+
s.pendingClaudeTaskCreates.delete(toolId);
|
|
517
|
+
if (!s.claudeTaskList.has(assignedId))
|
|
518
|
+
s.claudeTaskOrder.push(assignedId);
|
|
519
|
+
s.claudeTaskList.set(assignedId, { subject: pending.subject, status: 'pending' });
|
|
520
|
+
rebuildClaudePlanFromTasks(s);
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (tool?.name === 'TaskUpdate')
|
|
525
|
+
continue;
|
|
425
526
|
// Sub-agent tool_result closes out the sub-agent's lifecycle — flip its
|
|
426
527
|
// status and skip the regular activity append (the sub-agent card carries
|
|
427
528
|
// it). The result content text is the sub-agent's full response which
|
|
@@ -517,6 +618,17 @@ export function createClaudeStreamState(opts) {
|
|
|
517
618
|
activity: '',
|
|
518
619
|
recentActivity: [],
|
|
519
620
|
plan: null,
|
|
621
|
+
// Claude Code 2.x replaced the single `TodoWrite` plan tool with two
|
|
622
|
+
// separate tools — `TaskCreate` (one task per call, server-assigned id)
|
|
623
|
+
// and `TaskUpdate` (taskId + status). We maintain an ordered map and
|
|
624
|
+
// rebuild s.plan whenever either fires so the dashboard / IM plan card
|
|
625
|
+
// keeps showing total / current progress just like the TodoWrite era.
|
|
626
|
+
claudeTaskList: new Map(),
|
|
627
|
+
claudeTaskOrder: [],
|
|
628
|
+
/** Pending TaskCreate tool_uses indexed by tool_use id — the input
|
|
629
|
+
* carries the subject but Claude assigns the numeric task id only in
|
|
630
|
+
* the matching tool_result, so we have to bridge the two halves. */
|
|
631
|
+
pendingClaudeTaskCreates: new Map(),
|
|
520
632
|
claudeToolsById: new Map(),
|
|
521
633
|
seenClaudeToolIds: new Set(),
|
|
522
634
|
subAgents: new Map(),
|
|
@@ -811,6 +923,19 @@ async function doClaudeInteractiveStream(opts) {
|
|
|
811
923
|
s.text = s.msgs.join('\n\n');
|
|
812
924
|
if (!s.thinking.trim() && s.thinkParts.length)
|
|
813
925
|
s.thinking = s.thinkParts.join('\n\n');
|
|
926
|
+
// Catch the Claude CLI's synthetic "API Error: …" assistant body (transient
|
|
927
|
+
// Anthropic 5xx / 529 Overloaded). Without this rewrite the raw error string
|
|
928
|
+
// gets surfaced into the IM card as if it were Claude's reply, and the
|
|
929
|
+
// retry wrapper in `doClaudeStream` can't tell a transient failure apart
|
|
930
|
+
// from a real short reply.
|
|
931
|
+
const apiErrorReason = detectClaudeApiError(s.text);
|
|
932
|
+
if (apiErrorReason) {
|
|
933
|
+
agentWarn(`[claude] upstream API error detected: ${apiErrorReason}`);
|
|
934
|
+
s.stopReason = 'api_error';
|
|
935
|
+
s.text = '';
|
|
936
|
+
if (!s.errors)
|
|
937
|
+
s.errors = [`Anthropic API error: ${apiErrorReason}`];
|
|
938
|
+
}
|
|
814
939
|
const errorText = joinErrorMessages(s.errors);
|
|
815
940
|
const ok = procOk && !s.errors && !timedOut && !interrupted;
|
|
816
941
|
const error = errorText
|
|
@@ -1915,6 +2040,103 @@ export function isClaudePrintModeForced() {
|
|
|
1915
2040
|
return true;
|
|
1916
2041
|
return false;
|
|
1917
2042
|
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Single-attempt dispatch: print mode when forced via env, otherwise TUI mode
|
|
2045
|
+
* with print-mode fallback if TUI prerequisites are missing (node-pty absent,
|
|
2046
|
+
* PTY allocation refused, …).
|
|
2047
|
+
*/
|
|
2048
|
+
async function doClaudeStreamOnce(opts) {
|
|
2049
|
+
if (isClaudePrintModeForced()) {
|
|
2050
|
+
agentLog('[claude] print mode forced via env, using -p');
|
|
2051
|
+
return doClaudeStream(opts);
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
const mod = await import('./claude-tui.js');
|
|
2055
|
+
return await mod.doClaudeTuiStream(opts);
|
|
2056
|
+
}
|
|
2057
|
+
catch (err) {
|
|
2058
|
+
// TUI prerequisite failed (node-pty missing, PTY allocation refused, etc.).
|
|
2059
|
+
// Fall back to print mode so pikiclaw stays functional — with the caveat
|
|
2060
|
+
// that this turn lands on the Agent SDK credit pool.
|
|
2061
|
+
agentWarn(`[claude] TUI unavailable (${err?.message || err}); falling back to -p — this turn bills the Agent SDK credit pool`);
|
|
2062
|
+
return doClaudeStream(opts);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Backoff schedule (in ms) for retrying transient Anthropic upstream failures
|
|
2067
|
+
* — 529 Overloaded, 5xx, gateway timeouts. Total wait budget ~30s before we
|
|
2068
|
+
* surface the failure to the user. Non-retryable errors (auth, quota,
|
|
2069
|
+
* context-length) skip the loop and fail fast.
|
|
2070
|
+
*/
|
|
2071
|
+
const CLAUDE_API_RETRY_BACKOFFS_MS = [4000, 12000];
|
|
2072
|
+
function makeOverloadFriendlyResult(result, reason, attempts) {
|
|
2073
|
+
const wait = CLAUDE_API_RETRY_BACKOFFS_MS.slice(0, attempts).reduce((sum, ms) => sum + ms, 0);
|
|
2074
|
+
const elapsedNote = wait > 0 ? ` (retried ${attempts}× over ${Math.round(wait / 1000)}s)` : '';
|
|
2075
|
+
const message = [
|
|
2076
|
+
`Anthropic API temporarily overloaded${elapsedNote}.`,
|
|
2077
|
+
`Reason from upstream: ${reason}.`,
|
|
2078
|
+
'Please re-send your last message in a moment — your session is intact and will resume from where it stopped.',
|
|
2079
|
+
].join(' ');
|
|
2080
|
+
return {
|
|
2081
|
+
...result,
|
|
2082
|
+
ok: false,
|
|
2083
|
+
incomplete: true,
|
|
2084
|
+
stopReason: 'api_error',
|
|
2085
|
+
message,
|
|
2086
|
+
error: `Anthropic API error: ${reason}`,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Driver-entry wrapper. Detects the Claude CLI's synthetic "API Error: …"
|
|
2091
|
+
* assistant turn and re-issues the request with backoff for retryable upstream
|
|
2092
|
+
* conditions (Overloaded, 5xx, timeouts). Non-retryable failures surface
|
|
2093
|
+
* immediately. After the budget is exhausted, the final result carries a
|
|
2094
|
+
* friendly human-readable explanation in `message` so the IM card doesn't
|
|
2095
|
+
* dump raw "API Error: Overloaded" text on the user.
|
|
2096
|
+
*/
|
|
2097
|
+
async function doClaudeWithRetry(opts) {
|
|
2098
|
+
let lastResult = await doClaudeStreamOnce(opts);
|
|
2099
|
+
let attempts = 0;
|
|
2100
|
+
// Use the error text recorded by detectClaudeApiError-driven branches to
|
|
2101
|
+
// decide retry: lastResult.error is "Anthropic API error: <reason>" on
|
|
2102
|
+
// detection, undefined otherwise.
|
|
2103
|
+
const reasonOf = (r) => {
|
|
2104
|
+
if (r.stopReason !== 'api_error')
|
|
2105
|
+
return null;
|
|
2106
|
+
const m = (r.error || '').match(/^Anthropic API error:\s*(.+)$/i);
|
|
2107
|
+
return m ? m[1].trim() : null;
|
|
2108
|
+
};
|
|
2109
|
+
while (attempts < CLAUDE_API_RETRY_BACKOFFS_MS.length) {
|
|
2110
|
+
const reason = reasonOf(lastResult);
|
|
2111
|
+
if (!reason || !isRetryableClaudeApiError(reason))
|
|
2112
|
+
break;
|
|
2113
|
+
const wait = CLAUDE_API_RETRY_BACKOFFS_MS[attempts];
|
|
2114
|
+
attempts++;
|
|
2115
|
+
agentWarn(`[claude] API error "${reason}", retry ${attempts}/${CLAUDE_API_RETRY_BACKOFFS_MS.length} after ${wait}ms`);
|
|
2116
|
+
if (opts.abortSignal?.aborted) {
|
|
2117
|
+
agentWarn('[claude] retry skipped — abort signal already fired');
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
await new Promise(r => setTimeout(r, wait));
|
|
2121
|
+
if (opts.abortSignal?.aborted) {
|
|
2122
|
+
agentWarn('[claude] retry skipped after backoff — abort signal fired');
|
|
2123
|
+
break;
|
|
2124
|
+
}
|
|
2125
|
+
// Resume the same session so we don't restart from scratch. The previous
|
|
2126
|
+
// attempt may have written a synthetic "API Error" assistant block into
|
|
2127
|
+
// the JSONL; Claude resumes past it and re-answers the user's prompt.
|
|
2128
|
+
const nextOpts = {
|
|
2129
|
+
...opts,
|
|
2130
|
+
sessionId: lastResult.sessionId || opts.sessionId,
|
|
2131
|
+
};
|
|
2132
|
+
lastResult = await doClaudeStreamOnce(nextOpts);
|
|
2133
|
+
}
|
|
2134
|
+
const finalReason = reasonOf(lastResult);
|
|
2135
|
+
if (finalReason) {
|
|
2136
|
+
return makeOverloadFriendlyResult(lastResult, finalReason, attempts);
|
|
2137
|
+
}
|
|
2138
|
+
return lastResult;
|
|
2139
|
+
}
|
|
1918
2140
|
class ClaudeDriver {
|
|
1919
2141
|
id = 'claude';
|
|
1920
2142
|
cmd = 'claude';
|
|
@@ -1926,21 +2148,7 @@ class ClaudeDriver {
|
|
|
1926
2148
|
// `/anthropic/v1`, …). cf. src/model/injector.ts:claudeInjector.
|
|
1927
2149
|
acceptedProviderKinds = ['anthropic', 'openai-compatible'];
|
|
1928
2150
|
async doStream(opts) {
|
|
1929
|
-
|
|
1930
|
-
agentLog('[claude] print mode forced via env, using -p');
|
|
1931
|
-
return doClaudeStream(opts);
|
|
1932
|
-
}
|
|
1933
|
-
try {
|
|
1934
|
-
const mod = await import('./claude-tui.js');
|
|
1935
|
-
return await mod.doClaudeTuiStream(opts);
|
|
1936
|
-
}
|
|
1937
|
-
catch (err) {
|
|
1938
|
-
// TUI prerequisite failed (node-pty missing, PTY allocation refused,
|
|
1939
|
-
// etc.). Fall back to print mode so pikiclaw stays functional — with
|
|
1940
|
-
// the caveat that this turn lands on the Agent SDK credit pool.
|
|
1941
|
-
agentWarn(`[claude] TUI unavailable (${err?.message || err}); falling back to -p — this turn bills the Agent SDK credit pool`);
|
|
1942
|
-
return doClaudeStream(opts);
|
|
1943
|
-
}
|
|
2151
|
+
return doClaudeWithRetry(opts);
|
|
1944
2152
|
}
|
|
1945
2153
|
async getSessions(workdir, limit) {
|
|
1946
2154
|
return getClaudeSessions(workdir, limit);
|
package/dist/agent/index.js
CHANGED
|
@@ -21,7 +21,7 @@ export { IMAGE_EXTS } from './types.js';
|
|
|
21
21
|
// ── Re-export: image pipeline ──────────────────────────────────────────────
|
|
22
22
|
export { attachAgentImage, attachInlineImage, materializeImage, rewriteImageBlocksForTransport, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
|
|
23
23
|
// ── Re-export: utilities ────────────────────────────────────────────────────
|
|
24
|
-
export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
|
|
24
|
+
export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
|
|
25
25
|
// ── Re-export: session management ───────────────────────────────────────────
|
|
26
26
|
export { updateSessionMeta, promoteSessionId, recordFork, listPikiclawSessions, findPikiclawSession, getSessionStoredConfig, ensureManagedSession, findManagedThreadSession, stageSessionFiles, mergeManagedAndNativeSessions, getSessions, getSessionTail, getSessionMessages, applyTurnWindow, applyTurnFilter, classifySession, deriveUserStatus, exportSession, importSession, deleteAgentSession, isProcessAlive, isRunningSessionStale, reconcileOrphanedRunningSessions, } from './session.js';
|
|
27
27
|
// ── Re-export: stream & detection ───────────────────────────────────────────
|
package/dist/agent/utils.js
CHANGED
|
@@ -166,6 +166,42 @@ export function joinErrorMessages(errors) {
|
|
|
166
166
|
return '';
|
|
167
167
|
return errors.map(error => normalizeErrorMessage(error)).filter(Boolean).join('; ').trim();
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Detect Claude Code's synthetic "API Error: …" assistant message. When the
|
|
171
|
+
* upstream Anthropic API returns a transient error (529 Overloaded, 5xx, gateway
|
|
172
|
+
* timeouts, …), the Claude CLI swallows it and replaces the assistant turn with
|
|
173
|
+
* a single `text` block whose body is literally `API Error: <reason>`. The
|
|
174
|
+
* turn's stop_reason still claims `end_turn`, so the driver can't distinguish
|
|
175
|
+
* it from a normal short reply without inspecting the text.
|
|
176
|
+
*
|
|
177
|
+
* Heuristics — keep them tight so real prose mentioning "API Error" doesn't
|
|
178
|
+
* trip the detector:
|
|
179
|
+
* - exact prefix "API Error: "
|
|
180
|
+
* - total length ≤ 200 chars (the synthetic line is always short)
|
|
181
|
+
* - no newlines (legit prose containing "API Error" virtually always wraps)
|
|
182
|
+
*
|
|
183
|
+
* Returns the trimmed reason (e.g. "Overloaded", "Internal server error") when
|
|
184
|
+
* matched, otherwise null. Callers decide whether the reason is retryable —
|
|
185
|
+
* `looksRetryable` answers that.
|
|
186
|
+
*/
|
|
187
|
+
export function detectClaudeApiError(text) {
|
|
188
|
+
if (!text)
|
|
189
|
+
return null;
|
|
190
|
+
const trimmed = text.trim();
|
|
191
|
+
if (trimmed.length > 200 || trimmed.includes('\n'))
|
|
192
|
+
return null;
|
|
193
|
+
const m = trimmed.match(/^API Error:\s*(.+)$/i);
|
|
194
|
+
return m ? m[1].trim() : null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Retryable Claude Code API errors — transient upstream conditions that
|
|
198
|
+
* usually clear within seconds. Non-retryable conditions (auth, quota,
|
|
199
|
+
* context length) fall through and surface to the user immediately.
|
|
200
|
+
*/
|
|
201
|
+
export function isRetryableClaudeApiError(reason) {
|
|
202
|
+
const r = reason.toLowerCase();
|
|
203
|
+
return /overloaded|overload|timeout|timed out|rate limit|500|502|503|504|529|temporar|gateway|connection|network|internal (server )?error/i.test(r);
|
|
204
|
+
}
|
|
169
205
|
export function appendSystemPrompt(base, extra) {
|
|
170
206
|
const lhs = String(base || '').trim();
|
|
171
207
|
const rhs = String(extra || '').trim();
|
|
@@ -272,6 +308,10 @@ export function summarizeClaudeToolUse(name, input) {
|
|
|
272
308
|
}
|
|
273
309
|
if (bare === 'im_list_files')
|
|
274
310
|
return 'List workspace files';
|
|
311
|
+
if (bare === 'im_ask_user') {
|
|
312
|
+
const q = shortValue(input?.question, 120);
|
|
313
|
+
return q ? `Ask user: ${q}` : 'Ask user';
|
|
314
|
+
}
|
|
275
315
|
if (description)
|
|
276
316
|
return `${tool}: ${description}`;
|
|
277
317
|
const d = shortValue(input?.file_path || input?.path || input?.command || input?.query || input?.pattern || input?.url, 120);
|
package/dist/bot/bot.js
CHANGED
|
@@ -16,7 +16,7 @@ import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
|
|
|
16
16
|
import { terminateProcessTree } from '../core/process-control.js';
|
|
17
17
|
import { expandTilde } from '../core/platform.js';
|
|
18
18
|
import { VERSION } from '../core/version.js';
|
|
19
|
-
import { buildHumanLoopResponse, createEmptyHumanLoopAnswer, currentHumanLoopQuestion, isHumanLoopAwaitingText, setHumanLoopOption, setHumanLoopText, skipHumanLoopQuestion, } from './human-loop.js';
|
|
19
|
+
import { buildHumanLoopResponse, createEmptyHumanLoopAnswer, currentHumanLoopQuestion, isHumanLoopAwaitingText, setHumanLoopOption, setHumanLoopText, skipHumanLoopQuestion, summarizeResolvedHumanLoopAnswers, } from './human-loop.js';
|
|
20
20
|
import { writeScopedLog } from '../core/logging.js';
|
|
21
21
|
import { resolveAgentEffort, resolveAgentModel, } from '../core/config/runtime-config.js';
|
|
22
22
|
import { envBool, envString, envInt, shellSplit, whichSync, fmtTokens, parseAllowedChatIds, ensureGitignore, } from '../core/utils.js';
|
|
@@ -1122,6 +1122,7 @@ export class Bot {
|
|
|
1122
1122
|
resolve: resolvePrompt,
|
|
1123
1123
|
reject: rejectPrompt,
|
|
1124
1124
|
messageIds: [],
|
|
1125
|
+
silent: opts.silent,
|
|
1125
1126
|
};
|
|
1126
1127
|
this.humanLoopPrompts.set(promptId, prompt);
|
|
1127
1128
|
const chatKey = String(opts.chatId);
|
|
@@ -1157,6 +1158,7 @@ export class Bot {
|
|
|
1157
1158
|
this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
|
|
1158
1159
|
prompt.resolve(buildHumanLoopResponse(prompt));
|
|
1159
1160
|
this.emitInteractionResolved(prompt.taskId, promptId);
|
|
1161
|
+
this.fireInteractionAnswered(prompt, 'answered');
|
|
1160
1162
|
return prompt;
|
|
1161
1163
|
}
|
|
1162
1164
|
clearHumanLoopPrompt(promptId, error) {
|
|
@@ -1168,8 +1170,34 @@ export class Bot {
|
|
|
1168
1170
|
if (error)
|
|
1169
1171
|
prompt.reject(error);
|
|
1170
1172
|
this.emitInteractionResolved(prompt.taskId, promptId);
|
|
1173
|
+
this.fireInteractionAnswered(prompt, 'cancelled');
|
|
1171
1174
|
return prompt;
|
|
1172
1175
|
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Unified post-resolution hook for human-loop prompts. Each IM channel
|
|
1178
|
+
* overrides `onInteractionAnswered` to (1) collapse the original prompt card
|
|
1179
|
+
* to an answered/cancelled state and (2) echo the decision as a new chat
|
|
1180
|
+
* message so scrolling back shows what the user picked. Dashboard sessions
|
|
1181
|
+
* (chatId='dashboard') and channels that opt out remain silent.
|
|
1182
|
+
*/
|
|
1183
|
+
fireInteractionAnswered(prompt, status) {
|
|
1184
|
+
if (prompt.silent)
|
|
1185
|
+
return;
|
|
1186
|
+
if (prompt.chatId === 'dashboard')
|
|
1187
|
+
return;
|
|
1188
|
+
const summary = summarizeResolvedHumanLoopAnswers(prompt, status);
|
|
1189
|
+
void Promise.resolve()
|
|
1190
|
+
.then(() => this.onInteractionAnswered(prompt, summary))
|
|
1191
|
+
.catch(err => this.warn(`onInteractionAnswered failed: ${err?.message || err}`));
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Channel hook fired after a human-loop prompt resolves (answered or
|
|
1195
|
+
* cancelled). Default: no-op. Override in channel subclasses to update the
|
|
1196
|
+
* original card and post a decision-echo message.
|
|
1197
|
+
*/
|
|
1198
|
+
async onInteractionAnswered(_prompt, _summary) {
|
|
1199
|
+
// Default: no-op.
|
|
1200
|
+
}
|
|
1173
1201
|
emitInteractionResolved(taskId, promptId) {
|
|
1174
1202
|
const task = this.activeTasks.get(taskId);
|
|
1175
1203
|
if (task)
|
|
@@ -1328,9 +1356,10 @@ export class Bot {
|
|
|
1328
1356
|
const taskId = `ext-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1329
1357
|
const prompt = opts.prompt.trim();
|
|
1330
1358
|
const attachments = opts.attachments || [];
|
|
1359
|
+
const chatId = opts.chatId ?? 'dashboard';
|
|
1331
1360
|
this.beginTask({
|
|
1332
1361
|
taskId,
|
|
1333
|
-
chatId
|
|
1362
|
+
chatId,
|
|
1334
1363
|
agent: session.agent,
|
|
1335
1364
|
sessionKey: session.key,
|
|
1336
1365
|
prompt,
|
|
@@ -1348,16 +1377,35 @@ export class Bot {
|
|
|
1348
1377
|
return;
|
|
1349
1378
|
}
|
|
1350
1379
|
this.emitStreamStart(taskId, session);
|
|
1380
|
+
// Wire up IM rendering for non-dashboard chats so /goal-driven tasks stream
|
|
1381
|
+
// to the same channel that submitted them, matching handleMessage's UX.
|
|
1382
|
+
const presenter = chatId !== 'dashboard'
|
|
1383
|
+
? await this.createImTaskPresenter({
|
|
1384
|
+
chatId, taskId, session, agent: session.agent, prompt, attachments,
|
|
1385
|
+
}).catch(err => {
|
|
1386
|
+
this.warn(`[submitSessionTask] presenter setup failed task=${taskId}: ${err?.message || err}`);
|
|
1387
|
+
return null;
|
|
1388
|
+
})
|
|
1389
|
+
: null;
|
|
1351
1390
|
try {
|
|
1352
1391
|
const result = await this.runStream(prompt, session, attachments, (text, thinking, activity, meta, plan) => {
|
|
1353
1392
|
opts.onText?.(text, thinking, activity, meta, plan);
|
|
1393
|
+
presenter?.onText(text, thinking, activity, meta, plan);
|
|
1354
1394
|
this.emitStreamText(taskId, session.key, text, thinking, activity, meta, plan);
|
|
1355
|
-
}, undefined, undefined, abortController.signal, this.createInteractionHandler(
|
|
1395
|
+
}, undefined, undefined, abortController.signal, this.createInteractionHandler(chatId, taskId), undefined, undefined, opts.forkOf ? { forkOf: opts.forkOf } : undefined);
|
|
1356
1396
|
this.emitStreamDone(taskId, session.key, {
|
|
1357
1397
|
sessionId: result.sessionId || session.sessionId,
|
|
1358
1398
|
incomplete: !!result.incomplete,
|
|
1359
1399
|
...(result.ok ? {} : { error: result.error || result.message }),
|
|
1360
1400
|
});
|
|
1401
|
+
if (presenter) {
|
|
1402
|
+
try {
|
|
1403
|
+
await presenter.onSuccess(result);
|
|
1404
|
+
}
|
|
1405
|
+
catch (e) {
|
|
1406
|
+
this.warn(`[submitSessionTask] presenter onSuccess failed task=${taskId}: ${e?.message || e}`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1361
1409
|
try {
|
|
1362
1410
|
this.maybeEnqueueGoalContinuation(session, opts, result);
|
|
1363
1411
|
}
|
|
@@ -1366,13 +1414,23 @@ export class Bot {
|
|
|
1366
1414
|
}
|
|
1367
1415
|
}
|
|
1368
1416
|
catch (error) {
|
|
1417
|
+
const errMsg = error?.message || String(error);
|
|
1369
1418
|
this.emitStreamDone(taskId, session.key, {
|
|
1370
1419
|
sessionId: session.sessionId,
|
|
1371
1420
|
incomplete: true,
|
|
1372
|
-
error:
|
|
1421
|
+
error: errMsg,
|
|
1373
1422
|
});
|
|
1423
|
+
if (presenter) {
|
|
1424
|
+
try {
|
|
1425
|
+
await presenter.onFailure(errMsg);
|
|
1426
|
+
}
|
|
1427
|
+
catch (e) {
|
|
1428
|
+
this.warn(`[submitSessionTask] presenter onFailure failed task=${taskId}: ${e?.message || e}`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1374
1431
|
}
|
|
1375
1432
|
finally {
|
|
1433
|
+
presenter?.dispose();
|
|
1376
1434
|
this.finishTask(taskId);
|
|
1377
1435
|
this.syncSelectedChats(session);
|
|
1378
1436
|
}
|
|
@@ -1382,6 +1440,14 @@ export class Bot {
|
|
|
1382
1440
|
});
|
|
1383
1441
|
return { ok: true, taskId, sessionKey: session.key, queued: true };
|
|
1384
1442
|
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Channel hook — returns a presenter that streams the task's runStream
|
|
1445
|
+
* output to the IM chat that submitted it. Default: null (dashboard-only
|
|
1446
|
+
* chats and channels that haven't opted in stay silent in IM).
|
|
1447
|
+
*/
|
|
1448
|
+
async createImTaskPresenter(_opts) {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1385
1451
|
/**
|
|
1386
1452
|
* Goal continuation: after a turn ends, if a goal is still active for the
|
|
1387
1453
|
* session, account token + wall-clock usage, then enqueue one more task with
|
|
@@ -2127,9 +2193,16 @@ export class Bot {
|
|
|
2127
2193
|
}
|
|
2128
2194
|
}
|
|
2129
2195
|
const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(mcpSendFile ? buildMcpDeliveryPrompt() : '', onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled));
|
|
2196
|
+
// mcpSystemPrompt carries behaviour directives (use im_ask_user instead of
|
|
2197
|
+
// built-in AskUserQuestion, browser automation status, artifact delivery)
|
|
2198
|
+
// that must apply on every turn, not just the first — on resume the CLI
|
|
2199
|
+
// does not automatically re-inject the previous --append-system-prompt
|
|
2200
|
+
// contents, so Claude silently regresses to the built-in tools on turn 2+.
|
|
2201
|
+
// The caller-supplied `systemPrompt` (per-task scaffolding) remains
|
|
2202
|
+
// first-turn-only since later turns inherit it via the session transcript.
|
|
2130
2203
|
const effectiveSystemPrompt = isFirstTurnOfSession
|
|
2131
2204
|
? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
|
|
2132
|
-
: undefined;
|
|
2205
|
+
: (mcpSystemPrompt || undefined);
|
|
2133
2206
|
const syncNativeSessionId = (nativeSessionId) => {
|
|
2134
2207
|
const resolvedSessionId = nativeSessionId.trim();
|
|
2135
2208
|
if (!resolvedSessionId)
|
package/dist/bot/human-loop.js
CHANGED
|
@@ -121,3 +121,48 @@ export function buildHumanLoopResponse(prompt) {
|
|
|
121
121
|
}
|
|
122
122
|
return prompt.resolveWith(answers);
|
|
123
123
|
}
|
|
124
|
+
function displayValueForOption(question, value) {
|
|
125
|
+
const match = question.options?.find(opt => opt.value === value);
|
|
126
|
+
return match?.label || value;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Build a channel-agnostic summary of a prompt's resolved answers. Used by the
|
|
130
|
+
* base bot's `onInteractionAnswered` hook so each channel renders the same
|
|
131
|
+
* closed-state view + echo message without diverging.
|
|
132
|
+
*/
|
|
133
|
+
export function summarizeResolvedHumanLoopAnswers(prompt, status = 'answered') {
|
|
134
|
+
const rows = [];
|
|
135
|
+
const compactParts = [];
|
|
136
|
+
for (const question of prompt.questions) {
|
|
137
|
+
const answer = prompt.answers[question.id] || createEmptyHumanLoopAnswer();
|
|
138
|
+
let display;
|
|
139
|
+
if (answer.skipped) {
|
|
140
|
+
display = '(skip)';
|
|
141
|
+
}
|
|
142
|
+
else if (question.secret && (answer.selectedValue || answer.freeformText)) {
|
|
143
|
+
display = '(hidden)';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (answer.selectedValue)
|
|
148
|
+
parts.push(displayValueForOption(question, answer.selectedValue));
|
|
149
|
+
const freeform = answer.freeformText?.trim();
|
|
150
|
+
if (freeform)
|
|
151
|
+
parts.push(freeform);
|
|
152
|
+
display = parts.length ? parts.join(' · ') : '(no answer)';
|
|
153
|
+
}
|
|
154
|
+
const label = (question.header || question.prompt || question.id).trim();
|
|
155
|
+
rows.push({
|
|
156
|
+
label,
|
|
157
|
+
display,
|
|
158
|
+
skipped: !!answer.skipped,
|
|
159
|
+
secret: !!question.secret,
|
|
160
|
+
});
|
|
161
|
+
compactParts.push(display);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status,
|
|
165
|
+
rows,
|
|
166
|
+
display: compactParts.join(' · '),
|
|
167
|
+
};
|
|
168
|
+
}
|