pikiclaw 0.3.73 → 0.3.74

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,32 @@ export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
323
323
  return null;
324
324
  return limitNoticeFromText(extractTextBlocks(msgOrText.content));
325
325
  }
326
+ /**
327
+ * Evidence-based arbitration for a detected limit notice. The banner text is
328
+ * deliberately matched broadly (wording shifts across CLI versions and some
329
+ * notices are informational — "You're now using usage credits · Your session
330
+ * limit resets 3pm" means the turn CONTINUES on extra-usage credits), so a
331
+ * match alone must never fail the turn. What decides the outcome is whether
332
+ * the turn produced anything substantive after the banner:
333
+ *
334
+ * - 'info' — assistant text exists, or a substantive signal (non-synthetic
335
+ * assistant JSONL, hook tool event, sub-agent sidecar) postdates
336
+ * the notice. The turn is alive; the notice is informational.
337
+ * - 'fatal' — nothing substantive after the banner. The limit genuinely ate
338
+ * the turn; surface the banner text as a rate_limit failure.
339
+ * - 'none' — no notice was seen.
340
+ *
341
+ * Worst case of the broad matching is therefore an activity line, not a
342
+ * killed turn (the bug this replaced: the credits banner used to SIGTERM the
343
+ * process mid-answer).
344
+ */
345
+ export function resolveClaudeTuiLimitOutcome(input) {
346
+ if (!input.noticeText)
347
+ return 'none';
348
+ if (input.hasOutputText || input.lastSubstantiveEventAt > input.noticeAt)
349
+ return 'info';
350
+ return 'fatal';
351
+ }
326
352
  /**
327
353
  * Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
328
354
  * a slice of (ANSI-stripped) PTY screen output. When pikiclaw spawns the TUI
@@ -1048,6 +1074,7 @@ export async function doClaudeTuiStream(opts) {
1048
1074
  let exitCode = null;
1049
1075
  let exitSignal = null;
1050
1076
  let terminalLimitNotice = null;
1077
+ let terminalLimitNoticeAt = 0;
1051
1078
  let proc;
1052
1079
  const emit = () => {
1053
1080
  try {
@@ -1069,16 +1096,20 @@ export async function doClaudeTuiStream(opts) {
1069
1096
  }
1070
1097
  }, after);
1071
1098
  };
1072
- const markTerminalLimitNotice = (notice) => {
1099
+ // Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
1100
+ // informational (extra-usage credits kick in and the turn continues), so
1101
+ // killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
1102
+ // arbitrates later — at the stall watchdog and at result assembly — based
1103
+ // on whether the turn produced anything substantive after the banner.
1104
+ const noteTerminalLimitNotice = (notice) => {
1073
1105
  if (terminalLimitNotice)
1074
1106
  return;
1075
1107
  terminalLimitNotice = notice;
1076
- s.stopReason = 'rate_limit';
1077
- s.errors = [notice];
1078
- agentWarn(`[claude-tui] terminal limit notice detected: ${notice}`);
1108
+ terminalLimitNoticeAt = Date.now();
1109
+ agentWarn(`[claude-tui] limit notice observed (watching turn liveness): ${notice}`);
1110
+ pushRecentActivity(s.recentActivity, `Claude usage notice: ${notice}`);
1111
+ s.activity = s.recentActivity.join('\n');
1079
1112
  emit();
1080
- if (!processExited)
1081
- killProc('SIGTERM', 1500);
1082
1113
  };
1083
1114
  // Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
1084
1115
  const streamBuf = makeTuiStreamBuffer();
@@ -1317,7 +1348,7 @@ export async function doClaudeTuiStream(opts) {
1317
1348
  stderrCapture = stderrCapture.slice(0, 4096);
1318
1349
  const notice = detectClaudeTuiTerminalLimitNotice(stderrCapture);
1319
1350
  if (notice)
1320
- markTerminalLimitNotice(notice);
1351
+ noteTerminalLimitNotice(notice);
1321
1352
  }
1322
1353
  });
1323
1354
  // 7. Abort handling.
@@ -1367,6 +1398,11 @@ export async function doClaudeTuiStream(opts) {
1367
1398
  // decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
1368
1399
  let lastToolEventAt = start;
1369
1400
  let lastSidecarEventAt = 0;
1401
+ // Last non-synthetic assistant JSONL event — substantive-progress signal
1402
+ // for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
1403
+ // from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
1404
+ // last-prompt, …) that land right after submit and prove nothing.
1405
+ let lastAssistantEventAt = 0;
1370
1406
  let stallKilled = false;
1371
1407
  // Stall diagnostics (capture-only) — see writeStallDiag.
1372
1408
  let observedClaudeVersion = '';
@@ -1590,13 +1626,14 @@ export async function doClaudeTuiStream(opts) {
1590
1626
  if (!isSubAgentEvent && ev.type === 'assistant') {
1591
1627
  const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1592
1628
  if (notice) {
1593
- markTerminalLimitNotice(notice);
1629
+ noteTerminalLimitNotice(notice);
1594
1630
  touched = true;
1595
1631
  continue;
1596
1632
  }
1597
1633
  applyAssistantStreaming(s, ev.message, streamBuf);
1598
1634
  applyAssistantUsage(s, ev.message);
1599
1635
  if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1636
+ lastAssistantEventAt = Date.now();
1600
1637
  s.model = ev.message.model;
1601
1638
  applyModelContextWindow(s);
1602
1639
  }
@@ -1788,15 +1825,34 @@ export async function doClaudeTuiStream(opts) {
1788
1825
  screenSample: stallScreen.sample,
1789
1826
  });
1790
1827
  if (!s.errors) {
1791
- // Be honest about which kind of stall this is. looksLikePrompt here
1792
- // means the auto-answer (detectClaudeProceedPrompt) did NOT clear an
1793
- // interactive prompt so it's a blocking dialog, not the CLI freeze.
1794
- s.errors = [stallScreen.looksLikePrompt
1795
- ? `Claude blocked mid-turn on an interactive prompt (PTY quiet ${ptyQuietS}s) that auto-answer couldn't clear. Terminated for auto-resume.`
1796
- : `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.`];
1828
+ // Limit-notice arbitration first: a turn that showed a limit banner
1829
+ // and then produced nothing substantive didn't freeze — the limit
1830
+ // ate it. Label it rate_limit with the banner's own text (which
1831
+ // carries the reset time) so the user gets the real reason, and so
1832
+ // doClaudeWithRetry doesn't auto-resume into the same wall.
1833
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
1834
+ noticeText: terminalLimitNotice,
1835
+ noticeAt: terminalLimitNoticeAt,
1836
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
1837
+ hasOutputText: !!s.text.trim(),
1838
+ });
1839
+ if (limitOutcome === 'fatal') {
1840
+ s.stopReason = 'rate_limit';
1841
+ s.errors = [terminalLimitNotice];
1842
+ }
1843
+ else {
1844
+ // Be honest about which kind of stall this is. looksLikePrompt here
1845
+ // means the auto-answer (detectClaudeProceedPrompt) did NOT clear an
1846
+ // interactive prompt — so it's a blocking dialog, not the CLI freeze.
1847
+ s.errors = [stallScreen.looksLikePrompt
1848
+ ? `Claude blocked mid-turn on an interactive prompt (PTY quiet ${ptyQuietS}s) that auto-answer couldn't clear. Terminated for auto-resume.`
1849
+ : `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.`];
1850
+ }
1797
1851
  }
1798
- agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (pendingTools=${pendingHookToolIds.size}, ptyQuiet=${ptyQuietS}s) — terminating TUI pid=${proc.pid} for auto-resume`);
1799
- pushRecentActivity(s.recentActivity, `Agent stalled (${quietMin}m silent) — restarting turn`);
1852
+ agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (pendingTools=${pendingHookToolIds.size}, ptyQuiet=${ptyQuietS}s) — terminating TUI pid=${proc.pid}${s.stopReason === 'rate_limit' ? ' (usage limit)' : ' for auto-resume'}`);
1853
+ pushRecentActivity(s.recentActivity, s.stopReason === 'rate_limit'
1854
+ ? 'Usage limit blocked the turn — stopping'
1855
+ : `Agent stalled (${quietMin}m silent) — restarting turn`);
1800
1856
  s.activity = s.recentActivity.join('\n');
1801
1857
  emit();
1802
1858
  killProc('SIGTERM');
@@ -1845,13 +1901,14 @@ export async function doClaudeTuiStream(opts) {
1845
1901
  if (!isSubAgentEvent && ev.type === 'assistant') {
1846
1902
  const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1847
1903
  if (notice) {
1848
- markTerminalLimitNotice(notice);
1904
+ noteTerminalLimitNotice(notice);
1849
1905
  touched = true;
1850
1906
  continue;
1851
1907
  }
1852
1908
  applyAssistantStreaming(s, ev.message, streamBuf);
1853
1909
  applyAssistantUsage(s, ev.message);
1854
1910
  if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1911
+ lastAssistantEventAt = Date.now();
1855
1912
  s.model = ev.message.model;
1856
1913
  applyModelContextWindow(s);
1857
1914
  }
@@ -1908,6 +1965,23 @@ export async function doClaudeTuiStream(opts) {
1908
1965
  if (!s.errors)
1909
1966
  s.errors = [`Anthropic API error: ${apiErrorReason}`];
1910
1967
  }
1968
+ // Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
1969
+ // paths the stall watchdog never reaches: the TUI painted a limit banner,
1970
+ // then Stop fired on an empty turn or the process exited — nothing
1971
+ // substantive ever followed the banner, so the limit ate the turn. A banner
1972
+ // followed by real output stays informational (already in the activity log).
1973
+ if (!interrupted && !timedOut && !s.errors) {
1974
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
1975
+ noticeText: terminalLimitNotice,
1976
+ noticeAt: terminalLimitNoticeAt,
1977
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
1978
+ hasOutputText: !!s.text.trim(),
1979
+ });
1980
+ if (limitOutcome === 'fatal') {
1981
+ s.stopReason = 'rate_limit';
1982
+ s.errors = [terminalLimitNotice];
1983
+ }
1984
+ }
1911
1985
  const errorText = joinErrorMessages(s.errors);
1912
1986
  // "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
1913
1987
  // hook fired, which yields a non-zero exit), no errors from the parser, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.73",
3
+ "version": "0.3.74",
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": {