* fix(canary): include all publishable packages, not just linked group
* fix(canary): avoid glob dependency in fallback changeset
* fix(canary): harden publishable package scan
---------
Co-authored-by: suraj-markup <suraj@composio.dev>
The published @aoagents/ao package shipped with no README, so its npmjs.com
page was blank. Add a focused, install-first README (absolute image/link URLs
so they render on npm) and package keywords for search discoverability.
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): break circular links in DegradedProjectState
Both "Back to project" and "Open dashboard view" linked to
projectDashboardPath(projectId) — the same page the component is
rendered on. Replace with working navigation:
- "Back to project" → "/" (global dashboard)
- "Open dashboard view" → "/projects/{id}/settings" (edit config)
Fixes#1867
* fix(web): break circular links in DegradedProjectState
Both "Back to project" and "Open dashboard view" linked to
projectDashboardPath(projectId) — the same page the component is
rendered on. Replace with working navigation:
- "Back to project" → "/" (global dashboard)
- "Open dashboard view" → "/projects/{id}/settings" (edit config)
Fixes#1867
* fix(web): remove circular 'Edit settings' link from DegradedProjectState
The 'Edit settings' link pointed to /projects/{id}/settings, which also
renders DegradedProjectState — making the button a self-link on the
settings page. Remove it entirely; 'Back to dashboard' (/) is the only
escape hatch needed.
Add DegradedProjectState component tests and assert both pages show no
'Edit settings' link and that 'Back to dashboard' points to /.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Aditi Chauhan <aditi1178@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards
When AO runs inside a remote dev container or behind a reverse proxy
(Caddy/nginx/Traefik), `http://localhost:${port}` was hardcoded across
the CLI for console output, `ao open` browser launches, and the
session URLs surfaced to the orchestrator agent. None of those URLs
were reachable from outside the host.
Add an `AO_PUBLIC_URL` env var. When set, the new `dashboardUrl(port)`
helper returns it (with trailing slashes stripped) instead of the
localhost fallback. The helper replaces every user-facing
`http://localhost:${port}` literal in:
- `commands/dashboard.ts` — startup banner + browser open
- `commands/start.ts` — 12 spots: spinner, "Dashboard:" prints,
orchestrator URL fallback, `openUrl()` calls, and the running-state
reuse paths
- `lib/routes.ts` — `projectSessionUrl()` (used in the orchestrator
prompt template, so worker links land on the public hostname)
Internal IPC (`lib/daemon.ts` calling its own dashboard's
`/api/projects/reload`) is intentionally left on localhost — that
traffic never leaves the host, and routing it through a public URL
would just add latency and a failure surface.
Tests cover the env-var/localhost paths, whitespace trimming,
trailing-slash stripping, sub-path preservation, and non-default-port
URLs (`__tests__/lib/dashboard-url.test.ts`, 10 cases).
Setup guide gets a new "Public dashboard URL" entry under optional
env vars.
* docs: cover TERMINAL_WS_PATH + path-based mux routing in AO_PUBLIC_URL setup
The AO_PUBLIC_URL entry only mentioned terminal ports needing to be
reachable, which over-specifies what's required when fronting AO with
HTTPS through a reverse proxy. The dashboard's MuxProvider already
auto-detects standard ports (`loc.port === ""`/`"443"`/`"80"`) and
routes the mux WebSocket through `/ao-terminal-mux` on the same
hostname, so a single proxy rule pointing at the dashboard port is
sufficient — no extra subdomain or port forwarding for the WS.
For non-standard ports or custom paths, document the existing but
previously-undiscoverable `TERMINAL_WS_PATH` env var (read by
`/api/runtime/terminal/route.ts` and threaded through `MuxProvider`
as `proxyWsPath`).
Adds a minimal Caddy snippet so users have a working starting point.
* feat(web): accept /ao-terminal-mux as alias for /mux on direct-terminal-ws
The dashboard's MuxProvider already constructs `wss://hostname/ao-terminal-mux`
when accessed on a standard HTTPS port (443), but until now nothing on the
server side recognized that path — direct-terminal-ws only matched `/mux`,
and the Next.js dashboard doesn't handle WS upgrades at all. Deployments
fronted by a path-routing reverse proxy (cloudflared, nginx, Caddy, …) hit
the server at `/ao-terminal-mux`, fall through to Next.js, get a 404, and
the dashboard's terminal panes hang at "Connecting…" forever.
Fix is one line in the upgrade-routing allow-list: accept `/ao-terminal-mux`
in addition to `/mux`. The proxy can now route the path-based mux URL straight
at DIRECT_TERMINAL_PORT without needing a path-rewrite rule (which most
proxies — including cloudflared — don't natively support).
Existing `/mux` clients continue to work; the alias is strictly additive.
SETUP.md's AO_PUBLIC_URL section is updated to mention the path requirement
in one sentence, and a new integration test pins the behavior.
* feat(web): opt-in single-port mode (AO_PATH_BASED_MUX) for proxy-only deployments
Default behavior unchanged. When AO_PATH_BASED_MUX=1, start-all spawns a
small bundled HTTP/WS proxy on PORT that demultiplexes:
- HTTP requests forwarded to Next.js (shifted to PORT + 1000;
override with NEXT_INTERNAL_PORT)
- `wss://hostname/ao-terminal-mux` upgrades tunneled to
DIRECT_TERMINAL_PORT/mux
Use it when the reverse proxy in front of AO can only forward one
hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at a single
`service:` URL with no path-based ingress, or a managed-app platform
where you don't control the proxy config). One proxy rule then
suffices — the WS path is multiplexed onto the same TCP port and
demuxed inside the AO process.
Tradeoff: one extra Node process and one extra hop per HTTP request,
in exchange for proxy-config simplicity. For deployments that *can*
do path-based routing the alias added in the previous commit
(direct-terminal-ws accepting `/ao-terminal-mux` on its own port) is
the lower-overhead path.
The new server is pure Node http; no `next` import or other extra
dependencies. It's strictly opt-in — the env-var gate keeps the code
inert by default, so existing deployments see no behavior change and
no extra startup cost.
* fix(web): correct single-port proxy header handling, WS hangs, shutdown
Addresses review feedback on single-port-server.ts:
- Strip hop-by-hop headers (RFC 9110 §7.6.1) before forwarding upstream,
including any extras named in the client Connection header. Previously
the whole header set was copied verbatim, so a client Connection: close
could tear down the keep-alive socket to Next.js.
- Add X-Forwarded-For/-Proto/-Host so the upstream sees the real client
instead of 127.0.0.1; existing values from an outer proxy are preserved.
- Handle non-101 upstream responses on the WS upgrade path. The proxy only
listened for 'upgrade', so a 404/502/mid-restart response left the client
socket hanging until TCP timeout. A 'response' handler now relays the
status and closes the connection.
- Call server.closeAllConnections() on shutdown. server.close() alone waits
for keep-alive HTTP sockets and piped WS tunnels to drain on their own,
which they never do, so shutdown always hit the 5s force-exit timer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(web): cover single-port proxy + fix response-direction headers
Follow-up to the previous commit's review fixes, adding regression
coverage so future changes can't silently break the proxy.
- Refactor single-port-server.ts into an exported createSinglePortServer()
factory (mirrors direct-terminal-ws.ts) with a thin isMainModule()
entrypoint, so start-all.ts still spawns it as a script while tests can
drive it in-process against fake upstreams.
- Add single-port-server.integration.test.ts (5 tests, no tmux/Next.js
needed — runs on CI/Windows): hop-by-hop strip + X-Forwarded-*, 502 on
dead upstream, /ao-terminal-mux WS tunnel, non-101 upgrade relay, and
prompt shutdown with a live WS connection.
- The shutdown test caught that server.closeAllConnections() does NOT
destroy sockets already handed off via the 'upgrade' event — track
upgraded sockets explicitly and destroy them in shutdown().
- The header test caught the symmetric response-direction leak: the proxy
forwarded the upstream's Connection/Keep-Alive to the client, overriding
a client that asked for Connection: close. Strip hop-by-hop from upstream
responses too via filterResponseHeaders().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): externalize ao-core and better-sqlite3 from Next.js bundle
Fixes#1930.
Next.js bundles @aoagents/ao-core (via transpilePackages) and bakes
import.meta.url as the build machine's absolute path into the output.
At runtime on any other machine (especially Windows), createRequire()
receives a non-existent file:// URL and fails.
Adding @aoagents/ao-core and better-sqlite3 to serverExternalPackages
tells Next.js to not bundle these — they're resolved at runtime from
node_modules, so import.meta.url resolves to the actual install path.
This fixes:
- events-db.ts: createRequire(import.meta.url)("better-sqlite3")
- update-cache.ts: createRequire(fileURLToPath(import.meta.url))
- The Windows ERR_INVALID_ARG_VALUE error in the user report
* fix(web): move ao-core from transpilePackages to serverExternalPackages
Next.js errors when a package is in both lists:
'transpilePackages' conflicts with 'serverExternalPackages'
Remove @aoagents/ao-core from transpilePackages and keep it only
in serverExternalPackages. This prevents webpack from bundling
ao-core and baking import.meta.url as the build machine's path.
* feat(core): allow source: "hook" in ActivityLogEntry (#1941)
First step of the activity-detection hook refactor: extend the AO
activity-JSONL schema so platform-event hooks (Claude Code's
PermissionRequest / Stop / StopFailure / Notification / ...) can write
entries with explicit provenance, distinct from terminal-derived
("terminal") and agent-native-JSONL ("native") writes.
No behaviour change yet — hook writers come in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-claude-code): add activity-updater hook scripts (#1941)
Adds bash + Node script source strings (`ACTIVITY_UPDATER_SCRIPT` /
`ACTIVITY_UPDATER_SCRIPT_NODE`) that translate Claude Code lifecycle
hooks into AO activity-JSONL entries with `source: "hook"`.
Event mapping is intentional and verified against the live Claude Code
hooks reference (code.claude.com/docs/en/hooks, not the older anthropic.com
URL the RFC referenced):
- SessionStart / Stop / SubagentStop → ready
- UserPromptSubmit / PreToolUse / PostToolUse /
PostToolUseFailure / PreCompact / PostCompact /
SubagentStart / PostToolBatch → active
- PermissionRequest → waiting_input
- Notification(permission_prompt | idle_prompt) → waiting_input
- Notification(auth_success | elicitation_*) → no-op (the RFC's
blanket "Notification → waiting_input" would false-fire here)
- StopFailure → blocked
- everything else (SessionEnd, TaskCreated, ...) → no-op
Event name comes from the stdin JSON payload's `hook_event_name`
field — the RFC's proposed `$CLAUDE_HOOK_EVENT_NAME` env var does not
exist in Claude Code. The script never blocks Claude (`exit 0` on every
path, including parse failures and disk-full).
Bash variant uses `node -p 'new Date().toISOString()'` for the timestamp
because BSD date doesn't support `%3N`. Node is a hard runtime dep of
Claude Code so this is always available.
Plugin wiring + regex-layer removal come in subsequent commits — this
commit only adds the scripts and their 52-test (bash × node) parity
suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-claude-code): register activity-updater on every relevant hook (#1941)
`setupWorkspaceHooks` now installs the activity-updater script alongside
the existing metadata-updater and registers it on every Claude Code
event that carries activity information:
SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
PostToolUseFailure, PostToolBatch, Notification, PermissionRequest,
Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact
The metadata-updater stays registered on PostToolUse(Bash) only — git/gh
side-effect detection is unrelated to activity classification, and
splitting them keeps each script tight.
Implementation notes:
- Hook registration is now a declarative table (`HookRegistration[]`)
fed through a shared `upsertHookEntry` helper. Calling
`setupWorkspaceHooks` twice updates our entries in place; any
user-installed Stop/PreToolUse/... hook is preserved alongside ours.
- Activity-updater hooks register with matcher "" — Claude Code's
empty-string matcher fires on every variant of the event (e.g. every
Notification regardless of `notification_type`). Variant filtering
happens inside the script.
- Timeout for activity-updater is 2000ms (vs metadata-updater's 5000ms)
— the script does a single JSON parse + append.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-claude-code): retire terminal-regex layer (#1941)
`classifyTerminalOutput` was the source of the 15-commit churn in #1932:
every Claude UI tweak (footer wording, status verb, spinner glyph) broke
a heuristic and needed a tightening pattern. With Claude lifecycle hooks
now writing authoritative state directly to `.ao/activity.jsonl`
(`source: "hook"`), the regex layer is structurally obsolete.
This commit removes the patterns and reduces `classifyTerminalOutput` to
a stable `return "idle"` stub:
- `recordActivity` is no longer implemented on the Claude agent — the
hooks ARE the activity producer. Lifecycle manager guards
`agent.recordActivity?` so this is a clean drop.
- `detectActivity` is kept on the Agent interface (still required by
Aider/OpenCode/Codex fallback) but on Claude is now a constant
"idle" — the lifecycle's terminal-output fallback path therefore
records a neutral signal and the JSONL cascade is the only source
of truth for active/ready/waiting_input/blocked.
- Native Claude JSONL handling and `NOISE_JSONL_TYPES` are unchanged —
those operate on Claude's own session files, not on terminal pixels.
Tests that exercised the retired heuristics (~200 LOC of regex-pattern
assertions in `detectActivity` + `recordActivity` integration tests) are
replaced with:
- One it.each guarding that every previously-classified input now
returns "idle" (locks in the no-signal contract).
- A direct assertion that `agent.recordActivity` is undefined.
- New tests that write hook-sourced JSONL entries
(`source: "hook"`, trigger like "PermissionRequest (Bash)") and
verify the cascade surfaces them correctly.
Net: −200 LOC of heuristic, ditto of tests, zero regression risk because
the cascade already accepted any `source` value as long as the entry
parsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(changeset): claude activity hooks (#1941)
ao-core: minor (extends ActivityLogEntry.source / ActivitySignalSource
with "hook" — new value, no consumer break).
ao-plugin-agent-claude-code: minor (new activity-updater hook scripts,
terminal-regex layer retired).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-claude-code): address copilot review on #1945 (#1941)
Four reviewer concerns, all valid:
1. `upsertHookEntry` assumed `hooks[event]` is always an array. A
malformed/legacy settings.json with a non-array value there would
crash on `.push`. Normalize via `Array.isArray(existing) ? existing : []`
and start fresh on bad input. New test: malformed object instead of
array round-trips cleanly with our entry added.
2. `upsertHookEntry` unconditionally overwrote `entry.matcher` on
updates. If a user has co-located their own hook def in the same
`{ matcher, hooks: [...] }` object as ours, resetting the matcher
changes when the user's def fires. Now only refresh matcher when
the entry contains a single hook def (ours). New test: user hook
sharing an entry with us keeps its `Edit|Write` matcher across
re-setup calls.
3. Claude agent's `detectActivity` comment claimed the lifecycle
manager would "override" its result via the JSONL cascade. Not
accurate — lifecycle calls `detectActivity` only when
`getActivityState` returned null, and that path doesn't write to
.ao/activity.jsonl. Reworded to describe the actual behaviour:
`detectActivity` is the no-signal fallback when there's no JSONL
and no hook entry yet, and "idle" is the conservative answer.
4. `classifyTerminalOutput`'s docstring promised that the Claude
agent's `detectActivity` would delegate to it "rather than inlining
`() => "idle"`", but `detectActivity` actually inlined `return "idle"`.
Restored the delegation so the rationale matches the code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(agent-claude-code): skip bash activity-updater suite on Windows (#1941)
Greptile review on #1945: `execSync('bash "..."')` throws ENOENT on
Windows because bash isn't a native shell there, and the catch block
leaves `lastEntry = null` — making all ~26 bash-variant cases fail on
Windows CI.
Skip the bash suite on Windows via `describe.skipIf(... isWindows())`
(matches the convention used in
packages/core/src/__tests__/migration-storage-v2.test.ts). The Node
variant suite runs on every platform and is the canonical Windows path
for the activity-updater anyway, so parity between the two
implementations is still verified on Linux/macOS CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-claude-code): escape control chars in bash trigger output (#1941)
i-trytoohard's PR review flagged that bash's escape_json only handles \
and " — not \n / \r / \t / \b / \f — creating asymmetry with the Node
variant where JSON.stringify covers everything. Bounded today by
Claude's event/tool/error-name enums never containing control chars,
but adds latent risk if a future trigger source isn't equally clean.
Five-line fix: extend escape_json with the five common JSON control-char
escapes so both implementations stay in lockstep against any future
trigger payload shape.
Locks the parity with a new round-trip test that smuggles
\n / \t / \r / \\ / " through error_type — confirms exactly one JSONL
line is written (no literal newline splitting one entry into two) and
the parsed trigger round-trips bit-for-bit on both bash and Node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-claude-code): drop useless \$ escape in bash heredoc (#1941)
Lint job on #1945 failed with five `no-useless-escape` errors after the
escape-control-chars fix. The five new lines in escape_json wrote
`\$'\\n'` inside the JS template literal, but `$` is only special in JS
template literals when followed by `{` — outside of interpolation it
needs no backslash. Bash output is byte-identical (still emits `$'\n'`
for ANSI-C quoting), so the 54 round-trip tests stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(canary): list all 27 linked packages in dummy changeset
Only @aoagents/ao was listed, so changeset version --snapshot only
bumped that one package. The other 26 stayed at their stable version
and changeset publish skipped them as already published.
* fix(canary): generate dummy changeset from config.json
Reads linked packages from .changeset/config.json at runtime
instead of hardcoding them. Avoids drift when packages are
added/removed. Also replaces 1000-char printf with readable
node script.
* fix(canary): use heredoc for node script to avoid shell quoting issues
The double-quoted node -e broke on the \" inside the JS string.
Heredoc avoids all shell quoting problems.
* fix(canary): escape newlines in JS heredoc strings
Single-quoted JS strings cannot span real newlines.
Use \n escape sequences instead of literal line breaks.
* fix(canary): outdent heredoc terminator to column 0
YAML `run: |` strips 10-space base indentation, so `SCRIPT`
at 12-space YAML indent lands at column 2 in the shell.
Bash `<<` requires the terminator at column 0.
Move to 10-space indent (= column 0 after YAML strip).
* fix(agent-claude-code): map bookkeeping JSONL types to ready, not active
Claude writes several types AFTER finishing a turn — `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title`. Until now they fell through to the `default` switch branch and looked `active` for 30s, making finished sessions appear busy. This was almost certainly the root cause of "Claude looks like it's still working when it's done" reports (the #1908 family).
Add explicit cases mapping each to `ready`/`idle` by age, same as `assistant`/`summary`. Three existing tests that asserted these returned `active` were testing the buggy behavior — updated to the correct behavior, plus four new tests covering `attachment`, `permission-mode`, and `ai-title`.
* fix(agent-claude-code): broaden process regex to match real install variants
`(?:^|\/)claude(?:\s|$)` rejected several legitimate Claude installs, causing AO to declare sessions `exited` while Claude was still running:
- `claude-code` (some Anthropic CLI variants)
- `claude.exe` / `claude.js` / `claude.cjs` (Windows / shim installs)
- `.claude` (dot-prefix shim)
- `node /opt/.../@anthropic-ai/claude-code/cli.js` (npm shim — path-contains-claude case)
New regex `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)` allows optional dot prefix, hyphen/dot suffix chains, and `/` terminator (for paths with claude as a path component). Still anchored at `/` or start-of-line so `claudia`/`claudine` etc. don't false-match.
Tests: 7-case `it.each` for each install shape returning true, dedicated rejection test for `claudia`/`claudine`. Old strict test (`does not match claude-code`) deleted — it was testing the bug.
* fix(agent-claude-code): warn on non-ENOENT failures reading ~/.claude/projects/
Previously `findLatestSessionFile` swallowed every readdir error silently, so a permission-denied or fd-exhausted misconfig on `~/.claude/projects/<slug>` would leave the session looking permanently `idle` on the dashboard with zero telemetry — debugging this took hours in the past (filed as one of the gotchas in #1927's description).
Now: ENOENT (dir hasn't been created yet) stays silent because that's normal during the early lifecycle. Every other errno (EACCES, EPERM, EMFILE, etc.) emits a single console.warn naming the dir and the errno, plus noting that activity will fall back to the AO JSONL only. Caller behavior unchanged (still returns null).
Tests: one for EACCES (via chmod 0o000) confirming warn fires with the errno, one for the missing-dir case confirming warn does NOT fire.
* fix(agent-claude-code): resolve symlinked workspace paths before slugifying
If AO records `session.workspacePath` as a symlink (e.g. `/Users/me/symlinks/repo`) and Claude resolves the target before computing its on-disk slug (e.g. `/Users/me/code/repo`), the two slugs diverge and `findLatestSessionFile` looks in an empty `~/.claude/projects/<wrong-slug>/` dir forever. Session looks permanently `idle` on the dashboard with no telemetry.
Add `resolveWorkspaceForClaude(workspacePath)` (try realpathSync, fall back to literal on error). Use it in all three sites that slugify a workspace path: `getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`. Re-exported from `index.ts` for downstream consumers.
Kept `toClaudeProjectPath` as a pure string transform — the realpath happens in the caller, so the slug function stays trivially testable.
New test confirms: write JSONL under the target's slug, call getActivityState with the symlink path, expect `ready` (was `null` before the fix).
Test-setup also needed `realpathSync(mkdtempSync(...))` because /var/folders is itself a symlink on macOS, otherwise existing tests would set up JSONL under one slug and the code would now look under another.
* fix(agent-claude-code): disambiguate multi-session via claudeSessionUuid
When two Claude sessions are running in the same workspace, `findLatestSessionFile` picked newest-mtime — which is the WRONG session's JSONL whenever its sibling has just written. The `getSessionInfo` method already captures `session.metadata.claudeSessionUuid` (the UUID Claude uses as its JSONL filename), but `getClaudeActivityState` was ignoring it.
`findLatestSessionFile` now accepts an optional `preferredUuid`. If provided, it checks `<projectDir>/<preferredUuid>.jsonl` first via `stat()` and returns that path on success. Falls back to newest-mtime when the UUID is missing or the named file doesn't exist yet (fresh session not yet introspected, or file rotated/removed).
`getClaudeActivityState` reads `session.metadata.claudeSessionUuid` (coerces unknown→string, trims, treats empty as undefined) and passes it through.
Tests:
- prefers UUID-named JSONL when set, ignoring newer sibling file
- falls back to newest-mtime when UUID-named file doesn't exist
* chore: changeset for claude-activity-edge-cases
* fix(agent-claude-code): use ES import for realpathSync in test setup
CI lint rejected the `require()` form. Move realpathSync to the top-level node:fs import.
* refactor(agent-claude-code): drop dead tool_use and result switch cases
Both types were inherited from the Agent-interface spec but never actually emitted by Claude (verified on disk for #1927). They now fall to the `default` branch.
Behavior change:
- `tool_use` → previously in user/progress branch → identical to default. No real-world effect.
- `result` → previously in assistant/summary branch (never active) → now default (can be active when fresh). No real-world effect because Claude doesn't emit `result`.
If Claude ever introduces these types and we want different semantics, we add explicit cases back. Until then, the dead switch entries were noise.
Tests:
- Removed `returns 'active' for recent 'tool_use' entry` (default branch does the same)
- Removed `returns 'ready' for recent 'result' entry` (was testing dead behavior)
- Added `unknown types fall through to default branch — fresh → active` to lock the default-branch semantics for any future Claude type addition.
* feat(agent-claude-code): detect blocked from terminal regex too
Until now `blocked` was only sourced from native JSONL (`{type:"system", subtype:"api_error", level:"error"}` from #1927). The terminal-regex pipeline (`detectActivity` → `recordTerminalActivity` → AO activity-JSONL → `checkActivityLogState`) only emitted `waiting_input`/`idle`/`active`, never `blocked`. So when Claude's native JSONL was unreadable (slug drift, fresh session pre-introspection, etc.) the blocked state was unreachable via the safety net.
Patterns observed empirically by capturing tmux output during a real api.anthropic.com block (api blocked via /etc/hosts, fresh `ao spawn` session, `tmux capture-pane` after retries started):
⎿ Unable to connect to API (ConnectionRefused)
Retrying in 19s · attempt 7/10
Add two matches to `classifyTerminalOutput`:
- /Unable to connect to API/i — primary error wording
- /Retrying in \\d+s.*attempt \\d+\\/\\d+/i — retry counter (fires even when error scrolled off)
Placed BEFORE the existing waiting_input checks because Claude's static UI footer contains `bypass permissions on (shift+tab to cycle)` which the existing `bypass.*permissions` regex matches — without this ordering, a real blocked state would lose to that incidental match.
Tests cover: real captured output, alternate error code (FailedToOpenSocket), retry counter alone, and the bypass-permissions-footer precedence case.
* fix(agent-claude-code): address review feedback on PR #1932
Three issues raised by @greptile-apps review:
1. console.warn flooded on every poll (P1). getClaudeActivityState runs on
a polling interval — without a dedupe, a single EACCES path would log
60+ lines/minute indefinitely. Added module-level Set<string> keyed by
projectDir; warn only on first miss per path for the process lifetime.
Exported resetWarnedReaddirPaths for test isolation.
2. realpathSync blocked the event loop in fully-async callers (P2).
Switched to async realpath from node:fs/promises. Updated the three
call sites (getClaudeActivityState, getSessionInfo, getRestoreCommand)
to await. resolveWorkspaceForClaude is now Promise<string>.
3. Test regex /failed to read.*EACCES|EPERM/ parsed as
(failed to read.*EACCES) | (EPERM) because | has lowest precedence.
Wrapped the alternation in a non-capturing group: (?:EACCES|EPERM).
Also added a new test confirming the dedupe works: three consecutive polls
against an EACCES path produce exactly one warn.
* fix(agent-claude-code): skip UI-noise JSONL types when reading last entry
Regression caused by the earlier bookkeeping-types fix on this branch (commit 39c2b1dd). That commit moved `permission-mode`, `ai-title`, `agent-color`, `agent-name`, `custom-title` into the explicit ready/idle bookkeeping case alongside `file-history-snapshot` etc. — but these five types are UI-state snapshots Claude writes at random times (session attach, permission-mode change, title regeneration). They are NOT correlated with real activity.
Empirical proof: ao-144 had 73 trailing `permission-mode` + 73 trailing `ai-title` entries written over 6 dormant days. Last real assistant message was 2026-05-13 12:57. With the earlier fix in place, the dashboard oscillated between `ready` (recent noise mtime) and `idle` (older noise mtime) instead of staying `idle`. User reported "I am not seeing active but ready for all session" — this is what they were seeing.
Fix: define `NOISE_JSONL_TYPES` containing the five UI-state types. When the literal last entry is one of these, skip the native-JSONL classification and fall through to the AO activity-JSONL pipeline (terminal-derived). If that's also empty, the existing `staleNativeState` fallback returns `idle` from `session.createdAt`, which is correct for dormant sessions.
For sessions that ARE actively working but happen to have a noise type as the latest line, the AO JSONL fallback's terminal scrape catches the active state.
Removed the five noise types from the explicit bookkeeping case in the switch. Kept `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `last-prompt` — these plausibly correlate with real activity (file edits, PR creation, prompt submission).
Tests: updated `permission-mode → ready` to `→ idle`, same for `ai-title`. Added explicit tests for the other three noise types and a test confirming the AO JSONL pipeline still wins when last native entry is noise but AO has actionable state.
* fix(agent-claude-code): tighten waiting_input regex to ignore static UI footer
The existing `/bypass.*permissions/i` regex matched Claude's persistent UI footer `⏵⏵ bypass permissions on (shift+tab to cycle)` — which is visible on EVERY active Claude session. As long as native JSONL classification returned a definitive answer, this didn't matter. The previous noise-skip commit on this branch (faf43042) made many more sessions fall through to the AO JSONL pipeline, which exposed the bug: ao-143/144/151 all flipped to waiting_input even though they were genuinely dormant.
Tightened the regex to require "all future" — matching the actual permission-bypass prompt ("bypass all future permissions for this session") rather than the footer toggle ("bypass permissions on"). The other two waiting_input regexes (`Do you want to proceed?`, `(Y)es...(N)o`) are unchanged.
Verified: dashboard's stored AO activity-JSONL entries had `state: "waiting_input"` with trigger snippets containing the persistent footer text. With this fix, classifyTerminalOutput on the exact same terminal capture returns active instead.
Tests: existing `bypass all future permissions for this session` test still passes (the prompt phrase contains the new tighter pattern). Added a regression test confirming the static footer alone does NOT match waiting_input.
* fix(agent-claude-code): treat pr-link as re-snapshot noise too
Empirical evidence from production: ao-160's JSONL had the same PR (#1911) written as a `pr-link` entry 33 times in the last 200 lines, alternating with permission-mode/ai-title in the same trio pattern. Internal timestamps showed re-emissions ~1 minute apart (15:24:40, 15:26:27, 15:27:42 — same PR, three writes within ~3 minutes).
User-visible bug: ao-139 (last real work 11h ago) and ao-160 (last real work over a day ago) were both showing as `ready` on the dashboard because the file mtime kept moving forward via these pr-link re-snapshots, and pr-link was in the explicit ready/idle bookkeeping case.
Fix: move `pr-link` from the bookkeeping case into NOISE_JSONL_TYPES so the cascade skips it and falls through to the AO JSONL pipeline (and ultimately to the stale-native idle fallback for genuinely dormant sessions). Real PR creation is still observable via the assistant message that asked for it and via the gh-tracker.
Verified: 200/200 plugin tests pass. The existing pr-link test updated to expect idle (matches new behavior); added comment with the empirical evidence.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Root cause of dashboard showing ready for dormant sessions ao-143/144/151/154/160/139: classifyTerminalOutput defaulted to "active" for any output that didn't match a specific idle/blocked/waiting_input pattern. Claude's persistent UI footer + empty input area always look the same between "just finished" and "currently working", and the existing lastLine idle-check missed dormant sessions because the LAST line is the static footer, not the ❯ prompt.
So every poll cycle, recordTerminalActivity wrote "active" entries to AO activity-JSONL for dormant sessions. The cascade's age-decayed fallback then surfaced those as ready (30s–5min decay) forever. Pr-link being treated as turn-end bookkeeping in 4e0e64ab made things even worse (sessions oscillated between ready and idle).
Fix — invert the default:
- classifyTerminalOutput now defaults to "idle" and only returns "active"
when an explicit active-work indicator is present.
- Strongest active signal: gerund-form status word followed by trailing
ellipsis "…". Claude rotates many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.) and
status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
Thinking, Reasoning…), but the gerund+ellipsis combo is consistent.
Past-tense lines like "✻ Worked for 11s" or "✻ Crunched for 11s" lack
the ellipsis and stay idle.
- Removed reliance on:
- bare "esc to interrupt" (in static footer, not just active)
- "Working" without ellipsis (false-matches "working on issue #N")
- "⎿" line prefix (also appears in past tool-result content)
- Word-based fallbacks kept for specific cases: Reading file / Writing to
/ Searching codebase / Press up to edit queued / Germinating /
Crunched for Ns / Thinking…|Working…
- Widened tail window from 5 to 12 lines because Claude's spinner line
sits 6-8 lines above the bottom (above input area + footer).
Verified live on tmux captures from 6 dormant sessions (ao-143/144/151/154/160/139) and 1 active session (ao-161): correct classification for all 7. Previous code returned "active" for all 6 dormant ones; new code returns "idle".
Tests: 203/203 pass. Updated the "non-empty output with no patterns" test to expect idle (matches new behavior — was testing the bug). Added dormant-pane and active-pane regression tests using real captured strings.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Followup to f1fc21bc: also drop the "/\bCrunched\s+for\s+\d+\s*s/i" pattern. It's past-tense like "Worked for Ns" — Claude shows "✻ Crunched for 22s" as a turn-complete summary, not an active indicator. ao-154's terminal had exactly this line, making every poll write "active" to AO activity-JSONL.
After this commit the only past-tense traps left would also lack the gerund+ellipsis signal, so the gerund+ellipsis check above handles all real active spinners. The remaining word-based fallbacks (Germinating, Thinking…/Working…, Reading file, Writing to, Searching codebase, Press up to edit queued) all stay because they're either gerund-form or specific enough to be unambiguous.
Live-verified on tmux captures from all 7 sessions:
- ao-161 (active turn, "Fluttering…") → active ✅
- ao-143 ("Worked for 11s") → idle ✅
- ao-154 ("Crunched for 22s") → idle ✅ (was returning active before this commit)
- ao-144/151/160/139 (no active indicators in pane) → idle ✅
Tests: 203/203.
* fix(agent-claude-code): bound blocked detection to wideTail, not full buffer
Per @greptile-apps P1 review on PR #1932: `Unable to connect to API` / `Retrying in Ns · attempt N/M` were being matched against the entire `terminalOutput` rather than the last-12-lines `wideTail`. If Claude hit an api_error, retried successfully, and then generated enough subsequent output to push the error off the visible window but not out of full scrollback, every subsequent poll would re-detect "blocked" forever.
Move the `wideTail = lines.slice(-12).join("\n")` definition earlier in the function and use it for the two blocked patterns too. All existing blocked test fixtures are ≤12 lines so they pass unchanged.
Added a regression test that simulates the recovered-and-continued scenario: error text early in the input, then 15+ lines of subsequent work, then an active spinner at the bottom. Old code returned "blocked" (saw the error in the full buffer); new code returns "active" (wideTail only sees the spinner).
* refactor(agent-claude-code): split activity detection into its own module; remove dead JSONL cases
Move `getActivityState`, `findClaudeProcess`, the ps-cache, `classifyTerminalOutput`, `findLatestSessionFile`, and `toClaudeProjectPath` into a new `activity-detection.ts`. `index.ts` shrinks by ~190 lines and now delegates via thin wrappers; `toClaudeProjectPath` and `resetPsCache` are re-exported so existing unit-test and integration-test import sites keep working.
Drop two switch branches that could never fire: `case "permission_request"` → `waiting_input` and `case "error"` → `blocked`. Verified by reading every JSONL under `~/.claude/projects/`: Claude emits `agent-color, agent-name, ai-title, assistant, attachment, custom-title, file-history-snapshot, last-prompt, permission-mode, pr-link, progress, queue-operation, summary, system, user` — but never a top-level `permission_request` or `error`. Permission prompts have no native-JSONL signal at all (they only sit in the terminal until the user answers); API errors arrive under `type:"system"` with `subtype:"api_error"` / `level:"error"`, not as a top-level `error` type. `waiting_input` and `blocked` continue to flow through the terminal-regex → AO activity-JSONL path that was added upstream — that path is now the only source, which matches reality.
Behavior preserved: the cascade (process check → native JSONL → AO actionable → AO age-decay fallback → stale-native) is byte-equivalent for every type Claude actually emits. Four tests that wrote fake `permission_request` / `error` JSONL deleted.
* feat(agent-claude-code,core): detect blocked from Claude api_error JSONL entries
Extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields (additive — existing return shape unchanged). Map `{type:"system", level:"error"}` to `blocked` in `getClaudeActivityState`.
Claude writes API errors as `{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}, retryAttempt:N, maxRetries:10}`. When the LAST JSONL entry has that shape, Claude is mid-retry-loop or has exhausted retries — surfacing as `blocked` lets stuck-detection fire. Other `system` subtypes (`compact_boundary`, `local_command`, `turn_duration`, `away_summary`, etc.) continue to map to `ready`/`idle` by age via the `entry.lastLevel !== "error"` branch.
Closes the only remaining "no live producer" gap from the PR description: previously `blocked` had a slot in the cascade but no writer. Now native JSONL fills it directly, no AO activity-log roundtrip needed.
Tests:
- "blocked for 'system' api_error (level: error)"
- "ready for non-error 'system' subtypes (compact_boundary)"
- "'system' api_error ignores staleness (always blocked)"
Existing `system` test (no level field) continues to map to `ready` — verified.
* fix(agent-claude-code): require api_error subtype AND error level for blocked
Tighten the system-entry gate per @greptile-apps review on #1927. Today only `api_error` carries `level:"error"` under `type:"system"`, but pinning both fields makes intent explicit and prevents silent drift if Claude ever adds a non-fatal error-level diagnostic later.
Test locks the tightened gate: `{type:"system", subtype:"future_diagnostic", level:"error"}` → `ready` (not `blocked`).
* test(core): cover lastSubtype/lastLevel extraction in readLastJsonlEntry
Per reviewer note on #1927: the consumer side (claude-code plugin) tested the new fields, but the producer side in core didn't. Lock the extraction behavior so future refactors of `readLastJsonlEntry` can't regress silently.
Covers: real Claude api_error shape, absent fields, non-string field types.
* fix(web): make dashboard body scroll as one unit (closes#1923)
PR #1925 added internal scroll on .done-bar__cards, but the
desired UX is page-level scroll — kanban above, Done/Terminated
below, scrolling as one unit (the pre-#927 behavior). Internal
scroll on the cards container produced a separate scrollable
region inside an otherwise locked layout.
Root cause: af2af115 (#927, Warm Terminal design refresh) added
`flex: 1; min-height: 0` to .kanban-board-wrap. That made the
wrap greedily consume all remaining body height, so body content
always equalled body height exactly — `.dashboard-main__body
{ overflow-y: auto }` never triggered.
Drop .kanban-board-wrap from the combined flex:1 rule. It now
takes its content size (= the inner .kanban-board's fixed
`calc(100vh - 240px)`). When done-bar expands below, body
content exceeds body height and the existing overflow-y: auto
produces natural page scroll.
.board-wrapper (empty state + loading skeleton) keeps flex:1 —
it relies on filling space to vertically center its CTA.
Also revert the now-unneeded .done-bar__cards max-height +
overflow-y + scrollbar styling from #1925.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): make <main> a flex column so body's overflow-y actually scrolls
The body-level scroll has been broken since af2af115 (#927). The
JSX is <main className="dashboard-main flex-1 ...">, but neither
.dashboard-main nor any Tailwind class on <main> sets
display: flex. So .dashboard-main__body's `flex: 1` and
`overflow-y: auto` never functioned — body simply took content
height, never had overflow to scroll, and <main>'s overflow:
hidden clipped it.
Pre-regression markup was a <div> with `overflow-y-auto`
directly on it (the dashboard container itself scrolled). The
af2af115 refactor moved overflow to a nested body and assumed
main was a flex column — but never declared it.
Add `flex flex-col` to <main> in both Dashboard.tsx and the
projects/[projectId]/loading.tsx skeleton. Body's flex:1 now
correctly fills <main>'s height, overflow-y: auto activates
when body content exceeds, and the kanban-board-wrap fix from
f7f843cd lets that overflow actually happen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): bound project page wrapper + lock kanban/done from flex-shrink
The real clipper was .dashboard-shell--desktop (height 900px,
overflow: hidden), not anything inside Dashboard. The project
page wrapper (<div className="flex-1 min-h-screen ...">) was a
non-flex block with min-height: 100vh, so .dashboard-main--
desktop grew to its content height (~1411px) instead of being
bounded by the shell's 900px. As a result .dashboard-main__body
had overflow-y: auto but also grew to content height (~1308px),
so it never overflowed and no scrollbar appeared anywhere.
Verified in devtools: with this fix .dashboard-main__body
clientHeight is 797 and scrollHeight is 1308 — body becomes
the single vertical scroll container, kanban above and Done
section below, scrolling as one unit.
Changes:
1. /projects/[projectId]/page.tsx wrapper: 'flex-1 min-h-screen'
-> 'flex min-h-0 min-w-0 flex-1'. Makes the wrapper a
bounded flex item so .dashboard-main--desktop inherits a
constrained height from the shell row instead of growing
past it.
2. globals.css: flex-shrink: 0 on .kanban-board-wrap and
.done-bar. Prevents body's flex column from crushing the
kanban to make room for the expanded done section. Both
children now assert their content size; body content
exceeds body height; overflow-y: auto activates.
Root cause credit to harshitsinghbhandari — found via devtools
after my three prior attempts (#1925 internal scroll on cards,
plus the two prior commits on this branch) all addressed the
wrong layer of the layout tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): add flex-col to project page wrapper + regression test
Follow-up to 110a4225 — the wrapper was 'flex min-h-0 min-w-0
flex-1 ...' but missing flex-col. Because Dashboard renders
multiple siblings when inside ProjectLayoutClient (UpdateBanner,
ConnectionBar, mainPanel, bottomSheet), any visible
UpdateBanner becomes a horizontal flex sibling next to the
dashboard shell instead of stacking above it. ConnectionBar is
fixed-positioned, bottomSheet is overlay — but UpdateBanner is
in normal flow and exposes the row-vs-column bug whenever it's
visible.
Add flex-col so siblings stack vertically.
Also add a regression test in page.test.tsx that the Dashboard
parent has the bounded-flex classes (flex, min-h-0, min-w-0,
flex-1) and no longer has min-h-screen, so future refactors
can't silently re-break the dashboard's scroll container.
Credit to harshitsinghbhandari for catching the missing flex-col
and writing the test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): wrap loading skeleton in same bounded flex container
Addresses greptile P1 review feedback on PR #1929. The project
page (page.tsx) wraps <Dashboard> in <div className="flex min-h-0
min-w-0 flex-1 flex-col bg-..."> so .dashboard-main--desktop
inherits the project shell's 900px constraint instead of growing
past it. The loading skeleton (loading.tsx) renders
.dashboard-main--desktop directly as the root, landing in the
same layout slot WITHOUT that constraint — so during a slow load
the skeleton would still overflow the shell and reproduce the
original clip behavior.
Wrap the skeleton in the same outer bounded flex container as
page.tsx for layout parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): make Done/Terminated dashboard section scrollable (#1923)
The collapsible Done/Terminated cards grid had no max-height or
overflow handling, so it grew unbounded and was clipped by the
ancestor's overflow:hidden once enough terminated sessions
accumulated. The regression came from the Warm Terminal design
refresh (#927) which added `flex: 1` to `.kanban-board-wrap`,
defeating the body-level `overflow-y: auto` that previously
allowed natural page scroll past the done section.
Mirror the existing `.kanban-column-body` pattern by giving the
cards grid its own internal scroll container and matching thin
scrollbar styling. Each scrolling region owns its own scroll;
the Warm Terminal flex layout is preserved.
* style(web): document done-bar__cards max-height magic number
Addresses greptile P2 review feedback on PR #1925. The 320px in
calc(100vh - 320px) is the assumed chrome above the cards
(dashboard header + subhead + body padding + done-bar toggle and
margins). Adding a comment makes the implicit ancestor-height
contract explicit, and points to the kanban-board rule that
uses the same viewport-anchored pattern.
* fix(web): show Restore button for every exited session, including pr_merged
The Restore button was hidden for sessions exited with `pr_merged` reason
(legacy status `cleanup`) on the dashboard kanban and absent altogether
from the session-detail "Terminal ended" panel. The core `isRestorable()`
helper already allowed restoring these sessions; the dashboard helpers
were out of sync, compensating for a non-existent core constraint.
- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES`
only, matching core's `isRestorable`. Merged-but-running sessions
(runtime still alive) remain non-restorable.
- `DoneCard` no longer hides Restore for merged sessions.
- `SessionEndedSummary` now exposes a prominent `Restore session` button
alongside `Open PR` / `Back to dashboard`, so users don't have to find
the small icon button in the header.
Closes#1907
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): add :focus-visible styles to ended-summary action buttons
Address PR #1909 review feedback. The new Restore session button (and
existing Open PR / Back to dashboard pills) lacked a visible focus ring,
making keyboard navigation invisible. Apply the same accent outline used
elsewhere in the dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1654)
Adds "cli" to ActivityEventSource and emits ~30 activity events across the CLI
surface so `ao events list --source cli` can answer RCA questions like:
- Did AO start cleanly? When? (cli.start_invoked / cli.start_failed)
- Was AO shut down gracefully or did it crash? (cli.shutdown_signal /
cli.shutdown_completed / cli.shutdown_force_exit / cli.stale_running_pruned)
- Did ao spawn / ao update / ao stop / ao migrate-storage fail and why?
- Did the auto-restore prompt actually restore sessions?
Instrumented files:
- packages/core/src/activity-events.ts (cli source)
- packages/cli/src/lib/shutdown.ts (signal/completed/failed/force_exit/session_kill_failed)
- packages/cli/src/commands/start.ts (start_invoked/start_failed/restore_*/stop_*/daemon_*/last_stop_* /config_migrated)
- packages/cli/src/commands/spawn.ts (spawn_invoked/spawn_failed)
- packages/cli/src/commands/update.ts (update_invoked/update_failed)
- packages/cli/src/commands/setup.ts (setup_failed)
- packages/cli/src/commands/migrate-storage.ts (migration_completed/migration_failed)
- packages/cli/src/commands/project.ts (project_register_failed)
- packages/cli/src/lib/resolve-project.ts (project_resolve_failed/config_recovered/config_recovery_failed)
- packages/cli/src/lib/running-state.ts (lock_timeout/stale_running_pruned)
- packages/cli/src/lib/credential-resolver.ts (credential_load_failed)
All emits are sync, never wrapped in try/catch (recordActivityEvent never
throws), and put raw error text in data.errorMessage (not summary, which is
FTS-indexed and not credential-sanitized).
Tests:
- 16 new instrumentation tests across shutdown, migrate-storage, update,
resolve-project, and start/stop action paths covering MUST emits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: correct CLI activity event semantics
* fix(cli): emit migrate-storage invocation event
* fix(cli): record last-stop write failures
* fix(linear): retry transient API failures
* fix(cli): stabilize update instrumentation test
* fix(cli): stub process probes in stop instrumentation tests
* chore(ci): retrigger checks
* Fix CLI failure event review issues
* fix: bound linear integration cleanup
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
Co-authored-by: Adil Shaikh <106678504+whoisasx@users.noreply.github.com>
* feat(core): round out session-manager activity event instrumentation (#1657)
PR #1620 wired activity events for the worker-spawn happy path
(spawn_started/spawned/spawn_failed/killed). This extends the same
pattern across the rest of session-manager's failure surfaces so RCA
can answer questions today's logs can't:
- Did the orchestrator spawn fail? (orchestrator path had no AE
envelope at all; now wrapped like worker spawn)
- Did the agent's startup prompt deliver, or did all 3 retries fail?
- Did `runtime.destroy` / `workspace.destroy` succeed during kill, or
silently leak?
- Did `runtime_lost` reconciliation fire for this session?
- Did `ao send` actually deliver?
New event kinds (added to ActivityEventKind union):
session.kill_started, session.prompt_delivery_failed, session.send_failed,
session.restore_failed, session.restore_fallback, session.rollback_started,
session.rollback_step_failed, session.workspace_hooks_failed,
session.cleanup_error, session.orchestrator_conflict,
runtime.lost_detected, runtime.lost_persist_failed, runtime.destroy_failed,
workspace.destroy_failed, agent.opencode_purge_failed,
tracker.issue_fetch_failed, tracker.generate_prompt_failed,
metadata.corrupt_detected
Coverage (per #1657 spec):
- All 8 MUST emits implemented and have a regression test in
session-manager-instrumentation.test.ts
- 13 SHOULD emits implemented (cleanup loop, orchestrator subpaths,
kill silent catches, send/restore failure paths, etc.)
- 4 COULD emits implemented
- Out-of-scope per-list enrichment paths intentionally NOT instrumented
Invariants preserved (extends #1620's B1-B16):
- B1: state mutation BEFORE event emission (runtime_lost persist
happens before the AE fires; verified by test)
- B2: never wrap recordActivityEvent in try/catch
- B11: prompt content excluded from prompt_delivery_failed data
(verified by test that asserts the prompt string is absent)
- B16: failure-only — emit on final retry exhaustion only, not per
attempt
- B25 (new): cleanup-stack rollbacks emit per-step via the onError
callback hook, not in aggregate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address session activity event review
* fix: address session activity event review
* fix(core): avoid duplicate restore failure events
* chore(ci): retrigger checks
* Fix orchestrator activity event instrumentation
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* chore(npm): suppress prebuild-install deprecation warning
The prebuild-install package is no longer maintained, but it's a transitive
dependency of better-sqlite3 (optional) and node-pty (optional). Both packages
continue to use it reliably for downloading prebuilt binaries across platforms.
Suppressing this warning avoids noise in npm install output while we await
upstream changes. See https://github.com/ComposioHQ/agent-orchestrator/issues/1752
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(cli): warn users when spawning in permissionless mode
When agentConfig.permissions is unset, the schema defaults to
"permissionless", which passes --dangerously-skip-permissions to Claude
Code. Claude then shows a one-time confirmation prompt inside the tmux
session; if dismissed, the session exits silently with no user-facing
error — making it very hard to debug.
Add a post-spawn warning in ao spawn output explaining the mode and
how to either accept the prompt or switch to interactive mode. Also
improve the config-instruction docs to describe each permissions value
and the silent-exit risk.
Fixes#1754
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(cli): direct users to dashboard terminal for permissionless prompt
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* removed unwanted changes
* fix(cli): fall back to local config when global config is missing in project supervisor
There are two separate things reading config:
- Dashboard (Next.js web server): loadDashboardConfig() — tries global config, falls back to local.
- Project supervisor (CLI process): loadConfig(getGlobalConfigPath()) only — no fallback.
When a user runs ao start with a local agent-orchestrator.yaml that has never been
registered in ~/.agent-orchestrator/config.yaml, the supervisor silently returned
with 0 lifecycle workers, leaving sessions frozen in their last known state and
PR status never updating.
loadSupervisorConfig() mirrors the dashboard fallback: try global config first,
fall back to loadConfig() (local discovery) on ENOENT. ConfigNotFoundError (no
config anywhere) is handled by isMissingGlobalConfigError so the supervisor still
exits cleanly when AO has never been configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): address review feedback and fix failing supervisor tests
Review feedback from greptile:
- Remove unreachable ConfigNotFoundError branch from loadSupervisorConfig's
catch — loadConfig(globalConfigPath) cannot throw it with a non-null path.
The error still propagates from the fallback loadConfig() (no args) and is
caught by isMissingConfigError at the outer scope.
- Rename isMissingGlobalConfigError → isMissingConfigError. After extending it
to catch ConfigNotFoundError (the "no config anywhere" case), the old name
was misleading.
Test fix (CI was failing):
- Add ConfigNotFoundError to the vi.mock factory so tests that exercise the
fallback path compile.
- Add coverage for the new fallback paths:
- ENOENT on global config → falls back to loadConfig() (local discovery)
- Non-missing-config errors (e.g. invalid yaml) propagate up
- No config anywhere → supervisor exits cleanly, no workers attached
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(cli): drop unused config-instruction module
The getConfigInstruction() helper and its config-help subcommand were the
module's only consumers, so remove both. The schema URL referenced via
CONFIG_SCHEMA_URL still lives in core and is reachable from the yaml's
$schema field for editor-side completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(cli): tighten loadSupervisorConfig comment
Replace the rationale paragraph with a one-line description of what the
function does. The "why" lives in the PR description and commit history.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): scope loadSupervisorConfig ENOENT guard to the global config path
Review feedback from greptile: the ENOENT catch was broader than the matching
guard in isMissingConfigError. If the global config exists but references a
missing nested file, the supervisor would silently fall back to the local
config instead of surfacing the configuration error.
Mirror the path check from isMissingConfigError so only ENOENT for the global
config path itself triggers the fallback. Add a test covering the nested-file
case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: restore trailing newline in .npmrc
Review feedback from greptile. The trailing newline was accidentally dropped
by an earlier "removed unwanted changes" commit on this branch and is
unrelated to the supervisor fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Revert "chore(cli): drop unused config-instruction module"
This reverts commit cb58e15d02.
* revert the unwanted comments
* Addressing comments to make the resolved path as a fallback for supervisor
* test(cli): assert local fallback configPath propagates distinct from global
Reviewer nit: the ENOENT-fallback test was returning makeConfig with the
default `/tmp/global-config.yaml` path, so the assertion on
ensureLifecycleWorker could pass even if the supervisor accidentally
propagated the global path. Use a distinct local path in the fallback
fixture so the regression coverage is honest.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
* feat(core): record activity events for config, plugin-registry, and storage migration
Closes#1658.
Surfaces failures that previously left no trace in `ao events list`:
- Config: project_resolve_failed (degraded project), project_malformed,
project_invalid, migrated. Local-config errors carry the project's
projectId so `ao events list --project <id>` finds them.
- Plugin registry: load_failed (built-in and external), validation_failed,
specifier_failed. External plugin failures are now queryable instead of
living only in stderr.
- Migration: blocked (active sessions), project_failed (per-project),
rename_failed (.migrated rename), completed (one summary with totals),
rollback_skipped (post-migration sessions present).
- Agent report: api.agent_report.transition_rejected and apply_failed for
observability into rejected reports.
Migration emits at most one event per milestone (per project failure +
single completion summary) so event volume scales with errors, not input
size. Config events redact sensitive keys via the existing sanitizer.
Adds regression tests for all four MUST emits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(core): avoid duplicate config activity events
* test(core): cover blocked storage migration event
* test(core): satisfy lint in migration event mock
* fix(core): emit migration completed for no-op runs
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* feat(web): activity events for webhooks and mux WebSocket (#1656)
Closes#1656 — adds the 10 activity events called out in the issue, covering
webhook ingress (4) and the mux WebSocket terminal server (6). Builds on the
ActivityEvent infrastructure landed in #1620.
Webhook events (api source):
- api.webhook_unverified (warn) — 401 signature verification failure
- api.webhook_rejected (warn) — 413 payload exceeds maxBodyBytes
- api.webhook_received (info|warn) — 202 success, with parse/lifecycle error counts
- api.webhook_failed (error) — 500 outer catch / pipeline crash
Mux WS events (ui source — Node-side server only):
- ui.terminal_connected — one per mux WS connection
- ui.terminal_disconnected — one per close
- ui.terminal_heartbeat_lost (warn) — once on 3 missed pongs (was console-only)
- ui.terminal_pty_lost (warn) — fires only when subscribers are still attached,
distinguishing "PTY actually died" from "user closed browser"
- ui.terminal_protocol_error (warn) — invalid mux client message
- ui.session_broadcast_failed (warn) — emitted on the healthy→failing transition
only; re-arms after a successful poll so a long outage yields one event, not 20/min
Invariants honored: no raw payloads or signatures in `data`, no client IPs in
`summary` (kept in `data` only), no per-keystroke / per-pong fan-out — only on
state transitions. `api.webhook_unverified` is the security-audit event; data
captures `slug` and `remoteAddr` but never the failed signature.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): harden webhook and PTY activity events
* chore(ci): retrigger checks
* fix(web): record PTY loss across mux exit paths
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* feat(web): record activity events for API mutation routes
Wire recordActivityEvent calls into all POST/PATCH/DELETE handlers under
packages/web/src/app/api/ so RCA can answer "did the user click X?" beyond
just success/failure status codes. Source: "api" (added in #1620).
Coverage:
- session mutations: spawn, kill, send, message, restore, remap (with
failure variants for SessionNotRestorable/WorkspaceMissing/etc.)
- orchestrator + PR: orchestrator spawn, PR merge (with rejected/failed)
- project + config: add, update, remove, repair, reload
- issue + verify + labels: issue create, verify, label setup
Sanitization rules preserved:
- never include request/response bodies
- *_message_* events include messageLength only, never the message text
- project_updated records changedKeys, never values (config can carry tokens)
Tests: regression coverage for the 12 MUST emits across two new test
files, plus negative-path sanitization assertions.
Closes#1655
* fix(web): refine api activity failure events
* fix(web): align project activity event tests
* fix(web): avoid orchestrator spawn failure double emit
---------
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* feat(core): activity events for recovery, metadata corruption, agent-report
Wires activity events into three forensic-critical paths so RCA can
reconstruct what happened after the fact:
1. recovery subsystem
- recovery.session_failed (MUST) per failed session in runRecovery
- recovery.action_failed (SHOULD) on recoverSession outer catch
Adds "recovery" to ActivityEventSource so `ao events list --source
recovery` reconstructs an `ao recover` invocation timeline.
2. metadata corruption
- metadata.corrupt_detected (MUST) when mutateMetadata renames a
corrupt session-metadata file to .corrupt-{ts}. Includes
data.renamedTo and a 200-char data.contentSample (B11) for forensic
recovery. Previously only console.warn — silent overwrites had no
queryable signal.
3. agent-report apply path
- api.agent_report.transition_rejected (SHOULD)
- api.agent_report.session_not_found (COULD)
Per B22, recovery events fire per session, not per probe step. Per B11,
metadata.corrupt_detected truncates contentSample to 200 chars (full
file would exceed the 16KB sanitizer cap).
Closes#1660
* fix(core): align forensic activity event metadata
* fix(core): cover single-session recovery failures
* fix(core): improve corrupt metadata event attribution
* fix(cli): expose activity event source filter
---------
Co-authored-by: whoisasx <adil.business4064@gmail.com>
PR #1819 accidentally introduced ao-pr1483, hermes-agent, and libkrunfw
as submodule entries (mode 160000) with no .gitmodules. These are
orphaned refs that serve no purpose and should not be on main.
Co-authored-by: AO Bot <ao-bot@composio.dev>
* fix(cli): rebuild better-sqlite3 when binding is missing
* fix(cli): support Windows postinstall rebuild shims
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* fix: recover native session restore fallback
Persist native agent restore metadata from dead runtimes and fall back to a fresh launch when native restore context is missing, so stuck sessions do not permanently block startup.
* fix: avoid redundant dead-session metadata discovery
* test: harden Linear list issue polling
* perf(web): eliminate sidebar re-renders on every SSE tick
Resolves#1844
The SSE hook delivers a new sessions array reference every 5 seconds
even when content is identical, causing sessionsByProject to recompute
and all session rows to re-render on every tick.
Three fixes in ProjectSidebar.tsx:
* sessionsKey + sessionsRef: replace unstable array reference in memo
deps with a content-derived string; memo only fires on real changes
* SessionRow memoized component: rows skip re-renders when props are
unchanged; navigate and startRename stabilised with useCallback
* SessionDot memoized: status indicator skips re-renders when level
prop is unchanged
A quiet SSE tick now touches zero React components in the sidebar.
* fix(web): add displayName, displayNameUserSet, branch to sessionsKey hash
Without these fields, a session rename delivered via SSE did not
trigger sessionsByProject to recompute. The stale session object
held the old displayName, and once the optimistic pendingRename
was cleared the sidebar silently reverted to the pre-rename title.
Addresses review feedback on PR #1846.
* feat(web): sidebar and dashboard header UI/UX polish
Removes state text labels from sidebar session rows so the colored dot
is the sole status indicator, matching the intended design. Fixes the
sidebar compact header height to align with the 48px main header.
Adds session count summary pills to the dashboard project header.
Converts CopyDebugBundleButton to an icon-only compact form so the
actions row stays vertically centered. Fixes the project page wrapper
missing flex-1 which caused a right-side viewport gap in the horizontal
shell layout.
* fix(web): resolve lint errors from UI/UX polish
Remove LEVEL_LABELS, _isLoading prefix for unused loading var, and dead
title variable from ProjectSidebar. Remove unused isDashboardSessionStatus
and isActivityStateValue from the project session page. Remove
react-hooks/exhaustive-deps eslint-disable comments for a rule not in the
ESLint config. Stabilize startRename via pendingRenamesRef so the callback
does not recreate on every rename state change, preventing unnecessary
SessionRow re-renders. Remove non-null assertion in Dashboard.tsx
handleToggleSidebar with a null guard.
* fix(web): replace native Node 25 localStorage stub with full in-memory mock
Node.js 25 exposes a native localStorage via --localstorage-file that lacks
.clear() and .key(), causing all UpdateBanner tests to throw TypeError.
Replace the global with a complete in-memory implementation so test suites
work across all Node versions.
* fix(web): seed sidebar with all sessions on hard refresh and eliminate per-project layout re-render
- Hoist sidebar layout from projects/[projectId]/layout.tsx to projects/layout.tsx so it renders
once for the entire /projects/* subtree and never re-mounts when switching between projects
- Pass getDashboardPageData("all") so initial sessions cover every project, not just the primary
- Extract ProjectLayoutClient from the old client layout for clean server/client split
- Simplify per-project empty state to "No active sessions" only, removing the second hint line
- Restore accidentally deleted Dashboard empty-state test for zero-projects install
- Fix Reflect.deleteProperty lint error in localStorage mock; drop unused AttentionLevel import
and dead effectiveDisplayName/pending variables from ProjectSidebar editing block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): address PR review — orchestrators prop, sessionsKey, badge count, mobile overlay, tests
* fix(web): remove duplicate skeleton sidebar from project loading state
* fix(web): restore orchestrator button and eliminate Session unavailable flash
Re-add the orchestrator icon + menu item that were removed during the merge
conflict resolution. Convert the icon from a <Link> to an <a> that calls
navigate() with the full session object so ProjectSessionPage gets an
instant sessionStorage cache hit instead of starting with session=null
and briefly showing the "Session unavailable" error card.
* fix(web): eliminate Session unavailable flash on orchestrator navigation
React Strict Mode aborts the first fetchSession() during its unmount/remount
cycle. The aborted finally reset fetchingSessionRef but set loading=false,
briefly showing the error card before the retry completed. Fix: keep loading=true
on abort (no session yet), and immediately retry via fetchSession() once the
ref is clear. mountedRef guards the retry so it only fires on Strict Mode
remounts — not on genuine navigation-away unmounts, which would leak requests.
* fix(web): fix working pill count and layout of error states in project session page
- Dashboard topbar "working" pill now counts only actively working sessions,
not working + pending (which inflated the number incorrectly)
- Error/loading/missing states in ProjectSessionPage wrapped in
dashboard-main--desktop so they fill the flex shell beside the sidebar
instead of shrinking to content width
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web,core): "Launch Orchestrator (clean context)" button
Adds a dashboard action that replaces the project's canonical
orchestrator with a fresh one — killing any existing orchestrator,
deleting its metadata, and spawning a new session with no carryover.
Why: users had no way to start an orchestrator with a clean slate from
the dashboard. Previous orchestrator context (conversation history,
stale state) silently carried over via the existing "Open Orchestrator"
flow, which only worked for first-time spawn anyway.
- core: new SessionManager.relaunchOrchestrator(config) that kills +
deletes existing metadata then calls spawnOrchestrator. Ignores
project.orchestratorSessionStrategy — replacement is the whole point.
Coalesces concurrent calls via a dedicated relaunchOrchestratorPromises
map (separate from ensureOrchestratorPromises since the semantics
differ — a relaunch behind an ensure must not return the existing
session).
- web: POST /api/orchestrators accepts { clean: true } to route to the
new method. OrchestratorSelector renders a "Launch Orchestrator
(clean context)" button that uses window.confirm() before discarding
an existing orchestrator; no confirm when none exists.
Closes#1900, closes#1080.
* fix(core,web): address PR #1904 review
- core: cross-map race between ensureOrchestrator and relaunchOrchestrator.
Each now awaits the other's in-flight promise (keyed by sessionId) before
proceeding. Prevents (a) relaunch skipping the kill while ensure's
spawnOrchestrator is mid-reservation, and (b) ensure returning a session
that relaunch is about to kill. Adds two race regression tests.
- web: align handleSpawnNew with handleRelaunchClean via the void expression
form; add "Launching..." in-progress label to the clean-context button and
a test that asserts it renders during POST.
* refactor(web): rip out Orchestrator Selector page; relocate clean-launch action
There is only ever one orchestrator per project, so the /orchestrators
selector page is meaningless. Delete it along with its component, tests,
and the unused mapSessionsToOrchestrators util. Drop GET /api/orchestrators
(only consumer was the deleted page). Remove /orchestrators from project
revalidate lists.
The "Launch Orchestrator (clean context)" action that previously lived on
the deleted page now appears in two places:
- Dashboard header: a "Relaunch (clean)" button renders alongside the
Orchestrator link whenever a project orchestrator exists. Uses
window.confirm before discarding state.
- Orchestrator session page: a "Relaunch (clean)" button in the
SessionDetailHeader for live orchestrator sessions, calling
POST /api/orchestrators with clean:true and reloading the session view.
* refactor(web): remove Relaunch (clean) action from the Dashboard
Keep the clean-launch action only on the orchestrator session page —
that's where the user has the context to decide on a destructive
restart. The Dashboard header just links to the orchestrator (or shows
the existing Spawn Orchestrator button when none exists).
* fix(web): surface relaunch failures with an inline error banner
After confirm + POST /api/orchestrators with clean:true, the previous
implementation only logged failures to console.error — leaving the user
on a stale page with no signal that the destructive action partially
executed. relaunchOrchestrator kills before respawning, so a failed
respawn means the server has no orchestrator while the client still
renders the old session view.
Add local relaunchError state, set it on catch (parsed from the JSON
error response when available), and render a dismissible error banner
above the terminal area. The banner explicitly warns the user that the
previous orchestrator may already be terminated and points them at the
project dashboard to retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web,core): address PR #1904 review from @i-trytoohard
- web: navigate to the new orchestrator's session path (from POST
response) instead of window.location.reload(). Orchestrator session
IDs are fixed per project so the path is the same in practice, but
reading from the response is the right contract and a hard nav forces
the terminal WebSocket to reconnect cleanly against the new tmux.
- web: remove the `!terminalEnded` gate on the Relaunch (clean) button
in SessionDetailHeader. Terminated orchestrators are exactly when the
user wants to relaunch — hiding the button there was wrong.
- core: log a warning instead of silently swallowing when an in-flight
cross-map promise (ensure waiting on relaunch, or relaunch waiting on
ensure) rejects before its caller proceeds. The catch-and-continue
semantics are correct (the caller will re-check state anyway) but
invisible failures were a debugging hazard.
Adds a regression test that the button stays visible on terminated
orchestrator sessions and that successful relaunch navigates via
window.location.href.
* fix(agent-claude-code): add AO-JSONL safety net cascade
claude-code now has the same AO-JSONL fallback cascade as codex; protects against ~/.claude/projects slug drift (cf. #1611, commit 582c5373).
Closes#1897. References #1899.
* fix(agent-claude-code): let stale native activity use AO fallback
When Claude's latest native JSONL entry predates the AO session, fall through to the AO activity JSONL cascade before preserving the prior idle default. This keeps terminal-derived waiting_input from being hidden by an old native file.
Addresses review on #1903. References #1899 and closes#1897.
* fix(core): sm.list() no longer writes terminated state to disk (#1735)
sm.list() was bypassing the lifecycle manager's probe decision matrix by
persisting terminated state immediately on a single isAlive() failure.
The dashboard's 3s poll via /api/sessions/patches called sm.list() ~10x
more often than the lifecycle manager, so a transient runtime failure
would permanently kill the session before the lifecycle manager could
evaluate all three probes (runtime, process, activity).
Changes:
- sm.list() now persists "detecting" instead of "terminated" when it
detects a dead runtime, so the lifecycle manager's resolveProbeDecision
pipeline remains the single authority on terminal decisions.
- /api/sessions/patches now calls listCached() instead of list(),
preventing the dashboard's 3s poll from probing runtimes directly.
The cache TTL (35s) aligns with the lifecycle manager's 30s poll.
- Updated CLAUDE.md invariants to reflect the new behavior.
* fix(core): skip re-persisting detecting state on subsequent list() calls
Check the on-disk lifecycle state (raw metadata) instead of the
in-memory state when deciding whether to persist. Enrichment already
sets detecting in-memory, so the previous guard always skipped the
persist block. Using the on-disk state ensures:
- First detection: persists detecting + lastTransitionAt
- Subsequent calls: skips re-write, preserving the original timestamp
* feat(core): add CI failure summary SCM contract
Add an optional SCM getCIFailureSummary hook and shared CIFailureSummary shape so providers can return failed jobs, failed steps, run URLs, and bounded log tails without changing existing SCM implementations.
* feat(scm-github): summarize failed CI logs
Use failed GitHub check URLs to locate Actions run/job IDs, fetch gh run view --log-failed output, parse the step column from the gh log format, and cap each failed-job log tail at 120 lines. Return null when no failed run logs can be fetched so lifecycle can fall back cleanly.
* feat(lifecycle): send actionable CI failure details
Prefer SCM CI failure summaries when composing ci-failed agent messages, including failed job/step, run URL, and log tail. Fall back to check names/statuses for SCM plugins without getCIFailureSummary, and remove the generic default ci-failed text so default handling relies on the lifecycle-composed payload.
State invariants preserved: this only changes ci-failed reaction payload composition and dispatch hashing. It does not change lifecycle status decisions, state-machine transitions, ci-failed persistence, retry/escalation thresholds, or stable-passing tracker reset semantics.
* fix(lifecycle): harden CI failure summaries
Address review feedback: escape log-tail lines that could close markdown fences, report the last failed-step column from gh failed logs, and pass already-known failed checks into getCIFailureSummary to avoid duplicate CI check fetches.
State invariants preserved: lifecycle transition decisions, retry/escalation budgets, persistent ci-failed tracker behavior, and stable CI passing reset semantics remain unchanged.
* refactor(lifecycle): centralize CI failure check lookup
Deduplicate CI failure check lookup, filtering, and fingerprint generation across transition-time enrichment and follow-up CI-failure dispatch while preserving the existing fetch/no-fetch split.
State invariants preserved: lifecycle transition decisions, reaction retry/escalation behavior, ci-failed tracker persistence, and stable-passing reset semantics are unchanged.
* fix(scm-github): fetch in-progress failed job logs
* fix(lifecycle): label CI failure URLs explicitly
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* fix(core): clear terminal markers on non-terminal lifecycle transitions
Clear stale completedAt/terminatedAt whenever lifecycle helpers settle on a non-terminal session state, including restore-to-working paths.
Preserved invariants: terminal-state idempotence remains intact because done/terminated states keep their terminal markers; session state transitions still flow through existing code that updates lastTransitionAt before marker hygiene runs.
* fix(core): share terminal marker hygiene
Move non-terminal marker clearing into lifecycle-state so restore and lifecycle-transition paths use one terminal-state source of truth.
Preserved invariants: terminal-state idempotence still keeps done/terminated markers; existing transition code continues to update lastTransitionAt before marker hygiene runs.
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>