feat(agent): inner-agent lifecycle + file_change NDJSON events#270
feat(agent): inner-agent lifecycle + file_change NDJSON events#270
Conversation
Surface what the inner Claude SDK agent is doing so outer agents can mirror progress, attribute file changes to specific tools, and decide when to abort. Adds eight new event types to the agent-mode wire format: inner_agent_started — model + phase + planId at SessionStart tool_call — every PreToolUse, with privacy-safe summary file_change_planned — write-tool intent (path + create/modify/delete) file_change_applied — write-tool success, paired with planned event_plan_proposed — confirm_event_plan invocation event_plan_confirmed — decision + source (auto/human/flag) verification_started — pre-check phase boundary verification_result — pass/fail with structured failures Implementation is intentionally additive — no changes to agent-interface.ts to avoid conflicts with #243 (PreToolUse migration) and #149 (observability spine). The new `createInnerLifecycleHooks(config)` factory in src/lib/inner-lifecycle.ts returns hook callbacks shaped to merge into the existing `buildHooksConfig` call. Wiring is documented in the module header so a follow-up PR (or whoever lands #243) can compose the hooks in one ~5-line change. Helpers in agent-events.ts: - summarizeToolInput(name, input) — privacy-safe summary for PreToolUse payloads (file paths for Read/Edit/Write, command head for Bash, etc.) - classifyWriteOperation(name) — Write→create, Edit→modify, others→null - summarizeForEvent(s, max) — truncate-with-ellipsis for any string AgentUI emit methods are no-ops in TUI/CI mode — the helper checks `getUI() instanceof AgentUI` and short-circuits cleanly. Tests: +21 in inner-lifecycle.test.ts (1288 total). Suite green. Part of the wizard sub-agent design — Gap 2 of 4. Stacked on #255. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applied via @cursor push command
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Results will be posted here when complete. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Falsy empty-string drops
bytesfrom file-change event- Changed
content &&tocontent !== null &&so that empty-string content correctly reportsbytes: 0instead of omitting the field.
- Changed
Or push these changes by commenting:
@cursor push 4ba90a2001
Preview (4ba90a2001)
diff --git a/src/lib/inner-lifecycle.ts b/src/lib/inner-lifecycle.ts
--- a/src/lib/inner-lifecycle.ts
+++ b/src/lib/inner-lifecycle.ts
@@ -178,7 +178,7 @@
ui.emitFileChangeApplied({
path,
operation,
- ...(content && { bytes: Buffer.byteLength(content, 'utf8') }),
+ ...(content !== null && { bytes: Buffer.byteLength(content, 'utf8') }),
});
}
return Promise.resolve({});You can send follow-ups to the cloud agent here.
Bugbot caught: `content && { bytes: ... }` short-circuited on empty
string ('') because '' is falsy. A `Write` of an empty file would
produce a file_change_applied event without `bytes`, indistinguishable
from "byte count unknown".
Use `content !== null` so empty files report `bytes: 0`.
Co-Authored-By: Cursor Bugbot <bugbot@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Catch block crashes if non-Error value thrown
- Replaced
(e as Error).messagewithe instanceof Error ? e.message : String(e)to safely handle nullish or non-Error thrown values.
- Replaced
Or push these changes by commenting:
@cursor push bf8c150b17
Preview (bf8c150b17)
diff --git a/src/lib/inner-lifecycle.ts b/src/lib/inner-lifecycle.ts
--- a/src/lib/inner-lifecycle.ts
+++ b/src/lib/inner-lifecycle.ts
@@ -114,14 +114,14 @@
typeof input.tool_name === 'string'
? input.tool_name
: typeof input.toolName === 'string'
- ? input.toolName
- : 'unknown';
+ ? input.toolName
+ : 'unknown';
const toolInput =
typeof input.tool_input !== 'undefined'
? input.tool_input
: typeof input.toolInput !== 'undefined'
- ? input.toolInput
- : null;
+ ? input.toolInput
+ : null;
const summary = summarizeToolInput(toolName, toolInput);
ui.emitToolCall({ tool: toolName, summary });
@@ -137,8 +137,8 @@
typeof obj.file_path === 'string'
? obj.file_path
: typeof obj.path === 'string'
- ? obj.path
- : null;
+ ? obj.path
+ : null;
if (path) {
ui.emitFileChangePlanned({ path, operation });
}
@@ -153,16 +153,16 @@
typeof input.tool_name === 'string'
? input.tool_name
: typeof input.toolName === 'string'
- ? input.toolName
- : 'unknown';
+ ? input.toolName
+ : 'unknown';
const operation = classifyWriteOperation(toolName);
if (!operation) return Promise.resolve({});
const toolInput =
typeof input.tool_input !== 'undefined'
? input.tool_input
: typeof input.toolInput !== 'undefined'
- ? input.toolInput
- : null;
+ ? input.toolInput
+ : null;
const obj =
toolInput && typeof toolInput === 'object'
? (toolInput as Record<string, unknown>)
@@ -171,8 +171,8 @@
typeof obj.file_path === 'string'
? obj.file_path
: typeof obj.path === 'string'
- ? obj.path
- : null;
+ ? obj.path
+ : null;
if (path) {
const content = typeof obj.content === 'string' ? obj.content : null;
// Use `content !== null` not `content` — empty string `''` is falsy
@@ -214,7 +214,7 @@
ui.emitVerificationResult({
phase,
success: false,
- failures: [String((e as Error).message ?? e)],
+ failures: [e instanceof Error ? e.message : String(e)],
});
}
throw e;You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit dba49ff. Configure here.
Bugbot caught: `(e as Error).message` throws TypeError if `e` is null / undefined / a string (all valid throws in JS), which would swallow the original error and emit a confusing TypeError instead of the verification_result event. Use `e instanceof Error ? e.message : String(e)` so any throw produces a sensible failure message and the event still emits.
The inner Claude SDK agent writes files via Edit/Write/MultiEdit tool calls, but the TUI never showed users which files were being touched in real time. Users staring at the Setup screen for 30-90s with no file-level signal were the most common Ctrl+C cohort in the run logs. The `file_change_planned` / `file_change_applied` NDJSON events from PR #270 already stream this data in --agent mode. This PR routes them through the abstract WizardUI so InkUI can drive a TUI panel in parallel, with no schema change to the agent-mode envelope. Architecture: - New WizardUI methods recordFileChangePlanned / recordFileChangeApplied with implementations on InkUI (writes to nanostore atom), AgentUI (delegates to the existing emit* — NDJSON contract unchanged), and LoggingUI (single line per write in CI). - WizardStore gains a $fileWrites atom with FIFO eviction at MAX_FILE_WRITES=50, defensive collapse of duplicate planned events, and synthesis of orphan applied events (out-of-order hooks). - inner-lifecycle.ts stops branching on `instanceof AgentUI` for file_change events — they go through the abstract interface so all three UIs can subscribe. - New FileWritesPanel component renders below the Tasks list with per-row spinner / checkmark / cross, color by operation (CREATE green, MODIFY amber, DELETE red), bytes + duration on apply, and "generating…" with elapsed time on in-flight rows. Tests: 5 new in store.test.ts (planned-then-applied flow, orphan synthesis, dedup, FIFO eviction, multi-pass match-most-recent), 9 new in FileWritesPanel.test.tsx (hidden-when-empty, path relativization, op labels, generating/applied/failed states, header counter, maxVisible cap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(tui): real-time per-file write activity panel in RunScreen The inner Claude SDK agent writes files via Edit/Write/MultiEdit tool calls, but the TUI never showed users which files were being touched in real time. Users staring at the Setup screen for 30-90s with no file-level signal were the most common Ctrl+C cohort in the run logs. The `file_change_planned` / `file_change_applied` NDJSON events from PR #270 already stream this data in --agent mode. This PR routes them through the abstract WizardUI so InkUI can drive a TUI panel in parallel, with no schema change to the agent-mode envelope. Architecture: - New WizardUI methods recordFileChangePlanned / recordFileChangeApplied with implementations on InkUI (writes to nanostore atom), AgentUI (delegates to the existing emit* — NDJSON contract unchanged), and LoggingUI (single line per write in CI). - WizardStore gains a $fileWrites atom with FIFO eviction at MAX_FILE_WRITES=50, defensive collapse of duplicate planned events, and synthesis of orphan applied events (out-of-order hooks). - inner-lifecycle.ts stops branching on `instanceof AgentUI` for file_change events — they go through the abstract interface so all three UIs can subscribe. - New FileWritesPanel component renders below the Tasks list with per-row spinner / checkmark / cross, color by operation (CREATE green, MODIFY amber, DELETE red), bytes + duration on apply, and "generating…" with elapsed time on in-flight rows. Tests: 5 new in store.test.ts (planned-then-applied flow, orphan synthesis, dedup, FIFO eviction, multi-pass match-most-recent), 9 new in FileWritesPanel.test.tsx (hidden-when-empty, path relativization, op labels, generating/applied/failed states, header counter, maxVisible cap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tui): tighten installDir prefix match with directory boundary check A naive `startsWith(installDir)` check would falsely match sibling dirs that share a prefix — e.g. `/proj-backup/file.ts` against installDir `/proj` would render as `-backup/file.ts`. Require the next character after the prefix to be a path separator (or the path to equal the install dir exactly) so only true descendants get relativized. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


