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.
- package/dist/agent/drivers/claude-tui.js +315 -6
- package/dist/agent/drivers/claude.js +226 -18
- package/dist/agent/drivers/codex.js +58 -16
- package/dist/agent/index.js +1 -1
- package/dist/agent/utils.js +40 -0
- package/dist/bot/bot.js +78 -5
- package/dist/bot/human-loop.js +45 -0
- package/dist/bot/render-shared.js +50 -14
- package/dist/bot/streaming.js +92 -5
- package/dist/channels/feishu/bot.js +191 -80
- package/dist/channels/feishu/channel.js +49 -6
- package/dist/channels/feishu/render.js +23 -1
- package/dist/channels/telegram/bot.js +159 -37
- package/dist/channels/telegram/channel.js +6 -1
- package/dist/channels/telegram/live-preview.js +19 -1
- package/dist/channels/telegram/render.js +26 -1
- package/dist/channels/weixin/bot.js +64 -2
- package/dist/core/config/user-config.js +36 -0
- package/dist/core/utils.js +35 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
? (
|
|
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:
|
|
1164
|
+
message: messageBody,
|
|
856
1165
|
thinking: s.thinking.trim() || null,
|
|
857
1166
|
elapsedS,
|
|
858
1167
|
inputTokens: s.inputTokens,
|