pikiclaw 0.3.48 → 0.3.50

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.
@@ -32,8 +32,9 @@ import fs from 'node:fs';
32
32
  import path from 'node:path';
33
33
  import { randomUUID } from 'node:crypto';
34
34
  import { tmpdir } from 'node:os';
35
- import { Q, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, joinErrorMessages, emitSessionIdUpdate, normalizeClaudeModelId, } from '../utils.js';
35
+ import { Q, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, joinErrorMessages, emitSessionIdUpdate, normalizeClaudeModelId, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, detectClaudeApiError, } from '../utils.js';
36
36
  import { encodePathAsDirName, getHome, whichSync } from '../../core/platform.js';
37
+ import { stripAnsiEscapes } from '../../core/utils.js';
37
38
  import { AGENT_STREAM_HARD_KILL_GRACE_MS } from '../../core/constants.js';
38
39
  import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, } from './claude.js';
39
40
  async function loadPty() {
@@ -112,12 +113,29 @@ const HOOK_SCRIPT = `#!/usr/bin/env node
112
113
  const fs = require("node:fs");
113
114
  const event = process.argv[2] || "";
114
115
  const stateFile = process.argv[3] || "";
116
+ const toolEventsFile = process.argv[4] || "";
115
117
  let stdin = "";
116
118
  process.stdin.setEncoding("utf8");
117
119
  process.stdin.on("data", (d) => { stdin += d; });
118
120
  process.stdin.on("end", () => {
119
121
  let payload = {};
120
122
  try { payload = stdin ? JSON.parse(stdin) : {}; } catch (_) {}
123
+ // Tool events go to an append-only JSONL. Sequential lifecycle events
124
+ // (SessionStart / UserPromptSubmit / Stop) still use the state file —
125
+ // they fire once each so the read-modify-write race is benign there.
126
+ if ((event === "PreToolUse" || event === "PostToolUse") && toolEventsFile) {
127
+ const line = JSON.stringify({
128
+ event,
129
+ at: Date.now(),
130
+ tool_use_id: typeof payload.tool_use_id === "string" ? payload.tool_use_id : null,
131
+ tool_name: typeof payload.tool_name === "string" ? payload.tool_name : null,
132
+ tool_input: payload.tool_input || null,
133
+ tool_response: payload.tool_response || null,
134
+ }) + "\\n";
135
+ try { fs.appendFileSync(toolEventsFile, line); } catch (_) {}
136
+ process.stdout.write(JSON.stringify({ continue: true }) + "\\n");
137
+ return;
138
+ }
121
139
  let state = {};
122
140
  try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch (_) {}
123
141
  state.events = Array.isArray(state.events) ? state.events : [];
@@ -196,6 +214,205 @@ function makeTuiStreamBuffer() {
196
214
  * (see `callClaudeParseForTui`) — otherwise `claudeParse`'s "fill if empty"
197
215
  * fallback would clobber the buffered streaming.
198
216
  */
217
+ /**
218
+ * Pull the server-assigned task id out of a PostToolUse hook's tool_response.
219
+ * Claude Code's hook payload mirrors the JSONL tool_result shape — usually
220
+ * `{ task: { id, subject }, ...}` for TaskCreate. Falls back to scanning the
221
+ * textual response for "Task #N created" when the structured form is missing.
222
+ */
223
+ function readAssignedTaskIdFromHookResponse(toolResponse) {
224
+ const structured = toolResponse?.task?.id;
225
+ if (structured != null && String(structured).trim())
226
+ return String(structured).trim();
227
+ if (typeof toolResponse === 'string') {
228
+ const m = toolResponse.match(/Task #(\d+)/);
229
+ if (m)
230
+ return m[1];
231
+ }
232
+ if (toolResponse && typeof toolResponse.result === 'string') {
233
+ const m = toolResponse.result.match(/Task #(\d+)/);
234
+ if (m)
235
+ return m[1];
236
+ }
237
+ return null;
238
+ }
239
+ /**
240
+ * Apply a single PreToolUse / PostToolUse hook event to the parser state.
241
+ * Mirrors what `claudeParse` would do for the matching JSONL tool_use /
242
+ * tool_result, but fires the instant Claude calls the tool — so the IM
243
+ * placeholder card actually updates during the turn instead of staying empty
244
+ * until Stop. Dedup with the eventual JSONL flush is via `tool_use_id`:
245
+ * claudeParse skips tools already in `s.seenClaudeToolIds`, and the new
246
+ * `s.seenClaudeToolResultIds` guards tool_result re-pushes.
247
+ */
248
+ function applyHookToolEvent(ev, s) {
249
+ const toolUseId = String(ev?.tool_use_id || '').trim();
250
+ const toolName = String(ev?.tool_name || '').trim();
251
+ if (!toolName || !toolUseId)
252
+ return false;
253
+ if (ev.event === 'PreToolUse') {
254
+ if (s.seenClaudeToolIds.has(toolUseId))
255
+ return false;
256
+ if (toolName === 'TaskCreate') {
257
+ const subject = typeof ev.tool_input?.subject === 'string' ? ev.tool_input.subject.trim() : '';
258
+ if (subject)
259
+ s.pendingClaudeTaskCreates.set(toolUseId, { subject });
260
+ s.seenClaudeToolIds.add(toolUseId);
261
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: subject ? `Create task: ${subject}` : 'Create task' });
262
+ return true;
263
+ }
264
+ if (toolName === 'TaskUpdate') {
265
+ const taskId = String(ev.tool_input?.taskId ?? '').trim();
266
+ const rawStatus = String(ev.tool_input?.status ?? '').trim().toLowerCase();
267
+ if (taskId) {
268
+ if (rawStatus === 'deleted') {
269
+ s.claudeTaskList.delete(taskId);
270
+ s.claudeTaskOrder = s.claudeTaskOrder.filter((id) => id !== taskId);
271
+ }
272
+ else if (rawStatus) {
273
+ const existing = s.claudeTaskList.get(taskId);
274
+ if (existing)
275
+ existing.status = rawStatus;
276
+ }
277
+ rebuildClaudePlanFromTasksFromState(s);
278
+ }
279
+ s.seenClaudeToolIds.add(toolUseId);
280
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: `Update task ${taskId || '?'} → ${rawStatus || 'unknown'}` });
281
+ return true;
282
+ }
283
+ if (toolName === 'TodoWrite') {
284
+ const plan = parseTodoWriteAsPlanLite(ev.tool_input);
285
+ if (plan)
286
+ s.plan = plan;
287
+ s.seenClaudeToolIds.add(toolUseId);
288
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: 'Update plan' });
289
+ return true;
290
+ }
291
+ if (toolName === 'Task' || toolName === 'Agent') {
292
+ // Register the sub-agent so `meta.subAgents` lights up the new
293
+ // Sub-agent preview block. Sub-agents are isolated from parent activity
294
+ // by design (the dedicated section shows their own tool stream); pushing
295
+ // into parent recentActivity would re-introduce the noise the isolation
296
+ // is meant to prevent. Granular sub-agent tool calls land later via the
297
+ // sidecar pump → `routeClaudeSubAgentEvent`.
298
+ const input = ev.tool_input || {};
299
+ const desc = typeof input.description === 'string' ? input.description.trim() : '';
300
+ const kind = typeof input.subagent_type === 'string' ? input.subagent_type.trim() : '';
301
+ if (!s.subAgents.has(toolUseId)) {
302
+ s.subAgents.set(toolUseId, {
303
+ id: toolUseId,
304
+ kind: kind || null,
305
+ description: desc || null,
306
+ model: null,
307
+ tools: [],
308
+ status: 'running',
309
+ });
310
+ }
311
+ s.seenClaudeToolIds.add(toolUseId);
312
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
313
+ return true;
314
+ }
315
+ const summary = summarizeClaudeToolUse(toolName, ev.tool_input || {});
316
+ pushRecentActivity(s.recentActivity, summary);
317
+ s.seenClaudeToolIds.add(toolUseId);
318
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary });
319
+ s.activity = s.recentActivity.join('\n');
320
+ return true;
321
+ }
322
+ if (ev.event === 'PostToolUse') {
323
+ if (!s.seenClaudeToolResultIds)
324
+ s.seenClaudeToolResultIds = new Set();
325
+ if (s.seenClaudeToolResultIds.has(toolUseId))
326
+ return false;
327
+ if (toolName === 'TaskCreate') {
328
+ const pending = s.pendingClaudeTaskCreates.get(toolUseId);
329
+ const assignedId = readAssignedTaskIdFromHookResponse(ev.tool_response);
330
+ if (pending && assignedId) {
331
+ s.pendingClaudeTaskCreates.delete(toolUseId);
332
+ if (!s.claudeTaskList.has(assignedId))
333
+ s.claudeTaskOrder.push(assignedId);
334
+ s.claudeTaskList.set(assignedId, { subject: pending.subject, status: 'pending' });
335
+ rebuildClaudePlanFromTasksFromState(s);
336
+ }
337
+ s.seenClaudeToolResultIds.add(toolUseId);
338
+ return true;
339
+ }
340
+ if (toolName === 'TaskUpdate' || toolName === 'TodoWrite') {
341
+ s.seenClaudeToolResultIds.add(toolUseId);
342
+ return true;
343
+ }
344
+ if (toolName === 'Task' || toolName === 'Agent') {
345
+ // Sub-agent finished — flip its status so it drops out of the live
346
+ // Sub-agent preview block. The completion fact itself is implicit: the
347
+ // block stops listing this entry.
348
+ const sub = s.subAgents.get(toolUseId);
349
+ if (sub)
350
+ sub.status = ev.tool_response?.is_error ? 'failed' : 'done';
351
+ s.seenClaudeToolResultIds.add(toolUseId);
352
+ return true;
353
+ }
354
+ const tool = s.claudeToolsById.get(toolUseId);
355
+ if (tool) {
356
+ const summary = summarizeClaudeToolResult(tool, { content: ev.tool_response }, ev.tool_response);
357
+ if (summary) {
358
+ pushRecentActivity(s.recentActivity, summary);
359
+ s.activity = s.recentActivity.join('\n');
360
+ }
361
+ }
362
+ s.seenClaudeToolResultIds.add(toolUseId);
363
+ return true;
364
+ }
365
+ return false;
366
+ }
367
+ /**
368
+ * Lite TodoWrite parser used by the hook path — avoids pulling parseTodoWriteAsPlan
369
+ * from agent/utils into this file's already-large import surface. Identical
370
+ * semantics for the legacy 1.x plan tool.
371
+ */
372
+ function parseTodoWriteAsPlanLite(input) {
373
+ if (!input || typeof input !== 'object')
374
+ return null;
375
+ const rawTodos = Array.isArray(input.todos) ? input.todos : [];
376
+ if (!rawTodos.length)
377
+ return null;
378
+ const steps = [];
379
+ for (const todo of rawTodos) {
380
+ if (!todo || typeof todo !== 'object')
381
+ continue;
382
+ const content = typeof todo.content === 'string' ? todo.content.trim() : '';
383
+ if (!content)
384
+ continue;
385
+ const rawStatus = typeof todo.status === 'string' ? todo.status : 'pending';
386
+ const status = rawStatus === 'completed' ? 'completed'
387
+ : rawStatus === 'in_progress' ? 'inProgress'
388
+ : 'pending';
389
+ steps.push({ step: content, status });
390
+ }
391
+ if (!steps.length)
392
+ return null;
393
+ return { explanation: null, steps };
394
+ }
395
+ /**
396
+ * Reimplementation of claude.ts's rebuildClaudePlanFromTasks (it's private to
397
+ * that module). Kept tiny and dependency-free so the hook code path stays
398
+ * independent of the JSONL parser's internals.
399
+ */
400
+ function rebuildClaudePlanFromTasksFromState(s) {
401
+ if (!s.claudeTaskOrder?.length)
402
+ return;
403
+ const steps = [];
404
+ for (const id of s.claudeTaskOrder) {
405
+ const task = s.claudeTaskList.get(id);
406
+ if (!task)
407
+ continue;
408
+ const lowered = String(task.status || '').toLowerCase();
409
+ const status = lowered === 'completed' ? 'completed'
410
+ : lowered === 'in_progress' || lowered === 'inprogress' ? 'inProgress'
411
+ : 'pending';
412
+ steps.push({ step: task.subject, status });
413
+ }
414
+ s.plan = { explanation: null, steps };
415
+ }
199
416
  function applyAssistantStreaming(s, msg, buf) {
200
417
  if (!msg || msg.model === '<synthetic>')
201
418
  return;
@@ -319,19 +536,34 @@ export async function doClaudeTuiStream(opts) {
319
536
  }
320
537
  const hookPath = path.join(workDir, 'hook.cjs');
321
538
  const statePath = path.join(workDir, 'state.json');
539
+ const toolEventsPath = path.join(workDir, 'tool-events.jsonl');
322
540
  const settingsPath = path.join(workDir, 'settings.json');
323
541
  const ptyLogPath = path.join(workDir, 'pty.log');
324
542
  try {
325
543
  fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
326
544
  fs.writeFileSync(statePath, JSON.stringify({ events: [] }));
545
+ fs.writeFileSync(toolEventsPath, '');
327
546
  // Use the same Node binary that's running pikiclaw — `node` may not be on
328
547
  // PATH inside the claude TUI's hook subprocess on every distro.
329
548
  const nodeBin = Q(process.execPath);
330
- const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)}`;
549
+ const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
550
+ // Pre/PostToolUse hooks give us a live event stream. Claude Code 2.x
551
+ // buffers the JSONL transcript and only flushes it when Stop fires, so
552
+ // without these hooks the dashboard / IM see absolutely no progress
553
+ // during a 30s+ turn. The hook script writes to tool-events.jsonl via
554
+ // atomic appends, sidestepping the read-modify-write race that affects
555
+ // the shared state.json file.
556
+ // Pre/PostToolUse require an explicit `matcher` field — without it Claude
557
+ // Code's hook dispatcher silently never fires the hook (the lifecycle
558
+ // hooks below don't need a matcher because they aren't tool-scoped).
559
+ // `*` matches every tool. Without this, the entire live-streaming wire-up
560
+ // is dead code.
331
561
  const settings = {
332
562
  hooks: {
333
563
  SessionStart: [{ hooks: [{ type: 'command', command: hookCmd('SessionStart'), timeout: 5 }] }],
334
564
  UserPromptSubmit: [{ hooks: [{ type: 'command', command: hookCmd('UserPromptSubmit'), timeout: 5 }] }],
565
+ PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PreToolUse'), timeout: 5 }] }],
566
+ PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PostToolUse'), timeout: 5 }] }],
335
567
  Stop: [{ hooks: [{ type: 'command', command: hookCmd('Stop'), timeout: 5 }] }],
336
568
  },
337
569
  };
@@ -520,9 +752,13 @@ export async function doClaudeTuiStream(opts) {
520
752
  }
521
753
  // Capture stderr-ish bytes (TUI startup errors, "claude: command not
522
754
  // found"-style messages) for the final error payload when the run aborts
523
- // before any JSONL is written. Keep the buffer bounded.
755
+ // before any JSONL is written. Strip ANSI on the way in — otherwise the
756
+ // raw PTY screen (cursor positions, SGR colours, column-aligned reply
757
+ // rendering) leaks into IM as gibberish like "[3G你把 [8Gsnipe …" when a
758
+ // user hits Stop before the JSONL has flushed any assistant text. Keep
759
+ // the buffer bounded after stripping.
524
760
  if (stderrCapture.length < 4096) {
525
- stderrCapture += data;
761
+ stderrCapture += stripAnsiEscapes(data);
526
762
  if (stderrCapture.length > 4096)
527
763
  stderrCapture = stderrCapture.slice(0, 4096);
528
764
  }
@@ -576,6 +812,39 @@ export async function doClaudeTuiStream(opts) {
576
812
  let promptNudged = false;
577
813
  let pollHandle = null;
578
814
  let drainScheduled = false;
815
+ // Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
816
+ // tail it with the same incremental reader the JSONL transcript uses, so
817
+ // tool calls + plan changes surface live during the turn even while the
818
+ // canonical JSONL stays empty (Claude Code 2.x buffers the whole transcript
819
+ // until the Stop hook fires).
820
+ let toolEventsReadOffset = 0;
821
+ const drainToolEvents = () => {
822
+ if (!fs.existsSync(toolEventsPath))
823
+ return false;
824
+ const inc = readJsonlIncrement(toolEventsPath, toolEventsReadOffset);
825
+ toolEventsReadOffset = inc.offset;
826
+ let any = false;
827
+ for (const line of inc.lines) {
828
+ const trimmed = line.trim();
829
+ if (!trimmed || trimmed[0] !== '{')
830
+ continue;
831
+ let ev;
832
+ try {
833
+ ev = JSON.parse(trimmed);
834
+ }
835
+ catch {
836
+ continue;
837
+ }
838
+ try {
839
+ if (applyHookToolEvent(ev, s))
840
+ any = true;
841
+ }
842
+ catch (e) {
843
+ agentWarn(`[claude-tui] hook tool event apply threw: ${e?.message || e}`);
844
+ }
845
+ }
846
+ return any;
847
+ };
579
848
  const trackedSubAgents = new Map();
580
849
  const tryDiscoverSubAgents = () => {
581
850
  const sidecarDir = path.join(projectDir, activeSessionId, 'subagents');
@@ -730,6 +999,14 @@ export async function doClaudeTuiStream(opts) {
730
999
  scheduleStreamTick();
731
1000
  }
732
1001
  }
1002
+ // Live tool-events stream — fed by Pre/PostToolUse hooks. Order matters:
1003
+ // we drain hooks BEFORE the JSONL tail above already ran so any hook
1004
+ // events that beat their JSONL counterpart are recorded in
1005
+ // seenClaudeToolIds first; subsequent JSONL pass deduplicates naturally.
1006
+ // In practice JSONL doesn't land until Stop, so this is the only signal
1007
+ // that fires during a normal turn.
1008
+ if (drainToolEvents())
1009
+ emit();
733
1010
  // Sub-agent sidecar discovery + pump. Order matters: discovery first so a
734
1011
  // newly-spawned sub-agent gets registered for tailing this same tick if
735
1012
  // its events have already been written.
@@ -804,6 +1081,10 @@ export async function doClaudeTuiStream(opts) {
804
1081
  if (touched)
805
1082
  emit();
806
1083
  }
1084
+ // Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
1085
+ // between the last poll tick and process exit.
1086
+ if (drainToolEvents())
1087
+ emit();
807
1088
  // Final sub-agent drain. The sub-agent's last events (closing tool_results)
808
1089
  // may have landed after our last poll tick; mirror the main JSONL drain to
809
1090
  // make sure sub.tools / sub.status carry the complete picture into the
@@ -830,6 +1111,20 @@ export async function doClaudeTuiStream(opts) {
830
1111
  // doClaudeInteractiveStream so downstream consumers (finalizeStreamResult,
831
1112
  // dashboard rendering) cannot tell the two paths apart.
832
1113
  const errorText = joinErrorMessages(s.errors);
1114
+ const cleanStderr = stderrCapture.trim();
1115
+ // Detect Claude Code's synthetic "API Error: …" assistant reply (e.g.
1116
+ // 529 Overloaded). The text gets rewritten so the IM card doesn't surface
1117
+ // the raw "API Error: Overloaded" string to the user, and stopReason is
1118
+ // upgraded so the ClaudeDriver retry wrapper can decide to re-issue the
1119
+ // turn rather than letting the synthetic failure stick.
1120
+ const apiErrorReason = detectClaudeApiError(s.text);
1121
+ if (apiErrorReason) {
1122
+ agentWarn(`[claude-tui] upstream API error detected: ${apiErrorReason}`);
1123
+ s.stopReason = 'api_error';
1124
+ s.text = '';
1125
+ if (!s.errors)
1126
+ s.errors = [`Anthropic API error: ${apiErrorReason}`];
1127
+ }
833
1128
  // "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
834
1129
  // hook fired, which yields a non-zero exit), no errors from the parser, no
835
1130
  // user abort, no timeout. SIGTERM-after-Stop is the normal happy path.
@@ -840,19 +1135,33 @@ export async function doClaudeTuiStream(opts) {
840
1135
  || (interrupted ? 'Interrupted by user.' : null)
841
1136
  || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
842
1137
  || (!stopHookFired
843
- ? (stderrCapture.trim()
1138
+ ? (cleanStderr
844
1139
  || `Claude TUI exited (code=${exitCode}, signal=${exitSignal ?? '-'}) without completing the turn.`)
845
1140
  : null);
846
1141
  const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
847
1142
  const elapsedS = (Date.now() - start) / 1000;
848
1143
  agentLog(`[claude-tui] result ok=${ok} elapsed=${elapsedS.toFixed(1)}s text=${s.text.length}ch thinking=${s.thinking.length}ch session=${s.sessionId || '?'} stop=${stopHookFired}`);
1144
+ // Build the message body. Order:
1145
+ // 1. Any assistant text captured from JSONL (the canonical reply).
1146
+ // 2. Parser-surfaced errors.
1147
+ // 3. For interrupted runs with no text yet, a clear status — never the
1148
+ // raw PTY scrape (it would be a half-rendered TUI screen with no value
1149
+ // to the user, and pre-ANSI-strip used to render as garbled gibberish
1150
+ // in IM).
1151
+ // 4. Fall back to ANSI-stripped stderrCapture for genuine startup
1152
+ // failures like "claude: command not found".
1153
+ const messageBody = s.text.trim()
1154
+ || errorText
1155
+ || (interrupted ? '(Interrupted before any reply landed.)'
1156
+ : procOk ? '(no textual response)'
1157
+ : `Failed (exit=${exitCode}).\n\n${cleanStderr || '(no output)'}`);
849
1158
  return {
850
1159
  ok,
851
1160
  sessionId: s.sessionId,
852
1161
  workspacePath: null,
853
1162
  model: s.model,
854
1163
  thinkingEffort: s.thinkingEffort,
855
- message: s.text.trim() || errorText || (procOk ? '(no textual response)' : `Failed (exit=${exitCode}).\n\n${stderrCapture.trim() || '(no output)'}`),
1164
+ message: messageBody,
856
1165
  thinking: s.thinking.trim() || null,
857
1166
  elapsedS,
858
1167
  inputTokens: s.inputTokens,