Replaces #256 (auto-closed in the chain cascade after #269 / #255 merged). Same scope, rebased onto current
main. Conflicts inagent-events.tsandagent-ui.tsresolved by combining: kept theNeedsInputEventtype alias frommainAND added the inner-agent lifecycle event types from this PR.Summary
Adds NDJSON events surfacing the inner Claude SDK agent's lifecycle so outer orchestrators (Claude Code, Cursor, Codex) can mirror progress, attribute file changes, and decide when to abort.
New events emitted on the same NDJSON stream:
inner_agent_started— at SessionStart of the inner Claude runtool_call— PreToolUse hookfile_change_planned/file_change_applied— paired around Edit/Write/MultiEdit/NotebookEditevent_plan_proposed/event_plan_confirmed— bracketing the event-plan flowverification_started/verification_result— bracketingverifyWhy this is safe
Pure addition. New file
src/lib/inner-lifecycle.tswith hook factories. Event emission is per-tool inside existing hooks; no behavior change to the run itself.Test plan
pnpm tsc --noEmit✅pnpm test— 1383 passed (was 1362 after feat(cli): plan / apply / verify subcommands with plan persistence #269) — +21 frominner-lifecycle.test.tspnpm lint✅ (1 pre-existing warning unrelated to this PR)mainpost-feat(cli): plan / apply / verify subcommands with plan persistence #269 (1 small conflict resolved by combining type imports)🤖 Generated with Claude Code
Note
Medium Risk
Adds new NDJSON event types and emission paths in
AgentUI(via new inner-agent hook helpers), which may affect downstream orchestrators that parse agent-mode stdout even though existing events are unchanged.Overview
Adds a new set of inner-agent lifecycle NDJSON events so outer orchestrators can observe the embedded Claude SDK agent in real time, including
inner_agent_started,tool_call,file_change_planned/file_change_applied,event_plan_proposed/event_plan_confirmed, andverification_started/verification_result.Introduces
createInnerLifecycleHooksininner-lifecycle.tsto translateSessionStart/PreToolUse/PostToolUsehooks into these AgentUI emissions, including privacy-safe tool-input summarization and byte-count reporting for write operations.Extends
agent-events.tswith the new wire-data types plus helpers (summarizeForEvent,summarizeToolInput,classifyWriteOperation), updatesAgentUIwith matchingemit*methods, and addsinner-lifecycle.test.tscoverage for the helpers and emitted NDJSON output.Reviewed by Cursor Bugbot for commit 86bbf53. Bugbot is set up for automated code reviews on this repo. Configure here.