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.
@@ -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 card handles it
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
- if (isClaudePrintModeForced()) {
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);
@@ -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 ───────────────────────────────────────────
@@ -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: opts.chatId ?? 'dashboard',
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(opts.chatId ?? 'dashboard', taskId), undefined, undefined, opts.forkOf ? { forkOf: opts.forkOf } : undefined);
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: error?.message || String(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)
@@ -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
+ }