pikiclaw 0.3.62 → 0.3.63

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.
@@ -43,8 +43,8 @@ import { tmpdir } from 'node:os';
43
43
  import { Q, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, joinErrorMessages, emitSessionIdUpdate, normalizeClaudeModelId, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, detectClaudeApiError, } from '../utils.js';
44
44
  import { encodePathAsDirName, getHome, whichSync } from '../../core/platform.js';
45
45
  import { stripAnsiEscapes } from '../../core/utils.js';
46
- import { AGENT_STREAM_HARD_KILL_GRACE_MS, CLAUDE_TUI_STALL_QUIET_MS, CLAUDE_TUI_STALL_PENDING_TOOL_MS, } from '../../core/constants.js';
47
- import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, } from './claude.js';
46
+ import { AGENT_STREAM_HARD_KILL_GRACE_MS, CLAUDE_TUI_STALL_QUIET_MS, CLAUDE_TUI_STALL_PENDING_TOOL_MS, CLAUDE_TUI_STALL_PTY_DEAD_MS, CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS, } from '../../core/constants.js';
47
+ import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, } from './claude.js';
48
48
  async function loadPty() {
49
49
  // Dynamic import keeps node-pty an optional dependency — if it's not
50
50
  // installed the print-mode dispatcher in claude.ts will catch the throw
@@ -391,6 +391,12 @@ export function applyHookToolEvent(ev, s) {
391
391
  s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
392
392
  return true;
393
393
  }
394
+ // Background Bash — register like a backgrounded agent so the turn's Stop
395
+ // holds the PTY open until its <task-notification> lands, instead of
396
+ // SIGTERMing the still-running command (and its future report-back turn).
397
+ if (toolName === 'Bash' && ev.tool_input?.run_in_background === true) {
398
+ registerClaudeBackgroundBashLaunch(s, toolUseId);
399
+ }
394
400
  const summary = summarizeClaudeToolUse(toolName, ev.tool_input || {});
395
401
  pushRecentActivity(s.recentActivity, summary);
396
402
  s.seenClaudeToolIds.add(toolUseId);
@@ -457,6 +463,14 @@ export function applyHookToolEvent(ev, s) {
457
463
  s.activity = s.recentActivity.join('\n');
458
464
  }
459
465
  }
466
+ // Background Bash launch ack → map task id → tool_use for notification
467
+ // resolution (bash notifications usually omit <tool-use-id>).
468
+ if (toolName === 'Bash' && s.bgBashToolUseIds?.has(toolUseId)
469
+ && !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
470
+ const taskId = extractClaudeBackgroundTaskId(ev.tool_response);
471
+ if (taskId && !s.bgTaskIdToToolUse.has(taskId))
472
+ s.bgTaskIdToToolUse.set(taskId, toolUseId);
473
+ }
460
474
  s.seenClaudeToolResultIds.add(toolUseId);
461
475
  return true;
462
476
  }
@@ -630,10 +644,23 @@ const BG_RESETTLE_QUIET_MS = 30_000;
630
644
  * is still expected. Hold until a fresh Stop or BG_RESETTLE_QUIET_MS of
631
645
  * JSONL silence.
632
646
  * - `terminate`: the Stop is the genuine end of the turn.
647
+ *
648
+ * The `hold-background` path carries a quiet-TTL: a genuinely-running
649
+ * background agent keeps emitting hook/sidecar/JSONL traffic, so a hold whose
650
+ * every channel has been silent past CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS is a
651
+ * phantom (lost <task-notification> / completion never observed). Releasing
652
+ * it as a normal Stop keeps the turn's clean semantics — letting the stall
653
+ * watchdog reap it instead would mislabel a finished turn 'stalled' and
654
+ * inject a confusing auto-resume prompt into the next turn.
633
655
  */
