pikiclaw 0.3.73 → 0.3.75

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.
@@ -45,7 +45,7 @@ import { encodePathAsDirName, getHome, whichSync } from '../../core/platform.js'
45
45
  import { createRetainedLogSink } from '../../core/logging.js';
46
46
  import { stripAnsiEscapes } from '../../core/utils.js';
47
47
  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';
48
- import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, extractClaudeWorkflowRunId, } from './claude.js';
48
+ import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, extractClaudeWorkflowRunId, claudeEffortAndWorkflowArgs, } from './claude.js';
49
49
  // ---------------------------------------------------------------------------
50
50
  // Stall diagnostics (capture-only)
51
51
  // ---------------------------------------------------------------------------
@@ -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
@@ -976,8 +1002,9 @@ export async function doClaudeTuiStream(opts) {
976
1002
  claudeArgs.push('--model', model);
977
1003
  if (opts.claudePermissionMode)
978
1004
  claudeArgs.push('--permission-mode', opts.claudePermissionMode);
979
- if (opts.thinkingEffort)
980
- claudeArgs.push('--effort', opts.thinkingEffort);
1005
+ // Effort + Workflow gate — same source of truth as the `claude -p` driver, so
1006
+ // the TUI path drops the Workflow tool unless orchestration was opted in.
1007
+ claudeArgs.push(...claudeEffortAndWorkflowArgs(opts));
981
1008
  if (opts.claudeAppendSystemPrompt)
982
1009
  claudeArgs.push('--append-system-prompt', opts.claudeAppendSystemPrompt);
983
1010
  if (opts.mcpConfigPath)
@@ -1048,6 +1075,7 @@ export async function doClaudeTuiStream(opts) {
1048
1075
  let exitCode = null;
1049
1076
  let exitSignal = null;
1050
1077
  let terminalLimitNotice = null;
1078
+ let terminalLimitNoticeAt = 0;
1051
1079
  let proc;
1052
1080
  const emit = () => {
1053
1081
  try {
@@ -1069,16 +1097,20 @@ export async function doClaudeTuiStream(opts) {
1069
1097
  }
1070
1098
  }, after);
1071
1099
  };
1072
- const markTerminalLimitNotice = (notice) => {
1100
+ // Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
1101
+ // informational (extra-usage credits kick in and the turn continues), so
1102
+ // killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
1103
+ // arbitrates later — at the stall watchdog and at result assembly — based
1104
+ // on whether the turn produced anything substantive after the banner.
1105
+ const noteTerminalLimitNotice = (notice) => {
1073
1106
  if (terminalLimitNotice)
1074
1107
  return;
1075
1108
  terminalLimitNotice = notice;
1076
- s.stopReason = 'rate_limit';
1077
- s.errors = [notice];
1078
- agentWarn(`[claude-tui] terminal limit notice detected: ${notice}`);
1109
+ terminalLimitNoticeAt = Date.now();
1110
+ agentWarn(`[claude-tui] limit notice observed (watching turn liveness): ${notice}`);
1111
+ pushRecentActivity(s.recentActivity, `Claude usage notice: ${notice}`);
1112
+ s.activity = s.recentActivity.join('\n');
1079
1113
  emit();
1080
- if (!processExited)
1081
- killProc('SIGTERM', 1500);
1082
1114
  };
1083
1115
  // Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
1084
1116
  const streamBuf = makeTuiStreamBuffer();
@@ -1317,7 +1349,7 @@ export async function doClaudeTuiStream(opts) {
1317
1349
  stderrCapture = stderrCapture.slice(0, 4096);
1318
1350
  const notice = detectClaudeTuiTerminalLimitNotice(stderrCapture);
1319
1351
  if (notice)
1320
- markTerminalLimitNotice(notice);
1352
+ noteTerminalLimitNotice(notice);
1321
1353
  }
1322
1354
  });
1323
1355
  // 7. Abort handling.
