pikiloom 0.4.8 → 0.4.9

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.
@@ -916,6 +916,20 @@ export function decideClaudeTuiStop(input) {
916
916
  }
917
917
  return 'terminate';
918
918
  }
919
+ /**
920
+ * True for the `im_ask_user` MCP tool in any form — Claude Code names MCP tools
921
+ * `mcp__<server>__<tool>` (here `mcp__pikiloom__im_ask_user`), so match the
922
+ * namespaced suffix as well as the bare name (robust to the server name
923
+ * changing). The driver tails Pre/PostToolUse hooks by id but must also know
924
+ * *which* pending tool is the one that blocks on the user, so the stall
925
+ * watchdog can stay armed for every other tool and disarm only for a live
926
+ * question. See decideClaudeTuiStall(`awaitingUserReply`).
927
+ */
928
+ export function isAskUserToolName(name) {
929
+ if (typeof name !== 'string' || !name)
930
+ return false;
931
+ return name === 'im_ask_user' || name.endsWith('__im_ask_user');
932
+ }
919
933
  /**
920
934
  * Decide whether the turn has gone dead. claude CLI is known to freeze
921
935
  * mid-turn (observed 2026-06-02 on 2.1.160): after a tool_result lands the
@@ -937,8 +951,20 @@ export function decideClaudeTuiStop(input) {
937
951
  * past `ptyDeadMs`, declare the stall immediately instead of waiting out the
938
952
  * 10/30-minute quiet thresholds. Long thinking and long foreground commands
939
953
  * keep painting frames, which routes them to the slow thresholds as before.
954
+ *
955
+ * `awaitingUserReply` is the one state that is quiet BY DESIGN, not because the
956
+ * process froze: an `im_ask_user` MCP call is in flight (PreToolUse seen, no
957
+ * PostToolUse) and the turn is blocked on the user's reply — the `/ask-user`
958
+ * callback never times out. Every signal (JSONL, hooks, PTY) goes silent while
959
+ * the user thinks, which is indistinguishable by timing alone from a freeze, so
960
+ * the watchdog must never stall here. The hard turn deadline remains the
961
+ * backstop if the user never answers; PostToolUse re-arms the watchdog the
962
+ * instant the answer lands. Without this, the last question regularly gets
963
+ * SIGTERMed mid-wait and mislabelled a "CLI freeze".
940
964
  */
