pikiloop 0.4.0
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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,2297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude TUI driver — runs the interactive `claude` CLI under a PTY so usage
|
|
3
|
+
* counts against the user's Pro/Max subscription instead of the API-priced
|
|
4
|
+
* Agent SDK credit pool. Functionally near-equivalent to the headless -p
|
|
5
|
+
* stream: we tail the JSONL transcript that Claude Code writes incrementally
|
|
6
|
+
* to `~/.claude/projects/<encoded>/<id>.jsonl` and surface tool/text/usage
|
|
7
|
+
* events through the same `claudeParse` parser used by the print-mode driver.
|
|
8
|
+
*
|
|
9
|
+
* Default driver for Claude turns. Set `PIKILOOP_CLAUDE_PRINT=1` (or the
|
|
10
|
+
* legacy `PIKILOOP_CLAUDE_TUI=0`) to force the print-mode driver instead.
|
|
11
|
+
* When any startup prerequisite fails (node-pty missing, prebuilt helper
|
|
12
|
+
* unusable, PTY allocation refused) this function THROWS — the dispatcher in
|
|
13
|
+
* `claude.ts` catches that and falls back to print mode so pikiloop stays
|
|
14
|
+
* working out of the box.
|
|
15
|
+
*
|
|
16
|
+
* How it works:
|
|
17
|
+
* 1. Reserve a session id upfront (random UUID, or the resume target).
|
|
18
|
+
* 2. Drop a temp settings file with `SessionStart` / `Stop` /
|
|
19
|
+
* `UserPromptSubmit` hooks pointing at a tiny helper script — the script
|
|
20
|
+
* mutates a shared state JSON file so the parent process learns the real
|
|
21
|
+
* session id / transcript path / turn-end signal.
|
|
22
|
+
* 3. Spawn `claude` under a real PTY (via `node-pty`) with the prompt as
|
|
23
|
+
* positional argv. Claude TUI auto-submits the prompt on startup.
|
|
24
|
+
* 4. Poll the transcript JSONL incrementally; feed each line through
|
|
25
|
+
* `claudeParse`. JSONL records lack `stream_event` / `result` events, so
|
|
26
|
+
* we patch up the missing `s.text` / `s.thinking` accumulation and
|
|
27
|
+
* `assistant.message.usage` extraction in the loop.
|
|
28
|
+
* 5. When the `Stop` hook fires (Claude has finished the assistant turn),
|
|
29
|
+
* SIGTERM the PTY process. The JSONL is fully flushed by then.
|
|
30
|
+
* Exception — background sub-agents: Claude fires `Stop` whenever the
|
|
31
|
+
* main loop finishes a response segment, *including* the segment that
|
|
32
|
+
* launched `run_in_background` agents. Those agents live inside the
|
|
33
|
+
* claude process, so killing on that first Stop would destroy them
|
|
34
|
+
* mid-flight. The driver therefore refuses to terminate while launched
|
|
35
|
+
* background agents haven't reported their `<task-notification>`, and
|
|
36
|
+
* only accepts a Stop that is "fresh" (fired after the last
|
|
37
|
+
* notification) — see `decideClaudeTuiStop`.
|
|
38
|
+
*/
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { tmpdir } from 'node:os';
|
|
43
|
+
import { Q, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, joinErrorMessages, emitSessionIdUpdate, normalizeClaudeModelId, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, detectClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, } from '../utils.js';
|
|
44
|
+
import { encodePathAsDirName, getHome, whichSync } from '../../core/platform.js';
|
|
45
|
+
import { createRetainedLogSink } from '../../core/logging.js';
|
|
46
|
+
import { stripAnsiEscapes } from '../../core/utils.js';
|
|
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, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS, } from '../../core/constants.js';
|
|
48
|
+
import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, extractClaudeWorkflowRunId, claudeEffortAndWorkflowArgs, scrubClaudeSessionContextEnv, } from './claude.js';
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Stall diagnostics (capture-only)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
//
|
|
53
|
+
// We instrument the mid-turn freeze before tuning the watchdog: the next pause
|
|
54
|
+
// should be classified from data, not guesswork. Records land append-only in
|
|
55
|
+
// ~/.pikiloop/diagnostics/claude-tui-stall.jsonl across three moments:
|
|
56
|
+
// - 'quiet' — heartbeat while a turn has gone silent past the threshold
|
|
57
|
+
// (captures the lead-up to a stall, throttled)
|
|
58
|
+
// - 'stall' — the watchdog declared the turn dead and SIGTERMed it
|
|
59
|
+
// - 'resolved' — a turn that went quiet ended (completed / killed / aborted),
|
|
60
|
+
// so benign long-thinking is separable from true freezes
|
|
61
|
+
//
|
|
62
|
+
// The decisive field is `ptyQuietMs` vs `quietMs`: a large quietMs (JSONL/hook
|
|
63
|
+
// signals silent) paired with a small ptyQuietMs (PTY still painting frames)
|
|
64
|
+
// means the model stream froze behind a live spinner — which defeats the
|
|
65
|
+
// PTY-dead fast path and forces the slow 10-min quiet threshold. Confirming
|
|
66
|
+
// that pattern (or refuting it) is the whole point of this pass.
|
|
67
|
+
/** Begin recording heartbeats once no live signal has advanced for this long. */
|
|
68
|
+
const STALL_DIAG_QUIET_THRESHOLD_MS = 45_000;
|
|
69
|
+
/** Throttle heartbeats while a turn stays quiet, so a long freeze is sampled
|
|
70
|
+
* (not logged every 200ms poll tick). */
|
|
71
|
+
const STALL_DIAG_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
72
|
+
// undefined = not yet initialised; null = init failed (give up, never retry);
|
|
73
|
+
// function = ready. Shared across all turns so every session appends to one file.
|
|
74
|
+
let stallDiagSink;
|
|
75
|
+
function writeStallDiag(record) {
|
|
76
|
+
if (stallDiagSink === null)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
if (stallDiagSink === undefined) {
|
|
80
|
+
const file = path.join(getHome(), '.pikiloop', 'diagnostics', 'claude-tui-stall.jsonl');
|
|
81
|
+
stallDiagSink = createRetainedLogSink(file, {
|
|
82
|
+
maxLines: 50_000,
|
|
83
|
+
maxAgeMs: 14 * 24 * 60 * 60_000,
|
|
84
|
+
trimEveryWrites: 500,
|
|
85
|
+
});
|
|
86
|
+
agentLog(`[claude-tui] stall diagnostics → ${file}`);
|
|
87
|
+
}
|
|
88
|
+
stallDiagSink(JSON.stringify({ ts: Date.now(), ...record }) + '\n');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
stallDiagSink = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Cheap capture-only label for the last transcript event before a quiet
|
|
95
|
+
* stretch — the freeze signature is "next assistant never starts after a
|
|
96
|
+
* tool_result", so the kind of the last event matters. */
|
|
97
|
+
export function classifyClaudeJsonlEvent(ev) {
|
|
98
|
+
const type = typeof ev?.type === 'string' ? ev.type : 'unknown';
|
|
99
|
+
const content = ev?.message?.content;
|
|
100
|
+
if (Array.isArray(content)) {
|
|
101
|
+
if (content.some((b) => b?.type === 'tool_use'))
|
|
102
|
+
return `${type}:tool_use`;
|
|
103
|
+
if (content.some((b) => b?.type === 'tool_result'))
|
|
104
|
+
return `${type}:tool_result`;
|
|
105
|
+
if (content.some((b) => b?.type === 'thinking'))
|
|
106
|
+
return `${type}:thinking`;
|
|
107
|
+
if (content.some((b) => b?.type === 'text'))
|
|
108
|
+
return `${type}:text`;
|
|
109
|
+
}
|
|
110
|
+
return type;
|
|
111
|
+
}
|
|
112
|
+
async function loadPty() {
|
|
113
|
+
// Dynamic import keeps node-pty an optional dependency — if it's not
|
|
114
|
+
// installed the print-mode dispatcher in claude.ts will catch the throw
|
|
115
|
+
// and fall back to `-p`. The variable-specifier indirection is required so
|
|
116
|
+
// TypeScript does not try to resolve `node-pty` at compile time when the
|
|
117
|
+
// dep is absent.
|
|
118
|
+
const specifier = 'node-pty';
|
|
119
|
+
const mod = await import(/* @vite-ignore */ specifier);
|
|
120
|
+
const api = mod?.default ?? mod;
|
|
121
|
+
if (!api?.spawn)
|
|
122
|
+
throw new Error('node-pty loaded but spawn() is missing');
|
|
123
|
+
await preflightSpawnHelper();
|
|
124
|
+
return api;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* On macOS / Linux, node-pty's prebuilt `spawn-helper` ships without the
|
|
128
|
+
* executable bit set on some npm installs (the npm tarball drops mode bits
|
|
129
|
+
* when extracted under certain umask settings). Without the bit, every
|
|
130
|
+
* `pty.spawn` returns the cryptic `posix_spawnp failed.` because the helper
|
|
131
|
+
* itself can't run. Restore the bit eagerly the first time the driver loads
|
|
132
|
+
* so users don't have to debug this on their own.
|
|
133
|
+
*/
|
|
134
|
+
let spawnHelperPreflightDone = false;
|
|
135
|
+
async function preflightSpawnHelper() {
|
|
136
|
+
if (spawnHelperPreflightDone || process.platform === 'win32') {
|
|
137
|
+
spawnHelperPreflightDone = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
spawnHelperPreflightDone = true;
|
|
141
|
+
try {
|
|
142
|
+
// Resolve relative to the loaded node-pty package. require.resolve isn't
|
|
143
|
+
// available in ESM; walk node_modules from this file's URL instead.
|
|
144
|
+
const ptyRoot = await locatePtyPackageRoot();
|
|
145
|
+
if (!ptyRoot)
|
|
146
|
+
return;
|
|
147
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
148
|
+
const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
149
|
+
const helper = path.join(ptyRoot, 'prebuilds', `${platform}-${arch}`, 'spawn-helper');
|
|
150
|
+
if (!fs.existsSync(helper))
|
|
151
|
+
return;
|
|
152
|
+
const stat = fs.statSync(helper);
|
|
153
|
+
if ((stat.mode & 0o111) === 0) {
|
|
154
|
+
fs.chmodSync(helper, stat.mode | 0o755);
|
|
155
|
+
agentLog(`[claude-tui] restored executable bit on ${helper}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
agentWarn(`[claude-tui] spawn-helper preflight skipped: ${e?.message || e}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function locatePtyPackageRoot() {
|
|
163
|
+
// Walk up from this file looking for a node_modules/node-pty/package.json.
|
|
164
|
+
// This is the dist-time layout (compiled to dist/) AND the tsx runtime
|
|
165
|
+
// layout (running from src/) — both have node_modules at the project root.
|
|
166
|
+
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
167
|
+
for (let i = 0; i < 8; i++) {
|
|
168
|
+
const candidate = path.join(dir, 'node_modules', 'node-pty');
|
|
169
|
+
if (fs.existsSync(path.join(candidate, 'package.json')))
|
|
170
|
+
return candidate;
|
|
171
|
+
const parent = path.dirname(dir);
|
|
172
|
+
if (parent === dir)
|
|
173
|
+
break;
|
|
174
|
+
dir = parent;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Hook helper script — written to a temp dir per turn. Receives Claude Code
|
|
180
|
+
// hook JSON payloads on stdin and mutates a shared state file so the parent
|
|
181
|
+
// can react to lifecycle events without needing socket / IPC plumbing.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
184
|
+
"use strict";
|
|
185
|
+
const fs = require("node:fs");
|
|
186
|
+
const event = process.argv[2] || "";
|
|
187
|
+
const stateFile = process.argv[3] || "";
|
|
188
|
+
const toolEventsFile = process.argv[4] || "";
|
|
189
|
+
let stdin = "";
|
|
190
|
+
process.stdin.setEncoding("utf8");
|
|
191
|
+
process.stdin.on("data", (d) => { stdin += d; });
|
|
192
|
+
process.stdin.on("end", () => {
|
|
193
|
+
let payload = {};
|
|
194
|
+
try { payload = stdin ? JSON.parse(stdin) : {}; } catch (_) {}
|
|
195
|
+
// Tool events go to an append-only JSONL. Sequential lifecycle events
|
|
196
|
+
// (SessionStart / UserPromptSubmit / Stop) still use the state file —
|
|
197
|
+
// they fire once each so the read-modify-write race is benign there.
|
|
198
|
+
if ((event === "PreToolUse" || event === "PostToolUse") && toolEventsFile) {
|
|
199
|
+
const line = JSON.stringify({
|
|
200
|
+
event,
|
|
201
|
+
at: Date.now(),
|
|
202
|
+
tool_use_id: typeof payload.tool_use_id === "string" ? payload.tool_use_id : null,
|
|
203
|
+
tool_name: typeof payload.tool_name === "string" ? payload.tool_name : null,
|
|
204
|
+
tool_input: payload.tool_input || null,
|
|
205
|
+
tool_response: payload.tool_response || null,
|
|
206
|
+
// Claude Code tags sub-agent tool calls with agent_id so the parent can
|
|
207
|
+
// tell them apart from main-thread calls. Forwarding it lets the driver
|
|
208
|
+
// route the hook to the right sub-agent card instead of the parent's
|
|
209
|
+
// 执行 list.
|
|
210
|
+
agent_id: typeof payload.agent_id === "string" ? payload.agent_id : null,
|
|
211
|
+
}) + "\\n";
|
|
212
|
+
try { fs.appendFileSync(toolEventsFile, line); } catch (_) {}
|
|
213
|
+
process.stdout.write(JSON.stringify({ continue: true }) + "\\n");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let state = {};
|
|
217
|
+
try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch (_) {}
|
|
218
|
+
state.events = Array.isArray(state.events) ? state.events : [];
|
|
219
|
+
state.events.push({ event, at: Date.now() });
|
|
220
|
+
const sid = typeof payload.session_id === "string" ? payload.session_id : null;
|
|
221
|
+
const tpath = typeof payload.transcript_path === "string" ? payload.transcript_path : null;
|
|
222
|
+
if (sid) state.sessionId = sid;
|
|
223
|
+
if (tpath) state.transcriptPath = tpath;
|
|
224
|
+
if (event === "SessionStart") state.sessionStartedAt = Date.now();
|
|
225
|
+
else if (event === "UserPromptSubmit") state.promptSubmittedAt = Date.now();
|
|
226
|
+
else if (event === "Stop") state.stoppedAt = Date.now();
|
|
227
|
+
try { fs.writeFileSync(stateFile, JSON.stringify(state)); } catch (_) {}
|
|
228
|
+
process.stdout.write(JSON.stringify({ continue: true }) + "\\n");
|
|
229
|
+
});
|
|
230
|
+
process.stdin.on("error", () => {
|
|
231
|
+
try { process.stdout.write(JSON.stringify({ continue: true }) + "\\n"); } catch (_) {}
|
|
232
|
+
});
|
|
233
|
+
`;
|
|
234
|
+
function readHookState(statePath) {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Incremental JSONL tail. Reads from `fromOffset` to the file's current size,
|
|
244
|
+
* splits on newlines, and stops one line short if the last segment doesn't end
|
|
245
|
+
* with `\n` (so a partially-written final line gets re-read next tick rather
|
|
246
|
+
* than corrupting JSON.parse).
|
|
247
|
+
*/
|
|
248
|
+
function readJsonlIncrement(filePath, fromOffset) {
|
|
249
|
+
try {
|
|
250
|
+
const stat = fs.statSync(filePath);
|
|
251
|
+
if (stat.size <= fromOffset)
|
|
252
|
+
return { offset: fromOffset, lines: [] };
|
|
253
|
+
const len = stat.size - fromOffset;
|
|
254
|
+
const fd = fs.openSync(filePath, 'r');
|
|
255
|
+
const buf = Buffer.alloc(len);
|
|
256
|
+
fs.readSync(fd, buf, 0, len, fromOffset);
|
|
257
|
+
fs.closeSync(fd);
|
|
258
|
+
const chunk = buf.toString('utf8');
|
|
259
|
+
if (!chunk)
|
|
260
|
+
return { offset: fromOffset, lines: [] };
|
|
261
|
+
const endsWithNewline = chunk[chunk.length - 1] === '\n';
|
|
262
|
+
const segments = chunk.split('\n');
|
|
263
|
+
if (endsWithNewline) {
|
|
264
|
+
// Last segment after split is empty — drop it.
|
|
265
|
+
segments.pop();
|
|
266
|
+
return { offset: stat.size, lines: segments };
|
|
267
|
+
}
|
|
268
|
+
// Partial last line — keep its bytes unread for the next tick.
|
|
269
|
+
const lastLine = segments.pop() || '';
|
|
270
|
+
const consumed = stat.size - Buffer.byteLength(lastLine, 'utf8');
|
|
271
|
+
return { offset: consumed, lines: segments };
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return { offset: fromOffset, lines: [] };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// 20 chars / 20 ms = 1000 chars/s. Haiku generates ~150 tok/s (~600 chars/s),
|
|
278
|
+
// Sonnet/Opus are slower. Running ahead of the model keeps the buffer drained
|
|
279
|
+
// during continuous generation. CJK characters render at ~2x ASCII visual
|
|
280
|
+
// width but this rate still feels natural in both scripts.
|
|
281
|
+
const TUI_STREAM_CHUNK_CHARS = 20;
|
|
282
|
+
const TUI_STREAM_CHUNK_INTERVAL_MS = 20;
|
|
283
|
+
function makeTuiStreamBuffer() {
|
|
284
|
+
return { trueText: '', displayedLen: 0, timer: null };
|
|
285
|
+
}
|
|
286
|
+
function extractTextBlocks(content) {
|
|
287
|
+
if (!Array.isArray(content))
|
|
288
|
+
return '';
|
|
289
|
+
return content
|
|
290
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
291
|
+
.map((block) => block.text)
|
|
292
|
+
.join('\n')
|
|
293
|
+
.trim();
|
|
294
|
+
}
|
|
295
|
+
function normalizedNoticeLines(text) {
|
|
296
|
+
return stripAnsiEscapes(text)
|
|
297
|
+
.split(/\r?\n/)
|
|
298
|
+
.map(line => line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''))
|
|
299
|
+
.map(line => line.replace(/\s+/g, ' ').trim())
|
|
300
|
+
.filter(Boolean);
|
|
301
|
+
}
|
|
302
|
+
function limitNoticeFromText(text) {
|
|
303
|
+
if (!text)
|
|
304
|
+
return null;
|
|
305
|
+
const patterns = [
|
|
306
|
+
/you(?:'|’)ve hit your (?:session|usage) limit/i,
|
|
307
|
+
/you have hit your (?:session|usage) limit/i,
|
|
308
|
+
/(?:session|usage) limit (?:reached|exceeded)/i,
|
|
309
|
+
/(?:session|usage) limit.{0,100}resets?/i,
|
|
310
|
+
/(?:rate limit|rate limited).{0,100}(?:try again|resets?|later)/i,
|
|
311
|
+
/(?:try again|resets?|later).{0,100}(?:rate limit|rate limited)/i,
|
|
312
|
+
];
|
|
313
|
+
for (const line of normalizedNoticeLines(text)) {
|
|
314
|
+
if (patterns.some(pattern => pattern.test(line)))
|
|
315
|
+
return line.slice(0, 240);
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
|
|
320
|
+
if (typeof msgOrText === 'string')
|
|
321
|
+
return limitNoticeFromText(msgOrText);
|
|
322
|
+
if (!msgOrText || msgOrText.model !== '<synthetic>')
|
|
323
|
+
return null;
|
|
324
|
+
return limitNoticeFromText(extractTextBlocks(msgOrText.content));
|
|
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
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
|
|
354
|
+
* a slice of (ANSI-stripped) PTY screen output. When pikiloop spawns the TUI
|
|
355
|
+
* with `--permission-mode bypassPermissions` (the default) on a machine that
|
|
356
|
+
* has not yet accepted bypass mode, Claude paints a blocking prompt:
|
|
357
|
+
*
|
|
358
|
+
* WARNING: Claude Code running in Bypass Permissions mode
|
|
359
|
+
* ...
|
|
360
|
+
* ❯ 1. No, exit
|
|
361
|
+
* 2. Yes, I accept
|
|
362
|
+
*
|
|
363
|
+
* The default highlight sits on "No, exit", so the driver's blind prompt-submit
|
|
364
|
+
* Enter nudge would pick *exit* — the message never gets processed and the turn
|
|
365
|
+
* hangs on a pre-prompt. Seeding `bypassPermissionsModeAccepted` in config is
|
|
366
|
+
* not a reliable fix: it is version-fragile (observed no-op on 2.1.169) and
|
|
367
|
+
* gated by org policy (`isBypassPermissionsModeAvailable`). So we detect the
|
|
368
|
+
* dialog on the wire and auto-select "Yes, I accept". Require all three
|
|
369
|
+
* distinctive fragments so ordinary text mentioning "bypass" can't trigger it.
|
|
370
|
+
*/
|
|
371
|
+
export function detectClaudeBypassPrompt(screen) {
|
|
372
|
+
if (typeof screen !== 'string' || !screen)
|
|
373
|
+
return false;
|
|
374
|
+
// Claude's TUI lays words out with cursor-move escapes (`\x1b[<col>G`) rather
|
|
375
|
+
// than literal spaces, so once ANSI is stripped the on-screen text runs
|
|
376
|
+
// together — the real dialog reads "BypassPermissionsmode" / "Yes,Iaccept" /
|
|
377
|
+
// "No,exit", not the spaced form. Collapse all whitespace before matching so
|
|
378
|
+
// the detector fires on the live PTY screen *and* on space-preserving
|
|
379
|
+
// renderings. (Verified against claude 2.1.168's actual bypass screen.)
|
|
380
|
+
const t = stripAnsiEscapes(screen).replace(/\s+/g, '').toLowerCase();
|
|
381
|
+
return t.includes('bypasspermissionsmode')
|
|
382
|
+
&& t.includes('yes,iaccept')
|
|
383
|
+
&& t.includes('no,exit');
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Detect Claude Code's *mid-turn* per-command permission confirmation in a slice
|
|
387
|
+
* of (ANSI-stripped) PTY screen output. Even under `--permission-mode
|
|
388
|
+
* bypassPermissions`, an explicit `ask` rule in settings (e.g. `Bash(git tag:*)`,
|
|
389
|
+
* `git commit`, `git push`) is still honoured, so the TUI paints:
|
|
390
|
+
*
|
|
391
|
+
* Permission rule Bash(git tag:*) requires confirmation for this command.
|
|
392
|
+
* Do you want to proceed?
|
|
393
|
+
* ❯ 1. Yes
|
|
394
|
+
* 2. Yes, and don't ask again for: …
|
|
395
|
+
* 3. No
|
|
396
|
+
* Esc to cancel · Tab to amend · ctrl+e to explain
|
|
397
|
+
*
|
|
398
|
+
* Nothing answers it (detectClaudeBypassPrompt only handles the *startup* bypass
|
|
399
|
+
* dialog), so the turn hangs until the stall watchdog SIGTERMs it and mislabels
|
|
400
|
+
* the block as a "CLI freeze". We detect it on the wire and select "1. Yes" —
|
|
401
|
+
* restoring the bypass intent turn-by-turn without mutating the user's settings
|
|
402
|
+
* (option 2 "don't ask again" would).
|
|
403
|
+
*
|
|
404
|
+
* Thin wrapper over {@link classifyClaudeScreen} (state === 'confirm-prompt') so the in-flight
|
|
405
|
+
* auto-answer and the stall watchdog share ONE verdict. The earlier standalone implementation
|
|
406
|
+
* required the literal footer "esctocancel", which truncates at the 200-col screen edge
|
|
407
|
+
* ("sctocancel") and silently missed real prompts — the structural classifier does not.
|
|
408
|
+
*/
|
|
409
|
+
export function detectClaudeProceedPrompt(screen) {
|
|
410
|
+
return classifyClaudeScreen(screen).state === 'confirm-prompt';
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Read what determinate state Claude's TUI is in from a slice of (ANSI-stripped) PTY screen
|
|
414
|
+
* output. This is the single source of truth consumed by BOTH the in-flight auto-answer (onData)
|
|
415
|
+
* and the stall watchdog: when a turn goes quiet we cannot tell from timing alone whether the TUI
|
|
416
|
+
* is (a) frozen mid-turn (the known CLI freeze — PTY dead), (b) thinking for a long time (PTY
|
|
417
|
+
* repaints a spinner), (c) blocked on an interactive confirm bypass mode does NOT suppress
|
|
418
|
+
* (ask-rule "Do you want to proceed?", trust-a-new-folder), (d) sitting back at the idle REPL
|
|
419
|
+
* (turn finished but the Stop hook was missed/held), or (e) showing a model-unavailable banner.
|
|
420
|
+
*
|
|
421
|
+
* Keys on STRUCTURAL invariants, not exact footers — Claude lays words out with cursor-move
|
|
422
|
+
* escapes so the despaced screen runs together ("doyouwanttoproceed"), and footers TRUNCATE at the
|
|
423
|
+
* 200-col edge ("Esc to cancel" → "sctocancel"). So the footer is corroborating, never required;
|
|
424
|
+
* the load-bearing signals are the cursor'd numbered select (`❯`+`1.`) plus the proceed/confirm
|
|
425
|
+
* question, and the persistent idle mode-line. Robust to claude version churn for the same reason.
|
|
426
|
+
*
|
|
427
|
+
* Default-deny: anything not high-confidence returns 'unknown', because mislabelling a real freeze
|
|
428
|
+
* as a clearable/idle state would convert a self-healing stall (auto-resume) into a silently
|
|
429
|
+
* dropped turn — ambiguity must bias to the freeze path.
|
|
430
|
+
*/
|
|
431
|
+
export function classifyClaudeScreen(screen) {
|
|
432
|
+
if (typeof screen !== 'string' || !screen)
|
|
433
|
+
return { state: 'unknown', affirmativeKey: null, sample: '' };
|
|
434
|
+
const stripped = stripAnsiEscapes(screen);
|
|
435
|
+
const sample = stripped.replace(/\s+/g, ' ').trim().slice(-400);
|
|
436
|
+
// Claude positions words with cursor moves, so the live screen is spaceless; match against the
|
|
437
|
+
// despaced form (see detectClaudeBypassPrompt).
|
|
438
|
+
const ds = stripped.replace(/\s+/g, '').toLowerCase();
|
|
439
|
+
// 1. Startup bypass-permissions dialog (option 1 is "No, exit" — affirmative is option 2).
|
|
440
|
+
// Require all three distinctive fragments so ordinary "bypass" prose can't trigger it
|
|
441
|
+
// (mirrors detectClaudeBypassPrompt). Checked first: it overlaps the numbered-select shape.
|
|
442
|
+
if (ds.includes('bypasspermissionsmode') && ds.includes('yes,iaccept') && ds.includes('no,exit')) {
|
|
443
|
+
return { state: 'bypass-startup', affirmativeKey: '2', sample };
|
|
444
|
+
}
|
|
445
|
+
// 2. Selected-model-unavailable banner. Distinctive phrasing; reuse the shared detector so the
|
|
446
|
+
// -p and TUI paths stay in lockstep.
|
|
447
|
+
if (detectClaudeModelError(ds))
|
|
448
|
+
return { state: 'model-error', affirmativeKey: null, sample };
|
|
449
|
+
const asksProceed = ds.includes('doyouwanttoproceed') || ds.includes('wouldyouliketoproceed');
|
|
450
|
+
const hasCursorSelect = ds.includes('❯') && ds.includes('1.'); // a real Ink select, not prose
|
|
451
|
+
// 3. Plan-approval dialog (ExitPlanMode / Ultraplan). Distinctive option text. NOT auto-answered:
|
|
452
|
+
// affirmativeKey stays null so the chokepoint terminates cleanly and asks the user to re-send
|
|
453
|
+
// rather than pressing "Yes, and bypass permissions" (standing session bypass).
|
|
454
|
+
if ((asksProceed || ds.includes('readytoexecute'))
|
|
455
|
+
&& (ds.includes('manuallyapproveedits') || ds.includes('yes,andbypasspermissions'))) {
|
|
456
|
+
return { state: 'plan-approval', affirmativeKey: null, sample };
|
|
457
|
+
}
|
|
458
|
+
// 4. Mid-turn confirm/select. A proceed/confirm question + a cursor'd numbered select. Bypass and
|
|
459
|
+
// plan dialogs are already handled above, so by here option 1 is the "Yes" affirmative.
|
|
460
|
+
if ((asksProceed || ds.includes('requiresconfirmation')) && hasCursorSelect) {
|
|
461
|
+
return { state: 'confirm-prompt', affirmativeKey: '1', sample };
|
|
462
|
+
}
|
|
463
|
+
// 4b. Standalone interactive prompts distinctive enough to need no numbered select: the
|
|
464
|
+
// trust-a-new-folder dialog, and explicit (y/n) confirmations.
|
|
465
|
+
if (ds.includes('trustthisfolder'))
|
|
466
|
+
return { state: 'confirm-prompt', affirmativeKey: '1', sample };
|
|
467
|
+
if ((asksProceed || ds.includes('doyouwant')) && ds.includes('(y/n)')) {
|
|
468
|
+
return { state: 'confirm-prompt', affirmativeKey: 'y', sample };
|
|
469
|
+
}
|
|
470
|
+
// 5. Idle REPL — claude finished and is back at the input line. Key on the PERSISTENT mode-line
|
|
471
|
+
// (real idle screens carry typed-ahead like "/install", so an empty `❯` is unreliable) and
|
|
472
|
+
// require the absence of the active "esc to interrupt" hint so a frozen spinner frame that
|
|
473
|
+
// happens to still show the mode-line is NOT mistaken for idle.
|
|
474
|
+
if (ds.includes('bypasspermissionson')
|
|
475
|
+
&& (ds.includes('shift+tabtocycle') || ds.includes('foragents') || ds.includes('tomanage'))
|
|
476
|
+
&& !ds.includes('esctointerrupt')) {
|
|
477
|
+
return { state: 'idle-repl', affirmativeKey: null, sample };
|
|
478
|
+
}
|
|
479
|
+
return { state: 'unknown', affirmativeKey: null, sample };
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Backward-compatible capture-only wrapper retained for the stall-diagnostics heartbeat. A "prompt"
|
|
483
|
+
* is any blocking dialog state (confirm / plan / startup-bypass). Derives from
|
|
484
|
+
* {@link classifyClaudeScreen} so there is one classifier, not two.
|
|
485
|
+
*/
|
|
486
|
+
export function classifyStallScreen(screen) {
|
|
487
|
+
const info = classifyClaudeScreen(screen);
|
|
488
|
+
const looksLikePrompt = info.state === 'confirm-prompt'
|
|
489
|
+
|| info.state === 'plan-approval' || info.state === 'bypass-startup';
|
|
490
|
+
return { looksLikePrompt, sample: info.sample };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Extract text / thinking blocks from an assistant JSONL event and route them:
|
|
494
|
+
* text → the chunked stream buffer (slow drain), thinking → `s.thinking`
|
|
495
|
+
* directly. Tool uses, stop reasons, sub-agents, etc. are still handled by
|
|
496
|
+
* `claudeParse` once we've stripped the text/thinking blocks out of the event
|
|
497
|
+
* (see `callClaudeParseForTui`) — otherwise `claudeParse`'s "fill if empty"
|
|
498
|
+
* fallback would clobber the buffered streaming.
|
|
499
|
+
*/
|
|
500
|
+
/**
|
|
501
|
+
* Pull the server-assigned task id out of a PostToolUse hook's tool_response.
|
|
502
|
+
* Claude Code's hook payload mirrors the JSONL tool_result shape — usually
|
|
503
|
+
* `{ task: { id, subject }, ...}` for TaskCreate. Falls back to scanning the
|
|
504
|
+
* textual response for "Task #N created" when the structured form is missing.
|
|
505
|
+
*/
|
|
506
|
+
function readAssignedTaskIdFromHookResponse(toolResponse) {
|
|
507
|
+
const structured = toolResponse?.task?.id;
|
|
508
|
+
if (structured != null && String(structured).trim())
|
|
509
|
+
return String(structured).trim();
|
|
510
|
+
if (typeof toolResponse === 'string') {
|
|
511
|
+
const m = toolResponse.match(/Task #(\d+)/);
|
|
512
|
+
if (m)
|
|
513
|
+
return m[1];
|
|
514
|
+
}
|
|
515
|
+
if (toolResponse && typeof toolResponse.result === 'string') {
|
|
516
|
+
const m = toolResponse.result.match(/Task #(\d+)/);
|
|
517
|
+
if (m)
|
|
518
|
+
return m[1];
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Apply a single PreToolUse / PostToolUse hook event to the parser state.
|
|
524
|
+
* Mirrors what `claudeParse` would do for the matching JSONL tool_use /
|
|
525
|
+
* tool_result, but fires the instant Claude calls the tool — so the IM
|
|
526
|
+
* placeholder card actually updates during the turn instead of staying empty
|
|
527
|
+
* until Stop. Dedup with the eventual JSONL flush is via `tool_use_id`:
|
|
528
|
+
* claudeParse skips tools already in `s.seenClaudeToolIds`, and the new
|
|
529
|
+
* `s.seenClaudeToolResultIds` guards tool_result re-pushes.
|
|
530
|
+
*/
|
|
531
|
+
export function applyHookToolEvent(ev, s) {
|
|
532
|
+
const toolUseId = String(ev?.tool_use_id || '').trim();
|
|
533
|
+
const toolName = String(ev?.tool_name || '').trim();
|
|
534
|
+
if (!toolName || !toolUseId)
|
|
535
|
+
return false;
|
|
536
|
+
// Sub-agent tool calls fire the parent's Pre/PostToolUse hooks too (one
|
|
537
|
+
// hook pipeline per CLI process). Claude Code tags those payloads with
|
|
538
|
+
// `agent_id`; route them to the matching sub-agent's tool list instead of
|
|
539
|
+
// appending to the parent's recentActivity. Without this every Task spawn
|
|
540
|
+
// floods the parent's 执行 card with the children's tool stream while the
|
|
541
|
+
// sub-agent cards sit empty until the sidecar JSONL flushes at Stop.
|
|
542
|
+
const subAgentId = typeof ev?.agent_id === 'string' && ev.agent_id ? ev.agent_id : '';
|
|
543
|
+
if (subAgentId) {
|
|
544
|
+
if (ev.event === 'PreToolUse') {
|
|
545
|
+
const parentToolUseId = s.subAgentIdToParent?.get(subAgentId);
|
|
546
|
+
const sub = parentToolUseId ? s.subAgents?.get(parentToolUseId) : undefined;
|
|
547
|
+
if (sub && !sub.tools.some((t) => t.id === toolUseId)) {
|
|
548
|
+
const summary = toolName === 'TodoWrite'
|
|
549
|
+
? 'Update plan'
|
|
550
|
+
: summarizeClaudeToolUse(toolName, ev.tool_input || {});
|
|
551
|
+
sub.tools.push({ id: toolUseId, name: toolName, summary });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
if (ev.event === 'PreToolUse') {
|
|
557
|
+
if (s.seenClaudeToolIds.has(toolUseId))
|
|
558
|
+
return false;
|
|
559
|
+
if (toolName === 'TaskCreate') {
|
|
560
|
+
const subject = typeof ev.tool_input?.subject === 'string' ? ev.tool_input.subject.trim() : '';
|
|
561
|
+
if (subject)
|
|
562
|
+
s.pendingClaudeTaskCreates.set(toolUseId, { subject });
|
|
563
|
+
s.seenClaudeToolIds.add(toolUseId);
|
|
564
|
+
s.claudeToolsById.set(toolUseId, { name: toolName, summary: subject ? `Create task: ${subject}` : 'Create task' });
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
if (toolName === 'TaskUpdate') {
|
|
568
|
+
const taskId = String(ev.tool_input?.taskId ?? '').trim();
|
|
569
|
+
const rawStatus = String(ev.tool_input?.status ?? '').trim().toLowerCase();
|
|
570
|
+
if (taskId) {
|
|
571
|
+
if (rawStatus === 'deleted') {
|
|
572
|
+
s.claudeTaskList.delete(taskId);
|
|
573
|
+
s.claudeTaskOrder = s.claudeTaskOrder.filter((id) => id !== taskId);
|
|
574
|
+
}
|
|
575
|
+
else if (rawStatus) {
|
|
576
|
+
const existing = s.claudeTaskList.get(taskId);
|
|
577
|
+
if (existing)
|
|
578
|
+
existing.status = rawStatus;
|
|
579
|
+
}
|
|
580
|
+
rebuildClaudePlanFromTasksFromState(s);
|
|
581
|
+
}
|
|
582
|
+
s.seenClaudeToolIds.add(toolUseId);
|
|
583
|
+
s.claudeToolsById.set(toolUseId, { name: toolName, summary: `Update task ${taskId || '?'} → ${rawStatus || 'unknown'}` });
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
if (toolName === 'TodoWrite') {
|
|
587
|
+
const plan = parseTodoWriteAsPlanLite(ev.tool_input);
|
|
588
|
+
if (plan)
|
|
589
|
+
s.plan = plan;
|
|
590
|
+
s.seenClaudeToolIds.add(toolUseId);
|
|
591
|
+
s.claudeToolsById.set(toolUseId, { name: toolName, summary: 'Update plan' });
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
if (toolName === 'Task' || toolName === 'Agent') {
|
|
595
|
+
// Register the sub-agent so `meta.subAgents` lights up the new
|
|
596
|
+
// Sub-agent preview block. Sub-agents are isolated from parent activity
|
|
597
|
+
// by design (the dedicated section shows their own tool stream); pushing
|
|
598
|
+
// into parent recentActivity would re-introduce the noise the isolation
|
|
599
|
+
// is meant to prevent. Granular sub-agent tool calls land later via the
|
|
600
|
+
// sidecar pump → `routeClaudeSubAgentEvent`.
|
|
601
|
+
const input = ev.tool_input || {};
|
|
602
|
+
const desc = typeof input.description === 'string' ? input.description.trim() : '';
|
|
603
|
+
const kind = typeof input.subagent_type === 'string' ? input.subagent_type.trim() : '';
|
|
604
|
+
if (!s.subAgents.has(toolUseId)) {
|
|
605
|
+
s.subAgents.set(toolUseId, {
|
|
606
|
+
id: toolUseId,
|
|
607
|
+
kind: kind || null,
|
|
608
|
+
description: desc || null,
|
|
609
|
+
model: null,
|
|
610
|
+
tools: [],
|
|
611
|
+
status: 'running',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
// Backgrounded launch — track it so the turn doesn't end (and the PTY
|
|
615
|
+
// doesn't get SIGTERMed) until its <task-notification> arrives. The hook
|
|
616
|
+
// fires live; the JSONL replay of the same tool_use dedupes via
|
|
617
|
+
// seenClaudeToolIds, so this is the only registration point in TUI mode.
|
|
618
|
+
if (input.run_in_background === true)
|
|
619
|
+
registerClaudeBackgroundAgentLaunch(s, toolUseId);
|
|
620
|
+
s.seenClaudeToolIds.add(toolUseId);
|
|
621
|
+
s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
// Background Bash — register like a backgrounded agent so the turn's Stop
|
|
625
|
+
// holds the PTY open until its <task-notification> lands, instead of
|
|
626
|
+
// SIGTERMing the still-running command (and its future report-back turn).
|
|
627
|
+
if (toolName === 'Bash' && ev.tool_input?.run_in_background === true) {
|
|
628
|
+
registerClaudeBackgroundBashLaunch(s, toolUseId);
|
|
629
|
+
}
|
|
630
|
+
// Workflow → always-backgrounded multi-agent orchestration. Same in-process
|
|
631
|
+
// lifecycle as a run_in_background Task; register so the turn's Stop holds
|
|
632
|
+
// the PTY instead of SIGTERMing the in-flight workflow. The hook fires live;
|
|
633
|
+
// the JSONL replay of the same tool_use dedupes via seenClaudeToolIds, so
|
|
634
|
+
// this is the only registration point in TUI mode.
|
|
635
|
+
if (toolName === 'Workflow') {
|
|
636
|
+
registerClaudeBackgroundAgentLaunch(s, toolUseId);
|
|
637
|
+
}
|
|
638
|
+
const summary = summarizeClaudeToolUse(toolName, ev.tool_input || {});
|
|
639
|
+
pushRecentActivity(s.recentActivity, summary);
|
|
640
|
+
s.seenClaudeToolIds.add(toolUseId);
|
|
641
|
+
s.claudeToolsById.set(toolUseId, {
|
|
642
|
+
name: toolName,
|
|
643
|
+
summary,
|
|
644
|
+
input: previewToolCallInput(toolName, ev.tool_input),
|
|
645
|
+
status: 'running',
|
|
646
|
+
});
|
|
647
|
+
if (!s.claudeToolCallOrder)
|
|
648
|
+
s.claudeToolCallOrder = [];
|
|
649
|
+
s.claudeToolCallOrder.push(toolUseId);
|
|
650
|
+
s.activity = s.recentActivity.join('\n');
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
if (ev.event === 'PostToolUse') {
|
|
654
|
+
if (!s.seenClaudeToolResultIds)
|
|
655
|
+
s.seenClaudeToolResultIds = new Set();
|
|
656
|
+
if (s.seenClaudeToolResultIds.has(toolUseId))
|
|
657
|
+
return false;
|
|
658
|
+
if (toolName === 'TaskCreate') {
|
|
659
|
+
const pending = s.pendingClaudeTaskCreates.get(toolUseId);
|
|
660
|
+
const assignedId = readAssignedTaskIdFromHookResponse(ev.tool_response);
|
|
661
|
+
if (pending && assignedId) {
|
|
662
|
+
s.pendingClaudeTaskCreates.delete(toolUseId);
|
|
663
|
+
if (!s.claudeTaskList.has(assignedId))
|
|
664
|
+
s.claudeTaskOrder.push(assignedId);
|
|
665
|
+
s.claudeTaskList.set(assignedId, { subject: pending.subject, status: 'pending' });
|
|
666
|
+
rebuildClaudePlanFromTasksFromState(s);
|
|
667
|
+
}
|
|
668
|
+
s.seenClaudeToolResultIds.add(toolUseId);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
if (toolName === 'TaskUpdate' || toolName === 'TodoWrite') {
|
|
672
|
+
s.seenClaudeToolResultIds.add(toolUseId);
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
if (toolName === 'Task' || toolName === 'Agent') {
|
|
676
|
+
// Sub-agent finished — flip its status so it drops out of the live
|
|
677
|
+
// Sub-agent preview block. The completion fact itself is implicit: the
|
|
678
|
+
// block stops listing this entry.
|
|
679
|
+
// Backgrounded launches are the exception: their PostToolUse fires
|
|
680
|
+
// immediately with a launch ack while the agent keeps running. Leave the
|
|
681
|
+
// status alone — applyClaudeTaskNotification flips it when the real
|
|
682
|
+
// completion lands.
|
|
683
|
+
const sub = s.subAgents.get(toolUseId);
|
|
684
|
+
if (sub) {
|
|
685
|
+
const isBgLaunchAck = !ev.tool_response?.is_error
|
|
686
|
+
&& (ev.tool_input?.run_in_background === true
|
|
687
|
+
|| (s.bgAgentLaunchedToolUseIds?.has(toolUseId) && !s.bgAgentCompletedToolUseIds?.has(toolUseId)));
|
|
688
|
+
if (!isBgLaunchAck)
|
|
689
|
+
sub.status = ev.tool_response?.is_error ? 'failed' : 'done';
|
|
690
|
+
}
|
|
691
|
+
s.seenClaudeToolResultIds.add(toolUseId);
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
const tool = s.claudeToolsById.get(toolUseId);
|
|
695
|
+
if (tool) {
|
|
696
|
+
tool.result = previewToolCallResult(ev.tool_response);
|
|
697
|
+
tool.status = ev.tool_response?.is_error ? 'failed' : 'done';
|
|
698
|
+
const summary = summarizeClaudeToolResult(tool, { content: ev.tool_response }, ev.tool_response);
|
|
699
|
+
if (summary) {
|
|
700
|
+
pushRecentActivity(s.recentActivity, summary);
|
|
701
|
+
s.activity = s.recentActivity.join('\n');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// Background Bash launch ack → map task id → tool_use for notification
|
|
705
|
+
// resolution (bash notifications usually omit <tool-use-id>).
|
|
706
|
+
if (toolName === 'Bash' && s.bgBashToolUseIds?.has(toolUseId)
|
|
707
|
+
&& !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
|
|
708
|
+
const taskId = extractClaudeBackgroundTaskId(ev.tool_response);
|
|
709
|
+
if (taskId && !s.bgTaskIdToToolUse.has(taskId))
|
|
710
|
+
s.bgTaskIdToToolUse.set(taskId, toolUseId);
|
|
711
|
+
}
|
|
712
|
+
// Workflow launch ack → map runId → tool_use for notification resolution
|
|
713
|
+
// (the workflow's <task-notification> may carry only the task id).
|
|
714
|
+
if (toolName === 'Workflow' && s.bgAgentLaunchedToolUseIds?.has(toolUseId)
|
|
715
|
+
&& !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
|
|
716
|
+
const runId = extractClaudeWorkflowRunId(ev.tool_response);
|
|
717
|
+
if (runId && !s.bgTaskIdToToolUse.has(runId))
|
|
718
|
+
s.bgTaskIdToToolUse.set(runId, toolUseId);
|
|
719
|
+
}
|
|
720
|
+
s.seenClaudeToolResultIds.add(toolUseId);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Lite TodoWrite parser used by the hook path — avoids pulling parseTodoWriteAsPlan
|
|
727
|
+
* from agent/utils into this file's already-large import surface. Identical
|
|
728
|
+
* semantics for the legacy 1.x plan tool.
|
|
729
|
+
*/
|
|
730
|
+
function parseTodoWriteAsPlanLite(input) {
|
|
731
|
+
if (!input || typeof input !== 'object')
|
|
732
|
+
return null;
|
|
733
|
+
const rawTodos = Array.isArray(input.todos) ? input.todos : [];
|
|
734
|
+
if (!rawTodos.length)
|
|
735
|
+
return null;
|
|
736
|
+
const steps = [];
|
|
737
|
+
for (const todo of rawTodos) {
|
|
738
|
+
if (!todo || typeof todo !== 'object')
|
|
739
|
+
continue;
|
|
740
|
+
const content = typeof todo.content === 'string' ? todo.content.trim() : '';
|
|
741
|
+
if (!content)
|
|
742
|
+
continue;
|
|
743
|
+
const rawStatus = typeof todo.status === 'string' ? todo.status : 'pending';
|
|
744
|
+
const status = rawStatus === 'completed' ? 'completed'
|
|
745
|
+
: rawStatus === 'in_progress' ? 'inProgress'
|
|
746
|
+
: 'pending';
|
|
747
|
+
steps.push({ step: content, status });
|
|
748
|
+
}
|
|
749
|
+
if (!steps.length)
|
|
750
|
+
return null;
|
|
751
|
+
return { explanation: null, steps };
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Reimplementation of claude.ts's rebuildClaudePlanFromTasks (it's private to
|
|
755
|
+
* that module). Kept tiny and dependency-free so the hook code path stays
|
|
756
|
+
* independent of the JSONL parser's internals.
|
|
757
|
+
*/
|
|
758
|
+
function rebuildClaudePlanFromTasksFromState(s) {
|
|
759
|
+
if (!s.claudeTaskOrder?.length)
|
|
760
|
+
return;
|
|
761
|
+
const steps = [];
|
|
762
|
+
for (const id of s.claudeTaskOrder) {
|
|
763
|
+
const task = s.claudeTaskList.get(id);
|
|
764
|
+
if (!task)
|
|
765
|
+
continue;
|
|
766
|
+
const lowered = String(task.status || '').toLowerCase();
|
|
767
|
+
const status = lowered === 'completed' ? 'completed'
|
|
768
|
+
: lowered === 'in_progress' || lowered === 'inprogress' ? 'inProgress'
|
|
769
|
+
: 'pending';
|
|
770
|
+
steps.push({ step: task.subject, status });
|
|
771
|
+
}
|
|
772
|
+
s.plan = { explanation: null, steps };
|
|
773
|
+
}
|
|
774
|
+
function applyAssistantStreaming(s, msg, buf) {
|
|
775
|
+
if (!msg || msg.model === '<synthetic>')
|
|
776
|
+
return;
|
|
777
|
+
const contents = Array.isArray(msg.content) ? msg.content : [];
|
|
778
|
+
let appendText = '';
|
|
779
|
+
let appendThinking = '';
|
|
780
|
+
for (const block of contents) {
|
|
781
|
+
if (!block)
|
|
782
|
+
continue;
|
|
783
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
784
|
+
appendText += (appendText ? '\n\n' : '') + block.text;
|
|
785
|
+
}
|
|
786
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string') {
|
|
787
|
+
appendThinking += (appendThinking ? '\n\n' : '') + block.thinking;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (appendText) {
|
|
791
|
+
buf.trueText = buf.trueText ? `${buf.trueText}\n\n${appendText}` : appendText;
|
|
792
|
+
}
|
|
793
|
+
if (appendThinking) {
|
|
794
|
+
s.thinking = s.thinking ? `${s.thinking}\n\n${appendThinking}` : appendThinking;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Hand a JSONL event to the shared `claudeParse`, but for `assistant` events
|
|
799
|
+
* first strip out the text/thinking blocks. Reason: `claudeParse`'s assistant
|
|
800
|
+
* branch contains a `if (tx && !s.text.trim()) s.text = tx` fallback — useful
|
|
801
|
+
* for print mode where deltas may have missed, harmful here because it would
|
|
802
|
+
* dump the entire response into `s.text` in one go, bypassing the simulated
|
|
803
|
+
* stream we just routed into the buffer.
|
|
804
|
+
*/
|
|
805
|
+
function callClaudeParseForTui(ev, s) {
|
|
806
|
+
if (ev.type !== 'assistant' || !ev.message) {
|
|
807
|
+
claudeParse(ev, s);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const filtered = {
|
|
811
|
+
...ev,
|
|
812
|
+
message: {
|
|
813
|
+
...ev.message,
|
|
814
|
+
content: Array.isArray(ev.message.content)
|
|
815
|
+
? ev.message.content.filter((b) => b?.type !== 'text' && b?.type !== 'thinking')
|
|
816
|
+
: ev.message.content,
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
claudeParse(filtered, s);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Set `s.contextWindow` from a model id, the same way the `-p` parser does on
|
|
823
|
+
* each `system` / `stream_event` / `result` event. TUI mode never sees those
|
|
824
|
+
* events (JSONL is the source of truth and only carries `user`/`assistant`/
|
|
825
|
+
* `attachment`/`summary`), so without this call `s.contextWindow` stays null
|
|
826
|
+
* and `computeContext()` returns `contextPercent: null` → the dashboard's
|
|
827
|
+
* `ContextDot` and percent chip both disappear. Guarded by `byokContextWindow`
|
|
828
|
+
* so BYOK Profiles' externally-cached window wins (matches print-mode).
|
|
829
|
+
*/
|
|
830
|
+
function applyModelContextWindow(s) {
|
|
831
|
+
if (s.byokContextWindow)
|
|
832
|
+
return;
|
|
833
|
+
const advertised = claudeContextWindowFromModel(s.model);
|
|
834
|
+
const effective = claudeEffectiveContextWindow(advertised);
|
|
835
|
+
if (effective != null)
|
|
836
|
+
s.contextWindow = effective;
|
|
837
|
+
}
|
|
838
|
+
/** Per-call token usage from an assistant event's `message.usage`. -p mode
|
|
839
|
+
* derives these from `stream_event/message_delta`; JSONL only carries them
|
|
840
|
+
* here. Per-call semantics: each assistant event represents one LLM call and
|
|
841
|
+
* its usage replaces the prior snapshot. */
|
|
842
|
+
function applyAssistantUsage(s, msg) {
|
|
843
|
+
const u = msg?.usage;
|
|
844
|
+
if (!u || typeof u !== 'object')
|
|
845
|
+
return;
|
|
846
|
+
// JSONL has no message_start marker — a fresh message.id is the per-call
|
|
847
|
+
// boundary. Fold the finished call's output into the turn-cumulative base
|
|
848
|
+
// before this call's counters take over (events of the same call share an
|
|
849
|
+
// id and carry running totals, so only the id transition folds).
|
|
850
|
+
const msgId = typeof msg?.id === 'string' && msg.id ? msg.id : null;
|
|
851
|
+
if (msgId && msgId !== s.turnUsageMsgId) {
|
|
852
|
+
if (s.turnUsageMsgId != null)
|
|
853
|
+
s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
|
|
854
|
+
s.turnUsageMsgId = msgId;
|
|
855
|
+
}
|
|
856
|
+
if (typeof u.input_tokens === 'number')
|
|
857
|
+
s.inputTokens = u.input_tokens;
|
|
858
|
+
if (typeof u.output_tokens === 'number')
|
|
859
|
+
s.outputTokens = u.output_tokens;
|
|
860
|
+
if (typeof u.cache_read_input_tokens === 'number')
|
|
861
|
+
s.cachedInputTokens = u.cache_read_input_tokens;
|
|
862
|
+
if (typeof u.cache_creation_input_tokens === 'number')
|
|
863
|
+
s.cacheCreationInputTokens = u.cache_creation_input_tokens;
|
|
864
|
+
const total = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0) + (s.outputTokens ?? 0);
|
|
865
|
+
s.contextUsedTokens = total > 0 ? total : null;
|
|
866
|
+
}
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
// Stop-hook gating
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
/**
|
|
871
|
+
* After the last pending background agent reports its <task-notification>,
|
|
872
|
+
* the harness re-invokes the model (wrap-up segment) and a fresh Stop hook
|
|
873
|
+
* follows. A Stop timestamp that *predates* the latest notification belongs to
|
|
874
|
+
* an earlier segment and must not terminate the turn. If no re-invocation
|
|
875
|
+
* materialises, we accept the stale Stop once the main JSONL has been quiet
|
|
876
|
+
* for this long — the safety valve against waiting forever on a harness that
|
|
877
|
+
* chose not to resume.
|
|
878
|
+
*/
|
|
879
|
+
const BG_RESETTLE_QUIET_MS = 30_000;
|
|
880
|
+
/**
|
|
881
|
+
* Decide what a fired Stop hook means for the PTY lifecycle.
|
|
882
|
+
*
|
|
883
|
+
* - `hold-background`: launched `run_in_background` agents haven't reported
|
|
884
|
+
* completion. They live inside the claude process — SIGTERM now would
|
|
885
|
+
* destroy them mid-flight (the "进程退出把子代理打断" failure). Keep the
|
|
886
|
+
* PTY alive; the harness will deliver <task-notification> events and
|
|
887
|
+
* re-invoke the model, producing further segments and a later Stop.
|
|
888
|
+
* - `hold-resettle`: nothing pending, but the Stop predates the latest
|
|
889
|
+
* notification — the model's post-notification segment (and its own Stop)
|
|
890
|
+
* is still expected. Hold until a fresh Stop or BG_RESETTLE_QUIET_MS of
|
|
891
|
+
* JSONL silence.
|
|
892
|
+
* - `terminate`: the Stop is the genuine end of the turn.
|
|
893
|
+
*
|
|
894
|
+
* The `hold-background` path carries a quiet-TTL: a genuinely-running
|
|
895
|
+
* background agent keeps emitting hook/sidecar/JSONL traffic, so a hold whose
|
|
896
|
+
* every channel has been silent past CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS is a
|
|
897
|
+
* phantom (lost <task-notification> / completion never observed). Releasing
|
|
898
|
+
* it as a normal Stop keeps the turn's clean semantics — letting the stall
|
|
899
|
+
* watchdog reap it instead would mislabel a finished turn 'stalled' and
|
|
900
|
+
* inject a confusing auto-resume prompt into the next turn.
|
|
901
|
+
*/
|
|
902
|
+
export function decideClaudeTuiStop(input) {
|
|
903
|
+
if (input.pendingBackgroundAgents > 0) {
|
|
904
|
+
const ttl = input.holdQuietTtlMs ?? CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS;
|
|
905
|
+
const lastActivityAt = Math.max(input.stoppedAt, input.lastJsonlEventAt, input.lastTaskNotificationAt, input.lastHookOrSidecarEventAt ?? 0);
|
|
906
|
+
if (input.now - lastActivityAt > ttl)
|
|
907
|
+
return 'terminate'; // 幽灵 hold:全通道静默超 TTL
|
|
908
|
+
return 'hold-background';
|
|
909
|
+
}
|
|
910
|
+
const stopIsStale = input.lastTaskNotificationAt > 0 && input.lastTaskNotificationAt >= input.stoppedAt;
|
|
911
|
+
if (stopIsStale) {
|
|
912
|
+
const quietMs = input.resettleQuietMs ?? BG_RESETTLE_QUIET_MS;
|
|
913
|
+
const lastActivityAt = Math.max(input.lastJsonlEventAt, input.lastTaskNotificationAt);
|
|
914
|
+
if (input.now - lastActivityAt < quietMs)
|
|
915
|
+
return 'hold-resettle';
|
|
916
|
+
}
|
|
917
|
+
return 'terminate';
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Decide whether the turn has gone dead. claude CLI is known to freeze
|
|
921
|
+
* mid-turn (observed 2026-06-02 on 2.1.160): after a tool_result lands the
|
|
922
|
+
* next assistant segment never starts — the process stays alive, the JSONL
|
|
923
|
+
* goes permanently quiet, no Stop hook ever fires, no error surfaces. Without
|
|
924
|
+
* a watchdog the IM card spins forever.
|
|
925
|
+
*
|
|
926
|
+
* `lastProgressAt` is the freshest of every live signal the driver tracks
|
|
927
|
+
* (main JSONL, hook tool events, sub-agent sidecars, hook lifecycle state).
|
|
928
|
+
* A pending tool (PreToolUse seen, no PostToolUse) extends the threshold:
|
|
929
|
+
* the freeze can also hit mid-execution, but a legitimately long foreground
|
|
930
|
+
* command must not get shot — claude's own Bash timeout fires PostToolUse
|
|
931
|
+
* well inside CLAUDE_TUI_STALL_PENDING_TOOL_MS.
|
|
932
|
+
*
|
|
933
|
+
* Fast path: `lastPtyDataAt` is raw PTY output (any repaint frame counts). A
|
|
934
|
+
* healthy TUI animates continuously mid-turn — spinner, stream ticks, status
|
|
935
|
+
* line — so PTY byte-silence is the cheapest possible "event loop is dead"
|
|
936
|
+
* detector. When BOTH the PTY and all structured signals have been silent
|
|
937
|
+
* past `ptyDeadMs`, declare the stall immediately instead of waiting out the
|
|
938
|
+
* 10/30-minute quiet thresholds. Long thinking and long foreground commands
|
|
939
|
+
* keep painting frames, which routes them to the slow thresholds as before.
|
|
940
|
+
*/
|
|
941
|
+
export function decideClaudeTuiStall(input) {
|
|
942
|
+
const ptyAt = input.lastPtyDataAt ?? 0;
|
|
943
|
+
if (ptyAt > 0) {
|
|
944
|
+
const ptyDeadMs = input.ptyDeadMs ?? CLAUDE_TUI_STALL_PTY_DEAD_MS;
|
|
945
|
+
if (input.now - Math.max(ptyAt, input.lastProgressAt) > ptyDeadMs)
|
|
946
|
+
return 'stall';
|
|
947
|
+
}
|
|
948
|
+
const threshold = input.pendingToolCount > 0
|
|
949
|
+
? (input.pendingToolMs ?? CLAUDE_TUI_STALL_PENDING_TOOL_MS)
|
|
950
|
+
: (input.quietMs ?? CLAUDE_TUI_STALL_QUIET_MS);
|
|
951
|
+
return input.now - input.lastProgressAt > threshold ? 'stall' : 'wait';
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Map the screen state at the moment the stall watchdog would fire to what we should actually do.
|
|
955
|
+
* This is the chokepoint the diagnostics proved was missing: today the screen is classified at kill
|
|
956
|
+
* time but the verdict only changes the error STRING, never the action — so confirm-dialog /
|
|
957
|
+
* idle-REPL / model-error turns get SIGTERMed as 'stalled' and auto-resumed into the same wall.
|
|
958
|
+
*
|
|
959
|
+
* Safety (default-deny): a non-'unknown' state may only DOWNGRADE to a still-terminating, non-
|
|
960
|
+
* resuming outcome — it never cancels termination and never waits beyond one bounded retry. Anything
|
|
961
|
+
* ambiguous falls through to 'terminate-stalled' (today's self-healing path), because mislabelling a
|
|
962
|
+
* real freeze as clearable/idle would convert an auto-resumable stall into a silently dropped turn.
|
|
963
|
+
*/
|
|
964
|
+
export function decideStallAction(input) {
|
|
965
|
+
if (input.state === 'model-error')
|
|
966
|
+
return 'model-error';
|
|
967
|
+
// Answerable dialogs (ask-rule confirm / startup bypass). Plan-approval reaches here too but
|
|
968
|
+
// carries affirmativeKey=null under the current policy → falls straight to unanswered.
|
|
969
|
+
if (input.state === 'confirm-prompt' || input.state === 'plan-approval' || input.state === 'bypass-startup') {
|
|
970
|
+
if (input.affirmativeKey && !input.alreadyTriedAnswer)
|
|
971
|
+
return 'answer-retry';
|
|
972
|
+
return 'terminate-prompt-unanswered';
|
|
973
|
+
}
|
|
974
|
+
if (input.state === 'idle-repl') {
|
|
975
|
+
// Back at the prompt = the turn ended (Stop hook missed/held). Terminate cleanly ONLY when no
|
|
976
|
+
// background work is outstanding: a bg agent/bash lives inside the claude process and bg-Bash
|
|
977
|
+
// is silent by nature, so killing an idle-looking screen with pending bg would abort live work
|
|
978
|
+
// ("进程退出把子代理打断"). Leave those to the existing Stop-hold / TTL machinery.
|
|
979
|
+
return input.pendingBgAgents > 0 ? 'terminate-stalled' : 'terminate-clean';
|
|
980
|
+
}
|
|
981
|
+
return 'terminate-stalled';
|
|
982
|
+
}
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
// Main entry
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
export async function doClaudeTuiStream(opts) {
|
|
987
|
+
const start = Date.now();
|
|
988
|
+
const deadline = start + opts.timeout * 1000;
|
|
989
|
+
// 0. Probe node-pty FIRST — before any temp-dir creation or session work.
|
|
990
|
+
// If it's not installed (or its prebuilt helper can't be made executable),
|
|
991
|
+
// throw so the dispatcher in claude.ts catches the error and falls back to
|
|
992
|
+
// print mode. No cleanup needed because no resources have been allocated.
|
|
993
|
+
const pty = await loadPty();
|
|
994
|
+
// 1. Resolve session lifecycle.
|
|
995
|
+
const isFork = !!opts.forkOf;
|
|
996
|
+
const isResume = !isFork && !!opts.sessionId;
|
|
997
|
+
const newSessionId = (isFork || !isResume) ? randomUUID() : opts.sessionId;
|
|
998
|
+
const home = getHome();
|
|
999
|
+
const projectDir = path.join(home, '.claude', 'projects', encodePathAsDirName(opts.workdir));
|
|
1000
|
+
// For resume we know the exact file; for new/fork we either know upfront
|
|
1001
|
+
// (--session-id) or learn it from the SessionStart hook (--fork-session
|
|
1002
|
+
// rotates to a fresh uuid Claude generates on its own).
|
|
1003
|
+
let activeSessionId = isResume ? opts.sessionId : newSessionId;
|
|
1004
|
+
let activeJsonlPath = path.join(projectDir, `${activeSessionId}.jsonl`);
|
|
1005
|
+
// Resume: skip everything that was already in the transcript before our turn.
|
|
1006
|
+
let jsonlReadOffset = 0;
|
|
1007
|
+
if (isResume) {
|
|
1008
|
+
try {
|
|
1009
|
+
jsonlReadOffset = fs.statSync(activeJsonlPath).size;
|
|
1010
|
+
}
|
|
1011
|
+
catch { }
|
|
1012
|
+
}
|
|
1013
|
+
// 2. Temp workspace for hook script + state + settings.
|
|
1014
|
+
let workDir;
|
|
1015
|
+
try {
|
|
1016
|
+
workDir = fs.mkdtempSync(path.join(tmpdir(), 'pikiloop-claude-tui-'));
|
|
1017
|
+
}
|
|
1018
|
+
catch (e) {
|
|
1019
|
+
return makeErrorResult(opts, start, `Failed to create temp dir: ${e?.message || e}`);
|
|
1020
|
+
}
|
|
1021
|
+
const hookPath = path.join(workDir, 'hook.cjs');
|
|
1022
|
+
const statePath = path.join(workDir, 'state.json');
|
|
1023
|
+
const toolEventsPath = path.join(workDir, 'tool-events.jsonl');
|
|
1024
|
+
const settingsPath = path.join(workDir, 'settings.json');
|
|
1025
|
+
const ptyLogPath = path.join(workDir, 'pty.log');
|
|
1026
|
+
try {
|
|
1027
|
+
fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
1028
|
+
fs.writeFileSync(statePath, JSON.stringify({ events: [] }));
|
|
1029
|
+
fs.writeFileSync(toolEventsPath, '');
|
|
1030
|
+
// Use the same Node binary that's running pikiloop — `node` may not be on
|
|
1031
|
+
// PATH inside the claude TUI's hook subprocess on every distro.
|
|
1032
|
+
const nodeBin = Q(process.execPath);
|
|
1033
|
+
const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
|
|
1034
|
+
// Pre/PostToolUse hooks give us a live tool-event stream. The transcript
|
|
1035
|
+
// JSONL is itself written incrementally (events land ~0.2–1.2s after they
|
|
1036
|
+
// happen — measured on 2.1.173), but the hooks still earn their keep:
|
|
1037
|
+
// PreToolUse fires the instant a tool *starts* (the JSONL tool_use only
|
|
1038
|
+
// proves it was requested; a long-running Bash would otherwise sit
|
|
1039
|
+
// invisible), they carry agent_id for sub-agent attribution, and they are
|
|
1040
|
+
// the registration point for run_in_background launches that the Stop
|
|
1041
|
+
// gating in decideClaudeTuiStop depends on. The hook script writes to
|
|
1042
|
+
// tool-events.jsonl via atomic appends, sidestepping the
|
|
1043
|
+
// read-modify-write race that affects the shared state.json file.
|
|
1044
|
+
// Pre/PostToolUse require an explicit `matcher` field — without it Claude
|
|
1045
|
+
// Code's hook dispatcher silently never fires the hook (the lifecycle
|
|
1046
|
+
// hooks below don't need a matcher because they aren't tool-scoped).
|
|
1047
|
+
// `*` matches every tool. Without this, the entire live-streaming wire-up
|
|
1048
|
+
// is dead code.
|
|
1049
|
+
const settings = {
|
|
1050
|
+
hooks: {
|
|
1051
|
+
SessionStart: [{ hooks: [{ type: 'command', command: hookCmd('SessionStart'), timeout: 5 }] }],
|
|
1052
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: hookCmd('UserPromptSubmit'), timeout: 5 }] }],
|
|
1053
|
+
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PreToolUse'), timeout: 5 }] }],
|
|
1054
|
+
PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PostToolUse'), timeout: 5 }] }],
|
|
1055
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCmd('Stop'), timeout: 5 }] }],
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1059
|
+
}
|
|
1060
|
+
catch (e) {
|
|
1061
|
+
try {
|
|
1062
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
1063
|
+
}
|
|
1064
|
+
catch { }
|
|
1065
|
+
return makeErrorResult(opts, start, `Failed to seed hook scaffold: ${e?.message || e}`);
|
|
1066
|
+
}
|
|
1067
|
+
// 3. Build the claude argv. Crucially: NO `-p` — that's the whole point.
|
|
1068
|
+
const claudeArgs = [];
|
|
1069
|
+
if (isFork) {
|
|
1070
|
+
claudeArgs.push('--resume', opts.forkOf.parentSessionId, '--fork-session');
|
|
1071
|
+
}
|
|
1072
|
+
else if (isResume) {
|
|
1073
|
+
claudeArgs.push('--resume', opts.sessionId);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
claudeArgs.push('--session-id', newSessionId);
|
|
1077
|
+
}
|
|
1078
|
+
claudeArgs.push('--settings', settingsPath);
|
|
1079
|
+
const model = normalizeClaudeModelId(opts.claudeModel);
|
|
1080
|
+
if (model)
|
|
1081
|
+
claudeArgs.push('--model', model);
|
|
1082
|
+
if (opts.claudePermissionMode)
|
|
1083
|
+
claudeArgs.push('--permission-mode', opts.claudePermissionMode);
|
|
1084
|
+
// Effort + Workflow gate — same source of truth as the `claude -p` driver, so
|
|
1085
|
+
// the TUI path drops the Workflow tool unless orchestration was opted in.
|
|
1086
|
+
claudeArgs.push(...claudeEffortAndWorkflowArgs(opts));
|
|
1087
|
+
if (opts.claudeAppendSystemPrompt)
|
|
1088
|
+
claudeArgs.push('--append-system-prompt', opts.claudeAppendSystemPrompt);
|
|
1089
|
+
if (opts.mcpConfigPath)
|
|
1090
|
+
claudeArgs.push('--mcp-config', opts.mcpConfigPath);
|
|
1091
|
+
if (opts.claudeExtraArgs?.length)
|
|
1092
|
+
claudeArgs.push(...opts.claudeExtraArgs);
|
|
1093
|
+
// Attachments: TUI doesn't accept base64-image stream-json input. Reference
|
|
1094
|
+
// local paths via the @-mention syntax — Claude's TUI reads images from
|
|
1095
|
+
// disk and inlines them into the message.
|
|
1096
|
+
let fullPrompt = opts.prompt;
|
|
1097
|
+
if (opts.attachments?.length) {
|
|
1098
|
+
const refs = opts.attachments.map(p => `@${p}`).join(' ');
|
|
1099
|
+
fullPrompt = `${refs}\n\n${opts.prompt}`;
|
|
1100
|
+
}
|
|
1101
|
+
// `--mcp-config <configs...>` (and a few other Claude flags) are *variadic*
|
|
1102
|
+
// — without a `--` terminator the positional prompt would be consumed as
|
|
1103
|
+
// another MCP config path. Always end with `--` then the prompt.
|
|
1104
|
+
claudeArgs.push('--', fullPrompt);
|
|
1105
|
+
// 4. Honour the existing steer-callback contract — TUI mode can't accept
|
|
1106
|
+
// mid-turn additional input, but callers (bot.ts) always pass onSteerReady
|
|
1107
|
+
// and expect it to be invoked. Give them a no-op so the orchestration doesn't
|
|
1108
|
+
// hang waiting for the callback that never fires.
|
|
1109
|
+
try {
|
|
1110
|
+
opts.onSteerReady?.(async () => {
|
|
1111
|
+
agentWarn('[claude-tui] steer requested but TUI mode does not support mid-turn input — ignored');
|
|
1112
|
+
return false;
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
agentWarn(`[claude-tui] onSteerReady callback raised: ${e?.message || e}`);
|
|
1117
|
+
}
|
|
1118
|
+
// 5. Set up parser state and ensure the bot side has the upfront session id.
|
|
1119
|
+
const s = createClaudeStreamState(opts);
|
|
1120
|
+
// Resume: lock in the native id we are resuming. Fork: keep a placeholder until
|
|
1121
|
+
// Claude reports its rotated id via the SessionStart hook. New session: leave
|
|
1122
|
+
// s.sessionId at its initial value (null for a pending session) so the emit
|
|
1123
|
+
// below detects a change and fires the pending→native promotion callback.
|
|
1124
|
+
if (isResume || isFork)
|
|
1125
|
+
s.sessionId = activeSessionId;
|
|
1126
|
+
// Seed the context window from whatever model is configured up front (e.g.
|
|
1127
|
+
// "haiku" / "opus" / "sonnet" via opts.claudeModel) so the dashboard's
|
|
1128
|
+
// context-percent chip + green-dot indicator can render starting from the
|
|
1129
|
+
// very first emit, before any assistant event has arrived to confirm the
|
|
1130
|
+
// model. Subsequent assistant events with concrete model ids will refresh
|
|
1131
|
+
// s.model + recompute the window via applyModelContextWindow.
|
|
1132
|
+
if (!s.model && (opts.claudeModel || opts.model)) {
|
|
1133
|
+
s.model = opts.claudeModel || opts.model;
|
|
1134
|
+
}
|
|
1135
|
+
applyModelContextWindow(s);
|
|
1136
|
+
// A brand-new session uses the id we generated and passed via --session-id, so
|
|
1137
|
+
// it is final the instant we spawn. Emit it now: s.sessionId is still unset
|
|
1138
|
+
// here, so emitSessionIdUpdate fires the onSessionId callback that promotes the
|
|
1139
|
+
// pending pikiloop record (and its in-memory runtime) to the native id. The
|
|
1140
|
+
// prior `s.sessionId = activeSessionId` made this a silent no-op (emit dedups on
|
|
1141
|
+
// `id === s.sessionId`), so the record stayed `pending_*` for the whole run —
|
|
1142
|
+
// and since mergeManagedAndNativeSessions drops pending records, the dashboard
|
|
1143
|
+
// never saw the in-flight session as running on (re)load. Fork waits for the
|
|
1144
|
+
// SessionStart hook to report Claude's rotated id; resume is already native.
|
|
1145
|
+
if (!isResume && !isFork)
|
|
1146
|
+
emitSessionIdUpdate(s, activeSessionId);
|
|
1147
|
+
let stderrCapture = '';
|
|
1148
|
+
let lineCount = 0;
|
|
1149
|
+
let timedOut = false;
|
|
1150
|
+
let interrupted = false;
|
|
1151
|
+
let stopHookFired = false;
|
|
1152
|
+
let stopHookSeenAt = 0;
|
|
1153
|
+
let processExited = false;
|
|
1154
|
+
let exitCode = null;
|
|
1155
|
+
let exitSignal = null;
|
|
1156
|
+
let terminalLimitNotice = null;
|
|
1157
|
+
let terminalLimitNoticeAt = 0;
|
|
1158
|
+
let terminalModelError = null;
|
|
1159
|
+
let proc;
|
|
1160
|
+
const emit = () => {
|
|
1161
|
+
try {
|
|
1162
|
+
opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), s.plan);
|
|
1163
|
+
}
|
|
1164
|
+
catch { }
|
|
1165
|
+
};
|
|
1166
|
+
const killProc = (signal, after = 5000) => {
|
|
1167
|
+
try {
|
|
1168
|
+
proc.kill(signal);
|
|
1169
|
+
}
|
|
1170
|
+
catch { }
|
|
1171
|
+
setTimeout(() => {
|
|
1172
|
+
if (!processExited) {
|
|
1173
|
+
try {
|
|
1174
|
+
proc.kill('SIGKILL');
|
|
1175
|
+
}
|
|
1176
|
+
catch { }
|
|
1177
|
+
}
|
|
1178
|
+
}, after);
|
|
1179
|
+
};
|
|
1180
|
+
// Answer a confirm/select dialog: settle (Ink drops input on the dialog's first frames) → the
|
|
1181
|
+
// affirmative key → a split-out Enter (a combined "1\r" gets swallowed — only the digit lands)
|
|
1182
|
+
// → drop the answered frame from screenTail so re-detection only fires on a genuine repaint, not
|
|
1183
|
+
// the stale text we just answered. Shared by the in-flight onData path and the stall-chokepoint
|
|
1184
|
+
// retry so the keystroke discipline lives in one place.
|
|
1185
|
+
const sendConfirmAnswer = (key, settleMs, confirmDelayMs, onConfirmed) => {
|
|
1186
|
+
setTimeout(() => {
|
|
1187
|
+
if (processExited)
|
|
1188
|
+
return;
|
|
1189
|
+
try {
|
|
1190
|
+
proc.write(key);
|
|
1191
|
+
}
|
|
1192
|
+
catch { }
|
|
1193
|
+
setTimeout(() => {
|
|
1194
|
+
if (processExited)
|
|
1195
|
+
return;
|
|
1196
|
+
try {
|
|
1197
|
+
proc.write('\r');
|
|
1198
|
+
}
|
|
1199
|
+
catch { }
|
|
1200
|
+
screenTail = '';
|
|
1201
|
+
onConfirmed?.();
|
|
1202
|
+
}, confirmDelayMs);
|
|
1203
|
+
}, settleMs);
|
|
1204
|
+
};
|
|
1205
|
+
// Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
|
|
1206
|
+
// informational (extra-usage credits kick in and the turn continues), so
|
|
1207
|
+
// killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
|
|
1208
|
+
// arbitrates later — at the stall watchdog and at result assembly — based
|
|
1209
|
+
// on whether the turn produced anything substantive after the banner.
|
|
1210
|
+
const noteTerminalLimitNotice = (notice) => {
|
|
1211
|
+
if (terminalLimitNotice)
|
|
1212
|
+
return;
|
|
1213
|
+
terminalLimitNotice = notice;
|
|
1214
|
+
terminalLimitNoticeAt = Date.now();
|
|
1215
|
+
agentWarn(`[claude-tui] limit notice observed (watching turn liveness): ${notice}`);
|
|
1216
|
+
pushRecentActivity(s.recentActivity, `Claude usage notice: ${notice}`);
|
|
1217
|
+
s.activity = s.recentActivity.join('\n');
|
|
1218
|
+
emit();
|
|
1219
|
+
};
|
|
1220
|
+
// Selected-model-unavailable notice (404 model_not_found). Unlike the limit
|
|
1221
|
+
// banner this is terminal AND invisible to every structured signal: the TUI
|
|
1222
|
+
// paints it to the PTY screen, writes nothing to the JSONL, and fires no Stop
|
|
1223
|
+
// hook — so the turn would otherwise idle at the REPL until the 3–10 min stall
|
|
1224
|
+
// watchdog kills it with a misleading "CLI freeze" message. We surface the
|
|
1225
|
+
// real reason and end the turn now. The banner is still EVIDENCE, not a bare
|
|
1226
|
+
// verdict: a short settle confirms nothing substantive followed (cross-
|
|
1227
|
+
// validating the screen scrape) before we kill — never granting a lone text
|
|
1228
|
+
// match the authority to shoot a healthy turn.
|
|
1229
|
+
const noteTerminalModelError = (notice) => {
|
|
1230
|
+
if (terminalModelError)
|
|
1231
|
+
return;
|
|
1232
|
+
terminalModelError = notice;
|
|
1233
|
+
agentWarn(`[claude-tui] model unavailable observed (settling before terminate): ${notice}`);
|
|
1234
|
+
pushRecentActivity(s.recentActivity, notice);
|
|
1235
|
+
s.activity = s.recentActivity.join('\n');
|
|
1236
|
+
emit();
|
|
1237
|
+
setTimeout(() => {
|
|
1238
|
+
if (processExited || interrupted)
|
|
1239
|
+
return;
|
|
1240
|
+
const hadOutput = !!s.text.trim()
|
|
1241
|
+
|| lastAssistantEventAt > 0 || lastSidecarEventAt > 0 || lastToolEventAt > start;
|
|
1242
|
+
if (hadOutput) {
|
|
1243
|
+
agentWarn('[claude-tui] model-unavailable banner was followed by real output — not terminating');
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
agentWarn('[claude-tui] model unavailable confirmed (no JSONL/tool/Stop activity) — terminating turn');
|
|
1247
|
+
killProc('SIGTERM');
|
|
1248
|
+
}, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS);
|
|
1249
|
+
};
|
|
1250
|
+
// Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
|
|
1251
|
+
const streamBuf = makeTuiStreamBuffer();
|
|
1252
|
+
const scheduleStreamTick = () => {
|
|
1253
|
+
if (streamBuf.timer)
|
|
1254
|
+
return;
|
|
1255
|
+
if (processExited)
|
|
1256
|
+
return;
|
|
1257
|
+
if (streamBuf.displayedLen >= streamBuf.trueText.length)
|
|
1258
|
+
return;
|
|
1259
|
+
streamBuf.timer = setTimeout(() => {
|
|
1260
|
+
streamBuf.timer = null;
|
|
1261
|
+
if (streamBuf.displayedLen >= streamBuf.trueText.length)
|
|
1262
|
+
return;
|
|
1263
|
+
const next = Math.min(streamBuf.trueText.length, streamBuf.displayedLen + TUI_STREAM_CHUNK_CHARS);
|
|
1264
|
+
streamBuf.displayedLen = next;
|
|
1265
|
+
s.text = streamBuf.trueText.slice(0, next);
|
|
1266
|
+
emit();
|
|
1267
|
+
// Keep ticking until we catch up — or until flushStream cancels us.
|
|
1268
|
+
if (streamBuf.displayedLen < streamBuf.trueText.length)
|
|
1269
|
+
scheduleStreamTick();
|
|
1270
|
+
}, TUI_STREAM_CHUNK_INTERVAL_MS);
|
|
1271
|
+
};
|
|
1272
|
+
const flushStream = () => {
|
|
1273
|
+
if (streamBuf.timer) {
|
|
1274
|
+
clearTimeout(streamBuf.timer);
|
|
1275
|
+
streamBuf.timer = null;
|
|
1276
|
+
}
|
|
1277
|
+
if (streamBuf.displayedLen < streamBuf.trueText.length) {
|
|
1278
|
+
s.text = streamBuf.trueText;
|
|
1279
|
+
streamBuf.displayedLen = streamBuf.trueText.length;
|
|
1280
|
+
emit();
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
// 6. Spawn the TUI under PTY. (node-pty itself was already loaded at step
|
|
1284
|
+
// 0 — see the top of this function. By the time we reach this point the
|
|
1285
|
+
// module is guaranteed to be importable and `spawn-helper` is executable.)
|
|
1286
|
+
const spawnEnv = { TERM: 'xterm-256color' };
|
|
1287
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
1288
|
+
if (typeof v === 'string')
|
|
1289
|
+
spawnEnv[k] = v;
|
|
1290
|
+
}
|
|
1291
|
+
for (const [k, v] of Object.entries(opts.extraEnv || {})) {
|
|
1292
|
+
if (typeof v === 'string')
|
|
1293
|
+
spawnEnv[k] = v;
|
|
1294
|
+
}
|
|
1295
|
+
// Strip the session-context markers a parent claude process exports to its
|
|
1296
|
+
// subprocesses (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, …). Inherited e.g.
|
|
1297
|
+
// when an agent restarted the pikiloop daemon from inside a Claude Code
|
|
1298
|
+
// session, they flip this spawn into child-session mode — the transcript
|
|
1299
|
+
// JSONL (our only text source) is then never written locally and the turn
|
|
1300
|
+
// streams nothing. See CLAUDE_SESSION_CONTEXT_ENV_KEYS in claude.ts.
|
|
1301
|
+
scrubClaudeSessionContextEnv(spawnEnv);
|
|
1302
|
+
// Critical: leaving ANTHROPIC_API_KEY set would route TUI through API
|
|
1303
|
+
// billing too, defeating the whole point. Strip it unless the user
|
|
1304
|
+
// explicitly opts back in.
|
|
1305
|
+
if (process.env.PIKILOOP_CLAUDE_TUI_KEEP_API_KEY !== '1') {
|
|
1306
|
+
delete spawnEnv.ANTHROPIC_API_KEY;
|
|
1307
|
+
delete spawnEnv.ANTHROPIC_AUTH_TOKEN;
|
|
1308
|
+
}
|
|
1309
|
+
// Resolve `claude` to an absolute path. node-pty's `posix_spawnp` does not
|
|
1310
|
+
// reliably honour PATH on macOS when the lookup happens inside an embedded
|
|
1311
|
+
// libuv worker — passing the absolute path sidesteps cryptic
|
|
1312
|
+
// "posix_spawnp failed" errors. Falls back to the bare name (let
|
|
1313
|
+
// posix_spawnp try) when `which` can't resolve it.
|
|
1314
|
+
const claudeBin = whichSync('claude') || 'claude';
|
|
1315
|
+
agentLog(`[claude-tui] spawning ${claudeBin} TUI session=${activeSessionId} model=${model || '(default)'} prompt=${fullPrompt.length}ch resume=${isResume} fork=${isFork}`);
|
|
1316
|
+
try {
|
|
1317
|
+
proc = pty.spawn(claudeBin, claudeArgs, {
|
|
1318
|
+
cwd: opts.workdir,
|
|
1319
|
+
env: spawnEnv,
|
|
1320
|
+
cols: 200,
|
|
1321
|
+
rows: 50,
|
|
1322
|
+
name: 'xterm-256color',
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
catch (e) {
|
|
1326
|
+
// Throw rather than return an error result — pty.spawn failures (PTY
|
|
1327
|
+
// allocation refused in sandboxed CI / Docker without /dev/ptmx, etc.)
|
|
1328
|
+
// mean TUI can't run at all, so the dispatcher should fall back to
|
|
1329
|
+
// print mode. Clean up the temp scaffolding before bailing.
|
|
1330
|
+
try {
|
|
1331
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
1332
|
+
}
|
|
1333
|
+
catch { }
|
|
1334
|
+
throw new Error(`pty.spawn failed (bin=${claudeBin}): ${e?.message || e}`);
|
|
1335
|
+
}
|
|
1336
|
+
agentLog(`[claude-tui] pid=${proc.pid}`);
|
|
1337
|
+
const dbg = process.env.PIKILOOP_CLAUDE_TUI_DEBUG === '1';
|
|
1338
|
+
/** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
|
|
1339
|
+
let lastPtyDataAt = Date.now();
|
|
1340
|
+
// Startup-dialog auto-answer. Claude's TUI can paint a blocking "Bypass
|
|
1341
|
+
// Permissions mode" confirmation before it accepts our positional prompt
|
|
1342
|
+
// (default highlight = "No, exit"). We keep a bounded ANSI-stripped tail of
|
|
1343
|
+
// the screen, detect that dialog (see detectClaudeBypassPrompt), and select
|
|
1344
|
+
// "Yes, I accept" so the turn never stalls on a pre-prompt.
|
|
1345
|
+
const SCREEN_TAIL_MAX = 8192;
|
|
1346
|
+
const BYPASS_ACCEPT_MAX_ATTEMPTS = 3;
|
|
1347
|
+
// Settle delay after the dialog first paints before we send any key. Claude's
|
|
1348
|
+
// Ink select drops input aimed at it during the first frames — sending the
|
|
1349
|
+
// digit too early is a no-op. ~500ms is comfortably past readiness in repro.
|
|
1350
|
+
const BYPASS_SETTLE_MS = 500;
|
|
1351
|
+
// Gap between the selection key and the confirm Enter. Claude's Ink select
|
|
1352
|
+
// swallows a combined "2\r" (only the digit lands; the Enter is dropped before
|
|
1353
|
+
// the highlight repaints), so the two keystrokes must be split in time —
|
|
1354
|
+
// 600ms is what reproduces reliably against the live 2.1.168 dialog.
|
|
1355
|
+
const BYPASS_CONFIRM_DELAY_MS = 600;
|
|
1356
|
+
// How long after the last bypass-dialog repaint we still treat it as on
|
|
1357
|
+
// screen — suppresses the blind prompt-submit Enter nudge across the whole
|
|
1358
|
+
// select→confirm sequence so a stray CR can't land on "No, exit".
|
|
1359
|
+
const BYPASS_DIALOG_ACTIVE_WINDOW_MS = 2000;
|
|
1360
|
+
// Mid-turn permission-prompt auto-answer (see detectClaudeProceedPrompt).
|
|
1361
|
+
// Same Ink-select timing as the bypass dialog: settle past the frames where
|
|
1362
|
+
// input is dropped, send the digit, then a split-out Enter to confirm. Unlike
|
|
1363
|
+
// the bypass dialog (fires once at startup), `ask`-rule prompts recur — one
|
|
1364
|
+
// per gated command — so we re-arm after each answer instead of capping at a
|
|
1365
|
+
// few total attempts. PROCEED_ANSWER_MAX is only a runaway backstop.
|
|
1366
|
+
const PROCEED_SETTLE_MS = 500;
|
|
1367
|
+
const PROCEED_CONFIRM_DELAY_MS = 600;
|
|
1368
|
+
// After confirming, wait before re-arming so a stale dialog frame can't drive
|
|
1369
|
+
// a double "1\r"; a genuine next prompt always trails its command by longer.
|
|
1370
|
+
const PROCEED_REARM_MS = 1000;
|
|
1371
|
+
const PROCEED_ANSWER_MAX = 40;
|
|
1372
|
+
let screenTail = '';
|
|
1373
|
+
let bypassPromptLastSeenAt = 0;
|
|
1374
|
+
let bypassAcceptAttempts = 0;
|
|
1375
|
+
let bypassPhase = 'idle';
|
|
1376
|
+
let proceedAnswerCount = 0;
|
|
1377
|
+
let proceedPhase = 'idle';
|
|
1378
|
+
proc.onData((data) => {
|
|
1379
|
+
// We deliberately do not parse the TUI screen output. The JSONL is the
|
|
1380
|
+
// canonical source of structured events. Stash bytes only when debugging.
|
|
1381
|
+
// Raw byte arrival doubles as the cheapest liveness signal: a healthy TUI
|
|
1382
|
+
// repaints continuously mid-turn, so PTY silence = event loop dead — feeds
|
|
1383
|
+
// the stall watchdog's fast path (decideClaudeTuiStall.lastPtyDataAt).
|
|
1384
|
+
lastPtyDataAt = Date.now();
|
|
1385
|
+
if (dbg) {
|
|
1386
|
+
try {
|
|
1387
|
+
fs.appendFileSync(ptyLogPath, data);
|
|
1388
|
+
}
|
|
1389
|
+
catch { }
|
|
1390
|
+
}
|
|
1391
|
+
// Auto-answer the bypass-permissions confirmation. Detect it the moment it
|
|
1392
|
+
// paints (off the raw PTY, not the 200ms poll tick) and arm a short timed
|
|
1393
|
+
// keystroke sequence. Keep a bounded stripped tail across chunks so a dialog
|
|
1394
|
+
// split across reads still matches.
|
|
1395
|
+
screenTail = (screenTail + stripAnsiEscapes(data)).slice(-SCREEN_TAIL_MAX);
|
|
1396
|
+
if (detectClaudeBypassPrompt(screenTail)) {
|
|
1397
|
+
bypassPromptLastSeenAt = Date.now();
|
|
1398
|
+
if (bypassPhase === 'idle' && bypassAcceptAttempts < BYPASS_ACCEPT_MAX_ATTEMPTS) {
|
|
1399
|
+
bypassAcceptAttempts++;
|
|
1400
|
+
bypassPhase = 'armed';
|
|
1401
|
+
// Three timed steps — verified 3/3 against the live 2.1.168 dialog:
|
|
1402
|
+
// settle (dialog ignores input on its first frames)
|
|
1403
|
+
// → "2" (jumps to the second option "Yes, I accept"; idempotent —
|
|
1404
|
+
// re-sending can't overshoot a 2-option menu onto "No, exit")
|
|
1405
|
+
// → Enter (confirms; must arrive *after* the highlight repaints — a
|
|
1406
|
+
// combined "2\r" gets swallowed, only the digit lands).
|
|
1407
|
+
agentLog(`[claude-tui] bypass-permissions prompt — auto-accepting "Yes, I accept" (attempt ${bypassAcceptAttempts}/${BYPASS_ACCEPT_MAX_ATTEMPTS})`);
|
|
1408
|
+
setTimeout(() => {
|
|
1409
|
+
if (processExited)
|
|
1410
|
+
return;
|
|
1411
|
+
try {
|
|
1412
|
+
proc.write('2');
|
|
1413
|
+
}
|
|
1414
|
+
catch { }
|
|
1415
|
+
setTimeout(() => {
|
|
1416
|
+
if (processExited)
|
|
1417
|
+
return;
|
|
1418
|
+
try {
|
|
1419
|
+
proc.write('\r');
|
|
1420
|
+
}
|
|
1421
|
+
catch { }
|
|
1422
|
+
bypassPhase = 'confirmed';
|
|
1423
|
+
agentLog('[claude-tui] bypass-permissions — confirm Enter sent');
|
|
1424
|
+
// Drop the buffered dialog frame: the post-accept REPL output can be
|
|
1425
|
+
// tiny (e.g. a "Not logged in" line), so the old dialog text would
|
|
1426
|
+
// otherwise linger in the 8192-char tail and make the re-arm below
|
|
1427
|
+
// re-fire on a stale screen — typing "2"/Enter into the live prompt.
|
|
1428
|
+
// Clearing means the re-arm only sees output that arrives *after*
|
|
1429
|
+
// the confirm, so it re-fires only on a genuine repaint of the
|
|
1430
|
+
// dialog (accept didn't take), never on stale bytes.
|
|
1431
|
+
screenTail = '';
|
|
1432
|
+
setTimeout(() => {
|
|
1433
|
+
if (!processExited && detectClaudeBypassPrompt(screenTail))
|
|
1434
|
+
bypassPhase = 'idle';
|
|
1435
|
+
}, 1200);
|
|
1436
|
+
}, BYPASS_CONFIRM_DELAY_MS);
|
|
1437
|
+
}, BYPASS_SETTLE_MS);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// Auto-answer a *mid-turn* permission confirmation. `else if` so the startup bypass dialog
|
|
1441
|
+
// (handled above) never falls through here. The affirmative key comes from the classifier, not
|
|
1442
|
+
// a hard-coded "1": confirm-prompt → "1. Yes", (y/n) → "y". (plan-approval carries no key under
|
|
1443
|
+
// the current policy, so it is not auto-answered here.) These recur (one per ask-gated command),
|
|
1444
|
+
// so we re-arm after each answer rather than cap total attempts.
|
|
1445
|
+
else {
|
|
1446
|
+
const screenInfo = classifyClaudeScreen(screenTail);
|
|
1447
|
+
if (screenInfo.state === 'confirm-prompt' && screenInfo.affirmativeKey
|
|
1448
|
+
&& proceedPhase === 'idle' && proceedAnswerCount < PROCEED_ANSWER_MAX) {
|
|
1449
|
+
proceedAnswerCount++;
|
|
1450
|
+
proceedPhase = 'armed';
|
|
1451
|
+
const key = screenInfo.affirmativeKey;
|
|
1452
|
+
agentLog(`[claude-tui] mid-turn permission prompt — auto-selecting "${key}" (answer ${proceedAnswerCount}/${PROCEED_ANSWER_MAX})`);
|
|
1453
|
+
sendConfirmAnswer(key, PROCEED_SETTLE_MS, PROCEED_CONFIRM_DELAY_MS, () => {
|
|
1454
|
+
agentLog('[claude-tui] permission prompt — confirm Enter sent');
|
|
1455
|
+
setTimeout(() => { if (!processExited)
|
|
1456
|
+
proceedPhase = 'idle'; }, PROCEED_REARM_MS);
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
// Capture stderr-ish bytes (TUI startup errors, "claude: command not
|
|
1461
|
+
// found"-style messages) for the final error payload when the run aborts
|
|
1462
|
+
// before any JSONL is written. Strip ANSI on the way in — otherwise the
|
|
1463
|
+
// raw PTY screen (cursor positions, SGR colours, column-aligned reply
|
|
1464
|
+
// rendering) leaks into IM as gibberish like "[3G你把 [8Gsnipe …" when a
|
|
1465
|
+
// user hits Stop before the JSONL has flushed any assistant text. Keep
|
|
1466
|
+
// the buffer bounded after stripping.
|
|
1467
|
+
if (stderrCapture.length < 4096) {
|
|
1468
|
+
stderrCapture += stripAnsiEscapes(data);
|
|
1469
|
+
if (stderrCapture.length > 4096)
|
|
1470
|
+
stderrCapture = stderrCapture.slice(0, 4096);
|
|
1471
|
+
const notice = detectClaudeTuiTerminalLimitNotice(stderrCapture);
|
|
1472
|
+
if (notice)
|
|
1473
|
+
noteTerminalLimitNotice(notice);
|
|
1474
|
+
}
|
|
1475
|
+
// Selected-model-unavailable notice — see noteTerminalModelError. The TUI
|
|
1476
|
+
// only paints this to the screen (no JSONL, no Stop hook), so the live
|
|
1477
|
+
// screen tail is the sole signal. detectClaudeModelError is whitespace-
|
|
1478
|
+
// insensitive so it survives the TUI's char-by-char paint.
|
|
1479
|
+
if (!terminalModelError && detectClaudeModelError(screenTail)) {
|
|
1480
|
+
noteTerminalModelError(claudeModelErrorMessage(s.model || opts.claudeModel || null));
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
// 7. Abort handling.
|
|
1484
|
+
const abortStream = () => {
|
|
1485
|
+
if (interrupted || processExited)
|
|
1486
|
+
return;
|
|
1487
|
+
interrupted = true;
|
|
1488
|
+
s.stopReason = 'interrupted';
|
|
1489
|
+
agentWarn(`[claude-tui] abort requested pid=${proc.pid}`);
|
|
1490
|
+
killProc('SIGTERM');
|
|
1491
|
+
};
|
|
1492
|
+
if (opts.abortSignal?.aborted)
|
|
1493
|
+
abortStream();
|
|
1494
|
+
opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
|
|
1495
|
+
// 8. Hard deadline timer.
|
|
1496
|
+
const hardTimer = setTimeout(() => {
|
|
1497
|
+
if (processExited)
|
|
1498
|
+
return;
|
|
1499
|
+
timedOut = true;
|
|
1500
|
+
s.stopReason = 'timeout';
|
|
1501
|
+
agentWarn(`[claude-tui] hard deadline reached (${opts.timeout}s) pid=${proc.pid}`);
|
|
1502
|
+
killProc('SIGTERM');
|
|
1503
|
+
}, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
|
|
1504
|
+
// 9. Poll loop — hook state + JSONL tail.
|
|
1505
|
+
const POLL_INTERVAL_MS = 200;
|
|
1506
|
+
// After Stop hook fires we give the JSONL ~600ms to settle (matches the
|
|
1507
|
+
// print-mode driver's graceful-abort observation window) so the assistant's
|
|
1508
|
+
// final event lands before we SIGTERM.
|
|
1509
|
+
const POST_STOP_DRAIN_MS = 600;
|
|
1510
|
+
// Fallback Enter — most Claude versions auto-submit a positional prompt in
|
|
1511
|
+
// TUI mode, but if UserPromptSubmit hasn't fired by this deadline we type a
|
|
1512
|
+
// carriage return into the PTY in case the prompt is sitting on the input
|
|
1513
|
+
// line waiting for it.
|
|
1514
|
+
const PROMPT_SUBMIT_NUDGE_MS = 1500;
|
|
1515
|
+
// After the stall chokepoint fires its affirmative keystroke at a stuck dialog, give the dialog
|
|
1516
|
+
// this long to clear before deciding it's unanswerable. Generous enough to also cover the
|
|
1517
|
+
// in-flight onData answer cycle (settle + confirm + re-arm) so a chained next-prompt isn't
|
|
1518
|
+
// mistaken for a failed answer; short enough that we never re-wait the full 3-min quiet window.
|
|
1519
|
+
const CHOKEPOINT_ANSWER_GRACE_MS = 5000;
|
|
1520
|
+
let promptNudged = false;
|
|
1521
|
+
let pollHandle = null;
|
|
1522
|
+
let drainScheduled = false;
|
|
1523
|
+
// Wall-clock of the last parsed main-JSONL line. Feeds the stale-Stop
|
|
1524
|
+
// quiet-window check in decideClaudeTuiStop — sidecar / hook traffic is
|
|
1525
|
+
// deliberately excluded (only main-JSONL activity signals a model segment).
|
|
1526
|
+
let lastMainJsonlEventAt = start;
|
|
1527
|
+
// Last pending-background count we logged, so the waiting state logs on
|
|
1528
|
+
// transitions instead of every 200ms poll tick.
|
|
1529
|
+
let lastLoggedPendingBg = -1;
|
|
1530
|
+
// Stall-watchdog liveness signals. Together with lastMainJsonlEventAt they
|
|
1531
|
+
// answer "is the claude process still doing anything at all?" — see
|
|
1532
|
+
// decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
|
|
1533
|
+
let lastToolEventAt = start;
|
|
1534
|
+
let lastSidecarEventAt = 0;
|
|
1535
|
+
// Last non-synthetic assistant JSONL event — substantive-progress signal
|
|
1536
|
+
// for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
|
|
1537
|
+
// from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
|
|
1538
|
+
// last-prompt, …) that land right after submit and prove nothing.
|
|
1539
|
+
let lastAssistantEventAt = 0;
|
|
1540
|
+
let stallKilled = false;
|
|
1541
|
+
// Chokepoint answer-retry: when the watchdog is about to fire on a confirm dialog, send the
|
|
1542
|
+
// affirmative key ONCE against the now-stable (3-min-quiet, fully-painted) screen — the in-flight
|
|
1543
|
+
// onData answer only fires while bytes arrive, so a dialog that paints once then goes silent never
|
|
1544
|
+
// gets a second attempt (the diagnostics showed full-match prompts killed with answer-count 1).
|
|
1545
|
+
// `stallAnswerSentAt` arms a bounded grace check: if the dialog hasn't cleared by then, terminate
|
|
1546
|
+
// without auto-resuming (re-running would just re-paint the same dialog).
|
|
1547
|
+
let stallAnswerTried = false;
|
|
1548
|
+
let stallAnswerSentAt = 0;
|
|
1549
|
+
// Stall diagnostics (capture-only) — see writeStallDiag.
|
|
1550
|
+
let observedClaudeVersion = '';
|
|
1551
|
+
let lastMainJsonlType = '';
|
|
1552
|
+
let lastStallDiagHeartbeatAt = 0;
|
|
1553
|
+
let stallDiagWentQuiet = false;
|
|
1554
|
+
let stallDiagMaxQuietMs = 0;
|
|
1555
|
+
let stallDiagPtyAliveWhileQuiet = false;
|
|
1556
|
+
/** Last state.stoppedAt for which pendingHookToolIds was reconciled. */
|
|
1557
|
+
let lastClearedStopAt = 0;
|
|
1558
|
+
/** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
|
|
1559
|
+
const pendingHookToolIds = new Set();
|
|
1560
|
+
// Incremental main-JSONL drain — the canonical text/thinking/usage feed.
|
|
1561
|
+
// Used by both the 200ms poll tick and the post-exit final drain. Returns
|
|
1562
|
+
// true when any line was consumed so callers can emit().
|
|
1563
|
+
const drainMainJsonl = () => {
|
|
1564
|
+
// No existsSync guard: readJsonlIncrement returns no lines (offset unchanged)
|
|
1565
|
+
// for a missing file, so the guard was a redundant extra syscall every tick.
|
|
1566
|
+
const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
|
|
1567
|
+
jsonlReadOffset = inc.offset;
|
|
1568
|
+
let touched = false;
|
|
1569
|
+
for (const line of inc.lines) {
|
|
1570
|
+
const trimmed = line.trim();
|
|
1571
|
+
if (!trimmed || trimmed[0] !== '{')
|
|
1572
|
+
continue;
|
|
1573
|
+
lineCount++;
|
|
1574
|
+
let ev;
|
|
1575
|
+
try {
|
|
1576
|
+
ev = JSON.parse(trimmed);
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
// Ignore sub-agent sidecar events — they belong to a child agent's
|
|
1582
|
+
// stream and would re-enter the parent's accumulator. claudeParse's
|
|
1583
|
+
// own sub-agent routing handles them.
|
|
1584
|
+
const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
|
|
1585
|
+
if (!isSubAgentEvent && ev.type === 'assistant') {
|
|
1586
|
+
const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
|
|
1587
|
+
if (notice) {
|
|
1588
|
+
// A synthetic limit banner is not substantive progress — skip the
|
|
1589
|
+
// liveness/type bookkeeping below so the limit arbitration and the
|
|
1590
|
+
// stall watchdog don't mistake it for a live model segment.
|
|
1591
|
+
noteTerminalLimitNotice(notice);
|
|
1592
|
+
touched = true;
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
applyAssistantStreaming(s, ev.message, streamBuf);
|
|
1596
|
+
applyAssistantUsage(s, ev.message);
|
|
1597
|
+
if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
|
|
1598
|
+
lastAssistantEventAt = Date.now();
|
|
1599
|
+
s.model = ev.message.model;
|
|
1600
|
+
applyModelContextWindow(s);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
callClaudeParseForTui(ev, s);
|
|
1605
|
+
}
|
|
1606
|
+
catch (e) {
|
|
1607
|
+
agentWarn(`[claude-tui] claudeParse threw on line: ${e?.message || e}`);
|
|
1608
|
+
}
|
|
1609
|
+
touched = true;
|
|
1610
|
+
lastMainJsonlEventAt = Date.now();
|
|
1611
|
+
if (typeof ev.version === 'string' && ev.version)
|
|
1612
|
+
observedClaudeVersion = ev.version;
|
|
1613
|
+
if (!isSubAgentEvent)
|
|
1614
|
+
lastMainJsonlType = classifyClaudeJsonlEvent(ev);
|
|
1615
|
+
}
|
|
1616
|
+
return touched;
|
|
1617
|
+
};
|
|
1618
|
+
// Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
|
|
1619
|
+
// tail it with the same incremental reader the JSONL transcript uses. Hook
|
|
1620
|
+
// events usually beat their JSONL counterpart by a second or so (and
|
|
1621
|
+
// PreToolUse fires before the tool even runs); whichever feed arrives first
|
|
1622
|
+
// wins, the other dedups via seenClaudeToolIds / seenClaudeToolResultIds.
|
|
1623
|
+
let toolEventsReadOffset = 0;
|
|
1624
|
+
const drainToolEvents = () => {
|
|
1625
|
+
const inc = readJsonlIncrement(toolEventsPath, toolEventsReadOffset);
|
|
1626
|
+
toolEventsReadOffset = inc.offset;
|
|
1627
|
+
let any = false;
|
|
1628
|
+
for (const line of inc.lines) {
|
|
1629
|
+
const trimmed = line.trim();
|
|
1630
|
+
if (!trimmed || trimmed[0] !== '{')
|
|
1631
|
+
continue;
|
|
1632
|
+
let ev;
|
|
1633
|
+
try {
|
|
1634
|
+
ev = JSON.parse(trimmed);
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
// Stall-watchdog bookkeeping: any hook event is proof of life, and the
|
|
1640
|
+
// Pre/Post pairing tells the watchdog whether a tool is mid-execution
|
|
1641
|
+
// (which extends the stall threshold — long foreground commands are
|
|
1642
|
+
// legitimately silent).
|
|
1643
|
+
lastToolEventAt = Date.now();
|
|
1644
|
+
const hookToolId = typeof ev?.tool_use_id === 'string' ? ev.tool_use_id : '';
|
|
1645
|
+
if (hookToolId) {
|
|
1646
|
+
if (ev?.event === 'PreToolUse')
|
|
1647
|
+
pendingHookToolIds.add(hookToolId);
|
|
1648
|
+
else if (ev?.event === 'PostToolUse')
|
|
1649
|
+
pendingHookToolIds.delete(hookToolId);
|
|
1650
|
+
}
|
|
1651
|
+
// A Task PreToolUse and the first sub-agent tool PreToolUse can land in
|
|
1652
|
+
// the same tick batch. If the sub-agent's hook arrives before we've
|
|
1653
|
+
// discovered its sidecar (and thus before s.subAgentIdToParent knows
|
|
1654
|
+
// its agent_id), refresh discovery so the hook resolves its parent on
|
|
1655
|
+
// this pass instead of leaking through unattributed.
|
|
1656
|
+
const subAgentId = typeof ev?.agent_id === 'string' ? ev.agent_id : '';
|
|
1657
|
+
if (subAgentId && !s.subAgentIdToParent?.has(subAgentId))
|
|
1658
|
+
tryDiscoverSubAgents();
|
|
1659
|
+
try {
|
|
1660
|
+
if (applyHookToolEvent(ev, s))
|
|
1661
|
+
any = true;
|
|
1662
|
+
}
|
|
1663
|
+
catch (e) {
|
|
1664
|
+
agentWarn(`[claude-tui] hook tool event apply threw: ${e?.message || e}`);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return any;
|
|
1668
|
+
};
|
|
1669
|
+
const trackedSubAgents = new Map();
|
|
1670
|
+
const tryDiscoverSubAgents = () => {
|
|
1671
|
+
const sidecarDir = path.join(projectDir, activeSessionId, 'subagents');
|
|
1672
|
+
if (!fs.existsSync(sidecarDir))
|
|
1673
|
+
return;
|
|
1674
|
+
let entries;
|
|
1675
|
+
try {
|
|
1676
|
+
entries = fs.readdirSync(sidecarDir);
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
for (const name of entries) {
|
|
1682
|
+
if (!name.endsWith('.meta.json'))
|
|
1683
|
+
continue;
|
|
1684
|
+
const stem = name.slice(0, -'.meta.json'.length);
|
|
1685
|
+
if (trackedSubAgents.has(stem))
|
|
1686
|
+
continue;
|
|
1687
|
+
let meta;
|
|
1688
|
+
try {
|
|
1689
|
+
meta = JSON.parse(fs.readFileSync(path.join(sidecarDir, name), 'utf8'));
|
|
1690
|
+
}
|
|
1691
|
+
catch {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
const parentToolUseId = typeof meta?.toolUseId === 'string' ? meta.toolUseId : '';
|
|
1695
|
+
if (!parentToolUseId)
|
|
1696
|
+
continue;
|
|
1697
|
+
// Only start tailing once the parent Task tool_use has been registered
|
|
1698
|
+
// in s.subAgents — otherwise routeClaudeSubAgentEvent silently drops
|
|
1699
|
+
// every event because it can't find the parent.
|
|
1700
|
+
if (!s.subAgents.has(parentToolUseId))
|
|
1701
|
+
continue;
|
|
1702
|
+
const sidecarPath = path.join(sidecarDir, `${stem}.jsonl`);
|
|
1703
|
+
trackedSubAgents.set(stem, { sidecarPath, offset: 0, parentToolUseId });
|
|
1704
|
+
// `stem` is "agent-<id>"; Claude Code's hook payload `agent_id` carries
|
|
1705
|
+
// just the raw id. Keep both keys so applyHookToolEvent can attribute
|
|
1706
|
+
// sub-agent tool hooks to the parent's Task tool_use no matter which
|
|
1707
|
+
// form arrives.
|
|
1708
|
+
const rawAgentId = stem.startsWith('agent-') ? stem.slice('agent-'.length) : stem;
|
|
1709
|
+
if (!s.subAgentIdToParent)
|
|
1710
|
+
s.subAgentIdToParent = new Map();
|
|
1711
|
+
s.subAgentIdToParent.set(rawAgentId, parentToolUseId);
|
|
1712
|
+
s.subAgentIdToParent.set(stem, parentToolUseId);
|
|
1713
|
+
// <task-notification> events identify background tasks by this raw id
|
|
1714
|
+
// (and only sometimes carry <tool-use-id>) — keep the mapping so
|
|
1715
|
+
// applyClaudeTaskNotification can resolve them either way.
|
|
1716
|
+
if (!s.bgTaskIdToToolUse)
|
|
1717
|
+
s.bgTaskIdToToolUse = new Map();
|
|
1718
|
+
s.bgTaskIdToToolUse.set(rawAgentId, parentToolUseId);
|
|
1719
|
+
agentLog(`[claude-tui] subagent sidecar discovered ${stem} parent=${parentToolUseId.slice(0, 14)}`);
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
const pumpSubAgentSidecars = () => {
|
|
1723
|
+
let any = false;
|
|
1724
|
+
for (const tail of trackedSubAgents.values()) {
|
|
1725
|
+
const inc = readJsonlIncrement(tail.sidecarPath, tail.offset);
|
|
1726
|
+
tail.offset = inc.offset;
|
|
1727
|
+
for (const line of inc.lines) {
|
|
1728
|
+
const trimmed = line.trim();
|
|
1729
|
+
if (!trimmed || trimmed[0] !== '{')
|
|
1730
|
+
continue;
|
|
1731
|
+
let ev;
|
|
1732
|
+
try {
|
|
1733
|
+
ev = JSON.parse(trimmed);
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
// Inject parent_tool_use_id so claudeParse routes via routeClaudeSubAgentEvent
|
|
1739
|
+
// → updates sub.model + sub.tools on the existing s.subAgents entry.
|
|
1740
|
+
const injected = { ...ev, parent_tool_use_id: tail.parentToolUseId };
|
|
1741
|
+
try {
|
|
1742
|
+
callClaudeParseForTui(injected, s);
|
|
1743
|
+
}
|
|
1744
|
+
catch (e) {
|
|
1745
|
+
agentWarn(`[claude-tui] subagent parse threw: ${e?.message || e}`);
|
|
1746
|
+
}
|
|
1747
|
+
any = true;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
// Stall-watchdog: live sub-agents count as turn progress even while the
|
|
1751
|
+
// parent thread is quietly waiting on them.
|
|
1752
|
+
if (any)
|
|
1753
|
+
lastSidecarEventAt = Date.now();
|
|
1754
|
+
return any;
|
|
1755
|
+
};
|
|
1756
|
+
// End a turn that is blocked on a confirm/select dialog the auto-answer could not clear. Unlike a
|
|
1757
|
+
// 'stalled' kill this does NOT auto-resume — re-running the prompt just re-paints the same dialog
|
|
1758
|
+
// (the loop the user kept seeing). The session is intact; the user re-sends to continue.
|
|
1759
|
+
const terminatePromptUnanswered = (screenState, sample) => {
|
|
1760
|
+
stallKilled = true;
|
|
1761
|
+
const nowMs = Date.now();
|
|
1762
|
+
const progressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt);
|
|
1763
|
+
writeStallDiag({
|
|
1764
|
+
kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
|
|
1765
|
+
elapsedTurnMs: nowMs - start, quietMs: nowMs - progressAt, ptyQuietMs: nowMs - lastPtyDataAt,
|
|
1766
|
+
ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet, lastJsonlType: lastMainJsonlType,
|
|
1767
|
+
pendingHookTools: pendingHookToolIds.size, pendingBgAgents: pendingClaudeBackgroundAgentCount(s),
|
|
1768
|
+
looksLikePrompt: true, screenState, action: 'terminate-prompt-unanswered', screenSample: sample,
|
|
1769
|
+
});
|
|
1770
|
+
s.stopReason = 'prompt_unanswered';
|
|
1771
|
+
if (!s.errors)
|
|
1772
|
+
s.errors = ['Claude paused for a confirmation pikiloop could not auto-approve. Your session is intact — re-send your message (or reply "continue") to proceed.'];
|
|
1773
|
+
agentWarn(`[claude-tui] confirm dialog (${screenState}) did not clear after auto-answer — ending turn without auto-resume pid=${proc.pid}`);
|
|
1774
|
+
pushRecentActivity(s.recentActivity, 'Waiting on a confirmation pikiloop could not auto-approve — re-send to continue');
|
|
1775
|
+
s.activity = s.recentActivity.join('\n');
|
|
1776
|
+
emit();
|
|
1777
|
+
killProc('SIGTERM');
|
|
1778
|
+
};
|
|
1779
|
+
const tick = () => {
|
|
1780
|
+
pollHandle = null;
|
|
1781
|
+
if (processExited)
|
|
1782
|
+
return;
|
|
1783
|
+
if (Date.now() > deadline) {
|
|
1784
|
+
if (!timedOut) {
|
|
1785
|
+
timedOut = true;
|
|
1786
|
+
s.stopReason = 'timeout';
|
|
1787
|
+
agentWarn(`[claude-tui] deadline exceeded mid-poll`);
|
|
1788
|
+
killProc('SIGTERM');
|
|
1789
|
+
}
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
// Hook state — pick up real session id / transcript path.
|
|
1793
|
+
const state = readHookState(statePath);
|
|
1794
|
+
if (state.sessionId && state.sessionId !== activeSessionId) {
|
|
1795
|
+
const prevId = activeSessionId;
|
|
1796
|
+
activeSessionId = state.sessionId;
|
|
1797
|
+
activeJsonlPath = state.transcriptPath || path.join(projectDir, `${activeSessionId}.jsonl`);
|
|
1798
|
+
// For forks Claude rotates to a fresh UUID — start reading the new file
|
|
1799
|
+
// from offset 0 since we haven't read any of it yet.
|
|
1800
|
+
if (!isResume)
|
|
1801
|
+
jsonlReadOffset = 0;
|
|
1802
|
+
emitSessionIdUpdate(s, activeSessionId);
|
|
1803
|
+
agentLog(`[claude-tui] session id resolved ${prevId} -> ${activeSessionId} transcript=${activeJsonlPath}`);
|
|
1804
|
+
}
|
|
1805
|
+
else if (state.transcriptPath && state.transcriptPath !== activeJsonlPath) {
|
|
1806
|
+
activeJsonlPath = state.transcriptPath;
|
|
1807
|
+
}
|
|
1808
|
+
// Submit nudge — only if UserPromptSubmit hook hasn't fired yet. Suppress
|
|
1809
|
+
// it while the bypass-permissions dialog is (or was just) on screen: a blind
|
|
1810
|
+
// CR there lands on the default "No, exit" and kills the session. The dialog
|
|
1811
|
+
// auto-answer in onData drives that screen instead; once it clears the
|
|
1812
|
+
// prompt submits on its own (or this nudge fires on a later tick).
|
|
1813
|
+
const bypassDialogActive = bypassPromptLastSeenAt > 0
|
|
1814
|
+
&& Date.now() - bypassPromptLastSeenAt < BYPASS_DIALOG_ACTIVE_WINDOW_MS;
|
|
1815
|
+
if (!promptNudged && !state.promptSubmittedAt && !bypassDialogActive
|
|
1816
|
+
&& Date.now() - start > PROMPT_SUBMIT_NUDGE_MS) {
|
|
1817
|
+
promptNudged = true;
|
|
1818
|
+
try {
|
|
1819
|
+
proc.write('\r');
|
|
1820
|
+
}
|
|
1821
|
+
catch { }
|
|
1822
|
+
agentLog(`[claude-tui] prompt-submit nudge sent (no UserPromptSubmit after ${PROMPT_SUBMIT_NUDGE_MS}ms)`);
|
|
1823
|
+
}
|
|
1824
|
+
// JSONL tail.
|
|
1825
|
+
if (drainMainJsonl()) {
|
|
1826
|
+
// Emit immediately so non-text changes (tool_use, plan, activity,
|
|
1827
|
+
// thinking, usage) reach the dashboard without waiting for the
|
|
1828
|
+
// chunked stream tick. The streaming timer separately advances
|
|
1829
|
+
// s.text from the buffer over the next few ticks.
|
|
1830
|
+
emit();
|
|
1831
|
+
scheduleStreamTick();
|
|
1832
|
+
}
|
|
1833
|
+
// Live tool-events stream — fed by Pre/PostToolUse hooks. Hook and JSONL
|
|
1834
|
+
// feeds race per tool call; both record into seenClaudeToolIds /
|
|
1835
|
+
// seenClaudeToolResultIds so whichever lands first wins and the other
|
|
1836
|
+
// pass dedups naturally.
|
|
1837
|
+
if (drainToolEvents())
|
|
1838
|
+
emit();
|
|
1839
|
+
// Sub-agent sidecar discovery + pump. Order matters: discovery first so a
|
|
1840
|
+
// newly-spawned sub-agent gets registered for tailing this same tick if
|
|
1841
|
+
// its events have already been written. Skip the readdir + per-meta reads
|
|
1842
|
+
// until a Task tool_use is actually registered — discovery can't succeed
|
|
1843
|
+
// before then anyway (it requires the parent in s.subAgents), so the common
|
|
1844
|
+
// no-subagent turn would otherwise readdir the sidecar dir every 200ms for
|
|
1845
|
+
// nothing.
|
|
1846
|
+
if (s.subAgents.size > 0)
|
|
1847
|
+
tryDiscoverSubAgents();
|
|
1848
|
+
if (pumpSubAgentSidecars())
|
|
1849
|
+
emit();
|
|
1850
|
+
// Stop hook handling. A Stop is NOT automatically the end of the turn:
|
|
1851
|
+
// Claude fires it per response segment, including the segment that merely
|
|
1852
|
+
// *launched* run_in_background agents. Those agents run inside the claude
|
|
1853
|
+
// process — terminating here would destroy them (the "进程退出把子代理
|
|
1854
|
+
// 打断" incident). Hold the PTY open until every launched background agent
|
|
1855
|
+
// has reported its <task-notification> AND the latest Stop is fresher than
|
|
1856
|
+
// the latest notification (i.e. the model's wrap-up segment finished).
|
|
1857
|
+
if (state.stoppedAt && !stopHookFired) {
|
|
1858
|
+
// A fired Stop means no foreground tool is genuinely mid-flight any
|
|
1859
|
+
// more. Surviving entries in pendingHookToolIds are lost PostToolUse
|
|
1860
|
+
// hook events (MCP flap / hook timeout ate them) — clearing here stops
|
|
1861
|
+
// them from silently pushing the stall watchdog onto the 30-minute
|
|
1862
|
+
// pending-tool threshold for the rest of the turn.
|
|
1863
|
+
if (state.stoppedAt !== lastClearedStopAt) {
|
|
1864
|
+
lastClearedStopAt = state.stoppedAt;
|
|
1865
|
+
if (pendingHookToolIds.size) {
|
|
1866
|
+
agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
|
|
1867
|
+
pendingHookToolIds.clear();
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const pendingBg = pendingClaudeBackgroundAgentCount(s);
|
|
1871
|
+
const decision = decideClaudeTuiStop({
|
|
1872
|
+
stoppedAt: state.stoppedAt,
|
|
1873
|
+
pendingBackgroundAgents: pendingBg,
|
|
1874
|
+
lastTaskNotificationAt: s.lastTaskNotificationAt || 0,
|
|
1875
|
+
lastJsonlEventAt: lastMainJsonlEventAt,
|
|
1876
|
+
lastHookOrSidecarEventAt: Math.max(lastToolEventAt, lastSidecarEventAt),
|
|
1877
|
+
// Background *Bash* is silent by nature (no sidecar/hook traffic while
|
|
1878
|
+
// it runs) — give it the long pending-tool budget; agent-only holds
|
|
1879
|
+
// keep the default TTL (live agents emit sidecar events constantly).
|
|
1880
|
+
holdQuietTtlMs: pendingClaudeBackgroundBashCount(s) > 0
|
|
1881
|
+
? CLAUDE_TUI_STALL_PENDING_TOOL_MS
|
|
1882
|
+
: undefined,
|
|
1883
|
+
now: Date.now(),
|
|
1884
|
+
});
|
|
1885
|
+
if (decision === 'terminate') {
|
|
1886
|
+
stopHookFired = true;
|
|
1887
|
+
stopHookSeenAt = Date.now();
|
|
1888
|
+
if (pendingBg > 0) {
|
|
1889
|
+
// 幽灵 hold 释放:计数说还有后台 agent,但所有通道静默已超 TTL。
|
|
1890
|
+
agentWarn(`[claude-tui] releasing phantom hold — ${pendingBg} background agent(s) still counted pending but every channel quiet past TTL; treating Stop as final`);
|
|
1891
|
+
}
|
|
1892
|
+
agentLog(`[claude-tui] Stop hook fired — draining JSONL for ${POST_STOP_DRAIN_MS}ms before SIGTERM`);
|
|
1893
|
+
}
|
|
1894
|
+
else if (decision === 'hold-background' && pendingBg !== lastLoggedPendingBg) {
|
|
1895
|
+
lastLoggedPendingBg = pendingBg;
|
|
1896
|
+
agentLog(`[claude-tui] Stop hook fired with ${pendingBg} background agent(s) still running — holding TUI alive until they finish`);
|
|
1897
|
+
pushRecentActivity(s.recentActivity, `Waiting for ${pendingBg} background agent(s) to finish`);
|
|
1898
|
+
s.activity = s.recentActivity.join('\n');
|
|
1899
|
+
emit();
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
if (stopHookFired && !drainScheduled && Date.now() - stopHookSeenAt >= POST_STOP_DRAIN_MS) {
|
|
1903
|
+
drainScheduled = true;
|
|
1904
|
+
agentLog(`[claude-tui] drain complete, terminating TUI pid=${proc.pid}`);
|
|
1905
|
+
killProc('SIGTERM');
|
|
1906
|
+
// Continue polling so any post-Stop JSONL writes still get parsed; the
|
|
1907
|
+
// process will exit shortly and onExit will resolve the wait.
|
|
1908
|
+
}
|
|
1909
|
+
// Stall watchdog. claude CLI can freeze mid-turn (observed on 2.1.160):
|
|
1910
|
+
// a tool_result lands, then the next assistant segment never starts — the
|
|
1911
|
+
// process stays alive, every signal goes quiet, no Stop hook ever fires.
|
|
1912
|
+
// When ALL liveness signals have been silent past the threshold, declare
|
|
1913
|
+
// the turn stalled and SIGTERM; doClaudeWithRetry auto-resumes the session
|
|
1914
|
+
// once so the turn continues instead of spinning forever in the IM card.
|
|
1915
|
+
if (!stopHookFired && !timedOut && !interrupted && !stallKilled) {
|
|
1916
|
+
const lastProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.stoppedAt || 0, state.promptSubmittedAt || 0);
|
|
1917
|
+
// Pending background work (agents + bash) extends the stall budget the
|
|
1918
|
+
// same way a pending foreground tool does: a silent 15-minute background
|
|
1919
|
+
// build must not get shot by the 10-minute quiet threshold. The PTY
|
|
1920
|
+
// fast path still catches true process freezes within minutes.
|
|
1921
|
+
const pendingBgForStall = pendingClaudeBackgroundAgentCount(s);
|
|
1922
|
+
// PTY fast path is for *mid-turn* freezes only. While the TUI idles in a
|
|
1923
|
+
// post-Stop background hold it legitimately paints nothing — a static
|
|
1924
|
+
// screen there is healthy, not frozen. Stop being the freshest signal is
|
|
1925
|
+
// exactly that hold state → disarm the fast path (0 = unavailable).
|
|
1926
|
+
const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
|
|
1927
|
+
const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
|
|
1928
|
+
// Chokepoint answer-retry grace. We sent the affirmative key at a stuck dialog; give it time
|
|
1929
|
+
// to clear. If the screen is no longer a blocking dialog, the answer took — disarm and re-arm
|
|
1930
|
+
// so a later prompt in the same turn can also get a chokepoint retry. If it is STILL a dialog
|
|
1931
|
+
// (and the in-flight onData answer didn't clear it either), it is genuinely unanswerable —
|
|
1932
|
+
// end without auto-resume.
|
|
1933
|
+
if (stallAnswerSentAt > 0 && Date.now() - stallAnswerSentAt > CHOKEPOINT_ANSWER_GRACE_MS) {
|
|
1934
|
+
const after = classifyClaudeScreen(screenTail);
|
|
1935
|
+
const stillBlocking = after.state === 'confirm-prompt'
|
|
1936
|
+
|| after.state === 'plan-approval' || after.state === 'bypass-startup';
|
|
1937
|
+
if (stillBlocking) {
|
|
1938
|
+
terminatePromptUnanswered(after.state, after.sample);
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
agentLog(`[claude-tui] chokepoint answer cleared the dialog (now ${after.state}) — turn continues`);
|
|
1942
|
+
stallAnswerSentAt = 0;
|
|
1943
|
+
stallAnswerTried = false;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
// Stall diagnostics: sample the quiet lead-up so the watchdog can later be
|
|
1947
|
+
// tuned from data. Capture-only — changes no control flow.
|
|
1948
|
+
if (!stallKilled) {
|
|
1949
|
+
const nowMs = Date.now();
|
|
1950
|
+
const quietMs = nowMs - lastProgressAt;
|
|
1951
|
+
if (quietMs >= STALL_DIAG_QUIET_THRESHOLD_MS && !inPostStopHold) {
|
|
1952
|
+
const ptyQuietMs = nowMs - lastPtyDataAt;
|
|
1953
|
+
stallDiagWentQuiet = true;
|
|
1954
|
+
if (quietMs > stallDiagMaxQuietMs)
|
|
1955
|
+
stallDiagMaxQuietMs = quietMs;
|
|
1956
|
+
// PTY still painting while every structured signal is silent = the
|
|
1957
|
+
// frozen-stream-behind-a-live-spinner case that defeats the fast path.
|
|
1958
|
+
if (ptyQuietMs < CLAUDE_TUI_STALL_PTY_DEAD_MS)
|
|
1959
|
+
stallDiagPtyAliveWhileQuiet = true;
|
|
1960
|
+
if (nowMs - lastStallDiagHeartbeatAt >= STALL_DIAG_HEARTBEAT_INTERVAL_MS) {
|
|
1961
|
+
lastStallDiagHeartbeatAt = nowMs;
|
|
1962
|
+
// Snapshot the screen so a quiet stretch can later be classified as a frozen stream vs
|
|
1963
|
+
// a long think vs a blocking dialog vs an idle hold. Record the full screenState (not
|
|
1964
|
+
// just looksLikePrompt) so the lead-up to a kill is measurable as claude versions churn.
|
|
1965
|
+
const screenInfo = classifyClaudeScreen(screenTail);
|
|
1966
|
+
const looksLikePrompt = screenInfo.state === 'confirm-prompt'
|
|
1967
|
+
|| screenInfo.state === 'plan-approval' || screenInfo.state === 'bypass-startup';
|
|
1968
|
+
writeStallDiag({
|
|
1969
|
+
kind: 'quiet',
|
|
1970
|
+
sessionId: activeSessionId,
|
|
1971
|
+
version: observedClaudeVersion,
|
|
1972
|
+
model: s.model || null,
|
|
1973
|
+
elapsedTurnMs: nowMs - start,
|
|
1974
|
+
quietMs,
|
|
1975
|
+
ptyQuietMs,
|
|
1976
|
+
lastJsonlType: lastMainJsonlType,
|
|
1977
|
+
mainJsonlAgoMs: nowMs - lastMainJsonlEventAt,
|
|
1978
|
+
toolEventAgoMs: nowMs - lastToolEventAt,
|
|
1979
|
+
sidecarAgoMs: lastSidecarEventAt ? nowMs - lastSidecarEventAt : null,
|
|
1980
|
+
pendingHookTools: pendingHookToolIds.size,
|
|
1981
|
+
pendingBgAgents: pendingBgForStall,
|
|
1982
|
+
pendingBgBash: pendingClaudeBackgroundBashCount(s),
|
|
1983
|
+
looksLikePrompt,
|
|
1984
|
+
screenState: screenInfo.state,
|
|
1985
|
+
screenSample: screenInfo.sample,
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (!stallKilled) {
|
|
1991
|
+
const stallDecision = decideClaudeTuiStall({
|
|
1992
|
+
now: Date.now(),
|
|
1993
|
+
lastProgressAt,
|
|
1994
|
+
pendingToolCount: pendingHookToolIds.size + pendingBgForStall,
|
|
1995
|
+
lastPtyDataAt: inPostStopHold ? 0 : lastPtyDataAt,
|
|
1996
|
+
});
|
|
1997
|
+
if (stallDecision === 'stall') {
|
|
1998
|
+
const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
|
|
1999
|
+
const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
|
|
2000
|
+
// The screen is ground truth. Classify it, then map to an action — the chokepoint the
|
|
2001
|
+
// diagnostics proved was missing (the verdict used to change only the error string).
|
|
2002
|
+
const screen = classifyClaudeScreen(screenTail);
|
|
2003
|
+
const action = decideStallAction({
|
|
2004
|
+
state: screen.state,
|
|
2005
|
+
affirmativeKey: screen.affirmativeKey,
|
|
2006
|
+
pendingBgAgents: pendingBgForStall,
|
|
2007
|
+
alreadyTriedAnswer: stallAnswerTried,
|
|
2008
|
+
});
|
|
2009
|
+
const looksLikePrompt = screen.state === 'confirm-prompt'
|
|
2010
|
+
|| screen.state === 'plan-approval' || screen.state === 'bypass-startup';
|
|
2011
|
+
// Diagnostics oracle: record the screen state + chosen action on every kill-point so the
|
|
2012
|
+
// false-positive rate stays measurable as claude versions churn.
|
|
2013
|
+
const writeStallRecord = () => writeStallDiag({
|
|
2014
|
+
kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
|
|
2015
|
+
elapsedTurnMs: Date.now() - start, quietMs: Date.now() - lastProgressAt, ptyQuietMs: Date.now() - lastPtyDataAt,
|
|
2016
|
+
ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet, lastJsonlType: lastMainJsonlType,
|
|
2017
|
+
pendingHookTools: pendingHookToolIds.size, pendingBgAgents: pendingBgForStall,
|
|
2018
|
+
looksLikePrompt, screenState: screen.state, action, screenSample: screen.sample,
|
|
2019
|
+
});
|
|
2020
|
+
if (action === 'answer-retry' && screen.affirmativeKey) {
|
|
2021
|
+
// #1 fix: the in-flight onData answer only fires while bytes arrive, so a dialog that
|
|
2022
|
+
// painted once then went byte-silent never got a retry (full-match prompts were killed
|
|
2023
|
+
// with answer-count 1). Send the affirmative key now against the stable, fully-painted
|
|
2024
|
+
// screen (no settle — quiet 3 min) and let the grace check decide the outcome. No kill.
|
|
2025
|
+
stallAnswerTried = true;
|
|
2026
|
+
stallAnswerSentAt = Date.now();
|
|
2027
|
+
agentWarn(`[claude-tui] watchdog hit a ${screen.state} after ${quietMin}m quiet — auto-answering "${screen.affirmativeKey}" against the stable dialog (no kill yet) pid=${proc.pid}`);
|
|
2028
|
+
sendConfirmAnswer(screen.affirmativeKey, 0, PROCEED_CONFIRM_DELAY_MS);
|
|
2029
|
+
}
|
|
2030
|
+
else if (action === 'terminate-clean') {
|
|
2031
|
+
// Idle REPL, nothing pending — the turn finished and we merely missed/held its Stop hook.
|
|
2032
|
+
// Hand off to the normal post-Stop drain: clean end, no 'stalled', no auto-resume.
|
|
2033
|
+
writeStallRecord();
|
|
2034
|
+
stopHookFired = true;
|
|
2035
|
+
stopHookSeenAt = Date.now();
|
|
2036
|
+
agentLog(`[claude-tui] watchdog saw an idle REPL with no pending work after ${quietMin}m — treating as a finished turn (clean end, no resume) pid=${proc.pid}`);
|
|
2037
|
+
}
|
|
2038
|
+
else if (action === 'terminate-prompt-unanswered') {
|
|
2039
|
+
writeStallRecord();
|
|
2040
|
+
terminatePromptUnanswered(screen.state, screen.sample);
|
|
2041
|
+
}
|
|
2042
|
+
else if (action === 'model-error') {
|
|
2043
|
+
// Selected model unavailable (banner painted to screen only — no JSONL, no Stop hook).
|
|
2044
|
+
// Surface the real reason; 'model_error' is non-retryable so we never resume into it.
|
|
2045
|
+
stallKilled = true;
|
|
2046
|
+
if (!terminalModelError)
|
|
2047
|
+
terminalModelError = claudeModelErrorMessage(s.model || opts.claudeModel || null);
|
|
2048
|
+
writeStallRecord();
|
|
2049
|
+
s.stopReason = 'model_error';
|
|
2050
|
+
if (!s.errors)
|
|
2051
|
+
s.errors = [terminalModelError];
|
|
2052
|
+
agentWarn(`[claude-tui] watchdog hit a model-unavailable banner after ${quietMin}m — ending turn (model_error, no resume) pid=${proc.pid}`);
|
|
2053
|
+
pushRecentActivity(s.recentActivity, 'Selected model unavailable — stopping');
|
|
2054
|
+
s.activity = s.recentActivity.join('\n');
|
|
2055
|
+
emit();
|
|
2056
|
+
killProc('SIGTERM');
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
// terminate-stalled: a genuine freeze candidate (state 'unknown') OR an idle hold with
|
|
2060
|
+
// pending background work we must not interrupt. Keep the self-healing SIGTERM-as-
|
|
2061
|
+
// 'stalled' path (auto-resumes once) with the existing model / limit arbitration.
|
|
2062
|
+
stallKilled = true;
|
|
2063
|
+
s.stopReason = 'stalled';
|
|
2064
|
+
writeStallRecord();
|
|
2065
|
+
if (!s.errors) {
|
|
2066
|
+
if (terminalModelError && !s.text.trim()) {
|
|
2067
|
+
s.stopReason = 'model_error';
|
|
2068
|
+
s.errors = [terminalModelError];
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
const limitOutcome = resolveClaudeTuiLimitOutcome({
|
|
2072
|
+
noticeText: terminalLimitNotice,
|
|
2073
|
+
noticeAt: terminalLimitNoticeAt,
|
|
2074
|
+
lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
|
|
2075
|
+
hasOutputText: !!s.text.trim(),
|
|
2076
|
+
});
|
|
2077
|
+
if (limitOutcome === 'fatal') {
|
|
2078
|
+
s.stopReason = 'rate_limit';
|
|
2079
|
+
s.errors = [terminalLimitNotice];
|
|
2080
|
+
}
|
|
2081
|
+
else {
|
|
2082
|
+
s.errors = [`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.`];
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (state=${screen.state}, pendingTools=${pendingHookToolIds.size}, pendingBg=${pendingBgForStall}, ptyQuiet=${ptyQuietS}s) — terminating TUI pid=${proc.pid}${s.stopReason === 'rate_limit' ? ' (usage limit)' : s.stopReason === 'model_error' ? ' (model unavailable)' : ' for auto-resume'}`);
|
|
2087
|
+
pushRecentActivity(s.recentActivity, s.stopReason === 'rate_limit'
|
|
2088
|
+
? 'Usage limit blocked the turn — stopping'
|
|
2089
|
+
: s.stopReason === 'model_error'
|
|
2090
|
+
? 'Selected model unavailable — stopping'
|
|
2091
|
+
: `Agent stalled (${quietMin}m silent) — restarting turn`);
|
|
2092
|
+
s.activity = s.recentActivity.join('\n');
|
|
2093
|
+
emit();
|
|
2094
|
+
killProc('SIGTERM');
|
|
2095
|
+
}
|
|
2096
|
+
// Keep polling: onExit resolves the wait and the final drains pick up
|
|
2097
|
+
// whatever the dying process flushes.
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
|
|
2102
|
+
};
|
|
2103
|
+
pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
|
|
2104
|
+
// 10. Wait for process exit.
|
|
2105
|
+
await new Promise(resolve => {
|
|
2106
|
+
proc.onExit(({ exitCode: code, signal }) => {
|
|
2107
|
+
processExited = true;
|
|
2108
|
+
exitCode = code;
|
|
2109
|
+
exitSignal = typeof signal === 'number' ? signal : null;
|
|
2110
|
+
if (pollHandle) {
|
|
2111
|
+
clearTimeout(pollHandle);
|
|
2112
|
+
pollHandle = null;
|
|
2113
|
+
}
|
|
2114
|
+
clearTimeout(hardTimer);
|
|
2115
|
+
agentLog(`[claude-tui] exit code=${code} signal=${signal ?? '-'} lines=${lineCount}`);
|
|
2116
|
+
resolve();
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
opts.abortSignal?.removeEventListener('abort', abortStream);
|
|
2120
|
+
// 11. Final drain — pick up anything written between the last poll and
|
|
2121
|
+
// process exit. Claude flushes its remaining JSONL events on shutdown.
|
|
2122
|
+
if (drainMainJsonl())
|
|
2123
|
+
emit();
|
|
2124
|
+
// Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
|
|
2125
|
+
// between the last poll tick and process exit.
|
|
2126
|
+
if (drainToolEvents())
|
|
2127
|
+
emit();
|
|
2128
|
+
// Final sub-agent drain. The sub-agent's last events (closing tool_results)
|
|
2129
|
+
// may have landed after our last poll tick; mirror the main JSONL drain to
|
|
2130
|
+
// make sure sub.tools / sub.status carry the complete picture into the
|
|
2131
|
+
// final result.
|
|
2132
|
+
if (s.subAgents.size > 0)
|
|
2133
|
+
tryDiscoverSubAgents();
|
|
2134
|
+
if (pumpSubAgentSidecars())
|
|
2135
|
+
emit();
|
|
2136
|
+
// Process has exited and final drain is done — promote whatever is left in
|
|
2137
|
+
// the stream buffer into `s.text` so the final result message carries the
|
|
2138
|
+
// complete reply (not a truncated mid-stream prefix).
|
|
2139
|
+
flushStream();
|
|
2140
|
+
// 12. Cleanup temp dir. Keep it around when debugging so users can inspect
|
|
2141
|
+
// the captured PTY bytes + state file.
|
|
2142
|
+
if (!dbg) {
|
|
2143
|
+
try {
|
|
2144
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
2145
|
+
}
|
|
2146
|
+
catch { }
|
|
2147
|
+
}
|
|
2148
|
+
else {
|
|
2149
|
+
agentLog(`[claude-tui] debug artifacts retained in ${workDir}`);
|
|
2150
|
+
}
|
|
2151
|
+
// 13. Build the StreamResult — mirror the shape and semantics of
|
|
2152
|
+
// doClaudeInteractiveStream so downstream consumers (finalizeStreamResult,
|
|
2153
|
+
// dashboard rendering) cannot tell the two paths apart.
|
|
2154
|
+
const cleanStderr = stderrCapture.trim();
|
|
2155
|
+
// Detect Claude Code's synthetic "API Error: …" assistant reply (e.g.
|
|
2156
|
+
// 529 Overloaded). The text gets rewritten so the IM card doesn't surface
|
|
2157
|
+
// the raw "API Error: Overloaded" string to the user, and stopReason is
|
|
2158
|
+
// upgraded so the ClaudeDriver retry wrapper can decide to re-issue the
|
|
2159
|
+
// turn rather than letting the synthetic failure stick.
|
|
2160
|
+
const apiErrorReason = detectClaudeApiError(s.text);
|
|
2161
|
+
if (apiErrorReason) {
|
|
2162
|
+
agentWarn(`[claude-tui] upstream API error detected: ${apiErrorReason}`);
|
|
2163
|
+
s.stopReason = 'api_error';
|
|
2164
|
+
s.text = '';
|
|
2165
|
+
if (!s.errors)
|
|
2166
|
+
s.errors = [`Anthropic API error: ${apiErrorReason}`];
|
|
2167
|
+
}
|
|
2168
|
+
// Model-unavailable arbitration: the TUI painted the "selected model is
|
|
2169
|
+
// unavailable" banner (noteTerminalModelError) and the turn produced nothing
|
|
2170
|
+
// — no JSONL, no Stop hook. The early settle-timer's SIGTERM (or any exit)
|
|
2171
|
+
// brought us here; surface the real reason instead of a bare "(no textual
|
|
2172
|
+
// response)". stopReason 'model_error' is non-retryable (doClaudeWithRetry
|
|
2173
|
+
// only auto-resumes 'stalled'), so we never loop on the same dead model.
|
|
2174
|
+
if (!interrupted && !s.errors && terminalModelError && !s.text.trim()) {
|
|
2175
|
+
s.stopReason = 'model_error';
|
|
2176
|
+
s.errors = [terminalModelError];
|
|
2177
|
+
}
|
|
2178
|
+
// Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
|
|
2179
|
+
// paths the stall watchdog never reaches: the TUI painted a limit banner,
|
|
2180
|
+
// then Stop fired on an empty turn or the process exited — nothing
|
|
2181
|
+
// substantive ever followed the banner, so the limit ate the turn. A banner
|
|
2182
|
+
// followed by real output stays informational (already in the activity log).
|
|
2183
|
+
if (!interrupted && !timedOut && !s.errors) {
|
|
2184
|
+
const limitOutcome = resolveClaudeTuiLimitOutcome({
|
|
2185
|
+
noticeText: terminalLimitNotice,
|
|
2186
|
+
noticeAt: terminalLimitNoticeAt,
|
|
2187
|
+
lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
|
|
2188
|
+
hasOutputText: !!s.text.trim(),
|
|
2189
|
+
});
|
|
2190
|
+
if (limitOutcome === 'fatal') {
|
|
2191
|
+
s.stopReason = 'rate_limit';
|
|
2192
|
+
s.errors = [terminalLimitNotice];
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
const errorText = joinErrorMessages(s.errors);
|
|
2196
|
+
// "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
|
|
2197
|
+
// hook fired, which yields a non-zero exit), no errors from the parser, no
|
|
2198
|
+
// user abort, no timeout. SIGTERM-after-Stop is the normal happy path.
|
|
2199
|
+
const exitedViaStopHook = stopHookFired && !timedOut && !interrupted;
|
|
2200
|
+
const procOk = (exitCode === 0) || exitedViaStopHook;
|
|
2201
|
+
const ok = procOk && !s.errors && !timedOut && !interrupted && stopHookFired;
|
|
2202
|
+
const error = errorText
|
|
2203
|
+
|| (interrupted ? 'Interrupted by user.' : null)
|
|
2204
|
+
|| (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
|
|
2205
|
+
|| (!stopHookFired
|
|
2206
|
+
? (cleanStderr
|
|
2207
|
+
|| `Claude TUI exited (code=${exitCode}, signal=${exitSignal ?? '-'}) without completing the turn.`)
|
|
2208
|
+
: null);
|
|
2209
|
+
const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
|
|
2210
|
+
const elapsedS = (Date.now() - start) / 1000;
|
|
2211
|
+
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}`);
|
|
2212
|
+
// Stall diagnostics: a turn that went quiet has now ended. Recording the
|
|
2213
|
+
// outcome separates benign long-thinking/long-tool (completed) from true
|
|
2214
|
+
// freezes the watchdog had to kill (stalled-killed) — the calibration the
|
|
2215
|
+
// threshold tuning needs.
|
|
2216
|
+
if (stallDiagWentQuiet) {
|
|
2217
|
+
writeStallDiag({
|
|
2218
|
+
kind: 'resolved',
|
|
2219
|
+
sessionId: activeSessionId,
|
|
2220
|
+
version: observedClaudeVersion,
|
|
2221
|
+
model: s.model || null,
|
|
2222
|
+
elapsedTurnMs: Date.now() - start,
|
|
2223
|
+
maxQuietMs: stallDiagMaxQuietMs,
|
|
2224
|
+
ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet,
|
|
2225
|
+
lastJsonlType: lastMainJsonlType,
|
|
2226
|
+
outcome: stallKilled ? 'stalled-killed'
|
|
2227
|
+
: interrupted ? 'interrupted'
|
|
2228
|
+
: timedOut ? 'timeout'
|
|
2229
|
+
: stopHookFired ? 'completed'
|
|
2230
|
+
: 'exited-no-stop',
|
|
2231
|
+
stopReason: s.stopReason || null,
|
|
2232
|
+
ok,
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
// Build the message body. Order:
|
|
2236
|
+
// 1. Any assistant text captured from JSONL (the canonical reply).
|
|
2237
|
+
// 2. Parser-surfaced errors.
|
|
2238
|
+
// 3. For interrupted runs with no text yet, a clear status — never the
|
|
2239
|
+
// raw PTY scrape (it would be a half-rendered TUI screen with no value
|
|
2240
|
+
// to the user, and pre-ANSI-strip used to render as garbled gibberish
|
|
2241
|
+
// in IM).
|
|
2242
|
+
// 4. Fall back to ANSI-stripped stderrCapture for genuine startup
|
|
2243
|
+
// failures like "claude: command not found".
|
|
2244
|
+
const messageBody = s.text.trim()
|
|
2245
|
+
|| errorText
|
|
2246
|
+
|| (interrupted ? '(Interrupted before any reply landed.)'
|
|
2247
|
+
: procOk ? '(no textual response)'
|
|
2248
|
+
: `Failed (exit=${exitCode}).\n\n${cleanStderr || '(no output)'}`);
|
|
2249
|
+
return {
|
|
2250
|
+
ok,
|
|
2251
|
+
sessionId: s.sessionId,
|
|
2252
|
+
workspacePath: null,
|
|
2253
|
+
model: s.model,
|
|
2254
|
+
thinkingEffort: s.thinkingEffort,
|
|
2255
|
+
message: messageBody,
|
|
2256
|
+
thinking: s.thinking.trim() || null,
|
|
2257
|
+
elapsedS,
|
|
2258
|
+
inputTokens: s.inputTokens,
|
|
2259
|
+
outputTokens: s.outputTokens,
|
|
2260
|
+
cachedInputTokens: s.cachedInputTokens,
|
|
2261
|
+
cacheCreationInputTokens: s.cacheCreationInputTokens,
|
|
2262
|
+
contextWindow: s.contextWindow,
|
|
2263
|
+
contextUsedTokens: s.contextUsedTokens,
|
|
2264
|
+
contextPercent: computeContext(s).contextPercent,
|
|
2265
|
+
codexCumulative: null,
|
|
2266
|
+
error,
|
|
2267
|
+
plan: s.plan,
|
|
2268
|
+
stopReason: s.stopReason,
|
|
2269
|
+
incomplete,
|
|
2270
|
+
activity: s.activity.trim() || null,
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
function makeErrorResult(opts, start, message) {
|
|
2274
|
+
return {
|
|
2275
|
+
ok: false,
|
|
2276
|
+
sessionId: opts.sessionId,
|
|
2277
|
+
workspacePath: null,
|
|
2278
|
+
model: opts.model,
|
|
2279
|
+
thinkingEffort: opts.thinkingEffort,
|
|
2280
|
+
message,
|
|
2281
|
+
thinking: null,
|
|
2282
|
+
elapsedS: (Date.now() - start) / 1000,
|
|
2283
|
+
inputTokens: null,
|
|
2284
|
+
outputTokens: null,
|
|
2285
|
+
cachedInputTokens: null,
|
|
2286
|
+
cacheCreationInputTokens: null,
|
|
2287
|
+
contextWindow: null,
|
|
2288
|
+
contextUsedTokens: null,
|
|
2289
|
+
contextPercent: null,
|
|
2290
|
+
codexCumulative: null,
|
|
2291
|
+
error: message,
|
|
2292
|
+
plan: null,
|
|
2293
|
+
stopReason: null,
|
|
2294
|
+
incomplete: true,
|
|
2295
|
+
activity: null,
|
|
2296
|
+
};
|
|
2297
|
+
}
|