@@ -1367,6 +1399,11 @@ export async function doClaudeTuiStream(opts) {
1367
1399
  // decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
1368
1400
  let lastToolEventAt = start;
1369
1401
  let lastSidecarEventAt = 0;
1402
+ // Last non-synthetic assistant JSONL event — substantive-progress signal
1403
+ // for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
1404
+ // from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
1405
+ // last-prompt, …) that land right after submit and prove nothing.
1406
+ let lastAssistantEventAt = 0;
1370
1407
  let stallKilled = false;
1371
1408
  // Stall diagnostics (capture-only) — see writeStallDiag.
1372
1409
  let observedClaudeVersion = '';
@@ -1590,13 +1627,14 @@ export async function doClaudeTuiStream(opts) {
1590
1627
  if (!isSubAgentEvent && ev.type === 'assistant') {
1591
1628
  const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1592
1629
  if (notice) {
1593
- markTerminalLimitNotice(notice);
1630
+ noteTerminalLimitNotice(notice);
1594
1631
  touched = true;
1595
1632
  continue;
1596
1633
  }
1597
1634
  applyAssistantStreaming(s, ev.message, streamBuf);
1598
1635
  applyAssistantUsage(s, ev.message);
1599
1636
  if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1637
+ lastAssistantEventAt = Date.now();
1600
1638
  s.model = ev.message.model;
1601
1639
  applyModelContextWindow(s);
1602
1640
  }
@@ -1788,15 +1826,34 @@ export async function doClaudeTuiStream(opts) {
1788
1826
  screenSample: stallScreen.sample,
1789
1827
  });
1790
1828
  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.`];
1829
+ // Limit-notice arbitration first: a turn that showed a limit banner
1830
+ // and then produced nothing substantive didn't freeze — the limit
1831
+ // ate it. Label it rate_limit with the banner's own text (which
1832
+ // carries the reset time) so the user gets the real reason, and so
1833
+ // doClaudeWithRetry doesn't auto-resume into the same wall.
1834
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
1835
+ noticeText: terminalLimitNotice,
1836
+ noticeAt: terminalLimitNoticeAt,
1837
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
1838
+ hasOutputText: !!s.text.trim(),
1839
+ });
1840
+ if (limitOutcome === 'fatal') {
1841
+ s.stopReason = 'rate_limit';
1842
+ s.errors = [terminalLimitNotice];
1843
+ }
1844
+ else {
1845
+ // Be honest about which kind of stall this is. looksLikePrompt here
1846
+ // means the auto-answer (detectClaudeProceedPrompt) did NOT clear an
1847
+ // interactive prompt — so it's a blocking dialog, not the CLI freeze.
1848
+ s.errors = [stallScreen.looksLikePrompt
1849
+ ? `Claude blocked mid-turn on an interactive prompt (PTY quiet ${ptyQuietS}s) that auto-answer couldn't clear. Terminated for auto-resume.`
1850
+ : `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.`];
1851
+ }
1797
1852
  }
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`);
1853
+ 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'}`);
1854
+ pushRecentActivity(s.recentActivity, s.stopReason === 'rate_limit'
1855
+ ? 'Usage limit blocked the turn — stopping'
1856
+ : `Agent stalled (${quietMin}m silent) — restarting turn`);
1800
1857
  s.activity = s.recentActivity.join('\n');
1801
1858
  emit();
1802
1859
  killProc('SIGTERM');
@@ -1845,13 +1902,14 @@ export async function doClaudeTuiStream(opts) {
1845
1902
  if (!isSubAgentEvent && ev.type === 'assistant') {
1846
1903
  const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1847
1904
  if (notice) {
1848
- markTerminalLimitNotice(notice);
1905
+ noteTerminalLimitNotice(notice);
1849
1906
  touched = true;
1850
1907
  continue;
1851
1908
  }
1852
1909
  applyAssistantStreaming(s, ev.message, streamBuf);
1853
1910
  applyAssistantUsage(s, ev.message);
1854
1911
  if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1912
+ lastAssistantEventAt = Date.now();
1855
1913
  s.model = ev.message.model;
1856
1914
  applyModelContextWindow(s);
1857
1915
  }
@@ -1908,6 +1966,23 @@ export async function doClaudeTuiStream(opts) {
1908
1966
  if (!s.errors)
1909
1967
  s.errors = [`Anthropic API error: ${apiErrorReason}`];
1910
1968
  }
1969
+ // Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
1970
+ // paths the stall watchdog never reaches: the TUI painted a limit banner,
1971
+ // then Stop fired on an empty turn or the process exited — nothing
1972
+ // substantive ever followed the banner, so the limit ate the turn. A banner
1973
+ // followed by real output stays informational (already in the activity log).
1974
+ if (!interrupted && !timedOut && !s.errors) {
1975
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
1976
+ noticeText: terminalLimitNotice,
1977
+ noticeAt: terminalLimitNoticeAt,
1978
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
1979
+ hasOutputText: !!s.text.trim(),
1980
+ });
1981
+ if (limitOutcome === 'fatal') {
1982
+ s.stopReason = 'rate_limit';
1983
+ s.errors = [terminalLimitNotice];
1984
+ }
1985
+ }
1911
1986
  const errorText = joinErrorMessages(s.errors);
