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
|
-
|
|
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
|
|
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