pikiclaw 0.3.78 → 0.3.79

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, claudeEffortAndWorkflowArgs, } from './claude.js';
48
+ import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, extractClaudeWorkflowRunId, claudeEffortAndWorkflowArgs, scrubClaudeSessionContextEnv, } from './claude.js';
49
49
  // ---------------------------------------------------------------------------
50
50
  // Stall diagnostics (capture-only)
51
51
  // ---------------------------------------------------------------------------
@@ -956,12 +956,16 @@ export async function doClaudeTuiStream(opts) {
956
956
  // PATH inside the claude TUI's hook subprocess on every distro.
957
957
  const nodeBin = Q(process.execPath);
958
958
  const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
959
- // Pre/PostToolUse hooks give us a live event stream. Claude Code 2.x
960
- // buffers the JSONL transcript and only flushes it when Stop fires, so
961
- // without these hooks the dashboard / IM see absolutely no progress
962
- // during a 30s+ turn. The hook script writes to tool-events.jsonl via
963
- // atomic appends, sidestepping the read-modify-write race that affects
964
- // the shared state.json file.
959
+ // Pre/PostToolUse hooks give us a live tool-event stream. The transcript
960
+ // JSONL is itself written incrementally (events land ~0.2–1.2s after they
961
+ // happen measured on 2.1.173), but the hooks still earn their keep:
962
+ // PreToolUse fires the instant a tool *starts* (the JSONL tool_use only
963
+ // proves it was requested; a long-running Bash would otherwise sit
964
+ // invisible), they carry agent_id for sub-agent attribution, and they are
965
+ // the registration point for run_in_background launches that the Stop
966
+ // gating in decideClaudeTuiStop depends on. The hook script writes to
967
+ // tool-events.jsonl via atomic appends, sidestepping the
968
+ // read-modify-write race that affects the shared state.json file.
965
969
  // Pre/PostToolUse require an explicit `matcher` field — without it Claude
966
970
  // Code's hook dispatcher silently never fires the hook (the lifecycle
967
971
  // hooks below don't need a matcher because they aren't tool-scoped).
@@ -1157,9 +1161,13 @@ export async function doClaudeTuiStream(opts) {
1157
1161
  if (typeof v === 'string')
1158
1162
  spawnEnv[k] = v;
1159
1163
  }
1160
- // CLAUDECODE is set automatically by the parent claude process when calling
1161
- // children clear it so this is treated as a fresh top-level invocation.
1162
- delete spawnEnv.CLAUDECODE;
1164
+ // Strip the session-context markers a parent claude process exports to its
1165
+ // subprocesses (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, …). Inherited e.g.
1166
+ // when an agent restarted the pikiclaw daemon from inside a Claude Code
1167
+ // session, they flip this spawn into child-session mode — the transcript
1168
+ // JSONL (our only text source) is then never written locally and the turn
1169
+ // streams nothing. See CLAUDE_SESSION_CONTEXT_ENV_KEYS in claude.ts.
1170
+ scrubClaudeSessionContextEnv(spawnEnv);
1163
1171
  // Critical: leaving ANTHROPIC_API_KEY set would route TUI through API
1164
1172
  // billing too, defeating the whole point. Strip it unless the user
1165
1173
  // explicitly opts back in.
@@ -1416,11 +1424,69 @@ export async function doClaudeTuiStream(opts) {
1416
1424
  let lastClearedStopAt = 0;
1417
1425
  /** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
1418
1426
  const pendingHookToolIds = new Set();
1427
+ // Incremental main-JSONL drain — the canonical text/thinking/usage feed.
1428
+ // Used by both the 200ms poll tick and the post-exit final drain. Returns
1429
+ // true when any line was consumed so callers can emit().
1430
+ const drainMainJsonl = () => {
1431
+ if (!fs.existsSync(activeJsonlPath))
1432
+ return false;
1433
+ const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
1434
+ jsonlReadOffset = inc.offset;
1435
+ let touched = false;
1436
+ for (const line of inc.lines) {
1437
+ const trimmed = line.trim();
1438
+ if (!trimmed || trimmed[0] !== '{')
1439
+ continue;
1440
+ lineCount++;
1441
+ let ev;
1442
+ try {
1443
+ ev = JSON.parse(trimmed);
1444
+ }
1445
+ catch {
1446
+ continue;
1447
+ }
1448
+ // Ignore sub-agent sidecar events — they belong to a child agent's
1449
+ // stream and would re-enter the parent's accumulator. claudeParse's
1450
+ // own sub-agent routing handles them.
1451
+ const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
1452
+ if (!isSubAgentEvent && ev.type === 'assistant') {
1453
+ const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1454
+ if (notice) {
1455
+ // A synthetic limit banner is not substantive progress — skip the
1456
+ // liveness/type bookkeeping below so the limit arbitration and the
1457
+ // stall watchdog don't mistake it for a live model segment.
1458
+ noteTerminalLimitNotice(notice);
1459
+ touched = true;
1460
+ continue;
1461
+ }
1462
+ applyAssistantStreaming(s, ev.message, streamBuf);
1463
+ applyAssistantUsage(s, ev.message);
1464
+ if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1465
+ lastAssistantEventAt = Date.now();
1466
+ s.model = ev.message.model;
1467
+ applyModelContextWindow(s);
1468
+ }
1469
+ }
1470
+ try {
1471
+ callClaudeParseForTui(ev, s);
1472
+ }
1473
+ catch (e) {
1474
+ agentWarn(`[claude-tui] claudeParse threw on line: ${e?.message || e}`);
1475
+ }
1476
+ touched = true;
1477
+ lastMainJsonlEventAt = Date.now();
1478
+ if (typeof ev.version === 'string' && ev.version)
1479
+ observedClaudeVersion = ev.version;
1480
+ if (!isSubAgentEvent)
1481
+ lastMainJsonlType = classifyClaudeJsonlEvent(ev);
1482
+ }
1483
+ return touched;
1484
+ };
1419
1485
  // Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
1420
- // tail it with the same incremental reader the JSONL transcript uses, so
1421
- // tool calls + plan changes surface live during the turn even while the
1422
- // canonical JSONL stays empty (Claude Code 2.x buffers the whole transcript
1423
- // until the Stop hook fires).
1486
+ // tail it with the same incremental reader the JSONL transcript uses. Hook
1487
+ // events usually beat their JSONL counterpart by a second or so (and
1488
+ // PreToolUse fires before the tool even runs); whichever feed arrives first
1489
+ // wins, the other dedups via seenClaudeToolIds / seenClaudeToolResultIds.
1424
1490
  let toolEventsReadOffset = 0;
1425
1491
  const drainToolEvents = () => {
1426
1492
  if (!fs.existsSync(toolEventsPath))
@@ -1604,69 +1670,18 @@ export async function doClaudeTuiStream(opts) {
1604
1670
  agentLog(`[claude-tui] prompt-submit nudge sent (no UserPromptSubmit after ${PROMPT_SUBMIT_NUDGE_MS}ms)`);
1605
1671
  }
1606
1672
  // JSONL tail.
1607
- if (fs.existsSync(activeJsonlPath)) {
1608
- const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
1609
- jsonlReadOffset = inc.offset;
1610
- let touched = false;
1611
- for (const line of inc.lines) {
1612
- const trimmed = line.trim();
1613
- if (!trimmed || trimmed[0] !== '{')
1614
- continue;
1615
- lineCount++;
1616
- let ev;
1617
- try {
1618
- ev = JSON.parse(trimmed);
1619
- }
1620
- catch {
1621
- continue;
1622
- }
1623
- // Ignore sub-agent sidecar events — they belong to a child agent's
1624
- // stream and would re-enter the parent's accumulator. claudeParse's
1625
- // own sub-agent routing handles them.
1626
- const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
1627
- if (!isSubAgentEvent && ev.type === 'assistant') {
1628
- const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1629
- if (notice) {
1630
- noteTerminalLimitNotice(notice);
1631
- touched = true;
1632
- continue;
1633
- }
1634
- applyAssistantStreaming(s, ev.message, streamBuf);
1635
- applyAssistantUsage(s, ev.message);
1636
- if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1637
- lastAssistantEventAt = Date.now();
1638
- s.model = ev.message.model;
1639
- applyModelContextWindow(s);
1640
- }
1641
- }
1642
- try {
1643
- callClaudeParseForTui(ev, s);
1644
- }
1645
- catch (e) {
1646
- agentWarn(`[claude-tui] claudeParse threw on line: ${e?.message || e}`);
1647
- }
1648
- touched = true;
1649
- lastMainJsonlEventAt = Date.now();
1650
- if (typeof ev.version === 'string' && ev.version)
1651
- observedClaudeVersion = ev.version;
1652
- if (!isSubAgentEvent)
1653
- lastMainJsonlType = classifyClaudeJsonlEvent(ev);
1654
- }
1655
- if (touched) {
1656
- // Emit immediately so non-text changes (tool_use, plan, activity,
1657
- // thinking, usage) reach the dashboard without waiting for the
1658
- // chunked stream tick. The streaming timer separately advances
1659
- // s.text from the buffer over the next few ticks.
1660
- emit();
1661
- scheduleStreamTick();
1662
- }
1673
+ if (drainMainJsonl()) {
1674
+ // Emit immediately so non-text changes (tool_use, plan, activity,
1675
+ // thinking, usage) reach the dashboard without waiting for the
1676
+ // chunked stream tick. The streaming timer separately advances
1677
+ // s.text from the buffer over the next few ticks.
1678
+ emit();
1679
+ scheduleStreamTick();
1663
1680
  }
1664
- // Live tool-events stream — fed by Pre/PostToolUse hooks. Order matters:
1665
- // we drain hooks BEFORE the JSONL tail above already ran so any hook
1666
- // events that beat their JSONL counterpart are recorded in
1667
- // seenClaudeToolIds first; subsequent JSONL pass deduplicates naturally.
1668
- // In practice JSONL doesn't land until Stop, so this is the only signal
1669
- // that fires during a normal turn.
1681
+ // Live tool-events stream — fed by Pre/PostToolUse hooks. Hook and JSONL
1682
+ // feeds race per tool call; both record into seenClaudeToolIds /
1683
+ // seenClaudeToolResultIds so whichever lands first wins and the other
1684
+ // pass dedups naturally.
1670
1685
  if (drainToolEvents())
1671
1686
  emit();
1672
1687
  // Sub-agent sidecar discovery + pump. Order matters: discovery first so a
@@ -1882,47 +1897,8 @@ export async function doClaudeTuiStream(opts) {
1882
1897
  opts.abortSignal?.removeEventListener('abort', abortStream);
1883
1898
  // 11. Final drain — pick up anything written between the last poll and
1884
1899
  // process exit. Claude flushes its remaining JSONL events on shutdown.
1885
- if (fs.existsSync(activeJsonlPath)) {
1886
- const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
1887
- jsonlReadOffset = inc.offset;
1888
- let touched = false;
1889
- for (const line of inc.lines) {
1890
- const trimmed = line.trim();
1891
- if (!trimmed || trimmed[0] !== '{')
1892
- continue;
1893
- lineCount++;
1894
- let ev;
1895
- try {
1896
- ev = JSON.parse(trimmed);
1897
- }
1898
- catch {
1899
- continue;
1900
- }
1901
- const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
1902
- if (!isSubAgentEvent && ev.type === 'assistant') {
1903
- const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1904
- if (notice) {
1905
- noteTerminalLimitNotice(notice);
1906
- touched = true;
1907
- continue;
1908
- }
1909
- applyAssistantStreaming(s, ev.message, streamBuf);
1910
- applyAssistantUsage(s, ev.message);
1911
- if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1912
- lastAssistantEventAt = Date.now();
1913
- s.model = ev.message.model;
1914
- applyModelContextWindow(s);
1915
- }
1916
- }
1917
- try {
1918
- callClaudeParseForTui(ev, s);
1919
- }
1920
- catch { }
1921
- touched = true;
1922
- }
1923
- if (touched)
1924
- emit();
1925
- }
1900
+ if (drainMainJsonl())
1901
+ emit();
1926
1902
  // Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
1927
1903
  // between the last poll tick and process exit.
1928
1904
  if (drainToolEvents())
@@ -65,6 +65,39 @@ export function claudeEffortAndWorkflowArgs(o) {
65
65
  args.push('--disallowed-tools', 'Workflow');
66
66
  return args;
67
67
  }
68
+ /**
69
+ * Env keys the claude CLI exports to its own subprocesses (Bash tool, hooks)
70
+ * to mark them as children of a running session. If pikiclaw itself was
71
+ * launched from inside a Claude Code session — agent-driven `npm run dev`
72
+ * restarts, `! npx pikiclaw` typed into the TUI, the self-bootstrap path —
73
+ * these leak into the daemon's environment and every claude it spawns
74
+ * inherits them. A claude started with `CLAUDE_CODE_CHILD_SESSION` set runs
75
+ * in child-session mode: it mirrors transcript persistence to its (absent)
76
+ * SDK parent instead of writing `~/.claude/projects/<dir>/<id>.jsonl`.
77
+ * The TUI driver tails that JSONL as its only text source, so a contaminated
78
+ * spawn streams nothing, returns "(no textual response)", and loses the whole
79
+ * turn on SIGTERM. Verified on 2.1.173: with these vars set the transcript
80
+ * never grows past the ai-title line; with them scrubbed every event lands
81
+ * 0.2–1.2s after it happens.
82
+ *
83
+ * Deliberately a closed list: config-style vars users set on purpose
84
+ * (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_MAX_OUTPUT_TOKENS, …) must survive.
85
+ * Shared by both spawn paths (`claude -p` here and the PTY/TUI driver).
86
+ */
87
+ const CLAUDE_SESSION_CONTEXT_ENV_KEYS = [
88
+ 'CLAUDECODE',
89
+ 'CLAUDE_CODE_CHILD_SESSION',
90
+ 'CLAUDE_CODE_ENTRYPOINT',
91
+ 'CLAUDE_CODE_EXECPATH',
92
+ 'CLAUDE_CODE_SESSION_ID',
93
+ 'CLAUDE_CODE_SSE_PORT',
94
+ 'CLAUDE_EFFORT',
95
+ 'CLAUDE_PERMISSION_MODE',
96
+ ];
97
+ export function scrubClaudeSessionContextEnv(env) {
98
+ for (const key of CLAUDE_SESSION_CONTEXT_ENV_KEYS)
99
+ delete env[key];
100
+ }
68
101
  // ---------------------------------------------------------------------------
69
102
  // Command & parser
70
103
  // ---------------------------------------------------------------------------
@@ -1001,7 +1034,7 @@ async function doClaudeInteractiveStream(opts) {
1001
1034
  agentLog(`[spawn] timeout: ${opts.timeout}s session: ${opts.sessionId || '(new)'}`);
1002
1035
  agentLog(`[spawn] prompt (stdin): "${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
1003
1036
  const spawnEnv = { ...process.env, ...(opts.extraEnv || {}) };
1004
- delete spawnEnv.CLAUDECODE;
1037
+ scrubClaudeSessionContextEnv(spawnEnv);
1005
1038
  const proc = spawn(shellCmd, {
1006
1039
  cwd: opts.workdir,
1007
1040
  env: spawnEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.78",
3
+ "version": "0.3.79",
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": {