634
656
  export function decideClaudeTuiStop(input) {
635
- if (input.pendingBackgroundAgents > 0)
657
+ if (input.pendingBackgroundAgents > 0) {
658
+ const ttl = input.holdQuietTtlMs ?? CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS;
659
+ const lastActivityAt = Math.max(input.stoppedAt, input.lastJsonlEventAt, input.lastTaskNotificationAt, input.lastHookOrSidecarEventAt ?? 0);
660
+ if (input.now - lastActivityAt > ttl)
661
+ return 'terminate'; // 幽灵 hold:全通道静默超 TTL
636
662
  return 'hold-background';
663
+ }
637
664
  const stopIsStale = input.lastTaskNotificationAt > 0 && input.lastTaskNotificationAt >= input.stoppedAt;
638
665
  if (stopIsStale) {
639
666
  const quietMs = input.resettleQuietMs ?? BG_RESETTLE_QUIET_MS;
@@ -656,8 +683,22 @@ export function decideClaudeTuiStop(input) {
656
683
  * the freeze can also hit mid-execution, but a legitimately long foreground
657
684
  * command must not get shot — claude's own Bash timeout fires PostToolUse
658
685
  * well inside CLAUDE_TUI_STALL_PENDING_TOOL_MS.
686
+ *
687
+ * Fast path: `lastPtyDataAt` is raw PTY output (any repaint frame counts). A
688
+ * healthy TUI animates continuously mid-turn — spinner, stream ticks, status
689
+ * line — so PTY byte-silence is the cheapest possible "event loop is dead"
690
+ * detector. When BOTH the PTY and all structured signals have been silent
691
+ * past `ptyDeadMs`, declare the stall immediately instead of waiting out the
692
+ * 10/30-minute quiet thresholds. Long thinking and long foreground commands
693
+ * keep painting frames, which routes them to the slow thresholds as before.
659
694
  */
660
695
  export function decideClaudeTuiStall(input) {
696
+ const ptyAt = input.lastPtyDataAt ?? 0;
697
+ if (ptyAt > 0) {
698
+ const ptyDeadMs = input.ptyDeadMs ?? CLAUDE_TUI_STALL_PTY_DEAD_MS;
699
+ if (input.now - Math.max(ptyAt, input.lastProgressAt) > ptyDeadMs)
700
+ return 'stall';
701
+ }
661
702
  const threshold = input.pendingToolCount > 0
662
703
  ? (input.pendingToolMs ?? CLAUDE_TUI_STALL_PENDING_TOOL_MS)
663
704
  : (input.quietMs ?? CLAUDE_TUI_STALL_QUIET_MS);
@@ -934,9 +975,15 @@ export async function doClaudeTuiStream(opts) {
934
975
  }
935
976
  agentLog(`[claude-tui] pid=${proc.pid}`);
936
977
  const dbg = process.env.PIKICLAW_CLAUDE_TUI_DEBUG === '1';
978
+ /** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
979
+ let lastPtyDataAt = Date.now();
937
980
  proc.onData((data) => {
938
981
  // We deliberately do not parse the TUI screen output. The JSONL is the
939
982
  // canonical source of structured events. Stash bytes only when debugging.
983
+ // Raw byte arrival doubles as the cheapest liveness signal: a healthy TUI
984
+ // repaints continuously mid-turn, so PTY silence = event loop dead — feeds
985
+ // the stall watchdog's fast path (decideClaudeTuiStall.lastPtyDataAt).
986
+ lastPtyDataAt = Date.now();
940
987
  if (dbg) {
941
988
  try {
942
989
  fs.appendFileSync(ptyLogPath, data);
@@ -1007,6 +1054,8 @@ export async function doClaudeTuiStream(opts) {
1007
1054
  let lastToolEventAt = start;
1008
1055
  let lastSidecarEventAt = 0;
1009
1056
  let stallKilled = false;
1057
+ /** Last state.stoppedAt for which pendingHookToolIds was reconciled. */
1058
+ let lastClearedStopAt = 0;
1010
1059
  /** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
1011
1060
  const pendingHookToolIds = new Set();
1012
1061
  // Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
@@ -1264,17 +1313,40 @@ export async function doClaudeTuiStream(opts) {
1264
1313
  // has reported its <task-notification> AND the latest Stop is fresher than
1265
1314
  // the latest notification (i.e. the model's wrap-up segment finished).
1266
1315
  if (state.stoppedAt && !stopHookFired) {
1316
+ // A fired Stop means no foreground tool is genuinely mid-flight any
1317
+ // more. Surviving entries in pendingHookToolIds are lost PostToolUse
1318
+ // hook events (MCP flap / hook timeout ate them) — clearing here stops
1319
+ // them from silently pushing the stall watchdog onto the 30-minute
1320
+ // pending-tool threshold for the rest of the turn.
1321
+ if (state.stoppedAt !== lastClearedStopAt) {
1322
+ lastClearedStopAt = state.stoppedAt;
1323
+ if (pendingHookToolIds.size) {
1324
+ agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
1325
+ pendingHookToolIds.clear();
1326
+ }
1327
+ }
1267
1328
  const pendingBg = pendingClaudeBackgroundAgentCount(s);
1268
1329
  const decision = decideClaudeTuiStop({
1269
1330
  stoppedAt: state.stoppedAt,
1270
1331
  pendingBackgroundAgents: pendingBg,
1271
1332
  lastTaskNotificationAt: s.lastTaskNotificationAt || 0,
1272
1333
  lastJsonlEventAt: lastMainJsonlEventAt,
1334
+ lastHookOrSidecarEventAt: Math.max(lastToolEventAt, lastSidecarEventAt),
1335
+ // Background *Bash* is silent by nature (no sidecar/hook traffic while
1336
+ // it runs) — give it the long pending-tool budget; agent-only holds
1337
+ // keep the default TTL (live agents emit sidecar events constantly).
1338
+ holdQuietTtlMs: pendingClaudeBackgroundBashCount(s) > 0
1339
+ ? CLAUDE_TUI_STALL_PENDING_TOOL_MS
1340
+ : undefined,
1273
1341
  now: Date.now(),
1274
1342
  });
1275
1343
  if (decision === 'terminate') {
1276
1344
  stopHookFired = true;
1277
1345
  stopHookSeenAt = Date.now();
1346
+ if (pendingBg > 0) {
1347
+ // 幽灵 hold 释放:计数说还有后台 agent,但所有通道静默已超 TTL。
1348
+ agentWarn(`[claude-tui] releasing phantom hold — ${pendingBg} background agent(s) still counted pending but every channel quiet past TTL; treating Stop as final`);
1349
+ }
1278
1350
  agentLog(`[claude-tui] Stop hook fired — draining JSONL for ${POST_STOP_DRAIN_MS}ms before SIGTERM`);
1279
1351
  }
1280
1352
  else if (decision === 'hold-background' && pendingBg !== lastLoggedPendingBg) {
@@ -1300,19 +1372,32 @@ export async function doClaudeTuiStream(opts) {
1300
1372
  // once so the turn continues instead of spinning forever in the IM card.
1301
1373
  if (!stopHookFired && !timedOut && !interrupted && !stallKilled) {
1302
1374
  const lastProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.stoppedAt || 0, state.promptSubmittedAt || 0);
1375
+ // Pending background work (agents + bash) extends the stall budget the
1376
+ // same way a pending foreground tool does: a silent 15-minute background
1377
+ // build must not get shot by the 10-minute quiet threshold. The PTY
1378
+ // fast path still catches true process freezes within minutes.
1379
+ const pendingBgForStall = pendingClaudeBackgroundAgentCount(s);
1380
+ // PTY fast path is for *mid-turn* freezes only. While the TUI idles in a
1381
+ // post-Stop background hold it legitimately paints nothing — a static
1382
+ // screen there is healthy, not frozen. Stop being the freshest signal is
1383
+ // exactly that hold state → disarm the fast path (0 = unavailable).
1384
+ const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
1385
+ const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
1303
1386
  const stallDecision = decideClaudeTuiStall({
1304
1387
  now: Date.now(),
1305
1388
  lastProgressAt,
1306
- pendingToolCount: pendingHookToolIds.size,
1389
+ pendingToolCount: pendingHookToolIds.size + pendingBgForStall,
1390
+ lastPtyDataAt: inPostStopHold ? 0 : lastPtyDataAt,
1307
1391
  });
1308
1392
  if (stallDecision === 'stall') {
1309
1393
  stallKilled = true;
1310
1394
  const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
1395
+ const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
1311
1396
  s.stopReason = 'stalled';
1312
1397
  if (!s.errors) {
1313
- s.errors = [`Claude process went silent mid-turn for ${quietMin}m (no JSONL, hook, or sub-agent events) — known claude CLI freeze. Terminated for auto-resume.`];
1398
+ s.errors = [`Claude process went silent mid-turn for ${quietMin}m (no JSONL, hook, or sub-agent events; PTY quiet ${ptyQuietS}s) — known claude CLI freeze. Terminated for auto-resume.`];
1314
1399
  }
1315
- agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (pendingTools=${pendingHookToolIds.size}) — terminating TUI pid=${proc.pid} for auto-resume`);
1400
+ agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (pendingTools=${pendingHookToolIds.size}, ptyQuiet=${ptyQuietS}s) — terminating TUI pid=${proc.pid} for auto-resume`);
1316
1401
  pushRecentActivity(s.recentActivity, `Agent stalled (${quietMin}m silent) — restarting turn`);
1317
1402
  s.activity = s.recentActivity.join('\n');
1318
1403
  emit();
@@ -328,6 +328,8 @@ function ensureClaudeBgAgentState(s) {
328
328
  s.bgAgentLaunchedToolUseIds = new Set();
329
329
  if (!s.bgAgentCompletedToolUseIds)
330
330
  s.bgAgentCompletedToolUseIds = new Set();
331
+ if (!s.bgBashToolUseIds)
332
+ s.bgBashToolUseIds = new Set();
331
333
  if (!s.bgTaskIdToToolUse)
332
334
  s.bgTaskIdToToolUse = new Map();
333
335
  if (typeof s.lastTaskNotificationAt !== 'number')
@@ -341,7 +343,27 @@ export function registerClaudeBackgroundAgentLaunch(s, toolUseId) {
341
343
  ensureClaudeBgAgentState(s);
342
344
  s.bgAgentLaunchedToolUseIds.add(id);
343
345
  }
344
- /** Launched background agents whose <task-notification> hasn't arrived yet. */
346
+ /**
347
+ * Record a `Bash` tool_use launched with `run_in_background: true`.
348
+ *
349
+ * Background Bash lives *inside the claude process* exactly like a
350
+ * backgrounded sub-agent: its tool_result is a launch ack, the real
351
+ * completion arrives later as a `<task-notification>` which re-invokes the
352
+ * model in the same process. Before this registration existed only Task/Agent
353
+ * launches counted as "pending background work" — a turn that backgrounded a
354
+ * Bash command would hit Stop, decideClaudeTuiStop saw pending=0 and
355
+ * terminated the PTY, killing the command and its future report-back turn
356
+ * (the「claude 后台任务一停止就被掐死」failure).
357
+ */
358
+ export function registerClaudeBackgroundBashLaunch(s, toolUseId) {
359
+ const id = String(toolUseId || '').trim();
360
+ if (!id)
361
+ return;
362
+ ensureClaudeBgAgentState(s);
363
+ s.bgAgentLaunchedToolUseIds.add(id);
364
+ s.bgBashToolUseIds.add(id);
365
+ }
366
+ /** Launched background tasks (agents + bash) whose <task-notification> hasn't arrived yet. */
345
367
  export function pendingClaudeBackgroundAgentCount(s) {
346
368
  const launched = s?.bgAgentLaunchedToolUseIds;
347
369
  if (!launched?.size)
@@ -354,6 +376,51 @@ export function pendingClaudeBackgroundAgentCount(s) {
354
376
  }
355
377
  return pending;
356
378
  }
379
+ /** Pending background *Bash* tasks specifically. Unlike agents (whose sidecar
380
+ * JSONL keeps emitting events while alive), a background command is silent by
381
+ * nature — callers use this to pick a longer hold/stall budget. */
382
+ export function pendingClaudeBackgroundBashCount(s) {
383
+ const bash = s?.bgBashToolUseIds;
384
+ if (!bash?.size)
385
+ return 0;
386
+ const completed = s?.bgAgentCompletedToolUseIds;
387
+ let pending = 0;
388
+ for (const id of bash) {
389
+ if (!completed?.has(id))
390
+ pending++;
391
+ }
392
+ return pending;
393
+ }
394
+ /**
395
+ * Pull the background task id out of a launch ack. Claude Code's backgrounded
396
+ * Bash tool_result reads like "Command running in background with ID: bash_3
397
+ * (output: …)" — the id is what the later <task-notification> carries (its
398
+ * <tool-use-id> is often omitted for bash), so mapping id → tool_use here is
399
+ * what lets applyClaudeTaskNotification resolve the completion.
400
+ */
401
+ export function extractClaudeBackgroundTaskId(content) {
402
+ let text = '';
403
+ if (typeof content === 'string')
404
+ text = content;
405
+ else if (Array.isArray(content)) {
406
+ text = content
407
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
408
+ .map((b) => b.text)
409
+ .join('\n');
410
+ }
411
+ else if (content && typeof content === 'object') {
412
+ try {
413
+ text = JSON.stringify(content);
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ if (!text || !/background/i.test(text))
420
+ return null;
421
+ const m = text.match(/\b(?:ID|id)\s*[::]?\s*[`"']?([A-Za-z0-9][A-Za-z0-9_-]{1,63})/);
422
+ return m ? m[1] : null;
423
+ }
357
424
  /**
358
425
  * Parse a `<task-notification>` wrapper out of a user event's content.
359
426
  * Shape (observed, Claude Code 2.x):
@@ -583,6 +650,12 @@ export function claudeParse(ev, s) {
583
650
  s.claudeToolsById.set(toolId, { name: toolName, summary: subAgent.description || 'Run task' });
584
651
  continue;
585
652
  }
653
+ // Background Bash — same in-process lifecycle as a backgrounded agent:
654
+ // launch ack now, <task-notification> later. Register so the TUI driver
655
+ // holds the PTY open instead of SIGTERMing the command mid-flight.
656
+ if (toolName === 'Bash' && block?.input?.run_in_background === true) {
657
+ registerClaudeBackgroundBashLaunch(s, toolId);
658
+ }
586
659
  const tool = {
587
660
  name: toolName,
588
661
  summary: summarizeClaudeToolUse(block?.name, block?.input || {}),
@@ -667,6 +740,15 @@ export function claudeParse(ev, s) {
667
740
  tool.result = previewToolCallResult(block?.content);
668
741
  tool.status = block?.is_error ? 'failed' : 'done';
669
742
  }
743
+ // Background Bash launch ack → map its task id to the tool_use so the
744
+ // later <task-notification> (which usually omits <tool-use-id> for bash)
745
+ // can resolve and decrement the pending count.
746
+ if (tool?.name === 'Bash' && s.bgBashToolUseIds?.has(toolId)
747
+ && !s.bgAgentCompletedToolUseIds?.has(toolId)) {
748
+ const taskId = extractClaudeBackgroundTaskId(block?.content);
749
+ if (taskId && !s.bgTaskIdToToolUse.has(taskId))
750
+ s.bgTaskIdToToolUse.set(taskId, toolId);
751
+ }
670
752
  pushRecentActivity(s.recentActivity, summarizeClaudeToolResult(tool, block, ev.tool_use_result));
671
753
  // MCP / Skill tool_result with multimodal content — recurse for image
672
754
  // entries so the final StreamResult carries them. Filesystem-reading
@@ -306,6 +306,29 @@ export const CLAUDE_TUI_STALL_QUIET_MS = 10 * 60_000;
306
306
  * silent for this long means the freeze hit mid-execution.
307
307
  */
308
308
  export const CLAUDE_TUI_STALL_PENDING_TOOL_MS = 30 * 60_000;
309
+ /**
310
+ * Fast-path stall: a healthy claude TUI repaints continuously while a turn is
311
+ * in flight (spinner frames, stream ticks, status line) — the PTY never goes
312
+ * byte-silent for minutes. If NO PTY output arrives for this long AND every
313
+ * structured signal is equally quiet, the process event loop itself is gone
314
+ * (the 2.1.160 mid-turn freeze: attachment lands → next API call never
315
+ * assembles). Declare the stall now instead of waiting out the 10/30-minute
316
+ * quiet thresholds — turns a 10-30 分钟「卡死」into a ~3 分钟自愈。
317
+ * False-positive safe: long thinking / long Bash keep painting frames, which
318
+ * refreshes the PTY signal and defers this path to the slow thresholds.
319
+ */
320
+ export const CLAUDE_TUI_STALL_PTY_DEAD_MS = 3 * 60_000;
321
+ /**
322
+ * TTL for the post-Stop `hold-background` path. The hold protects
323
+ * run_in_background agents living inside the claude process — but a live
324
+ * agent keeps emitting hook/sidecar/JSONL traffic. If the hold sees no
325
+ * activity on ANY channel for this long, the pending count is phantom (lost
326
+ * <task-notification>, agents already finished): release as a NORMAL Stop.
327
+ * Without this TTL the stall watchdog eventually fires instead, mislabels the
328
+ * cleanly-finished turn 'stalled', and injects a confusing auto-resume prompt
329
+ * (the「回合明明答完了还被注入 Continue」symptom).
330
+ */
331
+ export const CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS = 10 * 60_000;
309
332
  /** Codex-specific grace period added to the user-configured timeout. */
310
333
  export const CODEX_STREAM_HARD_KILL_GRACE_MS = 5_000;
311
334
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.62",
3
+ "version": "0.3.63",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {