Skip to content

feat(agent): inner-agent lifecycle + file_change NDJSON events#270

Merged
kelsonpw merged 4 commits intomainfrom
kelsonpw/agent-inner-lifecycle
Apr 26, 2026
Merged

feat(agent): inner-agent lifecycle + file_change NDJSON events#270
kelsonpw merged 4 commits intomainfrom
kelsonpw/agent-inner-lifecycle

Conversation

@kelsonpw
Copy link
Copy Markdown
Collaborator

@kelsonpw kelsonpw commented Apr 26, 2026

Replaces #256 (auto-closed in the chain cascade after #269 / #255 merged). Same scope, rebased onto current main. Conflicts in agent-events.ts and agent-ui.ts resolved by combining: kept the NeedsInputEvent type alias from main AND 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 run
  • tool_call — PreToolUse hook
  • file_change_planned / file_change_applied — paired around Edit/Write/MultiEdit/NotebookEdit
  • event_plan_proposed / event_plan_confirmed — bracketing the event-plan flow
  • verification_started / verification_result — bracketing verify

Why this is safe

Pure addition. New file src/lib/inner-lifecycle.ts with hook factories. Event emission is per-tool inside existing hooks; no behavior change to the run itself.

Test plan

🤖 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, and verification_started/verification_result.

Introduces createInnerLifecycleHooks in inner-lifecycle.ts to translate SessionStart/PreToolUse/PostToolUse hooks into these AgentUI emissions, including privacy-safe tool-input summarization and byte-count reporting for write operations.

Extends agent-events.ts with the new wire-data types plus helpers (summarizeForEvent, summarizeToolInput, classifyWriteOperation), updates AgentUI with matching emit* methods, and adds inner-lifecycle.test.ts coverage 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.

kelsonpw and others added 2 commits April 25, 2026 21:05
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>
@kelsonpw kelsonpw requested a review from a team April 26, 2026 04:07
@kelsonpw kelsonpw requested a review from a team as a code owner April 26, 2026 04:07
@github-actions
Copy link
Copy Markdown
Contributor

🧙 Wizard CI

Run 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:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci django
  • /wizard-ci fastapi
  • /wizard-ci flask
  • /wizard-ci javascript-node
  • /wizard-ci javascript-web
  • /wizard-ci next-js
  • /wizard-ci python
  • /wizard-ci react-router
  • /wizard-ci vue

Test an individual app:

  • /wizard-ci django/django3-saas
  • /wizard-ci fastapi/fastapi3-ai-saas
  • /wizard-ci flask/flask3-social-media
Show more apps
  • /wizard-ci javascript-node/express-todo
  • /wizard-ci javascript-node/fastify-blog
  • /wizard-ci javascript-node/hono-links
  • /wizard-ci javascript-node/koa-notes
  • /wizard-ci javascript-node/native-http-contacts
  • /wizard-ci javascript-web/saas-dashboard
  • /wizard-ci next-js/15-app-router-saas
  • /wizard-ci next-js/15-app-router-todo
  • /wizard-ci next-js/15-pages-router-saas
  • /wizard-ci next-js/15-pages-router-todo
  • /wizard-ci python/meeting-summarizer
  • /wizard-ci react-router/react-router-v7-project
  • /wizard-ci react-router/rrv7-starter
  • /wizard-ci react-router/saas-template
  • /wizard-ci react-router/shopper
  • /wizard-ci vue/movies

Results will be posted here when complete.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bytes from file-change event
    • Changed content && to content !== null && so that empty-string content correctly reports bytes: 0 instead of omitting the field.

Create PR

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.

Comment thread src/lib/inner-lifecycle.ts Outdated
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>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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).message with e instanceof Error ? e.message : String(e) to safely handle nullish or non-Error thrown values.

Create PR

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.

Comment thread src/lib/inner-lifecycle.ts Outdated
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.
@kelsonpw kelsonpw merged commit 31be2eb into main Apr 26, 2026
10 checks passed
@kelsonpw kelsonpw deleted the kelsonpw/agent-inner-lifecycle branch April 26, 2026 04:32
kelsonpw added a commit that referenced this pull request Apr 29, 2026
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>
kelsonpw added a commit that referenced this pull request Apr 29, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants