pikiclaw 0.3.68 → 0.3.70

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.
@@ -323,6 +323,68 @@ export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
323
323
  return null;
324
324
  return limitNoticeFromText(extractTextBlocks(msgOrText.content));
325
325
  }
326
+ /**
327
+ * Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
328
+ * a slice of (ANSI-stripped) PTY screen output. When pikiclaw spawns the TUI
329
+ * with `--permission-mode bypassPermissions` (the default) on a machine that
330
+ * has not yet accepted bypass mode, Claude paints a blocking prompt:
331
+ *
332
+ * WARNING: Claude Code running in Bypass Permissions mode
333
+ * ...
334
+ * ❯ 1. No, exit
335
+ * 2. Yes, I accept
336
+ *
337
+ * The default highlight sits on "No, exit", so the driver's blind prompt-submit
338
+ * Enter nudge would pick *exit* — the message never gets processed and the turn
339
+ * hangs on a pre-prompt. Seeding `bypassPermissionsModeAccepted` in config is
340
+ * not a reliable fix: it is version-fragile (observed no-op on 2.1.169) and
341
+ * gated by org policy (`isBypassPermissionsModeAvailable`). So we detect the
342
+ * dialog on the wire and auto-select "Yes, I accept". Require all three
343
+ * distinctive fragments so ordinary text mentioning "bypass" can't trigger it.
344
+ */
345
+ export function detectClaudeBypassPrompt(screen) {
346
+ if (typeof screen !== 'string' || !screen)
347
+ return false;
348
+ // Claude's TUI lays words out with cursor-move escapes (`\x1b[<col>G`) rather
349
+ // than literal spaces, so once ANSI is stripped the on-screen text runs
350
+ // together — the real dialog reads "BypassPermissionsmode" / "Yes,Iaccept" /
351
+ // "No,exit", not the spaced form. Collapse all whitespace before matching so
352
+ // the detector fires on the live PTY screen *and* on space-preserving
353
+ // renderings. (Verified against claude 2.1.168's actual bypass screen.)
354
+ const t = stripAnsiEscapes(screen).replace(/\s+/g, '').toLowerCase();
355
+ return t.includes('bypasspermissionsmode')
356
+ && t.includes('yes,iaccept')
357
+ && t.includes('no,exit');
358
+ }
359
+ /**
360
+ * Capture-only classifier for the stall watchdog. When the turn goes quiet we
361
+ * cannot tell from timing alone whether the TUI is (a) frozen mid-turn (the
362
+ * known CLI bug — PTY dead), (b) just thinking for a long time (PTY repaints a
363
+ * spinner), or (c) blocked on an interactive prompt that bypass mode does NOT
364
+ * suppress and that's waiting for input it will never get (trust-a-new-folder,
365
+ * a "Do you want to proceed?" confirmation, an expired-login prompt, …). The
366
+ * raw PTY screen is the only thing that disambiguates them, and we don't
367
+ * otherwise persist it — so on a stall we record a compact stripped sample plus
368
+ * a conservative "looks like an interactive prompt" flag. Changes no control
369
+ * flow; it exists purely to make the next stall diagnosable from data.
370
+ */
371
+ export function classifyStallScreen(screen) {
372
+ if (typeof screen !== 'string' || !screen)
373
+ return { looksLikePrompt: false, sample: '' };
374
+ const stripped = stripAnsiEscapes(screen);
375
+ const sample = stripped.replace(/\s+/g, ' ').trim().slice(-400);
376
+ // Claude positions words with cursor moves, so the live screen is spaceless;
377
+ // match against the despaced form (see detectClaudeBypassPrompt).
378
+ const ds = stripped.replace(/\s+/g, '').toLowerCase();
379
+ const looksLikePrompt = ds.includes('esctocancel') // claude's confirm-dialog footer ("Enter to confirm · Esc to cancel")
380
+ || ds.includes('doyouwant')
381
+ || ds.includes('wouldyoulike')
382
+ || ds.includes('trustthisfolder')
383
+ || ds.includes('yes,iaccept')
384
+ || ds.includes('(y/n)')
385
+ || (ds.includes('❯') && ds.includes('1.') && ds.includes('2.')); // numbered select with cursor
386
+ return { looksLikePrompt, sample };
387
+ }
326
388
  /**
327
389
  * Extract text / thinking blocks from an assistant JSONL event and route them:
328
390
  * text → the chunked stream buffer (slow drain), thinking → `s.thinking`
@@ -1055,6 +1117,30 @@ export async function doClaudeTuiStream(opts) {
1055
1117
  const dbg = process.env.PIKICLAW_CLAUDE_TUI_DEBUG === '1';
1056
1118
  /** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
1057
1119
  let lastPtyDataAt = Date.now();
1120
+ // Startup-dialog auto-answer. Claude's TUI can paint a blocking "Bypass
1121
+ // Permissions mode" confirmation before it accepts our positional prompt
1122
+ // (default highlight = "No, exit"). We keep a bounded ANSI-stripped tail of
1123
+ // the screen, detect that dialog (see detectClaudeBypassPrompt), and select
1124
+ // "Yes, I accept" so the turn never stalls on a pre-prompt.
1125
+ const SCREEN_TAIL_MAX = 8192;
1126
+ const BYPASS_ACCEPT_MAX_ATTEMPTS = 3;
1127
+ // Settle delay after the dialog first paints before we send any key. Claude's
1128
+ // Ink select drops input aimed at it during the first frames — sending the
1129
+ // digit too early is a no-op. ~500ms is comfortably past readiness in repro.
1130
+ const BYPASS_SETTLE_MS = 500;
1131
+ // Gap between the selection key and the confirm Enter. Claude's Ink select
1132
+ // swallows a combined "2\r" (only the digit lands; the Enter is dropped before
1133
+ // the highlight repaints), so the two keystrokes must be split in time —
1134
+ // 600ms is what reproduces reliably against the live 2.1.168 dialog.
1135
+ const BYPASS_CONFIRM_DELAY_MS = 600;
1136
+ // How long after the last bypass-dialog repaint we still treat it as on
1137
+ // screen — suppresses the blind prompt-submit Enter nudge across the whole
1138
+ // select→confirm sequence so a stray CR can't land on "No, exit".
1139
+ const BYPASS_DIALOG_ACTIVE_WINDOW_MS = 2000;
1140
+ let screenTail = '';
1141
+ let bypassPromptLastSeenAt = 0;
1142
+ let bypassAcceptAttempts = 0;
1143
+ let bypassPhase = 'idle';
1058
1144
  proc.onData((data) => {
1059
1145
  // We deliberately do not parse the TUI screen output. The JSONL is the
1060
1146
  // canonical source of structured events. Stash bytes only when debugging.
@@ -1068,6 +1154,55 @@ export async function doClaudeTuiStream(opts) {
1068
1154
  }
1069
1155
  catch { }
1070
1156
  }
1157
+ // Auto-answer the bypass-permissions confirmation. Detect it the moment it
1158
+ // paints (off the raw PTY, not the 200ms poll tick) and arm a short timed
1159
+ // keystroke sequence. Keep a bounded stripped tail across chunks so a dialog
1160
+ // split across reads still matches.
1161
+ screenTail = (screenTail + stripAnsiEscapes(data)).slice(-SCREEN_TAIL_MAX);
1162
+ if (detectClaudeBypassPrompt(screenTail)) {
1163
+ bypassPromptLastSeenAt = Date.now();
1164
+ if (bypassPhase === 'idle' && bypassAcceptAttempts < BYPASS_ACCEPT_MAX_ATTEMPTS) {
1165
+ bypassAcceptAttempts++;
1166
+ bypassPhase = 'armed';
1167
+ // Three timed steps — verified 3/3 against the live 2.1.168 dialog:
1168
+ // settle (dialog ignores input on its first frames)
1169
+ // → "2" (jumps to the second option "Yes, I accept"; idempotent —
1170
+ // re-sending can't overshoot a 2-option menu onto "No, exit")
1171
+ // → Enter (confirms; must arrive *after* the highlight repaints — a
1172
+ // combined "2\r" gets swallowed, only the digit lands).
1173
+ agentLog(`[claude-tui] bypass-permissions prompt — auto-accepting "Yes, I accept" (attempt ${bypassAcceptAttempts}/${BYPASS_ACCEPT_MAX_ATTEMPTS})`);
1174
+ setTimeout(() => {
1175
+ if (processExited)
1176
+ return;
1177
+ try {
1178
+ proc.write('2');
1179
+ }
1180
+ catch { }
1181
+ setTimeout(() => {
1182
+ if (processExited)
1183
+ return;
1184
+ try {
1185
+ proc.write('\r');
1186
+ }
1187
+ catch { }
1188
+ bypassPhase = 'confirmed';
1189
+ agentLog('[claude-tui] bypass-permissions — confirm Enter sent');
1190
+ // Drop the buffered dialog frame: the post-accept REPL output can be
1191
+ // tiny (e.g. a "Not logged in" line), so the old dialog text would
1192
+ // otherwise linger in the 8192-char tail and make the re-arm below
1193
+ // re-fire on a stale screen — typing "2"/Enter into the live prompt.
1194
+ // Clearing means the re-arm only sees output that arrives *after*
1195
+ // the confirm, so it re-fires only on a genuine repaint of the
1196
+ // dialog (accept didn't take), never on stale bytes.
1197
+ screenTail = '';
1198
+ setTimeout(() => {
1199
+ if (!processExited && detectClaudeBypassPrompt(screenTail))
1200
+ bypassPhase = 'idle';
1201
+ }, 1200);
1202
+ }, BYPASS_CONFIRM_DELAY_MS);
1203
+ }, BYPASS_SETTLE_MS);
1204
+ }
1205
+ }
1071
1206
  // Capture stderr-ish bytes (TUI startup errors, "claude: command not
1072
1207
  // found"-style messages) for the final error payload when the run aborts
1073
1208
  // before any JSONL is written. Strip ANSI on the way in — otherwise the
@@ -1314,8 +1449,15 @@ export async function doClaudeTuiStream(opts) {
1314
1449
  else if (state.transcriptPath && state.transcriptPath !== activeJsonlPath) {
1315
1450
  activeJsonlPath = state.transcriptPath;
1316
1451
  }
1317
- // Submit nudge — only if UserPromptSubmit hook hasn't fired yet.
1318
- if (!promptNudged && !state.promptSubmittedAt && Date.now() - start > PROMPT_SUBMIT_NUDGE_MS) {
1452
+ // Submit nudge — only if UserPromptSubmit hook hasn't fired yet. Suppress
1453
+ // it while the bypass-permissions dialog is (or was just) on screen: a blind
1454
+ // CR there lands on the default "No, exit" and kills the session. The dialog
1455
+ // auto-answer in onData drives that screen instead; once it clears the
1456
+ // prompt submits on its own (or this nudge fires on a later tick).
1457
+ const bypassDialogActive = bypassPromptLastSeenAt > 0
1458
+ && Date.now() - bypassPromptLastSeenAt < BYPASS_DIALOG_ACTIVE_WINDOW_MS;
1459
+ if (!promptNudged && !state.promptSubmittedAt && !bypassDialogActive
1460
+ && Date.now() - start > PROMPT_SUBMIT_NUDGE_MS) {
1319
1461
  promptNudged = true;
1320
1462
  try {
1321
1463
  proc.write('\r');
@@ -1488,6 +1630,9 @@ export async function doClaudeTuiStream(opts) {
1488
1630
  stallDiagPtyAliveWhileQuiet = true;
1489
1631
  if (nowMs - lastStallDiagHeartbeatAt >= STALL_DIAG_HEARTBEAT_INTERVAL_MS) {
1490
1632
  lastStallDiagHeartbeatAt = nowMs;
1633
+ // Snapshot the screen so a quiet stretch can later be classified as
1634
+ // a frozen stream vs a long think vs a blocking interactive prompt.
1635
+ const screenInfo = classifyStallScreen(screenTail);
1491
1636
  writeStallDiag({
1492
1637
  kind: 'quiet',
1493
1638
  sessionId: activeSessionId,
@@ -1503,6 +1648,8 @@ export async function doClaudeTuiStream(opts) {
1503
1648
  pendingHookTools: pendingHookToolIds.size,
1504
1649
  pendingBgAgents: pendingBgForStall,
1505
1650
  pendingBgBash: pendingClaudeBackgroundBashCount(s),
1651
+ looksLikePrompt: screenInfo.looksLikePrompt,
1652
+ screenSample: screenInfo.sample,
1506
1653
  });
1507
1654
  }
1508
1655
  }
@@ -1518,6 +1665,7 @@ export async function doClaudeTuiStream(opts) {
1518
1665
  const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
1519
1666
  const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
1520
1667
  s.stopReason = 'stalled';
1668
+ const stallScreen = classifyStallScreen(screenTail);
1521
1669
  writeStallDiag({
1522
1670
  kind: 'stall',
1523
1671
  sessionId: activeSessionId,
@@ -1532,6 +1680,11 @@ export async function doClaudeTuiStream(opts) {
1532
1680
  lastJsonlType: lastMainJsonlType,
1533
1681
  pendingHookTools: pendingHookToolIds.size,
1534
1682
  pendingBgAgents: pendingBgForStall,
1683
+ // looksLikePrompt=true here is the signal that the "stall" was really
1684
+ // a blocking interactive prompt waiting for input bypass can't skip —
1685
+ // the mid-turn dialog-hang hypothesis, confirmable from screenSample.
1686
+ looksLikePrompt: stallScreen.looksLikePrompt,
1687
+ screenSample: stallScreen.sample,
1535
1688
  });
1536
1689
  if (!s.errors) {
1537
1690
  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.`];
@@ -2398,7 +2398,7 @@ async function doClaudeWithRetry(opts) {
2398
2398
  ok: false,
2399
2399
  incomplete: true,
2400
2400
  message: [
2401
- 'The agent process stalled mid-turn and could not be auto-recovered (known claude CLI freeze, seen on 2.1.160).',
2401
+ 'The agent process stalled mid-turn and could not be auto-recovered (a known claude CLI mid-turn freeze).',
2402
2402
  'Your session is intact — re-send your message (or say "continue") to pick up where it stopped.',
2403
2403
  ].join(' '),
2404
2404
  };
@@ -276,12 +276,6 @@ function renderSubAgentsForPreview(meta) {
276
276
  }
277
277
  return lines.join('\n');
278
278
  }
279
- /** After this much wall-clock, a still-running turn shows a text-only "still
280
- * working" banner (see StreamPreviewData.longRunHint) so a long silent
281
- * operation (held background task, slow command) doesn't read as a frozen
282
- * card. Deliberately above the chunked-stream cadence so quick turns never
283
- * flash it. */
284
- const LONG_RUN_HINT_AFTER_MS = 60_000;
285
279
  export function extractStreamPreviewData(input) {
286
280
  const maxBody = 2400;
287
281
  const display = input.bodyText.trim();
@@ -299,13 +293,6 @@ export function extractStreamPreviewData(input) {
299
293
  // freshly-opened card doesn't flash "0s".
300
294
  const elapsedMs = Math.max(0, input.elapsedMs);
301
295
  const thinkingProgressText = elapsedMs >= 1000 ? fmtCompactUptime(elapsedMs) : null;
302
- // After a turn has run a while, a long silent operation (a held background
303
- // task, a slow command) can make the card look frozen. Surface a text-only
304
- // "still working" line so the user knows it's alive and can switch away. No
305
- // elapsed time here — the footer keeps the single clock, so no second timer.
306
- const longRunHint = elapsedMs >= LONG_RUN_HINT_AFTER_MS
307
- ? '⏳ Still working — the result will update in this card'
308
- : null;
309
296
  return {
310
297
  display,
311
298
  rawThinking,
@@ -318,6 +305,5 @@ export function extractStreamPreviewData(input) {
318
305
  thinkSnippet,
319
306
  preview,
320
307
  thinkingProgressText,
321
- longRunHint,
322
308
  };
323
309
  }
@@ -246,8 +246,6 @@ function buildPreviewMarkdown(input, options) {
246
246
  // heartbeat, so the card still visibly advances.
247
247
  parts.push(`**${data.label}**`);
248
248
  }
249
- if (data.longRunHint)
250
- parts.push(data.longRunHint);
251
249
  if (options?.includeFooter !== false) {
252
250
  parts.push(formatPreviewFooter(input.agent, input.elapsedMs, input.meta ?? null, {
253
251
  model: input.model,
@@ -345,8 +345,6 @@ export function buildStreamPreviewHtml(input) {
345
345
  // heartbeat, so the card still visibly advances.
346
346
  parts.push(`<blockquote><b>${escapeHtml(data.label)}</b></blockquote>`);
347
347
  }
348
- if (data.longRunHint)
349
- parts.push(`<i>${escapeHtml(data.longRunHint)}</i>`);
350
348
  parts.push(formatPreviewFooterHtml(input.agent, input.elapsedMs, input.meta ?? null, {
351
349
  model: input.model,
352
350
  effort: input.effort,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
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": {