1912
1987
  // "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
1913
1988
  // hook fired, which yields a non-zero exit), no errors from the parser, no
@@ -42,6 +42,29 @@ function claudeUsesStreamJsonInput(o) {
42
42
  return !!o.attachments?.length || !!o.onSteerReady;
43
43
  }
44
44
  const CLAUDE_STEER_IDLE_CLOSE_MS = 1200;
45
+ /**
46
+ * Effort + multi-agent-Workflow gate args, shared by BOTH Claude spawn paths
47
+ * (`claude -p` in claudeCmd below and the PTY/TUI driver in claude-tui.ts).
48
+ * Kept in one place so the gate can never drift between them — the omission
49
+ * that once left the Workflow tool always-on under the TUI driver.
50
+ *
51
+ * "ultra" is a synthetic picker rung (max depth + Workflow orchestration), never
52
+ * a real --effort value — translate it to `max` so a stray "ultra" can't reach
53
+ * and break the CLI, and treat it as an implicit workflow opt-in. The Workflow
54
+ * tool ships in the default toolset and triggers on a bare "workflow" keyword;
55
+ * under the bypassPermissions mode pikiclaw runs by default that could auto-spawn
56
+ * a fleet of sub-agents, so drop it entirely unless orchestration was explicitly
57
+ * enabled (the workflow flag or the "ultra" rung).
58
+ */
59
+ export function claudeEffortAndWorkflowArgs(o) {
60
+ const args = [];
61
+ const ultraEffort = o.thinkingEffort === 'ultra';
62
+ if (o.thinkingEffort)
63
+ args.push('--effort', ultraEffort ? 'max' : o.thinkingEffort);
64
+ if (!o.claudeWorkflowEnabled && !ultraEffort)
65
+ args.push('--disallowed-tools', 'Workflow');
66
+ return args;
67
+ }
45
68
  // ---------------------------------------------------------------------------
46
69
  // Command & parser
47
70
  // ---------------------------------------------------------------------------
@@ -70,21 +93,9 @@ function claudeCmd(o) {
70
93
  if (o.attachments?.length)
71
94
  o._stdinOverride = buildClaudeUserMessage(o.prompt, o.attachments);
72
95
  }
73
- // "ultra" is a synthetic picker rung (max depth + Workflow orchestration),
74
- // never a real --effort value. The effort-write paths decompose it upstream;
75
- // translate defensively here too so a stray "ultra" can never reach — and
76
- // break — the CLI, and so it never suppresses the Workflow tool below.
77
- const ultraEffort = o.thinkingEffort === 'ultra';
78
- if (o.thinkingEffort)
79
- args.push('--effort', ultraEffort ? 'max' : o.thinkingEffort);
80
- // Multi-agent Workflow gate. The Workflow tool is always present in the
81
- // toolset and triggers on a bare "workflow" keyword — combined with the
82
- // bypassPermissions mode pikiclaw runs by default, that means an offhand
83
- // mention could auto-spawn a fleet of sub-agents. Unless orchestration is
84
- // explicitly enabled, drop the tool entirely so it can't fire at all. When
85
- // enabled, the bot injects a standing opt-in directive via the system prompt.
86
- if (!o.claudeWorkflowEnabled && !ultraEffort)
87
- args.push('--disallowed-tools', 'Workflow');
96
+ // Effort + Workflow gate shared with the TUI driver (claude-tui.ts) so the
97
+ // two spawn paths can never drift. See claudeEffortAndWorkflowArgs.
98
+ args.push(...claudeEffortAndWorkflowArgs(o));
88
99
  if (o.claudeAppendSystemPrompt)
89
100
  args.push('--append-system-prompt', o.claudeAppendSystemPrompt);
90
101
  if (o.mcpConfigPath)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.73",
3
+ "version": "0.3.75",
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": {