941
965
  export function decideClaudeTuiStall(input) {
966
+ if (input.awaitingUserReply)
967
+ return 'wait';
942
968
  const ptyAt = input.lastPtyDataAt ?? 0;
943
969
  if (ptyAt > 0) {
944
970
  const ptyDeadMs = input.ptyDeadMs ?? CLAUDE_TUI_STALL_PTY_DEAD_MS;
@@ -1557,6 +1583,13 @@ export async function doClaudeTuiStream(opts) {
1557
1583
  let lastClearedStopAt = 0;
1558
1584
  /** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
1559
1585
  const pendingHookToolIds = new Set();
1586
+ /** Subset of pendingHookToolIds that are `im_ask_user` calls — the turn is
1587
+ * intentionally blocked on the user's reply (the /ask-user callback never
1588
+ * times out), so the stall watchdog must stay disarmed while any are in
1589
+ * flight. Cleared by the answer (PostToolUse) or the turn ending (Stop). */
1590
+ const pendingAskUserToolIds = new Set();
1591
+ /** Edge-logged so the "watchdog disarmed" line fires on transitions only. */
1592
+ let loggedAwaitingUser = false;
1560
1593
  // Incremental main-JSONL drain — the canonical text/thinking/usage feed.
1561
1594
  // Used by both the 200ms poll tick and the post-exit final drain. Returns
1562
1595
  // true when any line was consumed so callers can emit().
@@ -1643,10 +1676,17 @@ export async function doClaudeTuiStream(opts) {
1643
1676
  lastToolEventAt = Date.now();
1644
1677
  const hookToolId = typeof ev?.tool_use_id === 'string' ? ev.tool_use_id : '';
1645
1678
  if (hookToolId) {
1646
- if (ev?.event === 'PreToolUse')
1679
+ if (ev?.event === 'PreToolUse') {
1647
1680
  pendingHookToolIds.add(hookToolId);
1648
- else if (ev?.event === 'PostToolUse')
1681
+ // im_ask_user blocks the turn on the user — track it separately so the
1682
+ // stall watchdog can tell an intentional question-wait from a freeze.
1683
+ if (isAskUserToolName(ev?.tool_name))
1684
+ pendingAskUserToolIds.add(hookToolId);
1685
+ }
1686
+ else if (ev?.event === 'PostToolUse') {
1649
1687
  pendingHookToolIds.delete(hookToolId);
1688
+ pendingAskUserToolIds.delete(hookToolId);
1689
+ }
1650
1690
  }
1651
1691
  // A Task PreToolUse and the first sub-agent tool PreToolUse can land in
1652
1692
  // the same tick batch. If the sub-agent's hook arrives before we've
@@ -1866,6 +1906,8 @@ export async function doClaudeTuiStream(opts) {
1866
1906
  agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
1867
1907
  pendingHookToolIds.clear();
1868
1908
  }
1909
+ // A fired Stop ends the turn — any tracked ask-user is moot.
1910
+ pendingAskUserToolIds.clear();
1869
1911
  }
1870
1912
  const pendingBg = pendingClaudeBackgroundAgentCount(s);
1871
1913
  const decision = decideClaudeTuiStop({
@@ -1925,6 +1967,19 @@ export async function doClaudeTuiStream(opts) {
1925
1967
  // exactly that hold state → disarm the fast path (0 = unavailable).
1926
1968
  const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
1927
1969
  const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
1970
+ // An im_ask_user call is in flight → the turn is blocked on the user's
1971
+ // reply, not frozen (the /ask-user callback never times out). Disarm the
1972
+ // stall watchdog AND the freeze diagnostics until the user answers
1973
+ // (PostToolUse clears the id). Otherwise the quiet wait trips
1974
+ // decideClaudeTuiStall, classifies 'unknown', and gets SIGTERMed +
1975
+ // mislabelled a "CLI freeze" — the last-question hang the user reported.
1976
+ const awaitingUserReply = pendingAskUserToolIds.size > 0;
1977
+ if (awaitingUserReply !== loggedAwaitingUser) {
1978
+ loggedAwaitingUser = awaitingUserReply;
1979
+ if (awaitingUserReply) {
1980
+ agentLog(`[claude-tui] im_ask_user in flight — stall watchdog disarmed until the user replies pid=${proc.pid}`);
1981
+ }
1982
+ }
1928
1983
  // Chokepoint answer-retry grace. We sent the affirmative key at a stuck dialog; give it time
1929
1984
  // to clear. If the screen is no longer a blocking dialog, the answer took — disarm and re-arm
1930
1985
  // so a later prompt in the same turn can also get a chokepoint retry. If it is STILL a dialog
@@ -1948,7 +2003,7 @@ export async function doClaudeTuiStream(opts) {
1948
2003
  if (!stallKilled) {
1949
2004
  const nowMs = Date.now();
1950
2005
  const quietMs = nowMs - lastProgressAt;
1951
- if (quietMs >= STALL_DIAG_QUIET_THRESHOLD_MS && !inPostStopHold) {
2006
+ if (quietMs >= STALL_DIAG_QUIET_THRESHOLD_MS && !inPostStopHold && !awaitingUserReply) {
1952
2007
  const ptyQuietMs = nowMs - lastPtyDataAt;
1953
2008
  stallDiagWentQuiet = true;
1954
2009
  if (quietMs > stallDiagMaxQuietMs)
@@ -1992,6 +2047,7 @@ export async function doClaudeTuiStream(opts) {
1992
2047
  now: Date.now(),
1993
2048
  lastProgressAt,
1994
2049
  pendingToolCount: pendingHookToolIds.size + pendingBgForStall,
2050
+ awaitingUserReply,
1995
2051
  lastPtyDataAt: inPostStopHold ? 0 : lastPtyDataAt,
1996
2052
  });
1997
2053
  if (stallDecision === 'stall') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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": {