pikiloom 0.4.26 → 0.4.28
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/dashboard/dist/assets/{AgentTab-BNJ73P0m.js → AgentTab-DtjuI8NU.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-BIx6a5o1.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-D-GT-G-w.js → DirBrowser-BINh_7NB.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-BrJMulJR.js → ExtensionsTab-CHpd147b.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-auPnx468.js → IMAccessTab-C4DbGxbG.js} +1 -1
- package/dashboard/dist/assets/{Modal-lYfO9m-I.js → Modal-D_PUi-5O.js} +1 -1
- package/dashboard/dist/assets/{Modals-PRdWOIY9.js → Modals-BahDEMqf.js} +1 -1
- package/dashboard/dist/assets/{Select-BhbU-rll.js → Select-Cp5VXb_2.js} +1 -1
- package/dashboard/dist/assets/SessionPanel-DZVNXptx.js +1 -0
- package/dashboard/dist/assets/SystemTab-CLznS31f.js +1 -0
- package/dashboard/dist/assets/index-CLGovkkw.js +3 -0
- package/dashboard/dist/assets/index-CXIN3nTr.css +1 -0
- package/dashboard/dist/assets/index-DcYOI6rL.js +23 -0
- package/dashboard/dist/assets/{shared-C5uXvhZJ.js → shared-C8DCTJ44.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/acp-client.js +0 -59
- package/dist/agent/artifacts.js +1 -38
- package/dist/agent/auto-update.js +2 -78
- package/dist/agent/await-resume.js +0 -20
- package/dist/agent/cli/auth.js +3 -51
- package/dist/agent/cli/catalog.js +0 -3
- package/dist/agent/cli/detector.js +1 -19
- package/dist/agent/cli/index.js +0 -3
- package/dist/agent/cli/registry.js +0 -25
- package/dist/agent/driver.js +0 -14
- package/dist/agent/drivers/claude-tui.js +2 -769
- package/dist/agent/drivers/claude.js +12 -631
- package/dist/agent/drivers/codex.js +11 -162
- package/dist/agent/drivers/gemini.js +6 -114
- package/dist/agent/drivers/hermes.js +0 -128
- package/dist/agent/goal.js +0 -45
- package/dist/agent/handover.js +1 -46
- package/dist/agent/images.js +7 -133
- package/dist/agent/index.js +1 -29
- package/dist/agent/mcp/bridge.js +1 -197
- package/dist/agent/mcp/extensions.js +5 -84
- package/dist/agent/mcp/oauth.js +2 -69
- package/dist/agent/mcp/registry.js +2 -33
- package/dist/agent/mcp/session-server.js +2 -45
- package/dist/agent/mcp/tools/ask-user.js +0 -6
- package/dist/agent/mcp/tools/await-resume.js +0 -15
- package/dist/agent/mcp/tools/goal.js +0 -25
- package/dist/agent/mcp/tools/types.js +0 -5
- package/dist/agent/mcp/tools/workspace.js +0 -23
- package/dist/agent/npm.js +0 -14
- package/dist/agent/session.js +86 -196
- package/dist/agent/skill-installer.js +3 -70
- package/dist/agent/skills.js +0 -43
- package/dist/agent/stream.js +5 -148
- package/dist/agent/types.js +0 -12
- package/dist/agent/utils.js +2 -120
- package/dist/bot/bot.js +3 -468
- package/dist/bot/command-ui.js +0 -45
- package/dist/bot/commands.js +0 -51
- package/dist/bot/headless-bot.js +0 -21
- package/dist/bot/host.js +1 -5
- package/dist/bot/human-loop.js +0 -8
- package/dist/bot/menu.js +0 -3
- package/dist/bot/orchestration.js +0 -3
- package/dist/bot/render-shared.js +0 -63
- package/dist/bot/session-hub.js +14 -92
- package/dist/bot/session-status.js +1 -5
- package/dist/bot/streaming.js +0 -53
- package/dist/browser-profile.js +0 -51
- package/dist/browser-supervisor.js +0 -75
- package/dist/catalog/cli-tools.js +0 -53
- package/dist/catalog/index.js +0 -18
- package/dist/catalog/local-models.js +0 -18
- package/dist/catalog/mcp-servers.js +0 -35
- package/dist/catalog/skill-repos.js +0 -32
- package/dist/channels/base.js +0 -19
- package/dist/channels/dingtalk/bot.js +0 -3
- package/dist/channels/dingtalk/channel.js +0 -33
- package/dist/channels/discord/bot.js +0 -7
- package/dist/channels/discord/channel.js +0 -22
- package/dist/channels/feishu/bot.js +0 -71
- package/dist/channels/feishu/channel.js +0 -78
- package/dist/channels/feishu/markdown.js +0 -5
- package/dist/channels/feishu/render.js +0 -45
- package/dist/channels/health.js +0 -46
- package/dist/channels/slack/bot.js +0 -7
- package/dist/channels/slack/channel.js +1 -27
- package/dist/channels/states.js +0 -3
- package/dist/channels/telegram/bot.js +0 -60
- package/dist/channels/telegram/channel.js +2 -99
- package/dist/channels/telegram/directory.js +0 -3
- package/dist/channels/telegram/live-preview.js +0 -8
- package/dist/channels/telegram/render.js +0 -30
- package/dist/channels/wecom/bot.js +0 -3
- package/dist/channels/wecom/channel.js +0 -58
- package/dist/channels/weixin/api.js +0 -9
- package/dist/channels/weixin/bot.js +0 -88
- package/dist/channels/weixin/channel.js +0 -6
- package/dist/cli/autostart.js +1 -44
- package/dist/cli/channel-supervisor.js +1 -59
- package/dist/cli/channels.js +0 -6
- package/dist/cli/main.js +1 -145
- package/dist/cli/onboarding.js +0 -6
- package/dist/cli/run.js +0 -11
- package/dist/cli/setup-wizard.js +0 -3
- package/dist/core/config/runtime-config.js +0 -38
- package/dist/core/config/user-config.js +0 -63
- package/dist/core/config/validation.js +0 -9
- package/dist/core/constants.js +0 -268
- package/dist/core/git.js +0 -31
- package/dist/core/legacy-compat.js +1 -28
- package/dist/core/logging.js +0 -3
- package/dist/core/platform.js +0 -28
- package/dist/core/process-control.js +0 -8
- package/dist/core/secrets/index.js +0 -17
- package/dist/core/secrets/inline-seal.js +0 -7
- package/dist/core/secrets/ref.js +0 -19
- package/dist/core/secrets/resolver.js +0 -8
- package/dist/core/secrets/store.js +1 -12
- package/dist/core/utils.js +1 -37
- package/dist/core/version.js +0 -3
- package/dist/dashboard/platform.js +1 -35
- package/dist/dashboard/routes/agents.js +2 -71
- package/dist/dashboard/routes/cli.js +0 -27
- package/dist/dashboard/routes/config.js +0 -54
- package/dist/dashboard/routes/extensions.js +0 -132
- package/dist/dashboard/routes/local-models.js +0 -68
- package/dist/dashboard/routes/models.js +0 -46
- package/dist/dashboard/routes/sessions.js +0 -100
- package/dist/dashboard/runtime.js +1 -58
- package/dist/dashboard/server.js +0 -49
- package/dist/dashboard/session-control.js +2 -69
- package/dist/model/catalog.js +1 -17
- package/dist/model/index.js +0 -15
- package/dist/model/injector.js +1 -181
- package/dist/model/provider-models.js +1 -48
- package/dist/model/responses-bridge.js +3 -45
- package/dist/model/store.js +0 -17
- package/dist/model/types.js +0 -12
- package/dist/model/validation.js +1 -18
- package/dist/pikichannel/adapter-pikiloom.js +0 -31
- package/dist/pikichannel/code.js +0 -12
- package/dist/pikichannel/codec.js +0 -14
- package/dist/pikichannel/host.js +4 -33
- package/dist/pikichannel/protocol.js +0 -52
- package/dist/pikichannel/rendezvous-broker.js +5 -26
- package/dist/pikichannel/rendezvous-host.js +3 -16
- package/dist/pikichannel/server.js +6 -55
- package/dist/pikichannel/transport.js +0 -25
- package/dist/pikichannel/transports/webrtc-host.js +0 -18
- package/dist/pikichannel/transports/webrtc-shared.js +4 -34
- package/dist/pikichannel/transports/websocket-host.js +1 -14
- package/dist/pikichannel/turn.js +2 -69
- package/dist/pikichannel/web/sdk.js +14 -71
- package/package.json +1 -1
- package/dashboard/dist/assets/ConnectionModal-Dt05H2f9.js +0 -1
- package/dashboard/dist/assets/SessionPanel-_0dLWvEb.js +0 -1
- package/dashboard/dist/assets/SystemTab-CN_QrRKt.js +0 -1
- package/dashboard/dist/assets/index-CP3LYJR2.js +0 -23
- package/dashboard/dist/assets/index-C_neLnaO.js +0 -3
- package/dashboard/dist/assets/index-CyCspgtc.css +0 -1
|
@@ -1,41 +1,3 @@
|
|
|
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 `PIKILOOM_CLAUDE_PRINT=1` (or the
|
|
10
|
-
* legacy `PIKILOOM_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 pikiloom 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
1
|
import fs from 'node:fs';
|
|
40
2
|
import path from 'node:path';
|
|
41
3
|
import { randomUUID } from 'node:crypto';
|
|
@@ -46,31 +8,8 @@ import { createRetainedLogSink } from '../../core/logging.js';
|
|
|
46
8
|
import { stripAnsiEscapes } from '../../core/utils.js';
|
|
47
9
|
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
10
|
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
|
-
// ~/.pikiloom/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
11
|
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
12
|
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
13
|
let stallDiagSink;
|
|
75
14
|
function writeStallDiag(record) {
|
|
76
15
|
if (stallDiagSink === null)
|
|
@@ -91,9 +30,6 @@ function writeStallDiag(record) {
|
|
|
91
30
|
stallDiagSink = null;
|
|
92
31
|
}
|
|
93
32
|
}
|
|
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
33
|
export function classifyClaudeJsonlEvent(ev) {
|
|
98
34
|
const type = typeof ev?.type === 'string' ? ev.type : 'unknown';
|
|
99
35
|
const content = ev?.message?.content;
|
|
@@ -110,11 +46,6 @@ export function classifyClaudeJsonlEvent(ev) {
|
|
|
110
46
|
return type;
|
|
111
47
|
}
|
|
112
48
|
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
49
|
const specifier = 'node-pty';
|
|
119
50
|
const mod = await import(/* @vite-ignore */ specifier);
|
|
120
51
|
const api = mod?.default ?? mod;
|
|
@@ -123,14 +54,6 @@ async function loadPty() {
|
|
|
123
54
|
await preflightSpawnHelper();
|
|
124
55
|
return api;
|
|
125
56
|
}
|
|
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
57
|
let spawnHelperPreflightDone = false;
|
|
135
58
|
async function preflightSpawnHelper() {
|
|
136
59
|
if (spawnHelperPreflightDone || process.platform === 'win32') {
|
|
@@ -139,8 +62,6 @@ async function preflightSpawnHelper() {
|
|
|
139
62
|
}
|
|
140
63
|
spawnHelperPreflightDone = true;
|
|
141
64
|
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
65
|
const ptyRoot = await locatePtyPackageRoot();
|
|
145
66
|
if (!ptyRoot)
|
|
146
67
|
return;
|
|
@@ -160,9 +81,6 @@ async function preflightSpawnHelper() {
|
|
|
160
81
|
}
|
|
161
82
|
}
|
|
162
83
|
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
84
|
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
167
85
|
for (let i = 0; i < 8; i++) {
|
|
168
86
|
const candidate = path.join(dir, 'node_modules', 'node-pty');
|
|
@@ -175,11 +93,6 @@ async function locatePtyPackageRoot() {
|
|
|
175
93
|
}
|
|
176
94
|
return null;
|
|
177
95
|
}
|
|
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
96
|
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
184
97
|
"use strict";
|
|
185
98
|
const fs = require("node:fs");
|
|
@@ -239,12 +152,6 @@ function readHookState(statePath) {
|
|
|
239
152
|
return {};
|
|
240
153
|
}
|
|
241
154
|
}
|
|
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
155
|
function readJsonlIncrement(filePath, fromOffset) {
|
|
249
156
|
try {
|
|
250
157
|
const stat = fs.statSync(filePath);
|
|
@@ -261,11 +168,9 @@ function readJsonlIncrement(filePath, fromOffset) {
|
|
|
261
168
|
const endsWithNewline = chunk[chunk.length - 1] === '\n';
|
|
262
169
|
const segments = chunk.split('\n');
|
|
263
170
|
if (endsWithNewline) {
|
|
264
|
-
// Last segment after split is empty — drop it.
|
|
265
171
|
segments.pop();
|
|
266
172
|
return { offset: stat.size, lines: segments };
|
|
267
173
|
}
|
|
268
|
-
// Partial last line — keep its bytes unread for the next tick.
|
|
269
174
|
const lastLine = segments.pop() || '';
|
|
270
175
|
const consumed = stat.size - Buffer.byteLength(lastLine, 'utf8');
|
|
271
176
|
return { offset: consumed, lines: segments };
|
|
@@ -274,10 +179,6 @@ function readJsonlIncrement(filePath, fromOffset) {
|
|
|
274
179
|
return { offset: fromOffset, lines: [] };
|
|
275
180
|
}
|
|
276
181
|
}
|
|
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
182
|
const TUI_STREAM_CHUNK_CHARS = 20;
|
|
282
183
|
const TUI_STREAM_CHUNK_INTERVAL_MS = 20;
|
|
283
184
|
function makeTuiStreamBuffer() {
|
|
@@ -323,25 +224,6 @@ export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
|
|
|
323
224
|
return null;
|
|
324
225
|
return limitNoticeFromText(extractTextBlocks(msgOrText.content));
|
|
325
226
|
}
|
|
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
227
|
export function resolveClaudeTuiLimitOutcome(input) {
|
|
346
228
|
if (!input.noticeText)
|
|
347
229
|
return 'none';
|
|
@@ -349,63 +231,14 @@ export function resolveClaudeTuiLimitOutcome(input) {
|
|
|
349
231
|
return 'info';
|
|
350
232
|
return 'fatal';
|
|
351
233
|
}
|
|
352
|
-
/**
|
|
353
|
-
* Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
|
|
354
|
-
* a slice of (ANSI-stripped) PTY screen output. When pikiloom 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
234
|
export function detectClaudeBypassPrompt(screen) {
|
|
372
235
|
if (typeof screen !== 'string' || !screen)
|
|
373
236
|
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
237
|
const t = stripAnsiEscapes(screen).replace(/\s+/g, '').toLowerCase();
|
|
381
238
|
return t.includes('bypasspermissionsmode')
|
|
382
239
|
&& t.includes('yes,iaccept')
|
|
383
240
|
&& t.includes('no,exit');
|
|
384
241
|
}
|
|
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
242
|
export function detectClaudeProceedPrompt(screen) {
|
|
410
243
|
return classifyClaudeScreen(screen).state === 'confirm-prompt';
|
|
411
244
|
}
|
|
@@ -433,44 +266,26 @@ export function classifyClaudeScreen(screen) {
|
|
|
433
266
|
return { state: 'unknown', affirmativeKey: null, sample: '' };
|
|
434
267
|
const stripped = stripAnsiEscapes(screen);
|
|
435
268
|
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
269
|
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
270
|
if (ds.includes('bypasspermissionsmode') && ds.includes('yes,iaccept') && ds.includes('no,exit')) {
|
|
443
271
|
return { state: 'bypass-startup', affirmativeKey: '2', sample };
|
|
444
272
|
}
|
|
445
|
-
// 2. Selected-model-unavailable banner. Distinctive phrasing; reuse the shared detector so the
|
|
446
|
-
// -p and TUI paths stay in lockstep.
|
|
447
273
|
if (detectClaudeModelError(ds))
|
|
448
274
|
return { state: 'model-error', affirmativeKey: null, sample };
|
|
449
275
|
const asksProceed = ds.includes('doyouwanttoproceed') || ds.includes('wouldyouliketoproceed');
|
|
450
|
-
const hasCursorSelect = ds.includes('❯') && ds.includes('1.');
|
|
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).
|
|
276
|
+
const hasCursorSelect = ds.includes('❯') && ds.includes('1.');
|
|
454
277
|
if ((asksProceed || ds.includes('readytoexecute'))
|
|
455
278
|
&& (ds.includes('manuallyapproveedits') || ds.includes('yes,andbypasspermissions'))) {
|
|
456
279
|
return { state: 'plan-approval', affirmativeKey: null, sample };
|
|
457
280
|
}
|
|
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
281
|
if ((asksProceed || ds.includes('requiresconfirmation')) && hasCursorSelect) {
|
|
461
282
|
return { state: 'confirm-prompt', affirmativeKey: '1', sample };
|
|
462
283
|
}
|
|
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
284
|
if (ds.includes('trustthisfolder'))
|
|
466
285
|
return { state: 'confirm-prompt', affirmativeKey: '1', sample };
|
|
467
286
|
if ((asksProceed || ds.includes('doyouwant')) && ds.includes('(y/n)')) {
|
|
468
287
|
return { state: 'confirm-prompt', affirmativeKey: 'y', sample };
|
|
469
288
|
}
|
|
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
289
|
if (ds.includes('bypasspermissionson')
|
|
475
290
|
&& (ds.includes('shift+tabtocycle') || ds.includes('foragents') || ds.includes('tomanage'))
|
|
476
291
|
&& !ds.includes('esctointerrupt')) {
|
|
@@ -478,31 +293,12 @@ export function classifyClaudeScreen(screen) {
|
|
|
478
293
|
}
|
|
479
294
|
return { state: 'unknown', affirmativeKey: null, sample };
|
|
480
295
|
}
|
|
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
296
|
export function classifyStallScreen(screen) {
|
|
487
297
|
const info = classifyClaudeScreen(screen);
|
|
488
298
|
const looksLikePrompt = info.state === 'confirm-prompt'
|
|
489
299
|
|| info.state === 'plan-approval' || info.state === 'bypass-startup';
|
|
490
300
|
return { looksLikePrompt, sample: info.sample };
|
|
491
301
|
}
|
|
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
302
|
function readAssignedTaskIdFromHookResponse(toolResponse) {
|
|
507
303
|
const structured = toolResponse?.task?.id;
|
|
508
304
|
if (structured != null && String(structured).trim())
|
|
@@ -519,26 +315,11 @@ function readAssignedTaskIdFromHookResponse(toolResponse) {
|
|
|
519
315
|
}
|
|
520
316
|
return null;
|
|
521
317
|
}
|
|
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
318
|
export function applyHookToolEvent(ev, s) {
|
|
532
319
|
const toolUseId = String(ev?.tool_use_id || '').trim();
|
|
533
320
|
const toolName = String(ev?.tool_name || '').trim();
|
|
534
321
|
if (!toolName || !toolUseId)
|
|
535
322
|
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
323
|
const subAgentId = typeof ev?.agent_id === 'string' && ev.agent_id ? ev.agent_id : '';
|
|
543
324
|
if (subAgentId) {
|
|
544
325
|
if (ev.event === 'PreToolUse') {
|
|
@@ -592,12 +373,6 @@ export function applyHookToolEvent(ev, s) {
|
|
|
592
373
|
return true;
|
|
593
374
|
}
|
|
594
375
|
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
376
|
const input = ev.tool_input || {};
|
|
602
377
|
const desc = typeof input.description === 'string' ? input.description.trim() : '';
|
|
603
378
|
const kind = typeof input.subagent_type === 'string' ? input.subagent_type.trim() : '';
|
|
@@ -611,27 +386,15 @@ export function applyHookToolEvent(ev, s) {
|
|
|
611
386
|
status: 'running',
|
|
612
387
|
});
|
|
613
388
|
}
|
|
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
389
|
if (input.run_in_background === true)
|
|
619
390
|
registerClaudeBackgroundAgentLaunch(s, toolUseId);
|
|
620
391
|
s.seenClaudeToolIds.add(toolUseId);
|
|
621
392
|
s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
|
|
622
393
|
return true;
|
|
623
394
|
}
|
|
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
395
|
if (toolName === 'Bash' && ev.tool_input?.run_in_background === true) {
|
|
628
396
|
registerClaudeBackgroundBashLaunch(s, toolUseId);
|
|
629
397
|
}
|
|
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
398
|
if (toolName === 'Workflow') {
|
|
636
399
|
registerClaudeBackgroundAgentLaunch(s, toolUseId);
|
|
637
400
|
}
|
|
@@ -673,13 +436,6 @@ export function applyHookToolEvent(ev, s) {
|
|
|
673
436
|
return true;
|
|
674
437
|
}
|
|
675
438
|
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
439
|
const sub = s.subAgents.get(toolUseId);
|
|
684
440
|
if (sub) {
|
|
685
441
|
const isBgLaunchAck = !ev.tool_response?.is_error
|
|
@@ -701,16 +457,12 @@ export function applyHookToolEvent(ev, s) {
|
|
|
701
457
|
s.activity = s.recentActivity.join('\n');
|
|
702
458
|
}
|
|
703
459
|
}
|
|
704
|
-
// Background Bash launch ack → map task id → tool_use for notification
|
|
705
|
-
// resolution (bash notifications usually omit <tool-use-id>).
|
|
706
460
|
if (toolName === 'Bash' && s.bgBashToolUseIds?.has(toolUseId)
|
|
707
461
|
&& !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
|
|
708
462
|
const taskId = extractClaudeBackgroundTaskId(ev.tool_response);
|
|
709
463
|
if (taskId && !s.bgTaskIdToToolUse.has(taskId))
|
|
710
464
|
s.bgTaskIdToToolUse.set(taskId, toolUseId);
|
|
711
465
|
}
|
|
712
|
-
// Workflow launch ack → map runId → tool_use for notification resolution
|
|
713
|
-
// (the workflow's <task-notification> may carry only the task id).
|
|
714
466
|
if (toolName === 'Workflow' && s.bgAgentLaunchedToolUseIds?.has(toolUseId)
|
|
715
467
|
&& !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
|
|
716
468
|
const runId = extractClaudeWorkflowRunId(ev.tool_response);
|
|
@@ -722,11 +474,6 @@ export function applyHookToolEvent(ev, s) {
|
|
|
722
474
|
}
|
|
723
475
|
return false;
|
|
724
476
|
}
|
|
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
477
|
function parseTodoWriteAsPlanLite(input) {
|
|
731
478
|
if (!input || typeof input !== 'object')
|
|
732
479
|
return null;
|
|
@@ -750,11 +497,6 @@ function parseTodoWriteAsPlanLite(input) {
|
|
|
750
497
|
return null;
|
|
751
498
|
return { explanation: null, steps };
|
|
752
499
|
}
|
|
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
500
|
function rebuildClaudePlanFromTasksFromState(s) {
|
|
759
501
|
if (!s.claudeTaskOrder?.length)
|
|
760
502
|
return;
|
|
@@ -794,14 +536,6 @@ function applyAssistantStreaming(s, msg, buf) {
|
|
|
794
536
|
s.thinking = s.thinking ? `${s.thinking}\n\n${appendThinking}` : appendThinking;
|
|
795
537
|
}
|
|
796
538
|
}
|
|
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
539
|
function callClaudeParseForTui(ev, s) {
|
|
806
540
|
if (ev.type !== 'assistant' || !ev.message) {
|
|
807
541
|
claudeParse(ev, s);
|
|
@@ -818,15 +552,6 @@ function callClaudeParseForTui(ev, s) {
|
|
|
818
552
|
};
|
|
819
553
|
claudeParse(filtered, s);
|
|
820
554
|
}
|
|
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
555
|
function applyModelContextWindow(s) {
|
|
831
556
|
if (s.byokContextWindow)
|
|
832
557
|
return;
|
|
@@ -835,18 +560,10 @@ function applyModelContextWindow(s) {
|
|
|
835
560
|
if (effective != null)
|
|
836
561
|
s.contextWindow = effective;
|
|
837
562
|
}
|
|
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
563
|
function applyAssistantUsage(s, msg) {
|
|
843
564
|
const u = msg?.usage;
|
|
844
565
|
if (!u || typeof u !== 'object')
|
|
845
566
|
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
567
|
const msgId = typeof msg?.id === 'string' && msg.id ? msg.id : null;
|
|
851
568
|
if (msgId && msgId !== s.turnUsageMsgId) {
|
|
852
569
|
if (s.turnUsageMsgId != null)
|
|
@@ -864,47 +581,13 @@ function applyAssistantUsage(s, msg) {
|
|
|
864
581
|
const total = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0) + (s.outputTokens ?? 0);
|
|
865
582
|
s.contextUsedTokens = total > 0 ? total : null;
|
|
866
583
|
}
|
|
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
584
|
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
585
|
export function decideClaudeTuiStop(input) {
|
|
903
586
|
if (input.pendingBackgroundAgents > 0) {
|
|
904
587
|
const ttl = input.holdQuietTtlMs ?? CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS;
|
|
905
588
|
const lastActivityAt = Math.max(input.stoppedAt, input.lastJsonlEventAt, input.lastTaskNotificationAt, input.lastHookOrSidecarEventAt ?? 0);
|
|
906
589
|
if (input.now - lastActivityAt > ttl)
|
|
907
|
-
return 'terminate';
|
|
590
|
+
return 'terminate';
|
|
908
591
|
return 'hold-background';
|
|
909
592
|
}
|
|
910
593
|
const stopIsStale = input.lastTaskNotificationAt > 0 && input.lastTaskNotificationAt >= input.stoppedAt;
|
|
@@ -916,52 +599,11 @@ export function decideClaudeTuiStop(input) {
|
|
|
916
599
|
}
|
|
917
600
|
return 'terminate';
|
|
918
601
|
}
|
|
919
|
-
/**
|
|
920
|
-
* True for the `im_ask_user` MCP tool in any form — Claude Code names MCP tools
|
|
921
|
-
* `mcp__<server>__<tool>` (here `mcp__pikiloom__im_ask_user`), so match the
|
|
922
|
-
* namespaced suffix as well as the bare name (robust to the server name
|
|
923
|
-
* changing). The driver tails Pre/PostToolUse hooks by id but must also know
|
|
924
|
-
* *which* pending tool is the one that blocks on the user, so the stall
|
|
925
|
-
* watchdog can stay armed for every other tool and disarm only for a live
|
|
926
|
-
* question. See decideClaudeTuiStall(`awaitingUserReply`).
|
|
927
|
-
*/
|
|
928
602
|
export function isAskUserToolName(name) {
|
|
929
603
|
if (typeof name !== 'string' || !name)
|
|
930
604
|
return false;
|
|
931
605
|
return name === 'im_ask_user' || name.endsWith('__im_ask_user');
|
|
932
606
|
}
|
|
933
|
-
/**
|
|
934
|
-
* Decide whether the turn has gone dead. claude CLI is known to freeze
|
|
935
|
-
* mid-turn (observed 2026-06-02 on 2.1.160): after a tool_result lands the
|
|
936
|
-
* next assistant segment never starts — the process stays alive, the JSONL
|
|
937
|
-
* goes permanently quiet, no Stop hook ever fires, no error surfaces. Without
|
|
938
|
-
* a watchdog the IM card spins forever.
|
|
939
|
-
*
|
|
940
|
-
* `lastProgressAt` is the freshest of every live signal the driver tracks
|
|
941
|
-
* (main JSONL, hook tool events, sub-agent sidecars, hook lifecycle state).
|
|
942
|
-
* A pending tool (PreToolUse seen, no PostToolUse) extends the threshold:
|
|
943
|
-
* the freeze can also hit mid-execution, but a legitimately long foreground
|
|
944
|
-
* command must not get shot — claude's own Bash timeout fires PostToolUse
|
|
945
|
-
* well inside CLAUDE_TUI_STALL_PENDING_TOOL_MS.
|
|
946
|
-
*
|
|
947
|
-
* Fast path: `lastPtyDataAt` is raw PTY output (any repaint frame counts). A
|
|
948
|
-
* healthy TUI animates continuously mid-turn — spinner, stream ticks, status
|
|
949
|
-
* line — so PTY byte-silence is the cheapest possible "event loop is dead"
|
|
950
|
-
* detector. When BOTH the PTY and all structured signals have been silent
|
|
951
|
-
* past `ptyDeadMs`, declare the stall immediately instead of waiting out the
|
|
952
|
-
* 10/30-minute quiet thresholds. Long thinking and long foreground commands
|
|
953
|
-
* keep painting frames, which routes them to the slow thresholds as before.
|
|
954
|
-
*
|
|
955
|
-
* `awaitingUserReply` is the one state that is quiet BY DESIGN, not because the
|
|
956
|
-
* process froze: an `im_ask_user` MCP call is in flight (PreToolUse seen, no
|
|
957
|
-
* PostToolUse) and the turn is blocked on the user's reply — the `/ask-user`
|
|
958
|
-
* callback never times out. Every signal (JSONL, hooks, PTY) goes silent while
|
|
959
|
-
* the user thinks, which is indistinguishable by timing alone from a freeze, so
|
|
960
|
-
* the watchdog must never stall here. The hard turn deadline remains the
|
|
961
|
-
* backstop if the user never answers; PostToolUse re-arms the watchdog the
|
|
962
|
-
* instant the answer lands. Without this, the last question regularly gets
|
|
963
|
-
* SIGTERMed mid-wait and mislabelled a "CLI freeze".
|
|
964
|
-
*/
|
|
965
607
|
export function decideClaudeTuiStall(input) {
|
|
966
608
|
if (input.awaitingUserReply)
|
|
967
609
|
return 'wait';
|
|
@@ -976,59 +618,30 @@ export function decideClaudeTuiStall(input) {
|
|
|
976
618
|
: (input.quietMs ?? CLAUDE_TUI_STALL_QUIET_MS);
|
|
977
619
|
return input.now - input.lastProgressAt > threshold ? 'stall' : 'wait';
|
|
978
620
|
}
|
|
979
|
-
/**
|
|
980
|
-
* Map the screen state at the moment the stall watchdog would fire to what we should actually do.
|
|
981
|
-
* This is the chokepoint the diagnostics proved was missing: today the screen is classified at kill
|
|
982
|
-
* time but the verdict only changes the error STRING, never the action — so confirm-dialog /
|
|
983
|
-
* idle-REPL / model-error turns get SIGTERMed as 'stalled' and auto-resumed into the same wall.
|
|
984
|
-
*
|
|
985
|
-
* Safety (default-deny): a non-'unknown' state may only DOWNGRADE to a still-terminating, non-
|
|
986
|
-
* resuming outcome — it never cancels termination and never waits beyond one bounded retry. Anything
|
|
987
|
-
* ambiguous falls through to 'terminate-stalled' (today's self-healing path), because mislabelling a
|
|
988
|
-
* real freeze as clearable/idle would convert an auto-resumable stall into a silently dropped turn.
|
|
989
|
-
*/
|
|
990
621
|
export function decideStallAction(input) {
|
|
991
622
|
if (input.state === 'model-error')
|
|
992
623
|
return 'model-error';
|
|
993
|
-
// Answerable dialogs (ask-rule confirm / startup bypass). Plan-approval reaches here too but
|
|
994
|
-
// carries affirmativeKey=null under the current policy → falls straight to unanswered.
|
|
995
624
|
if (input.state === 'confirm-prompt' || input.state === 'plan-approval' || input.state === 'bypass-startup') {
|
|
996
625
|
if (input.affirmativeKey && !input.alreadyTriedAnswer)
|
|
997
626
|
return 'answer-retry';
|
|
998
627
|
return 'terminate-prompt-unanswered';
|
|
999
628
|
}
|
|
1000
629
|
if (input.state === 'idle-repl') {
|
|
1001
|
-
// Back at the prompt = the turn ended (Stop hook missed/held). Terminate cleanly ONLY when no
|
|
1002
|
-
// background work is outstanding: a bg agent/bash lives inside the claude process and bg-Bash
|
|
1003
|
-
// is silent by nature, so killing an idle-looking screen with pending bg would abort live work
|
|
1004
|
-
// ("进程退出把子代理打断"). Leave those to the existing Stop-hold / TTL machinery.
|
|
1005
630
|
return input.pendingBgAgents > 0 ? 'terminate-stalled' : 'terminate-clean';
|
|
1006
631
|
}
|
|
1007
632
|
return 'terminate-stalled';
|
|
1008
633
|
}
|
|
1009
|
-
// ---------------------------------------------------------------------------
|
|
1010
|
-
// Main entry
|
|
1011
|
-
// ---------------------------------------------------------------------------
|
|
1012
634
|
export async function doClaudeTuiStream(opts) {
|
|
1013
635
|
const start = Date.now();
|
|
1014
636
|
const deadline = start + opts.timeout * 1000;
|
|
1015
|
-
// 0. Probe node-pty FIRST — before any temp-dir creation or session work.
|
|
1016
|
-
// If it's not installed (or its prebuilt helper can't be made executable),
|
|
1017
|
-
// throw so the dispatcher in claude.ts catches the error and falls back to
|
|
1018
|
-
// print mode. No cleanup needed because no resources have been allocated.
|
|
1019
637
|
const pty = await loadPty();
|
|
1020
|
-
// 1. Resolve session lifecycle.
|
|
1021
638
|
const isFork = !!opts.forkOf;
|
|
1022
639
|
const isResume = !isFork && !!opts.sessionId;
|
|
1023
640
|
const newSessionId = (isFork || !isResume) ? randomUUID() : opts.sessionId;
|
|
1024
641
|
const home = getHome();
|
|
1025
642
|
const projectDir = path.join(home, '.claude', 'projects', encodePathAsDirName(opts.workdir));
|
|
1026
|
-
// For resume we know the exact file; for new/fork we either know upfront
|
|
1027
|
-
// (--session-id) or learn it from the SessionStart hook (--fork-session
|
|
1028
|
-
// rotates to a fresh uuid Claude generates on its own).
|
|
1029
643
|
let activeSessionId = isResume ? opts.sessionId : newSessionId;
|
|
1030
644
|
let activeJsonlPath = path.join(projectDir, `${activeSessionId}.jsonl`);
|
|
1031
|
-
// Resume: skip everything that was already in the transcript before our turn.
|
|
1032
645
|
let jsonlReadOffset = 0;
|
|
1033
646
|
if (isResume) {
|
|
1034
647
|
try {
|
|
@@ -1036,7 +649,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1036
649
|
}
|
|
1037
650
|
catch { }
|
|
1038
651
|
}
|
|
1039
|
-
// 2. Temp workspace for hook script + state + settings.
|
|
1040
652
|
let workDir;
|
|
1041
653
|
try {
|
|
1042
654
|
workDir = fs.mkdtempSync(path.join(tmpdir(), 'pikiloom-claude-tui-'));
|
|
@@ -1053,25 +665,8 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1053
665
|
fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
1054
666
|
fs.writeFileSync(statePath, JSON.stringify({ events: [] }));
|
|
1055
667
|
fs.writeFileSync(toolEventsPath, '');
|
|
1056
|
-
// Use the same Node binary that's running pikiloom — `node` may not be on
|
|
1057
|
-
// PATH inside the claude TUI's hook subprocess on every distro.
|
|
1058
668
|
const nodeBin = Q(process.execPath);
|
|
1059
669
|
const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
|
|
1060
|
-
// Pre/PostToolUse hooks give us a live tool-event stream. The transcript
|
|
1061
|
-
// JSONL is itself written incrementally (events land ~0.2–1.2s after they
|
|
1062
|
-
// happen — measured on 2.1.173), but the hooks still earn their keep:
|
|
1063
|
-
// PreToolUse fires the instant a tool *starts* (the JSONL tool_use only
|
|
1064
|
-
// proves it was requested; a long-running Bash would otherwise sit
|
|
1065
|
-
// invisible), they carry agent_id for sub-agent attribution, and they are
|
|
1066
|
-
// the registration point for run_in_background launches that the Stop
|
|
1067
|
-
// gating in decideClaudeTuiStop depends on. The hook script writes to
|
|
1068
|
-
// tool-events.jsonl via atomic appends, sidestepping the
|
|
1069
|
-
// read-modify-write race that affects the shared state.json file.
|
|
1070
|
-
// Pre/PostToolUse require an explicit `matcher` field — without it Claude
|
|
1071
|
-
// Code's hook dispatcher silently never fires the hook (the lifecycle
|
|
1072
|
-
// hooks below don't need a matcher because they aren't tool-scoped).
|
|
1073
|
-
// `*` matches every tool. Without this, the entire live-streaming wire-up
|
|
1074
|
-
// is dead code.
|
|
1075
670
|
const settings = {
|
|
1076
671
|
hooks: {
|
|
1077
672
|
SessionStart: [{ hooks: [{ type: 'command', command: hookCmd('SessionStart'), timeout: 5 }] }],
|
|
@@ -1090,7 +685,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1090
685
|
catch { }
|
|
1091
686
|
return makeErrorResult(opts, start, `Failed to seed hook scaffold: ${e?.message || e}`);
|
|
1092
687
|
}
|
|
1093
|
-
// 3. Build the claude argv. Crucially: NO `-p` — that's the whole point.
|
|
1094
688
|
const claudeArgs = [];
|
|
1095
689
|
if (isFork) {
|
|
1096
690
|
claudeArgs.push('--resume', opts.forkOf.parentSessionId, '--fork-session');
|
|
@@ -1107,8 +701,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1107
701
|
claudeArgs.push('--model', model);
|
|
1108
702
|
if (opts.claudePermissionMode)
|
|
1109
703
|
claudeArgs.push('--permission-mode', opts.claudePermissionMode);
|
|
1110
|
-
// Effort + Workflow gate — same source of truth as the `claude -p` driver, so
|
|
1111
|
-
// the TUI path drops the Workflow tool unless orchestration was opted in.
|
|
1112
704
|
claudeArgs.push(...claudeEffortAndWorkflowArgs(opts));
|
|
1113
705
|
if (opts.claudeAppendSystemPrompt)
|
|
1114
706
|
claudeArgs.push('--append-system-prompt', opts.claudeAppendSystemPrompt);
|
|
@@ -1116,22 +708,12 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1116
708
|
claudeArgs.push('--mcp-config', opts.mcpConfigPath);
|
|
1117
709
|
if (opts.claudeExtraArgs?.length)
|
|
1118
710
|
claudeArgs.push(...opts.claudeExtraArgs);
|
|
1119
|
-
// Attachments: TUI doesn't accept base64-image stream-json input. Reference
|
|
1120
|
-
// local paths via the @-mention syntax — Claude's TUI reads images from
|
|
1121
|
-
// disk and inlines them into the message.
|
|
1122
711
|
let fullPrompt = opts.prompt;
|
|
1123
712
|
if (opts.attachments?.length) {
|
|
1124
713
|
const refs = opts.attachments.map(p => `@${p}`).join(' ');
|
|
1125
714
|
fullPrompt = `${refs}\n\n${opts.prompt}`;
|
|
1126
715
|
}
|
|
1127
|
-
// `--mcp-config <configs...>` (and a few other Claude flags) are *variadic*
|
|
1128
|
-
// — without a `--` terminator the positional prompt would be consumed as
|
|
1129
|
-
// another MCP config path. Always end with `--` then the prompt.
|
|
1130
716
|
claudeArgs.push('--', fullPrompt);
|
|
1131
|
-
// 4. Honour the existing steer-callback contract — TUI mode can't accept
|
|
1132
|
-
// mid-turn additional input, but callers (bot.ts) always pass onSteerReady
|
|
1133
|
-
// and expect it to be invoked. Give them a no-op so the orchestration doesn't
|
|
1134
|
-
// hang waiting for the callback that never fires.
|
|
1135
717
|
try {
|
|
1136
718
|
opts.onSteerReady?.(async () => {
|
|
1137
719
|
agentWarn('[claude-tui] steer requested but TUI mode does not support mid-turn input — ignored');
|
|
@@ -1141,33 +723,13 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1141
723
|
catch (e) {
|
|
1142
724
|
agentWarn(`[claude-tui] onSteerReady callback raised: ${e?.message || e}`);
|
|
1143
725
|
}
|
|
1144
|
-
// 5. Set up parser state and ensure the bot side has the upfront session id.
|
|
1145
726
|
const s = createClaudeStreamState(opts);
|
|
1146
|
-
// Resume: lock in the native id we are resuming. Fork: keep a placeholder until
|
|
1147
|
-
// Claude reports its rotated id via the SessionStart hook. New session: leave
|
|
1148
|
-
// s.sessionId at its initial value (null for a pending session) so the emit
|
|
1149
|
-
// below detects a change and fires the pending→native promotion callback.
|
|
1150
727
|
if (isResume || isFork)
|
|
1151
728
|
s.sessionId = activeSessionId;
|
|
1152
|
-
// Seed the context window from whatever model is configured up front (e.g.
|
|
1153
|
-
// "haiku" / "opus" / "sonnet" via opts.claudeModel) so the dashboard's
|
|
1154
|
-
// context-percent chip + green-dot indicator can render starting from the
|
|
1155
|
-
// very first emit, before any assistant event has arrived to confirm the
|
|
1156
|
-
// model. Subsequent assistant events with concrete model ids will refresh
|
|
1157
|
-
// s.model + recompute the window via applyModelContextWindow.
|
|
1158
729
|
if (!s.model && (opts.claudeModel || opts.model)) {
|
|
1159
730
|
s.model = opts.claudeModel || opts.model;
|
|
1160
731
|
}
|
|
1161
732
|
applyModelContextWindow(s);
|
|
1162
|
-
// A brand-new session uses the id we generated and passed via --session-id, so
|
|
1163
|
-
// it is final the instant we spawn. Emit it now: s.sessionId is still unset
|
|
1164
|
-
// here, so emitSessionIdUpdate fires the onSessionId callback that promotes the
|
|
1165
|
-
// pending pikiloom record (and its in-memory runtime) to the native id. The
|
|
1166
|
-
// prior `s.sessionId = activeSessionId` made this a silent no-op (emit dedups on
|
|
1167
|
-
// `id === s.sessionId`), so the record stayed `pending_*` for the whole run —
|
|
1168
|
-
// and since mergeManagedAndNativeSessions drops pending records, the dashboard
|
|
1169
|
-
// never saw the in-flight session as running on (re)load. Fork waits for the
|
|
1170
|
-
// SessionStart hook to report Claude's rotated id; resume is already native.
|
|
1171
733
|
if (!isResume && !isFork)
|
|
1172
734
|
emitSessionIdUpdate(s, activeSessionId);
|
|
1173
735
|
let stderrCapture = '';
|
|
@@ -1203,11 +765,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1203
765
|
}
|
|
1204
766
|
}, after);
|
|
1205
767
|
};
|
|
1206
|
-
// Answer a confirm/select dialog: settle (Ink drops input on the dialog's first frames) → the
|
|
1207
|
-
// affirmative key → a split-out Enter (a combined "1\r" gets swallowed — only the digit lands)
|
|
1208
|
-
// → drop the answered frame from screenTail so re-detection only fires on a genuine repaint, not
|
|
1209
|
-
// the stale text we just answered. Shared by the in-flight onData path and the stall-chokepoint
|
|
1210
|
-
// retry so the keystroke discipline lives in one place.
|
|
1211
768
|
const sendConfirmAnswer = (key, settleMs, confirmDelayMs, onConfirmed) => {
|
|
1212
769
|
setTimeout(() => {
|
|
1213
770
|
if (processExited)
|
|
@@ -1228,11 +785,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1228
785
|
}, confirmDelayMs);
|
|
1229
786
|
}, settleMs);
|
|
1230
787
|
};
|
|
1231
|
-
// Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
|
|
1232
|
-
// informational (extra-usage credits kick in and the turn continues), so
|
|
1233
|
-
// killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
|
|
1234
|
-
// arbitrates later — at the stall watchdog and at result assembly — based
|
|
1235
|
-
// on whether the turn produced anything substantive after the banner.
|
|
1236
788
|
const noteTerminalLimitNotice = (notice) => {
|
|
1237
789
|
if (terminalLimitNotice)
|
|
1238
790
|
return;
|
|
@@ -1243,15 +795,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1243
795
|
s.activity = s.recentActivity.join('\n');
|
|
1244
796
|
emit();
|
|
1245
797
|
};
|
|
1246
|
-
// Selected-model-unavailable notice (404 model_not_found). Unlike the limit
|
|
1247
|
-
// banner this is terminal AND invisible to every structured signal: the TUI
|
|
1248
|
-
// paints it to the PTY screen, writes nothing to the JSONL, and fires no Stop
|
|
1249
|
-
// hook — so the turn would otherwise idle at the REPL until the 3–10 min stall
|
|
1250
|
-
// watchdog kills it with a misleading "CLI freeze" message. We surface the
|
|
1251
|
-
// real reason and end the turn now. The banner is still EVIDENCE, not a bare
|
|
1252
|
-
// verdict: a short settle confirms nothing substantive followed (cross-
|
|
1253
|
-
// validating the screen scrape) before we kill — never granting a lone text
|
|
1254
|
-
// match the authority to shoot a healthy turn.
|
|
1255
798
|
const noteTerminalModelError = (notice) => {
|
|
1256
799
|
if (terminalModelError)
|
|
1257
800
|
return;
|
|
@@ -1273,7 +816,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1273
816
|
killProc('SIGTERM');
|
|
1274
817
|
}, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS);
|
|
1275
818
|
};
|
|
1276
|
-
// Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
|
|
1277
819
|
const streamBuf = makeTuiStreamBuffer();
|
|
1278
820
|
const scheduleStreamTick = () => {
|
|
1279
821
|
if (streamBuf.timer)
|
|
@@ -1290,7 +832,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1290
832
|
streamBuf.displayedLen = next;
|
|
1291
833
|
s.text = streamBuf.trueText.slice(0, next);
|
|
1292
834
|
emit();
|
|
1293
|
-
// Keep ticking until we catch up — or until flushStream cancels us.
|
|
1294
835
|
if (streamBuf.displayedLen < streamBuf.trueText.length)
|
|
1295
836
|
scheduleStreamTick();
|
|
1296
837
|
}, TUI_STREAM_CHUNK_INTERVAL_MS);
|
|
@@ -1306,9 +847,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1306
847
|
emit();
|
|
1307
848
|
}
|
|
1308
849
|
};
|
|
1309
|
-
// 6. Spawn the TUI under PTY. (node-pty itself was already loaded at step
|
|
1310
|
-
// 0 — see the top of this function. By the time we reach this point the
|
|
1311
|
-
// module is guaranteed to be importable and `spawn-helper` is executable.)
|
|
1312
850
|
const spawnEnv = { TERM: 'xterm-256color' };
|
|
1313
851
|
for (const [k, v] of Object.entries(process.env)) {
|
|
1314
852
|
if (typeof v === 'string')
|
|
@@ -1318,25 +856,11 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1318
856
|
if (typeof v === 'string')
|
|
1319
857
|
spawnEnv[k] = v;
|
|
1320
858
|
}
|
|
1321
|
-
// Strip the session-context markers a parent claude process exports to its
|
|
1322
|
-
// subprocesses (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, …). Inherited e.g.
|
|
1323
|
-
// when an agent restarted the pikiloom daemon from inside a Claude Code
|
|
1324
|
-
// session, they flip this spawn into child-session mode — the transcript
|
|
1325
|
-
// JSONL (our only text source) is then never written locally and the turn
|
|
1326
|
-
// streams nothing. See CLAUDE_SESSION_CONTEXT_ENV_KEYS in claude.ts.
|
|
1327
859
|
scrubClaudeSessionContextEnv(spawnEnv);
|
|
1328
|
-
// Critical: leaving ANTHROPIC_API_KEY set would route TUI through API
|
|
1329
|
-
// billing too, defeating the whole point. Strip it unless the user
|
|
1330
|
-
// explicitly opts back in.
|
|
1331
860
|
if (process.env.PIKILOOM_CLAUDE_TUI_KEEP_API_KEY !== '1') {
|
|
1332
861
|
delete spawnEnv.ANTHROPIC_API_KEY;
|
|
1333
862
|
delete spawnEnv.ANTHROPIC_AUTH_TOKEN;
|
|
1334
863
|
}
|
|
1335
|
-
// Resolve `claude` to an absolute path. node-pty's `posix_spawnp` does not
|
|
1336
|
-
// reliably honour PATH on macOS when the lookup happens inside an embedded
|
|
1337
|
-
// libuv worker — passing the absolute path sidesteps cryptic
|
|
1338
|
-
// "posix_spawnp failed" errors. Falls back to the bare name (let
|
|
1339
|
-
// posix_spawnp try) when `which` can't resolve it.
|
|
1340
864
|
const claudeBin = whichSync('claude') || 'claude';
|
|
1341
865
|
agentLog(`[claude-tui] spawning ${claudeBin} TUI session=${activeSessionId} model=${model || '(default)'} prompt=${fullPrompt.length}ch resume=${isResume} fork=${isFork}`);
|
|
1342
866
|
try {
|
|
@@ -1349,10 +873,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1349
873
|
});
|
|
1350
874
|
}
|
|
1351
875
|
catch (e) {
|
|
1352
|
-
// Throw rather than return an error result — pty.spawn failures (PTY
|
|
1353
|
-
// allocation refused in sandboxed CI / Docker without /dev/ptmx, etc.)
|
|
1354
|
-
// mean TUI can't run at all, so the dispatcher should fall back to
|
|
1355
|
-
// print mode. Clean up the temp scaffolding before bailing.
|
|
1356
876
|
try {
|
|
1357
877
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
1358
878
|
}
|
|
@@ -1361,38 +881,14 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1361
881
|
}
|
|
1362
882
|
agentLog(`[claude-tui] pid=${proc.pid}`);
|
|
1363
883
|
const dbg = process.env.PIKILOOM_CLAUDE_TUI_DEBUG === '1';
|
|
1364
|
-
/** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
|
|
1365
884
|
let lastPtyDataAt = Date.now();
|
|
1366
|
-
// Startup-dialog auto-answer. Claude's TUI can paint a blocking "Bypass
|
|
1367
|
-
// Permissions mode" confirmation before it accepts our positional prompt
|
|
1368
|
-
// (default highlight = "No, exit"). We keep a bounded ANSI-stripped tail of
|
|
1369
|
-
// the screen, detect that dialog (see detectClaudeBypassPrompt), and select
|
|
1370
|
-
// "Yes, I accept" so the turn never stalls on a pre-prompt.
|
|
1371
885
|
const SCREEN_TAIL_MAX = 8192;
|
|
1372
886
|
const BYPASS_ACCEPT_MAX_ATTEMPTS = 3;
|
|
1373
|
-
// Settle delay after the dialog first paints before we send any key. Claude's
|
|
1374
|
-
// Ink select drops input aimed at it during the first frames — sending the
|
|
1375
|
-
// digit too early is a no-op. ~500ms is comfortably past readiness in repro.
|
|
1376
887
|
const BYPASS_SETTLE_MS = 500;
|
|
1377
|
-
// Gap between the selection key and the confirm Enter. Claude's Ink select
|
|
1378
|
-
// swallows a combined "2\r" (only the digit lands; the Enter is dropped before
|
|
1379
|
-
// the highlight repaints), so the two keystrokes must be split in time —
|
|
1380
|
-
// 600ms is what reproduces reliably against the live 2.1.168 dialog.
|
|
1381
888
|
const BYPASS_CONFIRM_DELAY_MS = 600;
|
|
1382
|
-
// How long after the last bypass-dialog repaint we still treat it as on
|
|
1383
|
-
// screen — suppresses the blind prompt-submit Enter nudge across the whole
|
|
1384
|
-
// select→confirm sequence so a stray CR can't land on "No, exit".
|
|
1385
889
|
const BYPASS_DIALOG_ACTIVE_WINDOW_MS = 2000;
|
|
1386
|
-
// Mid-turn permission-prompt auto-answer (see detectClaudeProceedPrompt).
|
|
1387
|
-
// Same Ink-select timing as the bypass dialog: settle past the frames where
|
|
1388
|
-
// input is dropped, send the digit, then a split-out Enter to confirm. Unlike
|
|
1389
|
-
// the bypass dialog (fires once at startup), `ask`-rule prompts recur — one
|
|
1390
|
-
// per gated command — so we re-arm after each answer instead of capping at a
|
|
1391
|
-
// few total attempts. PROCEED_ANSWER_MAX is only a runaway backstop.
|
|
1392
890
|
const PROCEED_SETTLE_MS = 500;
|
|
1393
891
|
const PROCEED_CONFIRM_DELAY_MS = 600;
|
|
1394
|
-
// After confirming, wait before re-arming so a stale dialog frame can't drive
|
|
1395
|
-
// a double "1\r"; a genuine next prompt always trails its command by longer.
|
|
1396
892
|
const PROCEED_REARM_MS = 1000;
|
|
1397
893
|
const PROCEED_ANSWER_MAX = 40;
|
|
1398
894
|
let screenTail = '';
|
|
@@ -1402,11 +898,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1402
898
|
let proceedAnswerCount = 0;
|
|
1403
899
|
let proceedPhase = 'idle';
|
|
1404
900
|
proc.onData((data) => {
|
|
1405
|
-
// We deliberately do not parse the TUI screen output. The JSONL is the
|
|
1406
|
-
// canonical source of structured events. Stash bytes only when debugging.
|
|
1407
|
-
// Raw byte arrival doubles as the cheapest liveness signal: a healthy TUI
|
|
1408
|
-
// repaints continuously mid-turn, so PTY silence = event loop dead — feeds
|
|
1409
|
-
// the stall watchdog's fast path (decideClaudeTuiStall.lastPtyDataAt).
|
|
1410
901
|
lastPtyDataAt = Date.now();
|
|
1411
902
|
if (dbg) {
|
|
1412
903
|
try {
|
|
@@ -1414,22 +905,12 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1414
905
|
}
|
|
1415
906
|
catch { }
|
|
1416
907
|
}
|
|
1417
|
-
// Auto-answer the bypass-permissions confirmation. Detect it the moment it
|
|
1418
|
-
// paints (off the raw PTY, not the 200ms poll tick) and arm a short timed
|
|
1419
|
-
// keystroke sequence. Keep a bounded stripped tail across chunks so a dialog
|
|
1420
|
-
// split across reads still matches.
|
|
1421
908
|
screenTail = (screenTail + stripAnsiEscapes(data)).slice(-SCREEN_TAIL_MAX);
|
|
1422
909
|
if (detectClaudeBypassPrompt(screenTail)) {
|
|
1423
910
|
bypassPromptLastSeenAt = Date.now();
|
|
1424
911
|
if (bypassPhase === 'idle' && bypassAcceptAttempts < BYPASS_ACCEPT_MAX_ATTEMPTS) {
|
|
1425
912
|
bypassAcceptAttempts++;
|
|
1426
913
|
bypassPhase = 'armed';
|
|
1427
|
-
// Three timed steps — verified 3/3 against the live 2.1.168 dialog:
|
|
1428
|
-
// settle (dialog ignores input on its first frames)
|
|
1429
|
-
// → "2" (jumps to the second option "Yes, I accept"; idempotent —
|
|
1430
|
-
// re-sending can't overshoot a 2-option menu onto "No, exit")
|
|
1431
|
-
// → Enter (confirms; must arrive *after* the highlight repaints — a
|
|
1432
|
-
// combined "2\r" gets swallowed, only the digit lands).
|
|
1433
914
|
agentLog(`[claude-tui] bypass-permissions prompt — auto-accepting "Yes, I accept" (attempt ${bypassAcceptAttempts}/${BYPASS_ACCEPT_MAX_ATTEMPTS})`);
|
|
1434
915
|
setTimeout(() => {
|
|
1435
916
|
if (processExited)
|
|
@@ -1447,13 +928,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1447
928
|
catch { }
|
|
1448
929
|
bypassPhase = 'confirmed';
|
|
1449
930
|
agentLog('[claude-tui] bypass-permissions — confirm Enter sent');
|
|
1450
|
-
// Drop the buffered dialog frame: the post-accept REPL output can be
|
|
1451
|
-
// tiny (e.g. a "Not logged in" line), so the old dialog text would
|
|
1452
|
-
// otherwise linger in the 8192-char tail and make the re-arm below
|
|
1453
|
-
// re-fire on a stale screen — typing "2"/Enter into the live prompt.
|
|
1454
|
-
// Clearing means the re-arm only sees output that arrives *after*
|
|
1455
|
-
// the confirm, so it re-fires only on a genuine repaint of the
|
|
1456
|
-
// dialog (accept didn't take), never on stale bytes.
|
|
1457
931
|
screenTail = '';
|
|
1458
932
|
setTimeout(() => {
|
|
1459
933
|
if (!processExited && detectClaudeBypassPrompt(screenTail))
|
|
@@ -1463,11 +937,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1463
937
|
}, BYPASS_SETTLE_MS);
|
|
1464
938
|
}
|
|
1465
939
|
}
|
|
1466
|
-
// Auto-answer a *mid-turn* permission confirmation. `else if` so the startup bypass dialog
|
|
1467
|
-
// (handled above) never falls through here. The affirmative key comes from the classifier, not
|
|
1468
|
-
// a hard-coded "1": confirm-prompt → "1. Yes", (y/n) → "y". (plan-approval carries no key under
|
|
1469
|
-
// the current policy, so it is not auto-answered here.) These recur (one per ask-gated command),
|
|
1470
|
-
// so we re-arm after each answer rather than cap total attempts.
|
|
1471
940
|
else {
|
|
1472
941
|
const screenInfo = classifyClaudeScreen(screenTail);
|
|
1473
942
|
if (screenInfo.state === 'confirm-prompt' && screenInfo.affirmativeKey
|
|
@@ -1483,13 +952,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1483
952
|
});
|
|
1484
953
|
}
|
|
1485
954
|
}
|
|
1486
|
-
// Capture stderr-ish bytes (TUI startup errors, "claude: command not
|
|
1487
|
-
// found"-style messages) for the final error payload when the run aborts
|
|
1488
|
-
// before any JSONL is written. Strip ANSI on the way in — otherwise the
|
|
1489
|
-
// raw PTY screen (cursor positions, SGR colours, column-aligned reply
|
|
1490
|
-
// rendering) leaks into IM as gibberish like "[3G你把 [8Gsnipe …" when a
|
|
1491
|
-
// user hits Stop before the JSONL has flushed any assistant text. Keep
|
|
1492
|
-
// the buffer bounded after stripping.
|
|
1493
955
|
if (stderrCapture.length < 4096) {
|
|
1494
956
|
stderrCapture += stripAnsiEscapes(data);
|
|
1495
957
|
if (stderrCapture.length > 4096)
|
|
@@ -1498,15 +960,10 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1498
960
|
if (notice)
|
|
1499
961
|
noteTerminalLimitNotice(notice);
|
|
1500
962
|
}
|
|
1501
|
-
// Selected-model-unavailable notice — see noteTerminalModelError. The TUI
|
|
1502
|
-
// only paints this to the screen (no JSONL, no Stop hook), so the live
|
|
1503
|
-
// screen tail is the sole signal. detectClaudeModelError is whitespace-
|
|
1504
|
-
// insensitive so it survives the TUI's char-by-char paint.
|
|
1505
963
|
if (!terminalModelError && detectClaudeModelError(screenTail)) {
|
|
1506
964
|
noteTerminalModelError(claudeModelErrorMessage(s.model || opts.claudeModel || null));
|
|
1507
965
|
}
|
|
1508
966
|
});
|
|
1509
|
-
// 7. Abort handling.
|
|
1510
967
|
const abortStream = () => {
|
|
1511
968
|
if (interrupted || processExited)
|
|
1512
969
|
return;
|
|
@@ -1518,7 +975,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1518
975
|
if (opts.abortSignal?.aborted)
|
|
1519
976
|
abortStream();
|
|
1520
977
|
opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
|
|
1521
|
-
// 8. Hard deadline timer.
|
|
1522
978
|
const hardTimer = setTimeout(() => {
|
|
1523
979
|
if (processExited)
|
|
1524
980
|
return;
|
|
@@ -1527,75 +983,32 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1527
983
|
agentWarn(`[claude-tui] hard deadline reached (${opts.timeout}s) pid=${proc.pid}`);
|
|
1528
984
|
killProc('SIGTERM');
|
|
1529
985
|
}, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
|
|
1530
|
-
// 9. Poll loop — hook state + JSONL tail.
|
|
1531
986
|
const POLL_INTERVAL_MS = 200;
|
|
1532
|
-
// After Stop hook fires we give the JSONL ~600ms to settle (matches the
|
|
1533
|
-
// print-mode driver's graceful-abort observation window) so the assistant's
|
|
1534
|
-
// final event lands before we SIGTERM.
|
|
1535
987
|
const POST_STOP_DRAIN_MS = 600;
|
|
1536
|
-
// Fallback Enter — most Claude versions auto-submit a positional prompt in
|
|
1537
|
-
// TUI mode, but if UserPromptSubmit hasn't fired by this deadline we type a
|
|
1538
|
-
// carriage return into the PTY in case the prompt is sitting on the input
|
|
1539
|
-
// line waiting for it.
|
|
1540
988
|
const PROMPT_SUBMIT_NUDGE_MS = 1500;
|
|
1541
|
-
// After the stall chokepoint fires its affirmative keystroke at a stuck dialog, give the dialog
|
|
1542
|
-
// this long to clear before deciding it's unanswerable. Generous enough to also cover the
|
|
1543
|
-
// in-flight onData answer cycle (settle + confirm + re-arm) so a chained next-prompt isn't
|
|
1544
|
-
// mistaken for a failed answer; short enough that we never re-wait the full 3-min quiet window.
|
|
1545
989
|
const CHOKEPOINT_ANSWER_GRACE_MS = 5000;
|
|
1546
990
|
let promptNudged = false;
|
|
1547
991
|
let pollHandle = null;
|
|
1548
992
|
let drainScheduled = false;
|
|
1549
|
-
// Wall-clock of the last parsed main-JSONL line. Feeds the stale-Stop
|
|
1550
|
-
// quiet-window check in decideClaudeTuiStop — sidecar / hook traffic is
|
|
1551
|
-
// deliberately excluded (only main-JSONL activity signals a model segment).
|
|
1552
993
|
let lastMainJsonlEventAt = start;
|
|
1553
|
-
// Last pending-background count we logged, so the waiting state logs on
|
|
1554
|
-
// transitions instead of every 200ms poll tick.
|
|
1555
994
|
let lastLoggedPendingBg = -1;
|
|
1556
|
-
// Stall-watchdog liveness signals. Together with lastMainJsonlEventAt they
|
|
1557
|
-
// answer "is the claude process still doing anything at all?" — see
|
|
1558
|
-
// decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
|
|
1559
995
|
let lastToolEventAt = start;
|
|
1560
996
|
let lastSidecarEventAt = 0;
|
|
1561
|
-
// Last non-synthetic assistant JSONL event — substantive-progress signal
|
|
1562
|
-
// for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
|
|
1563
|
-
// from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
|
|
1564
|
-
// last-prompt, …) that land right after submit and prove nothing.
|
|
1565
997
|
let lastAssistantEventAt = 0;
|
|
1566
998
|
let stallKilled = false;
|
|
1567
|
-
// Chokepoint answer-retry: when the watchdog is about to fire on a confirm dialog, send the
|
|
1568
|
-
// affirmative key ONCE against the now-stable (3-min-quiet, fully-painted) screen — the in-flight
|
|
1569
|
-
// onData answer only fires while bytes arrive, so a dialog that paints once then goes silent never
|
|
1570
|
-
// gets a second attempt (the diagnostics showed full-match prompts killed with answer-count 1).
|
|
1571
|
-
// `stallAnswerSentAt` arms a bounded grace check: if the dialog hasn't cleared by then, terminate
|
|
1572
|
-
// without auto-resuming (re-running would just re-paint the same dialog).
|
|
1573
999
|
let stallAnswerTried = false;
|
|
1574
1000
|
let stallAnswerSentAt = 0;
|
|
1575
|
-
// Stall diagnostics (capture-only) — see writeStallDiag.
|
|
1576
1001
|
let observedClaudeVersion = '';
|
|
1577
1002
|
let lastMainJsonlType = '';
|
|
1578
1003
|
let lastStallDiagHeartbeatAt = 0;
|
|
1579
1004
|
let stallDiagWentQuiet = false;
|
|
1580
1005
|
let stallDiagMaxQuietMs = 0;
|
|
1581
1006
|
let stallDiagPtyAliveWhileQuiet = false;
|
|
1582
|
-
/** Last state.stoppedAt for which pendingHookToolIds was reconciled. */
|
|
1583
1007
|
let lastClearedStopAt = 0;
|
|
1584
|
-
/** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
|
|
1585
1008
|
const pendingHookToolIds = new Set();
|
|
1586
|
-
/** Subset of pendingHookToolIds that are `im_ask_user` calls — the turn is
|
|
1587
|
-
* intentionally blocked on the user's reply (the /ask-user callback never
|
|
1588
|
-
* times out), so the stall watchdog must stay disarmed while any are in
|
|
1589
|
-
* flight. Cleared by the answer (PostToolUse) or the turn ending (Stop). */
|
|
1590
1009
|
const pendingAskUserToolIds = new Set();
|
|
1591
|
-
/** Edge-logged so the "watchdog disarmed" line fires on transitions only. */
|
|
1592
1010
|
let loggedAwaitingUser = false;
|
|
1593
|
-
// Incremental main-JSONL drain — the canonical text/thinking/usage feed.
|
|
1594
|
-
// Used by both the 200ms poll tick and the post-exit final drain. Returns
|
|
1595
|
-
// true when any line was consumed so callers can emit().
|
|
1596
1011
|
const drainMainJsonl = () => {
|
|
1597
|
-
// No existsSync guard: readJsonlIncrement returns no lines (offset unchanged)
|
|
1598
|
-
// for a missing file, so the guard was a redundant extra syscall every tick.
|
|
1599
1012
|
const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
|
|
1600
1013
|
jsonlReadOffset = inc.offset;
|
|
1601
1014
|
let touched = false;
|
|
@@ -1611,16 +1024,10 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1611
1024
|
catch {
|
|
1612
1025
|
continue;
|
|
1613
1026
|
}
|
|
1614
|
-
// Ignore sub-agent sidecar events — they belong to a child agent's
|
|
1615
|
-
// stream and would re-enter the parent's accumulator. claudeParse's
|
|
1616
|
-
// own sub-agent routing handles them.
|
|
1617
1027
|
const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
|
|
1618
1028
|
if (!isSubAgentEvent && ev.type === 'assistant') {
|
|
1619
1029
|
const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
|
|
1620
1030
|
if (notice) {
|
|
1621
|
-
// A synthetic limit banner is not substantive progress — skip the
|
|
1622
|
-
// liveness/type bookkeeping below so the limit arbitration and the
|
|
1623
|
-
// stall watchdog don't mistake it for a live model segment.
|
|
1624
1031
|
noteTerminalLimitNotice(notice);
|
|
1625
1032
|
touched = true;
|
|
1626
1033
|
continue;
|
|
@@ -1648,11 +1055,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1648
1055
|
}
|
|
1649
1056
|
return touched;
|
|
1650
1057
|
};
|
|
1651
|
-
// Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
|
|
1652
|
-
// tail it with the same incremental reader the JSONL transcript uses. Hook
|
|
1653
|
-
// events usually beat their JSONL counterpart by a second or so (and
|
|
1654
|
-
// PreToolUse fires before the tool even runs); whichever feed arrives first
|
|
1655
|
-
// wins, the other dedups via seenClaudeToolIds / seenClaudeToolResultIds.
|
|
1656
1058
|
let toolEventsReadOffset = 0;
|
|
1657
1059
|
const drainToolEvents = () => {
|
|
1658
1060
|
const inc = readJsonlIncrement(toolEventsPath, toolEventsReadOffset);
|
|
@@ -1669,17 +1071,11 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1669
1071
|
catch {
|
|
1670
1072
|
continue;
|
|
1671
1073
|
}
|
|
1672
|
-
// Stall-watchdog bookkeeping: any hook event is proof of life, and the
|
|
1673
|
-
// Pre/Post pairing tells the watchdog whether a tool is mid-execution
|
|
1674
|
-
// (which extends the stall threshold — long foreground commands are
|
|
1675
|
-
// legitimately silent).
|
|
1676
1074
|
lastToolEventAt = Date.now();
|
|
1677
1075
|
const hookToolId = typeof ev?.tool_use_id === 'string' ? ev.tool_use_id : '';
|
|
1678
1076
|
if (hookToolId) {
|
|
1679
1077
|
if (ev?.event === 'PreToolUse') {
|
|
1680
1078
|
pendingHookToolIds.add(hookToolId);
|
|
1681
|
-
// im_ask_user blocks the turn on the user — track it separately so the
|
|
1682
|
-
// stall watchdog can tell an intentional question-wait from a freeze.
|
|
1683
1079
|
if (isAskUserToolName(ev?.tool_name))
|
|
1684
1080
|
pendingAskUserToolIds.add(hookToolId);
|
|
1685
1081
|
}
|
|
@@ -1688,11 +1084,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1688
1084
|
pendingAskUserToolIds.delete(hookToolId);
|
|
1689
1085
|
}
|
|
1690
1086
|
}
|
|
1691
|
-
// A Task PreToolUse and the first sub-agent tool PreToolUse can land in
|
|
1692
|
-
// the same tick batch. If the sub-agent's hook arrives before we've
|
|
1693
|
-
// discovered its sidecar (and thus before s.subAgentIdToParent knows
|
|
1694
|
-
// its agent_id), refresh discovery so the hook resolves its parent on
|
|
1695
|
-
// this pass instead of leaking through unattributed.
|
|
1696
1087
|
const subAgentId = typeof ev?.agent_id === 'string' ? ev.agent_id : '';
|
|
1697
1088
|
if (subAgentId && !s.subAgentIdToParent?.has(subAgentId))
|
|
1698
1089
|
tryDiscoverSubAgents();
|
|
@@ -1734,25 +1125,15 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1734
1125
|
const parentToolUseId = typeof meta?.toolUseId === 'string' ? meta.toolUseId : '';
|
|
1735
1126
|
if (!parentToolUseId)
|
|
1736
1127
|
continue;
|
|
1737
|
-
// Only start tailing once the parent Task tool_use has been registered
|
|
1738
|
-
// in s.subAgents — otherwise routeClaudeSubAgentEvent silently drops
|
|
1739
|
-
// every event because it can't find the parent.
|
|
1740
1128
|
if (!s.subAgents.has(parentToolUseId))
|
|
1741
1129
|
continue;
|
|
1742
1130
|
const sidecarPath = path.join(sidecarDir, `${stem}.jsonl`);
|
|
1743
1131
|
trackedSubAgents.set(stem, { sidecarPath, offset: 0, parentToolUseId });
|
|
1744
|
-
// `stem` is "agent-<id>"; Claude Code's hook payload `agent_id` carries
|
|
1745
|
-
// just the raw id. Keep both keys so applyHookToolEvent can attribute
|
|
1746
|
-
// sub-agent tool hooks to the parent's Task tool_use no matter which
|
|
1747
|
-
// form arrives.
|
|
1748
1132
|
const rawAgentId = stem.startsWith('agent-') ? stem.slice('agent-'.length) : stem;
|
|
1749
1133
|
if (!s.subAgentIdToParent)
|
|
1750
1134
|
s.subAgentIdToParent = new Map();
|
|
1751
1135
|
s.subAgentIdToParent.set(rawAgentId, parentToolUseId);
|
|
1752
1136
|
s.subAgentIdToParent.set(stem, parentToolUseId);
|
|
1753
|
-
// <task-notification> events identify background tasks by this raw id
|
|
1754
|
-
// (and only sometimes carry <tool-use-id>) — keep the mapping so
|
|
1755
|
-
// applyClaudeTaskNotification can resolve them either way.
|
|
1756
1137
|
if (!s.bgTaskIdToToolUse)
|
|
1757
1138
|
s.bgTaskIdToToolUse = new Map();
|
|
1758
1139
|
s.bgTaskIdToToolUse.set(rawAgentId, parentToolUseId);
|
|
@@ -1775,8 +1156,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1775
1156
|
catch {
|
|
1776
1157
|
continue;
|
|
1777
1158
|
}
|
|
1778
|
-
// Inject parent_tool_use_id so claudeParse routes via routeClaudeSubAgentEvent
|
|
1779
|
-
// → updates sub.model + sub.tools on the existing s.subAgents entry.
|
|
1780
1159
|
const injected = { ...ev, parent_tool_use_id: tail.parentToolUseId };
|
|
1781
1160
|
try {
|
|
1782
1161
|
callClaudeParseForTui(injected, s);
|
|
@@ -1787,15 +1166,10 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1787
1166
|
any = true;
|
|
1788
1167
|
}
|
|
1789
1168
|
}
|
|
1790
|
-
// Stall-watchdog: live sub-agents count as turn progress even while the
|
|
1791
|
-
// parent thread is quietly waiting on them.
|
|
1792
1169
|
if (any)
|
|
1793
1170
|
lastSidecarEventAt = Date.now();
|
|
1794
1171
|
return any;
|
|
1795
1172
|
};
|
|
1796
|
-
// End a turn that is blocked on a confirm/select dialog the auto-answer could not clear. Unlike a
|
|
1797
|
-
// 'stalled' kill this does NOT auto-resume — re-running the prompt just re-paints the same dialog
|
|
1798
|
-
// (the loop the user kept seeing). The session is intact; the user re-sends to continue.
|
|
1799
1173
|
const terminatePromptUnanswered = (screenState, sample) => {
|
|
1800
1174
|
stallKilled = true;
|
|
1801
1175
|
const nowMs = Date.now();
|
|
@@ -1829,14 +1203,11 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1829
1203
|
}
|
|
1830
1204
|
return;
|
|
1831
1205
|
}
|
|
1832
|
-
// Hook state — pick up real session id / transcript path.
|
|
1833
1206
|
const state = readHookState(statePath);
|
|
1834
1207
|
if (state.sessionId && state.sessionId !== activeSessionId) {
|
|
1835
1208
|
const prevId = activeSessionId;
|
|
1836
1209
|
activeSessionId = state.sessionId;
|
|
1837
1210
|
activeJsonlPath = state.transcriptPath || path.join(projectDir, `${activeSessionId}.jsonl`);
|
|
1838
|
-
// For forks Claude rotates to a fresh UUID — start reading the new file
|
|
1839
|
-
// from offset 0 since we haven't read any of it yet.
|
|
1840
1211
|
if (!isResume)
|
|
1841
1212
|
jsonlReadOffset = 0;
|
|
1842
1213
|
emitSessionIdUpdate(s, activeSessionId);
|
|
@@ -1845,11 +1216,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1845
1216
|
else if (state.transcriptPath && state.transcriptPath !== activeJsonlPath) {
|
|
1846
1217
|
activeJsonlPath = state.transcriptPath;
|
|
1847
1218
|
}
|
|
1848
|
-
// Submit nudge — only if UserPromptSubmit hook hasn't fired yet. Suppress
|
|
1849
|
-
// it while the bypass-permissions dialog is (or was just) on screen: a blind
|
|
1850
|
-
// CR there lands on the default "No, exit" and kills the session. The dialog
|
|
1851
|
-
// auto-answer in onData drives that screen instead; once it clears the
|
|
1852
|
-
// prompt submits on its own (or this nudge fires on a later tick).
|
|
1853
1219
|
const bypassDialogActive = bypassPromptLastSeenAt > 0
|
|
1854
1220
|
&& Date.now() - bypassPromptLastSeenAt < BYPASS_DIALOG_ACTIVE_WINDOW_MS;
|
|
1855
1221
|
if (!promptNudged && !state.promptSubmittedAt && !bypassDialogActive
|
|
@@ -1861,52 +1227,23 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1861
1227
|
catch { }
|
|
1862
1228
|
agentLog(`[claude-tui] prompt-submit nudge sent (no UserPromptSubmit after ${PROMPT_SUBMIT_NUDGE_MS}ms)`);
|
|
1863
1229
|
}
|
|
1864
|
-
// JSONL tail.
|
|
1865
1230
|
if (drainMainJsonl()) {
|
|
1866
|
-
// Emit immediately so non-text changes (tool_use, plan, activity,
|
|
1867
|
-
// thinking, usage) reach the dashboard without waiting for the
|
|
1868
|
-
// chunked stream tick. The streaming timer separately advances
|
|
1869
|
-
// s.text from the buffer over the next few ticks.
|
|
1870
1231
|
emit();
|
|
1871
1232
|
scheduleStreamTick();
|
|
1872
1233
|
}
|
|
1873
|
-
// Live tool-events stream — fed by Pre/PostToolUse hooks. Hook and JSONL
|
|
1874
|
-
// feeds race per tool call; both record into seenClaudeToolIds /
|
|
1875
|
-
// seenClaudeToolResultIds so whichever lands first wins and the other
|
|
1876
|
-
// pass dedups naturally.
|
|
1877
1234
|
if (drainToolEvents())
|
|
1878
1235
|
emit();
|
|
1879
|
-
// Sub-agent sidecar discovery + pump. Order matters: discovery first so a
|
|
1880
|
-
// newly-spawned sub-agent gets registered for tailing this same tick if
|
|
1881
|
-
// its events have already been written. Skip the readdir + per-meta reads
|
|
1882
|
-
// until a Task tool_use is actually registered — discovery can't succeed
|
|
1883
|
-
// before then anyway (it requires the parent in s.subAgents), so the common
|
|
1884
|
-
// no-subagent turn would otherwise readdir the sidecar dir every 200ms for
|
|
1885
|
-
// nothing.
|
|
1886
1236
|
if (s.subAgents.size > 0)
|
|
1887
1237
|
tryDiscoverSubAgents();
|
|
1888
1238
|
if (pumpSubAgentSidecars())
|
|
1889
1239
|
emit();
|
|
1890
|
-
// Stop hook handling. A Stop is NOT automatically the end of the turn:
|
|
1891
|
-
// Claude fires it per response segment, including the segment that merely
|
|
1892
|
-
// *launched* run_in_background agents. Those agents run inside the claude
|
|
1893
|
-
// process — terminating here would destroy them (the "进程退出把子代理
|
|
1894
|
-
// 打断" incident). Hold the PTY open until every launched background agent
|
|
1895
|
-
// has reported its <task-notification> AND the latest Stop is fresher than
|
|
1896
|
-
// the latest notification (i.e. the model's wrap-up segment finished).
|
|
1897
1240
|
if (state.stoppedAt && !stopHookFired) {
|
|
1898
|
-
// A fired Stop means no foreground tool is genuinely mid-flight any
|
|
1899
|
-
// more. Surviving entries in pendingHookToolIds are lost PostToolUse
|
|
1900
|
-
// hook events (MCP flap / hook timeout ate them) — clearing here stops
|
|
1901
|
-
// them from silently pushing the stall watchdog onto the 30-minute
|
|
1902
|
-
// pending-tool threshold for the rest of the turn.
|
|
1903
1241
|
if (state.stoppedAt !== lastClearedStopAt) {
|
|
1904
1242
|
lastClearedStopAt = state.stoppedAt;
|
|
1905
1243
|
if (pendingHookToolIds.size) {
|
|
1906
1244
|
agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
|
|
1907
1245
|
pendingHookToolIds.clear();
|
|
1908
1246
|
}
|
|
1909
|
-
// A fired Stop ends the turn — any tracked ask-user is moot.
|
|
1910
1247
|
pendingAskUserToolIds.clear();
|
|
1911
1248
|
}
|
|
1912
1249
|
const pendingBg = pendingClaudeBackgroundAgentCount(s);
|
|
@@ -1916,9 +1253,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1916
1253
|
lastTaskNotificationAt: s.lastTaskNotificationAt || 0,
|
|
1917
1254
|
lastJsonlEventAt: lastMainJsonlEventAt,
|
|
1918
1255
|
lastHookOrSidecarEventAt: Math.max(lastToolEventAt, lastSidecarEventAt),
|
|
1919
|
-
// Background *Bash* is silent by nature (no sidecar/hook traffic while
|
|
1920
|
-
// it runs) — give it the long pending-tool budget; agent-only holds
|
|
1921
|
-
// keep the default TTL (live agents emit sidecar events constantly).
|
|
1922
1256
|
holdQuietTtlMs: pendingClaudeBackgroundBashCount(s) > 0
|
|
1923
1257
|
? CLAUDE_TUI_STALL_PENDING_TOOL_MS
|
|
1924
1258
|
: undefined,
|
|
@@ -1928,7 +1262,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1928
1262
|
stopHookFired = true;
|
|
1929
1263
|
stopHookSeenAt = Date.now();
|
|
1930
1264
|
if (pendingBg > 0) {
|
|
1931
|
-
// 幽灵 hold 释放:计数说还有后台 agent,但所有通道静默已超 TTL。
|
|
1932
1265
|
agentWarn(`[claude-tui] releasing phantom hold — ${pendingBg} background agent(s) still counted pending but every channel quiet past TTL; treating Stop as final`);
|
|
1933
1266
|
}
|
|
1934
1267
|
agentLog(`[claude-tui] Stop hook fired — draining JSONL for ${POST_STOP_DRAIN_MS}ms before SIGTERM`);
|
|
@@ -1945,34 +1278,12 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1945
1278
|
drainScheduled = true;
|
|
1946
1279
|
agentLog(`[claude-tui] drain complete, terminating TUI pid=${proc.pid}`);
|
|
1947
1280
|
killProc('SIGTERM');
|
|
1948
|
-
// Continue polling so any post-Stop JSONL writes still get parsed; the
|
|
1949
|
-
// process will exit shortly and onExit will resolve the wait.
|
|
1950
1281
|
}
|
|
1951
|
-
// Stall watchdog. claude CLI can freeze mid-turn (observed on 2.1.160):
|
|
1952
|
-
// a tool_result lands, then the next assistant segment never starts — the
|
|
1953
|
-
// process stays alive, every signal goes quiet, no Stop hook ever fires.
|
|
1954
|
-
// When ALL liveness signals have been silent past the threshold, declare
|
|
1955
|
-
// the turn stalled and SIGTERM; doClaudeWithRetry auto-resumes the session
|
|
1956
|
-
// once so the turn continues instead of spinning forever in the IM card.
|
|
1957
1282
|
if (!stopHookFired && !timedOut && !interrupted && !stallKilled) {
|
|
1958
1283
|
const lastProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.stoppedAt || 0, state.promptSubmittedAt || 0);
|
|
1959
|
-
// Pending background work (agents + bash) extends the stall budget the
|
|
1960
|
-
// same way a pending foreground tool does: a silent 15-minute background
|
|
1961
|
-
// build must not get shot by the 10-minute quiet threshold. The PTY
|
|
1962
|
-
// fast path still catches true process freezes within minutes.
|
|
1963
1284
|
const pendingBgForStall = pendingClaudeBackgroundAgentCount(s);
|
|
1964
|
-
// PTY fast path is for *mid-turn* freezes only. While the TUI idles in a
|
|
1965
|
-
// post-Stop background hold it legitimately paints nothing — a static
|
|
1966
|
-
// screen there is healthy, not frozen. Stop being the freshest signal is
|
|
1967
|
-
// exactly that hold state → disarm the fast path (0 = unavailable).
|
|
1968
1285
|
const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
|
|
1969
1286
|
const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
|
|
1970
|
-
// An im_ask_user call is in flight → the turn is blocked on the user's
|
|
1971
|
-
// reply, not frozen (the /ask-user callback never times out). Disarm the
|
|
1972
|
-
// stall watchdog AND the freeze diagnostics until the user answers
|
|
1973
|
-
// (PostToolUse clears the id). Otherwise the quiet wait trips
|
|
1974
|
-
// decideClaudeTuiStall, classifies 'unknown', and gets SIGTERMed +
|
|
1975
|
-
// mislabelled a "CLI freeze" — the last-question hang the user reported.
|
|
1976
1287
|
const awaitingUserReply = pendingAskUserToolIds.size > 0;
|
|
1977
1288
|
if (awaitingUserReply !== loggedAwaitingUser) {
|
|
1978
1289
|
loggedAwaitingUser = awaitingUserReply;
|
|
@@ -1980,11 +1291,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1980
1291
|
agentLog(`[claude-tui] im_ask_user in flight — stall watchdog disarmed until the user replies pid=${proc.pid}`);
|
|
1981
1292
|
}
|
|
1982
1293
|
}
|
|
1983
|
-
// Chokepoint answer-retry grace. We sent the affirmative key at a stuck dialog; give it time
|
|
1984
|
-
// to clear. If the screen is no longer a blocking dialog, the answer took — disarm and re-arm
|
|
1985
|
-
// so a later prompt in the same turn can also get a chokepoint retry. If it is STILL a dialog
|
|
1986
|
-
// (and the in-flight onData answer didn't clear it either), it is genuinely unanswerable —
|
|
1987
|
-
// end without auto-resume.
|
|
1988
1294
|
if (stallAnswerSentAt > 0 && Date.now() - stallAnswerSentAt > CHOKEPOINT_ANSWER_GRACE_MS) {
|
|
1989
1295
|
const after = classifyClaudeScreen(screenTail);
|
|
1990
1296
|
const stillBlocking = after.state === 'confirm-prompt'
|
|
@@ -1998,8 +1304,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
1998
1304
|
stallAnswerTried = false;
|
|
1999
1305
|
}
|
|
2000
1306
|
}
|
|
2001
|
-
// Stall diagnostics: sample the quiet lead-up so the watchdog can later be
|
|
2002
|
-
// tuned from data. Capture-only — changes no control flow.
|
|
2003
1307
|
if (!stallKilled) {
|
|
2004
1308
|
const nowMs = Date.now();
|
|
2005
1309
|
const quietMs = nowMs - lastProgressAt;
|
|
@@ -2008,15 +1312,10 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2008
1312
|
stallDiagWentQuiet = true;
|
|
2009
1313
|
if (quietMs > stallDiagMaxQuietMs)
|
|
2010
1314
|
stallDiagMaxQuietMs = quietMs;
|
|
2011
|
-
// PTY still painting while every structured signal is silent = the
|
|
2012
|
-
// frozen-stream-behind-a-live-spinner case that defeats the fast path.
|
|
2013
1315
|
if (ptyQuietMs < CLAUDE_TUI_STALL_PTY_DEAD_MS)
|
|
2014
1316
|
stallDiagPtyAliveWhileQuiet = true;
|
|
2015
1317
|
if (nowMs - lastStallDiagHeartbeatAt >= STALL_DIAG_HEARTBEAT_INTERVAL_MS) {
|
|
2016
1318
|
lastStallDiagHeartbeatAt = nowMs;
|
|
2017
|
-
// Snapshot the screen so a quiet stretch can later be classified as a frozen stream vs
|
|
2018
|
-
// a long think vs a blocking dialog vs an idle hold. Record the full screenState (not
|
|
2019
|
-
// just looksLikePrompt) so the lead-up to a kill is measurable as claude versions churn.
|
|
2020
1319
|
const screenInfo = classifyClaudeScreen(screenTail);
|
|
2021
1320
|
const looksLikePrompt = screenInfo.state === 'confirm-prompt'
|
|
2022
1321
|
|| screenInfo.state === 'plan-approval' || screenInfo.state === 'bypass-startup';
|
|
@@ -2053,8 +1352,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2053
1352
|
if (stallDecision === 'stall') {
|
|
2054
1353
|
const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
|
|
2055
1354
|
const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
|
|
2056
|
-
// The screen is ground truth. Classify it, then map to an action — the chokepoint the
|
|
2057
|
-
// diagnostics proved was missing (the verdict used to change only the error string).
|
|
2058
1355
|
const screen = classifyClaudeScreen(screenTail);
|
|
2059
1356
|
const action = decideStallAction({
|
|
2060
1357
|
state: screen.state,
|
|
@@ -2064,8 +1361,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2064
1361
|
});
|
|
2065
1362
|
const looksLikePrompt = screen.state === 'confirm-prompt'
|
|
2066
1363
|
|| screen.state === 'plan-approval' || screen.state === 'bypass-startup';
|
|
2067
|
-
// Diagnostics oracle: record the screen state + chosen action on every kill-point so the
|
|
2068
|
-
// false-positive rate stays measurable as claude versions churn.
|
|
2069
1364
|
const writeStallRecord = () => writeStallDiag({
|
|
2070
1365
|
kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
|
|
2071
1366
|
elapsedTurnMs: Date.now() - start, quietMs: Date.now() - lastProgressAt, ptyQuietMs: Date.now() - lastPtyDataAt,
|
|
@@ -2074,18 +1369,12 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2074
1369
|
looksLikePrompt, screenState: screen.state, action, screenSample: screen.sample,
|
|
2075
1370
|
});
|
|
2076
1371
|
if (action === 'answer-retry' && screen.affirmativeKey) {
|
|
2077
|
-
// #1 fix: the in-flight onData answer only fires while bytes arrive, so a dialog that
|
|
2078
|
-
// painted once then went byte-silent never got a retry (full-match prompts were killed
|
|
2079
|
-
// with answer-count 1). Send the affirmative key now against the stable, fully-painted
|
|
2080
|
-
// screen (no settle — quiet 3 min) and let the grace check decide the outcome. No kill.
|
|
2081
1372
|
stallAnswerTried = true;
|
|
2082
1373
|
stallAnswerSentAt = Date.now();
|
|
2083
1374
|
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}`);
|
|
2084
1375
|
sendConfirmAnswer(screen.affirmativeKey, 0, PROCEED_CONFIRM_DELAY_MS);
|
|
2085
1376
|
}
|
|
2086
1377
|
else if (action === 'terminate-clean') {
|
|
2087
|
-
// Idle REPL, nothing pending — the turn finished and we merely missed/held its Stop hook.
|
|
2088
|
-
// Hand off to the normal post-Stop drain: clean end, no 'stalled', no auto-resume.
|
|
2089
1378
|
writeStallRecord();
|
|
2090
1379
|
stopHookFired = true;
|
|
2091
1380
|
stopHookSeenAt = Date.now();
|
|
@@ -2096,8 +1385,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2096
1385
|
terminatePromptUnanswered(screen.state, screen.sample);
|
|
2097
1386
|
}
|
|
2098
1387
|
else if (action === 'model-error') {
|
|
2099
|
-
// Selected model unavailable (banner painted to screen only — no JSONL, no Stop hook).
|
|
2100
|
-
// Surface the real reason; 'model_error' is non-retryable so we never resume into it.
|
|
2101
1388
|
stallKilled = true;
|
|
2102
1389
|
if (!terminalModelError)
|
|
2103
1390
|
terminalModelError = claudeModelErrorMessage(s.model || opts.claudeModel || null);
|
|
@@ -2112,9 +1399,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2112
1399
|
killProc('SIGTERM');
|
|
2113
1400
|
}
|
|
2114
1401
|
else {
|
|
2115
|
-
// terminate-stalled: a genuine freeze candidate (state 'unknown') OR an idle hold with
|
|
2116
|
-
// pending background work we must not interrupt. Keep the self-healing SIGTERM-as-
|
|
2117
|
-
// 'stalled' path (auto-resumes once) with the existing model / limit arbitration.
|
|
2118
1402
|
stallKilled = true;
|
|
2119
1403
|
s.stopReason = 'stalled';
|
|
2120
1404
|
writeStallRecord();
|
|
@@ -2149,15 +1433,12 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2149
1433
|
emit();
|
|
2150
1434
|
killProc('SIGTERM');
|
|
2151
1435
|
}
|
|
2152
|
-
// Keep polling: onExit resolves the wait and the final drains pick up
|
|
2153
|
-
// whatever the dying process flushes.
|
|
2154
1436
|
}
|
|
2155
1437
|
}
|
|
2156
1438
|
}
|
|
2157
1439
|
pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
|
|
2158
1440
|
};
|
|
2159
1441
|
pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
|
|
2160
|
-
// 10. Wait for process exit.
|
|
2161
1442
|
await new Promise(resolve => {
|
|
2162
1443
|
proc.onExit(({ exitCode: code, signal }) => {
|
|
2163
1444
|
processExited = true;
|
|
@@ -2173,28 +1454,15 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2173
1454
|
});
|
|
2174
1455
|
});
|
|
2175
1456
|
opts.abortSignal?.removeEventListener('abort', abortStream);
|
|
2176
|
-
// 11. Final drain — pick up anything written between the last poll and
|
|
2177
|
-
// process exit. Claude flushes its remaining JSONL events on shutdown.
|
|
2178
1457
|
if (drainMainJsonl())
|
|
2179
1458
|
emit();
|
|
2180
|
-
// Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
|
|
2181
|
-
// between the last poll tick and process exit.
|
|
2182
1459
|
if (drainToolEvents())
|
|
2183
1460
|
emit();
|
|
2184
|
-
// Final sub-agent drain. The sub-agent's last events (closing tool_results)
|
|
2185
|
-
// may have landed after our last poll tick; mirror the main JSONL drain to
|
|
2186
|
-
// make sure sub.tools / sub.status carry the complete picture into the
|
|
2187
|
-
// final result.
|
|
2188
1461
|
if (s.subAgents.size > 0)
|
|
2189
1462
|
tryDiscoverSubAgents();
|
|
2190
1463
|
if (pumpSubAgentSidecars())
|
|
2191
1464
|
emit();
|
|
2192
|
-
// Process has exited and final drain is done — promote whatever is left in
|
|
2193
|
-
// the stream buffer into `s.text` so the final result message carries the
|
|
2194
|
-
// complete reply (not a truncated mid-stream prefix).
|
|
2195
1465
|
flushStream();
|
|
2196
|
-
// 12. Cleanup temp dir. Keep it around when debugging so users can inspect
|
|
2197
|
-
// the captured PTY bytes + state file.
|
|
2198
1466
|
if (!dbg) {
|
|
2199
1467
|
try {
|
|
2200
1468
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
@@ -2204,15 +1472,7 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2204
1472
|
else {
|
|
2205
1473
|
agentLog(`[claude-tui] debug artifacts retained in ${workDir}`);
|
|
2206
1474
|
}
|
|
2207
|
-
// 13. Build the StreamResult — mirror the shape and semantics of
|
|
2208
|
-
// doClaudeInteractiveStream so downstream consumers (finalizeStreamResult,
|
|
2209
|
-
// dashboard rendering) cannot tell the two paths apart.
|
|
2210
1475
|
const cleanStderr = stderrCapture.trim();
|
|
2211
|
-
// Detect Claude Code's synthetic "API Error: …" assistant reply (e.g.
|
|
2212
|
-
// 529 Overloaded). The text gets rewritten so the IM card doesn't surface
|
|
2213
|
-
// the raw "API Error: Overloaded" string to the user, and stopReason is
|
|
2214
|
-
// upgraded so the ClaudeDriver retry wrapper can decide to re-issue the
|
|
2215
|
-
// turn rather than letting the synthetic failure stick.
|
|
2216
1476
|
const apiErrorReason = detectClaudeApiError(s.text);
|
|
2217
1477
|
if (apiErrorReason) {
|
|
2218
1478
|
agentWarn(`[claude-tui] upstream API error detected: ${apiErrorReason}`);
|
|
@@ -2221,21 +1481,10 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2221
1481
|
if (!s.errors)
|
|
2222
1482
|
s.errors = [`Anthropic API error: ${apiErrorReason}`];
|
|
2223
1483
|
}
|
|
2224
|
-
// Model-unavailable arbitration: the TUI painted the "selected model is
|
|
2225
|
-
// unavailable" banner (noteTerminalModelError) and the turn produced nothing
|
|
2226
|
-
// — no JSONL, no Stop hook. The early settle-timer's SIGTERM (or any exit)
|
|
2227
|
-
// brought us here; surface the real reason instead of a bare "(no textual
|
|
2228
|
-
// response)". stopReason 'model_error' is non-retryable (doClaudeWithRetry
|
|
2229
|
-
// only auto-resumes 'stalled'), so we never loop on the same dead model.
|
|
2230
1484
|
if (!interrupted && !s.errors && terminalModelError && !s.text.trim()) {
|
|
2231
1485
|
s.stopReason = 'model_error';
|
|
2232
1486
|
s.errors = [terminalModelError];
|
|
2233
1487
|
}
|
|
2234
|
-
// Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
|
|
2235
|
-
// paths the stall watchdog never reaches: the TUI painted a limit banner,
|
|
2236
|
-
// then Stop fired on an empty turn or the process exited — nothing
|
|
2237
|
-
// substantive ever followed the banner, so the limit ate the turn. A banner
|
|
2238
|
-
// followed by real output stays informational (already in the activity log).
|
|
2239
1488
|
if (!interrupted && !timedOut && !s.errors) {
|
|
2240
1489
|
const limitOutcome = resolveClaudeTuiLimitOutcome({
|
|
2241
1490
|
noticeText: terminalLimitNotice,
|
|
@@ -2249,9 +1498,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2249
1498
|
}
|
|
2250
1499
|
}
|
|
2251
1500
|
const errorText = joinErrorMessages(s.errors);
|
|
2252
|
-
// "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
|
|
2253
|
-
// hook fired, which yields a non-zero exit), no errors from the parser, no
|
|
2254
|
-
// user abort, no timeout. SIGTERM-after-Stop is the normal happy path.
|
|
2255
1501
|
const exitedViaStopHook = stopHookFired && !timedOut && !interrupted;
|
|
2256
1502
|
const procOk = (exitCode === 0) || exitedViaStopHook;
|
|
2257
1503
|
const ok = procOk && !s.errors && !timedOut && !interrupted && stopHookFired;
|
|
@@ -2265,10 +1511,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2265
1511
|
const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
|
|
2266
1512
|
const elapsedS = (Date.now() - start) / 1000;
|
|
2267
1513
|
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}`);
|
|
2268
|
-
// Stall diagnostics: a turn that went quiet has now ended. Recording the
|
|
2269
|
-
// outcome separates benign long-thinking/long-tool (completed) from true
|
|
2270
|
-
// freezes the watchdog had to kill (stalled-killed) — the calibration the
|
|
2271
|
-
// threshold tuning needs.
|
|
2272
1514
|
if (stallDiagWentQuiet) {
|
|
2273
1515
|
writeStallDiag({
|
|
2274
1516
|
kind: 'resolved',
|
|
@@ -2288,15 +1530,6 @@ export async function doClaudeTuiStream(opts) {
|
|
|
2288
1530
|
ok,
|
|
2289
1531
|
});
|
|
2290
1532
|
}
|
|
2291
|
-
// Build the message body. Order:
|
|
2292
|
-
// 1. Any assistant text captured from JSONL (the canonical reply).
|
|
2293
|
-
// 2. Parser-surfaced errors.
|
|
2294
|
-
// 3. For interrupted runs with no text yet, a clear status — never the
|
|
2295
|
-
// raw PTY scrape (it would be a half-rendered TUI screen with no value
|
|
2296
|
-
// to the user, and pre-ANSI-strip used to render as garbled gibberish
|
|
2297
|
-
// in IM).
|
|
2298
|
-
// 4. Fall back to ANSI-stripped stderrCapture for genuine startup
|
|
2299
|
-
// failures like "claude: command not found".
|
|
2300
1533
|
const messageBody = s.text.trim()
|
|
2301
1534
|
|| errorText
|
|
2302
1535
|
|| (interrupted ? '(Interrupted before any reply landed.)'
|