From 90ec1011467c43c8b8a6697387350d5d07f79c56 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 30 May 2026 02:48:00 -0700 Subject: [PATCH] Add Claude workflow stale resume actions Co-authored-by: Orca --- .../claude-workflow-stale-resume-actions.md | 149 +++++++++++++ .../claude-workflow-action-registry.test.ts | 129 +++++++++++ .../claude-workflow-action-registry.ts | 211 ++++++++++++++++++ src/main/agent-hooks/server.test.ts | 74 ++++++ src/main/agent-hooks/server.ts | 69 +++++- src/main/index.ts | 2 + src/main/ipc/agent-hooks.test.ts | 8 + src/main/ipc/agent-hooks.ts | 26 ++- src/preload/api-types.ts | 4 + src/preload/index.ts | 7 + .../dashboard/DashboardAgentRow.tsx | 28 +++ .../DashboardAgentWorkflowActions.tsx | 196 ++++++++++++++++ .../components/dashboard/useDashboardData.ts | 8 +- .../components/sidebar/WorktreeCardAgents.tsx | 26 +++ .../sidebar/useWorktreeAgentRows.ts | 21 +- src/renderer/src/hooks/useIpcEvents.ts | 11 +- .../agent-status-freshness-scheduler.ts | 6 +- .../src/store/slices/agent-status.test.ts | 36 +++ src/renderer/src/store/slices/agent-status.ts | 8 +- src/renderer/src/web/web-preload-api.ts | 3 + src/shared/agent-hook-listener.ts | 206 +++++++++++++++++ src/shared/agent-status-types.ts | 4 + src/shared/claude-workflow-actions.test.ts | 94 ++++++++ src/shared/claude-workflow-actions.ts | 164 ++++++++++++++ 24 files changed, 1479 insertions(+), 11 deletions(-) create mode 100644 docs/reference/claude-workflow-stale-resume-actions.md create mode 100644 src/main/agent-hooks/claude-workflow-action-registry.test.ts create mode 100644 src/main/agent-hooks/claude-workflow-action-registry.ts create mode 100644 src/renderer/src/components/dashboard/DashboardAgentWorkflowActions.tsx create mode 100644 src/shared/claude-workflow-actions.test.ts create mode 100644 src/shared/claude-workflow-actions.ts diff --git a/docs/reference/claude-workflow-stale-resume-actions.md b/docs/reference/claude-workflow-stale-resume-actions.md new file mode 100644 index 00000000000..65e0544159a --- /dev/null +++ b/docs/reference/claude-workflow-stale-resume-actions.md @@ -0,0 +1,149 @@ +# Claude Workflow Stale Resume Actions + +## Problem + +Claude dynamic workflows can be interrupted, abandoned, or left stale while the parent Claude pane looks merely idle. Orca already decays stale working agent rows after `AGENT_STATUS_STALE_AFTER_MS` (`src/shared/agent-status-types.ts`), with the dashboard deriving the same decay in `buildAgentRowsForWorktree` (`src/renderer/src/components/dashboard/useDashboardData.ts`) and sidebar rows deriving it in `buildWorktreeAgentRows` (`src/renderer/src/components/sidebar/useWorktreeAgentRows.ts`). That tells the user something stopped updating, but the current Claude hook/status path only preserves generic state, prompt, tool preview, assistant message, and interrupt fields; it does not expose workflow-specific recovery data such as `scriptPath` and `resumeFromRunId`. + +## Goal + +Add a compact recovery surface on existing Claude agent rows: stale/resumable workflow summaries should show a clear stale hint and offer safe actions to copy resume instructions, reveal the generated JS script, reveal transcript files, and jump back to the parent Claude pane. The primary outcome is confidence and recoverability, not automatic execution. + +## Non-goals + +- Do not auto-send the resume command into a Claude terminal. +- Do not mutate Claude workflow files. +- Do not add workflow status grouping, a workflow detail panel, or a new row type beyond the minimal metadata/action surface needed by existing agent rows. +- Do not depend on GitHub-only or local-only paths. +- Do not expose a generic renderer-controlled "open this path" action for workflow files. + +## Design + +1. Add a small workflow metadata model. + - Use `src/shared/claude-workflow-actions.ts` for action-safe types; project-owned declarations stay in `.ts`, not `.d.ts`. + - Add an optional workflow recovery sidecar to `AgentHookEventPayload` / the enriched main cache, `AgentStatusIpcPayload`, and `AgentStatusEntry` so `agentStatus:set` and `agentStatus:getSnapshot` carry the same shape through `useIpcEvents`. + - Do not rely on adding unknown fields to `AgentStatusPayload` / `ParsedAgentStatusPayload` alone. `normalizeAgentStatusPayload` intentionally reconstructs a known-field object, and `useIpcEvents` currently re-normalizes only status fields before calling `setAgentStatus`; workflow recovery must be copied explicitly as a sidecar into the store. + - The renderer-facing shape should contain an opaque `workflowId`, `parentPaneKey`, `worktreeId`, `updatedAt`, stale/resumable booleans, and per-action availability/disabled reason. + - Keep raw `scriptPath` and `transcriptDir` in a main-process lookup keyed by `workflowId`. Do not require the renderer to pass paths back for reveal actions. + - Preserve source identity: local entries have `connectionId: null`; SSH entries carry the remote `connectionId` and disable local reveal actions unless a real remote reveal API exists. + +2. Detect stale workflow runs. + - Add a Claude workflow response extractor alongside the current Claude hook normalization. Today `extractClaudeToolFields` only reads generic `tool_name`, `tool_input`, `tool_response`, `last_assistant_message`, and `transcript_path` data; workflow recovery fields must be parsed before the generic status normalizer truncates/drops unknown data. + - Attach recovery metadata to the parent Claude agent row; do not create a separate workflow group. + - Mark a workflow stale when it has active workflow child work and neither workflow file mtime nor hook `receivedAt` has advanced for `AGENT_STATUS_STALE_AFTER_MS`. + - Do not change the generic agent state contract. The row may still decay to `idle`; workflow staleness is a separate `workflowRecovery.isStale` hint. + - Mark `resumable` when both `scriptPath` and `resumeFromRunId` are present. Copy can be available for SSH if the remote path text is known; local reveal remains disabled for SSH paths. + +3. Add safe action APIs. + - Extend the preload `agentStatus` API, not a separate top-level namespace: `copyWorkflowResumeCommand(workflowId)`, `revealWorkflowScript(workflowId)`, and `revealWorkflowTranscripts(workflowId)`. + - Main owns command construction, clipboard write, path lookup, path normalization, existence/type checks, and Electron `shell.showItemInFolder` calls. Do not route workflow reveal through the existing loose `shell:openPath` bridge. + - Validate every action at click time and return a discriminated result such as `{ ok: true } | { ok: false; reason: 'missing-path' | 'remote-path' | 'not-found' | 'wrong-kind' | 'stale-id' | 'clipboard-failed' | 'launch-failed' }`. + - For local paths, require absolute paths, use Node `path` utilities (`normalize`, `isAbsolute`) instead of string separators, and verify file vs directory with `fs.stat`. + - "Open parent Claude pane" is renderer navigation, not main IPC: parse `parentPaneKey`, verify the tab still exists in the row's `worktreeId`, then route through `activateAndRevealWorktree` and `activateTabAndFocusPane`. If validation fails, toast and do not guess a terminal. + +4. Add row-level action affordances. + - Add an icon-only shadcn `DropdownMenu` trigger only when a `DashboardAgentRow` has workflow recovery metadata; normal agent rows must not gain the trigger. + - Menu items: Copy resume command, Reveal workflow script, Reveal transcripts, Open parent Claude pane. + - Disabled items carry concise visible menu text; tooltips only name icon-only triggers. + - Stale badge/copy should be muted and consistent with existing state labels, not a bright warning banner. + - Use shadcn primitives, lucide icons, token classes from `main.css`, and the existing `DashboardAgentRow` layout so the sidebar and dashboard stay aligned. + +5. Generate resume text conservatively. + - Copy human-readable paste text, not a shell command: + ```text + Resume this Claude Code workflow: + scriptPath: + resumeFromRunId: + ``` + - Do not promise a shell command if Claude Code requires invoking the built-in `Workflow` tool from the TUI. + - Include script path and run id in separate lines so the user can paste them into Claude and edit safely. + +6. Add pruning and retention. + - Workflow metadata follows the existing live/retained agent row lifecycle; do not add a second completed-workflow list. + - Drop renderer metadata when `dropAgentStatus`, `dismissRetainedAgent`, `dismissRetainedAgentsByWorktree`, or worktree removal drops the owning row. + - Drop main-process path lookup entries when the owning pane/worktree is dismissed or pruned, and keep only a short retained window for clean `done` rows that already qualify for retained agent display. + - Do not assume every renderer pruning path already notifies main. `dropAgentStatus` and `dismissRetainedAgentsByWorktree` fan out `agentStatus:drop`, but worktree-level/prune paths can remove renderer rows without an existing main lookup callback; add explicit cleanup or a bounded main-side TTL for workflow lookup entries. + +## Data flow + +```text +Claude workflow response extractor + -> main workflow recovery lookup + AgentStatusIpcPayload metadata + -> useIpcEvents normalizes status fields and copies workflow sidecar into AgentStatusEntry + -> existing dashboard/sidebar row builders + -> row dropdown action + -> agentStatus action IPC for copy/reveal, renderer navigation for parent focus + -> typed result + -> toast +``` + +## Edge cases + +- Workflow has `resumeFromRunId` but no `scriptPath`. +- Script was deleted by the user before clicking reveal/copy. +- Parent pane is gone but script/transcripts remain. +- Remote SSH workflow paths are not local Finder/Explorer paths. +- Claude workflow file mtimes update while the user has the menu open. +- Clipboard write fails due OS permission. +- Multiple resumed runs share one script path. +- Main-process workflow id was pruned between menu render and click. +- Malformed, relative, URI, or Windows/Posix-opposite paths arrive from Claude metadata. +- Snapshot hydration delivers older metadata after a newer live update. + +## Test plan + +- Unit: stale derivation from workflow update time, hook `receivedAt`, child-active signal, and threshold. +- Unit: resume copy text includes script path/run id on separate lines and handles missing fields. +- Unit: action resolver returns typed errors for missing path, remote path, stale id, not found, wrong kind, malformed path, and clipboard failure. +- Unit: Windows and POSIX path validation use `path` utilities and do not split on `/` or `\`. +- Hook/server: workflow sidecar survives Claude hook parsing, `setListener` fanout, status snapshot hydration, and remote `connectionId` stamping without passing through generic status-field normalization. +- IPC/preload: `agentStatus` API exposes the new methods in `api-types.ts`, `index.ts`, and the web preload fallback stubs. +- Renderer store: snapshot/update handling preserves workflow metadata without regressing older payloads that omit it, including older snapshot entries whose timestamps lose to newer live updates. +- Component: workflow row menu renders enabled/disabled items correctly and does not affect normal agent rows. +- Component/store: dismissing a workflow clears retained action metadata only for that workflow. +- Electron: stale row menu, copy action toast, disabled remote reveal state, parent-pane focus smoke, normal-row smoke. + +## UI quality bar + +- The stale state must be noticeable but not alarming: state dot plus compact label, not a modal or banner. +- Actions should be discoverable from the workflow row without bloating every normal agent row. +- Copy/reveal/open results should use short toasts; no persistent panels. +- Disabled remote/missing-path states must explain the limitation in one line. +- The row height must stay stable when the action trigger appears on hover/focus. +- Keyboard users must be able to open the menu and activate each enabled item; the outer row remains a plain clickable surface with real buttons inside. + +## Review screenshots + +1. Stale resumable workflow row with action menu open. +2. Disabled remote/missing-path menu state. +3. Copy resume command success toast. +4. Completed retained workflow row with reveal actions available. +5. Normal agent row smoke with no workflow action trigger. + +Do not commit evidence images; attach them to the PR conversation. + +## Rollout + +1. Add shared workflow recovery metadata and stale/resume derivation tests. +2. Add main action lookup, IPC handlers, preload API, and typed result tests. +3. Thread metadata through renderer status snapshot/update handling and row builders. +4. Add row menu UI, parent-pane navigation, and toasts. +5. Validate in Electron with screenshots. + +## Lightweight Eng Review + +- Scope: Reduced to user-initiated recovery actions on existing agent rows. No automatic resume injection, because PTY keystroke injection into Claude can resume the wrong turn if focus or prompt state is stale. +- Architecture/data flow: Main owns filesystem/clipboard/action validation and stores raw paths; renderer renders available actions, calls typed IPC methods, and performs only existing pane navigation. This keeps SSH/local differences behind runtime checks. +- Failure modes covered: + - Missing script/run id disables copy or returns a typed error. + - Remote paths do not call local reveal APIs. + - Parent pane focus is best-effort and never guesses a new terminal. + - Clipboard/reveal failures surface as toasts. + - Stale threshold updates when workflow mtimes change. +- Test coverage required: + - Pure stale/resume command tests. + - IPC handler tests for local, remote, missing file, malformed path, stale id. + - Component tests for menu visibility and disabled states. + - Electron screenshots for stale, menu, toast, disabled remote, and normal row smoke. +- Performance/blast radius: No extra filesystem crawling beyond the workflow index. Action metadata is summary-sized. Menus mount per visible row only. +- UI quality bar: Existing list-row density, icon-only trigger with tooltip, shadcn dropdown, no noisy badges or card nesting. +- Residual risks: Claude's actual resume UX is a tool call, not a stable shell command. Copy text must be explicit enough for Claude Code users without implying Orca can resume automatically. diff --git a/src/main/agent-hooks/claude-workflow-action-registry.test.ts b/src/main/agent-hooks/claude-workflow-action-registry.test.ts new file mode 100644 index 00000000000..01e8f4b9bdc --- /dev/null +++ b/src/main/agent-hooks/claude-workflow-action-registry.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + _claudeWorkflowActionRegistryInternals, + clearClaudeWorkflowRecoveryLookups, + copyClaudeWorkflowResumeCommand, + revealClaudeWorkflowScript, + revealClaudeWorkflowTranscripts +} from './claude-workflow-action-registry' + +function register( + overrides: Partial[0]> = {} +) { + return _claudeWorkflowActionRegistryInternals.register( + { + workflowId: 'wf-1', + parentPaneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + scriptPath: '/tmp/workflow.js', + transcriptDir: '/tmp/transcripts', + resumeFromRunId: 'run-1', + hasActiveChildWork: true, + ...overrides + }, + { connectionId: null, receivedAt: Date.now() } + ) +} + +afterEach(() => { + clearClaudeWorkflowRecoveryLookups() + vi.restoreAllMocks() +}) + +describe('Claude workflow action registry', () => { + it('copies resume text after validating a local script file', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-')) + try { + const scriptPath = join(dir, 'workflow.js') + writeFileSync(scriptPath, 'console.log("workflow")') + register({ scriptPath }) + const writeText = vi.fn() + + const result = copyClaudeWorkflowResumeCommand('wf-1', writeText) + + expect(result).toEqual({ ok: true }) + expect(writeText).toHaveBeenCalledWith( + [ + 'Resume this Claude Code workflow:', + `scriptPath: ${scriptPath}`, + 'resumeFromRunId: run-1' + ].join('\n') + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns typed failures for missing, malformed, and wrong-kind script paths', () => { + register({ scriptPath: undefined }) + expect(copyClaudeWorkflowResumeCommand('wf-1', vi.fn())).toEqual({ + ok: false, + reason: 'missing-path' + }) + + clearClaudeWorkflowRecoveryLookups() + register({ scriptPath: 'file:///tmp/workflow.js' }) + expect(revealClaudeWorkflowScript('wf-1', vi.fn())).toEqual({ + ok: false, + reason: 'invalid-path' + }) + + const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-')) + try { + clearClaudeWorkflowRecoveryLookups() + register({ scriptPath: dir }) + expect(revealClaudeWorkflowScript('wf-1', vi.fn())).toEqual({ + ok: false, + reason: 'wrong-kind' + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('blocks local reveal for remote workflow paths but still copies known remote text', () => { + _claudeWorkflowActionRegistryInternals.register( + { + workflowId: 'wf-remote', + parentPaneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + scriptPath: '/remote/workflow.js', + resumeFromRunId: 'run-remote', + hasActiveChildWork: true + }, + { connectionId: 'conn-1', receivedAt: Date.now() } + ) + const writeText = vi.fn() + + expect(revealClaudeWorkflowScript('wf-remote', vi.fn())).toEqual({ + ok: false, + reason: 'remote-path' + }) + expect(copyClaudeWorkflowResumeCommand('wf-remote', writeText)).toEqual({ ok: true }) + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('scriptPath: /remote/workflow.js') + ) + }) + + it('reveals transcript directories and reports stale ids', () => { + expect(revealClaudeWorkflowTranscripts('missing', vi.fn())).toEqual({ + ok: false, + reason: 'stale-id' + }) + + const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-')) + try { + const transcripts = join(dir, 'transcripts') + mkdirSync(transcripts) + register({ transcriptDir: transcripts }) + const show = vi.fn() + + expect(revealClaudeWorkflowTranscripts('wf-1', show)).toEqual({ ok: true }) + expect(show).toHaveBeenCalledWith(transcripts) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/claude-workflow-action-registry.ts b/src/main/agent-hooks/claude-workflow-action-registry.ts new file mode 100644 index 00000000000..b2a32fc3be6 --- /dev/null +++ b/src/main/agent-hooks/claude-workflow-action-registry.ts @@ -0,0 +1,211 @@ +import { dirname, isAbsolute, normalize } from 'path' +import { statSync } from 'fs' + +import { + buildClaudeWorkflowResumeText, + deriveClaudeWorkflowRecoveryMetadata, + type ClaudeWorkflowRecoveryActionResult, + type ClaudeWorkflowRecoveryHookMetadata, + type ClaudeWorkflowRecoveryLookupEntry, + type ClaudeWorkflowRecoveryMetadata +} from '../../shared/claude-workflow-actions' + +const WORKFLOW_LOOKUP_TTL_MS = 24 * 60 * 60 * 1000 + +const lookupByWorkflowId = new Map() + +type LocalPathKind = 'file' | 'directory' +type LocalPathValidation = + | { ok: true; path: string } + | Extract + +function pruneExpiredWorkflowLookups(now = Date.now()): void { + for (const [workflowId, entry] of lookupByWorkflowId) { + if (now - entry.receivedAt > WORKFLOW_LOOKUP_TTL_MS) { + lookupByWorkflowId.delete(workflowId) + } + } +} + +function getLookupEntry(workflowId: unknown): ClaudeWorkflowRecoveryLookupEntry | null { + if (typeof workflowId !== 'string' || workflowId.trim().length === 0) { + return null + } + pruneExpiredWorkflowLookups() + return lookupByWorkflowId.get(workflowId) ?? null +} + +function normalizeLocalPath(rawPath: string | undefined): string | null { + if (typeof rawPath !== 'string') { + return null + } + const trimmed = rawPath.trim() + if (trimmed.length === 0) { + return null + } + // Why: Electron reveal APIs operate on local filesystem paths only. Reject + // URI-shaped metadata up front so a malformed Claude payload cannot be + // interpreted by the host shell as something broader than a path. + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)) { + return null + } + const normalized = normalize(trimmed) + return isAbsolute(normalized) ? normalized : null +} + +function validateLocalPath( + rawPath: string | undefined, + expectedKind: LocalPathKind +): LocalPathValidation { + const path = normalizeLocalPath(rawPath) + if (!path) { + return { ok: false, reason: rawPath ? 'invalid-path' : 'missing-path' } + } + let stats + try { + stats = statSync(path) + } catch { + return { ok: false, reason: 'not-found' } + } + const kindMatches = expectedKind === 'file' ? stats.isFile() : stats.isDirectory() + if (!kindMatches) { + return { ok: false, reason: 'wrong-kind' } + } + return { ok: true, path } +} + +function getWorkflowUpdatedAt(entry: ClaudeWorkflowRecoveryLookupEntry): number { + const scriptPath = normalizeLocalPath(entry.scriptPath) + if (entry.connectionId !== null || !scriptPath) { + return entry.updatedAt ?? entry.receivedAt + } + try { + const stats = statSync(scriptPath) + return Math.max(entry.updatedAt ?? 0, stats.mtimeMs, entry.receivedAt) + } catch { + return entry.updatedAt ?? entry.receivedAt + } +} + +function getTranscriptDirectory(entry: ClaudeWorkflowRecoveryLookupEntry): string | undefined { + if (entry.transcriptDir) { + return entry.transcriptDir + } + const transcriptPath = normalizeLocalPath(entry.transcriptPath) + return transcriptPath ? dirname(transcriptPath) : undefined +} + +export function registerClaudeWorkflowRecoveryLookup( + metadata: ClaudeWorkflowRecoveryHookMetadata, + args: { connectionId: string | null; receivedAt: number } +): ClaudeWorkflowRecoveryMetadata { + const entry: ClaudeWorkflowRecoveryLookupEntry = { + ...metadata, + connectionId: args.connectionId, + receivedAt: args.receivedAt + } + lookupByWorkflowId.set(metadata.workflowId, entry) + pruneExpiredWorkflowLookups(args.receivedAt) + return deriveClaudeWorkflowRecoveryMetadata( + { + ...entry, + transcriptDir: getTranscriptDirectory(entry), + workflowUpdatedAt: getWorkflowUpdatedAt(entry) + }, + args.receivedAt + ) +} + +export function dropClaudeWorkflowRecoveryLookupsForPane(paneKey: string): void { + for (const [workflowId, entry] of lookupByWorkflowId) { + if (entry.parentPaneKey === paneKey) { + lookupByWorkflowId.delete(workflowId) + } + } +} + +export function clearClaudeWorkflowRecoveryLookups(): void { + lookupByWorkflowId.clear() +} + +export function copyClaudeWorkflowResumeCommand( + workflowId: unknown, + writeText: (text: string) => void +): ClaudeWorkflowRecoveryActionResult { + const entry = getLookupEntry(workflowId) + if (!entry) { + return { ok: false, reason: 'stale-id' } + } + const scriptPath = entry.scriptPath?.trim() + const resumeFromRunId = entry.resumeFromRunId?.trim() + if (!scriptPath) { + return { ok: false, reason: 'missing-path' } + } + if (!resumeFromRunId) { + return { ok: false, reason: 'missing-run-id' } + } + if (entry.connectionId === null) { + const validated = validateLocalPath(scriptPath, 'file') + if (!validated.ok) { + return validated + } + } + try { + writeText(buildClaudeWorkflowResumeText({ scriptPath, resumeFromRunId })) + return { ok: true } + } catch { + return { ok: false, reason: 'clipboard-failed' } + } +} + +export function revealClaudeWorkflowScript( + workflowId: unknown, + showItemInFolder: (path: string) => void +): ClaudeWorkflowRecoveryActionResult { + const entry = getLookupEntry(workflowId) + if (!entry) { + return { ok: false, reason: 'stale-id' } + } + if (entry.connectionId !== null) { + return { ok: false, reason: 'remote-path' } + } + const validated = validateLocalPath(entry.scriptPath, 'file') + if (!validated.ok) { + return validated + } + try { + showItemInFolder(validated.path) + return { ok: true } + } catch { + return { ok: false, reason: 'launch-failed' } + } +} + +export function revealClaudeWorkflowTranscripts( + workflowId: unknown, + showItemInFolder: (path: string) => void +): ClaudeWorkflowRecoveryActionResult { + const entry = getLookupEntry(workflowId) + if (!entry) { + return { ok: false, reason: 'stale-id' } + } + if (entry.connectionId !== null) { + return { ok: false, reason: 'remote-path' } + } + const validated = validateLocalPath(getTranscriptDirectory(entry), 'directory') + if (!validated.ok) { + return validated + } + try { + showItemInFolder(validated.path) + return { ok: true } + } catch { + return { ok: false, reason: 'launch-failed' } + } +} + +export const _claudeWorkflowActionRegistryInternals = { + clear: clearClaudeWorkflowRecoveryLookups, + size: (): number => lookupByWorkflowId.size, + register: registerClaudeWorkflowRecoveryLookup +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 5901c084fd7..737718c1464 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -76,6 +76,80 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { + it('extracts Claude workflow recovery sidecar before generic status normalization', () => { + const normalized = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Workflow', + tool_response: { + scriptPath: '/tmp/workflow.js', + resumeFromRunId: 'run-1', + transcriptDir: '/tmp/transcripts' + } + }), + 'production' + ) + + expect(normalized).toEqual( + expect.objectContaining({ + paneKey: PANE, + workflowRecoveryLookup: expect.objectContaining({ + parentPaneKey: PANE, + scriptPath: '/tmp/workflow.js', + resumeFromRunId: 'run-1', + transcriptDir: '/tmp/transcripts', + hasActiveChildWork: true + }), + payload: expect.objectContaining({ + state: 'working', + agentType: 'claude' + }) + }) + ) + }) + + it('stamps remote workflow recovery metadata without enabling local reveal actions', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'p', agentType: 'claude' }, + workflowRecoveryLookup: { + workflowId: 'wf-remote', + parentPaneKey: PANE, + worktreeId: 'wt-1', + scriptPath: '/remote/workflow.js', + resumeFromRunId: 'run-remote', + hasActiveChildWork: true + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + workflowRecovery: expect.objectContaining({ + workflowId: 'wf-remote', + worktreeId: 'wt-1', + isResumable: true, + actions: expect.objectContaining({ + copyResumeCommand: { available: true }, + revealScript: { available: false, disabledReason: 'remote-path' } + }) + }) + }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('applies inferred interrupts through the cached status lifecycle', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 636b1e4affb..545bbf7b6d8 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -44,6 +44,15 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import type { + ClaudeWorkflowRecoveryHookMetadata, + ClaudeWorkflowRecoveryMetadata +} from '../../shared/claude-workflow-actions' +import { + clearClaudeWorkflowRecoveryLookups, + dropClaudeWorkflowRecoveryLookupsForPane, + registerClaudeWorkflowRecoveryLookup +} from './claude-workflow-action-registry' import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest @@ -64,6 +73,7 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + workflowRecovery?: ClaudeWorkflowRecoveryMetadata } export type AgentHookStatusChangeEntry = { @@ -206,6 +216,13 @@ function sanitizeHydratedEntry( if (!payload) { return null } + const workflowRecoveryLookup = sanitizeWorkflowRecoveryLookup(record.workflowRecoveryLookup) + const workflowRecovery = workflowRecoveryLookup + ? registerClaudeWorkflowRecoveryLookup(workflowRecoveryLookup, { + connectionId, + receivedAt + }) + : undefined return { paneKey, tabId: typeof tabId === 'string' ? tabId : undefined, @@ -216,12 +233,46 @@ function sanitizeHydratedEntry( toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + workflowRecoveryLookup, + workflowRecovery, payload, receivedAt, stateStartedAt } } +function readHydratedString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function sanitizeWorkflowRecoveryLookup( + raw: unknown +): ClaudeWorkflowRecoveryHookMetadata | undefined { + if (typeof raw !== 'object' || raw === null) { + return undefined + } + const record = raw as Record + const workflowId = readHydratedString(record, 'workflowId') + const parentPaneKey = readHydratedString(record, 'parentPaneKey') + const hasActiveChildWork = record.hasActiveChildWork + if (!workflowId || !parentPaneKey || typeof hasActiveChildWork !== 'boolean') { + return undefined + } + const updatedAt = record.updatedAt + return { + workflowId, + parentPaneKey, + worktreeId: readHydratedString(record, 'worktreeId'), + scriptPath: readHydratedString(record, 'scriptPath'), + transcriptDir: readHydratedString(record, 'transcriptDir'), + transcriptPath: readHydratedString(record, 'transcriptPath'), + resumeFromRunId: readHydratedString(record, 'resumeFromRunId'), + hasActiveChildWork, + updatedAt: typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : undefined + } +} + function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { return { paneKey: entry.paneKey, @@ -230,7 +281,8 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, - ...entry.payload + ...entry.payload, + workflowRecovery: entry.workflowRecovery } } @@ -546,10 +598,17 @@ export class AgentHookServer { | undefined const stateStartedAt = previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now + const workflowRecovery = payload.workflowRecoveryLookup + ? registerClaudeWorkflowRecoveryLookup(payload.workflowRecoveryLookup, { + connectionId: payload.connectionId, + receivedAt: now + }) + : previous?.workflowRecovery return { ...payload, receivedAt: now, - stateStartedAt + stateStartedAt, + workflowRecovery } } @@ -808,6 +867,7 @@ export class AgentHookServer { // this PTY is later proven dead before ptyPaneKey is rebuilt, alias // cleanup is the only path that can evict that retained status. clearPaneCacheState(this.state, entry.stablePaneKey) + dropClaudeWorkflowRecoveryLookupsForPane(entry.stablePaneKey) this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) } @@ -865,6 +925,7 @@ export class AgentHookServer { toolUseId?: string toolAgentId?: string toolAgentType?: string + workflowRecoveryLookup?: unknown isReplay?: boolean payload: unknown }, @@ -968,6 +1029,7 @@ export class AgentHookServer { toolUseId, toolAgentId, toolAgentType, + workflowRecoveryLookup: sanitizeWorkflowRecoveryLookup(envelope.workflowRecoveryLookup), isReplay: envelope.isReplay === true ? true : undefined, payload: normalizedPayload } @@ -1107,6 +1169,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.clear() this.promptSentDedupeByPaneKey.clear() this.legacyPaneKeyAliases.clear() + clearClaudeWorkflowRecoveryLookups() clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() } @@ -1125,6 +1188,7 @@ export class AgentHookServer { } const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + dropClaudeWorkflowRecoveryLookupsForPane(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) if (existing?.payload.state === 'done') { @@ -1143,6 +1207,7 @@ export class AgentHookServer { const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) + dropClaudeWorkflowRecoveryLookupsForPane(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { diff --git a/src/main/index.ts b/src/main/index.ts index 8ba2c81c749..8092a77aa54 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -539,6 +539,7 @@ function openMainWindow(): BrowserWindow { payload, receivedAt, stateStartedAt, + workflowRecovery, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -554,6 +555,7 @@ function openMainWindow(): BrowserWindow { connectionId, receivedAt, stateStartedAt, + ...(workflowRecovery ? { workflowRecovery } : {}), ...(orchestration ? { orchestration } : {}) }) recordCrashBreadcrumb('agent_state_changed', { diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index c15d5425b9a..4df812ad8e5 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -14,9 +14,14 @@ const onHandlers = new Map void> const handleHandlers = new Map unknown>() const removeHandler = vi.fn() const removeAllListeners = vi.fn() +const clipboardWriteText = vi.fn() +const showItemInFolder = vi.fn() const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') vi.mock('electron', () => ({ + clipboard: { + writeText: clipboardWriteText + }, ipcMain: { handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { handleHandlers.set(channel, handler) @@ -26,6 +31,9 @@ vi.mock('electron', () => ({ }, removeHandler, removeAllListeners + }, + shell: { + showItemInFolder } })) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 2287db0ea4e..a1c3845c2c1 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { clipboard, ipcMain, shell } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { AgentStatusIpcPayload, @@ -21,6 +21,12 @@ import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { + copyClaudeWorkflowResumeCommand, + revealClaudeWorkflowScript, + revealClaudeWorkflowTranscripts +} from '../agent-hooks/claude-workflow-action-registry' +import type { ClaudeWorkflowRecoveryActionResult } from '../../shared/claude-workflow-actions' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -46,6 +52,9 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:hermesStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') + ipcMain.removeHandler('agentStatus:copyWorkflowResumeCommand') + ipcMain.removeHandler('agentStatus:revealWorkflowScript') + ipcMain.removeHandler('agentStatus:revealWorkflowTranscripts') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't @@ -78,6 +87,21 @@ export function registerAgentHookHandlers(): void { } return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest) }) + ipcMain.handle( + 'agentStatus:copyWorkflowResumeCommand', + (_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult => + copyClaudeWorkflowResumeCommand(workflowId, (text) => clipboard.writeText(text)) + ) + ipcMain.handle( + 'agentStatus:revealWorkflowScript', + (_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult => + revealClaudeWorkflowScript(workflowId, (filePath) => shell.showItemInFolder(filePath)) + ) + ipcMain.handle( + 'agentStatus:revealWorkflowTranscripts', + (_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult => + revealClaudeWorkflowTranscripts(workflowId, (dirPath) => shell.showItemInFolder(dirPath)) + ) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fce1dc1d607..c1480c3e725 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -173,6 +173,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { ClaudeWorkflowRecoveryActionResult } from '../shared/claude-workflow-actions' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { RuntimeBrowserDriverState, @@ -2149,6 +2150,9 @@ export type PreloadApi = { /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise + copyWorkflowResumeCommand: (workflowId: string) => Promise + revealWorkflowScript: (workflowId: string) => Promise + revealWorkflowTranscripts: (workflowId: string) => Promise /** Listen for PTYs that still use a legacy numeric pane key but have * registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 26e016048c7..f136d20916c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { ClaudeWorkflowRecoveryActionResult } from '../shared/claude-workflow-actions' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { SpeechErrorEvent, @@ -3341,6 +3342,12 @@ const api = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), + copyWorkflowResumeCommand: (workflowId: string): Promise => + ipcRenderer.invoke('agentStatus:copyWorkflowResumeCommand', workflowId), + revealWorkflowScript: (workflowId: string): Promise => + ipcRenderer.invoke('agentStatus:revealWorkflowScript', workflowId), + revealWorkflowTranscripts: (workflowId: string): Promise => + ipcRenderer.invoke('agentStatus:revealWorkflowTranscripts', workflowId), onMigrationUnsupported: ( callback: (entry: MigrationUnsupportedPtyEntry) => void ): (() => void) => { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index ebfe16de9ba..2dbbe149532 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this row owns the dense agent-row layout, + expansion behavior, nested controls, and secondary status lines in one + component so dashboard/sidebar row rendering stays pixel-aligned. */ import React, { useState, useCallback } from 'react' import { X, Wrench, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' @@ -7,7 +10,9 @@ import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' +import { DashboardAgentWorkflowActions } from './DashboardAgentWorkflowActions' import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' // Why: the dashboard tracks its own rollup states (incl. 'idle'); narrow to the @@ -75,6 +80,7 @@ type Props = { * through so the caller can acknowledge (mark-visited) the specific row * that was clicked, without having to re-derive it from the tab id. */ onActivate: (tabId: string, paneKey: string) => void + onOpenWorkflowParent?: (workflow: ClaudeWorkflowRecoveryMetadata) => boolean /** * Why: the relative-time labels ("Xm ago") need a periodic re-render to stay * honest. We accept `now` from a parent container so a single 30s tick owned @@ -127,6 +133,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ agent, onDismiss, onActivate, + onOpenWorkflowParent, now, isUnvisited = false, stateDotSize = 'md', @@ -186,6 +193,13 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ }, [onActivate, agent.tab.id, agent.paneKey] ) + const workflowRecovery = agent.entry.workflowRecovery + const fallbackOpenWorkflowParent = useCallback(() => { + if (!workflowRecovery) { + return + } + onActivate(agent.tab.id, workflowRecovery.parentPaneKey) + }, [agent.tab.id, onActivate, workflowRecovery]) const startedAt = agent.startedAt > 0 ? agent.startedAt : null const doneAt = lastEnteredDoneAt(agent) const prompt = agent.entry.prompt.trim() @@ -354,6 +368,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ > {displayLabel} + {workflowRecovery?.isStale && ( + + workflow stale + + )} {/* Why: "+N" badge mirrors the leading chevron — without it the parent row reads identical to a leaf row when collapsed, and the child count is invisible. Hidden when expanded because the @@ -370,6 +389,15 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ place. State belongs in the leading gutter; repeating it here as text makes interrupted rows look like the old badge treatment. */} + {workflowRecovery && ( + + )} {/* Why: timestamp and dismiss-X share a single slot so passive rows show "time ago" and hovered rows swap in the X — no reserved-space gap, no competing columns. Grid stacks both diff --git a/src/renderer/src/components/dashboard/DashboardAgentWorkflowActions.tsx b/src/renderer/src/components/dashboard/DashboardAgentWorkflowActions.tsx new file mode 100644 index 00000000000..30ed35abdf6 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentWorkflowActions.tsx @@ -0,0 +1,196 @@ +import { Clipboard, Ellipsis, FileCode2, FolderOpen, PanelTopOpen } from 'lucide-react' +import React from 'react' +import { toast } from 'sonner' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { + ClaudeWorkflowRecoveryActionResult, + ClaudeWorkflowRecoveryDisabledReason, + ClaudeWorkflowRecoveryMetadata +} from '../../../../shared/claude-workflow-actions' + +type Props = { + workflowRecovery: ClaudeWorkflowRecoveryMetadata + onOpenWorkflowParent?: (workflow: ClaudeWorkflowRecoveryMetadata) => boolean + fallbackOpenParent: () => void + stopMouseDown: (event: React.MouseEvent) => void + stopKeyDown: (event: React.KeyboardEvent) => void +} + +function workflowDisabledLabel(reason: ClaudeWorkflowRecoveryDisabledReason): string { + switch (reason) { + case 'missing-script': + return 'Missing script path' + case 'missing-run-id': + return 'Missing resume id' + case 'missing-transcripts': + return 'No transcripts' + case 'remote-path': + return 'Remote path' + case 'invalid-path': + return 'Invalid path' + case 'parent-pane-gone': + return 'Parent pane gone' + default: + return 'Unavailable' + } +} + +function workflowActionErrorToast(result: ClaudeWorkflowRecoveryActionResult): string { + if (result.ok) { + return '' + } + switch (result.reason) { + case 'missing-path': + return 'Workflow path is missing.' + case 'remote-path': + return 'Remote workflow paths cannot be revealed locally.' + case 'not-found': + return 'Workflow file was not found.' + case 'wrong-kind': + return 'Workflow path has the wrong type.' + case 'stale-id': + return 'Workflow action expired.' + case 'clipboard-failed': + return 'Clipboard write failed.' + case 'launch-failed': + return 'Could not reveal workflow path.' + case 'invalid-path': + return 'Workflow path is invalid.' + case 'missing-run-id': + return 'Workflow resume id is missing.' + default: + return 'Workflow action failed.' + } +} + +function formatWorkflowMenuLabel( + base: string, + action: ClaudeWorkflowRecoveryMetadata['actions'][keyof ClaudeWorkflowRecoveryMetadata['actions']] +): string { + return action.available ? base : `${base} - ${workflowDisabledLabel(action.disabledReason)}` +} + +function handleWorkflowResult(result: ClaudeWorkflowRecoveryActionResult, success: string): void { + if (result.ok) { + toast.success(success) + return + } + toast.error(workflowActionErrorToast(result)) +} + +const workflowMenuItemClassName = 'whitespace-nowrap' + +export function DashboardAgentWorkflowActions({ + workflowRecovery, + onOpenWorkflowParent, + fallbackOpenParent, + stopMouseDown, + stopKeyDown +}: Props) { + const [menuOpen, setMenuOpen] = React.useState(false) + const [tooltipOpen, setTooltipOpen] = React.useState(false) + const handleCopyWorkflowResume = (): void => { + void window.api.agentStatus + .copyWorkflowResumeCommand(workflowRecovery.workflowId) + .then((result) => handleWorkflowResult(result, 'Copied workflow resume text.')) + } + const handleRevealWorkflowScript = (): void => { + void window.api.agentStatus + .revealWorkflowScript(workflowRecovery.workflowId) + .then((result) => handleWorkflowResult(result, 'Revealed workflow script.')) + } + const handleRevealWorkflowTranscripts = (): void => { + void window.api.agentStatus + .revealWorkflowTranscripts(workflowRecovery.workflowId) + .then((result) => handleWorkflowResult(result, 'Revealed workflow transcripts.')) + } + const handleOpenWorkflowParent = (): void => { + const opened = onOpenWorkflowParent ? onOpenWorkflowParent(workflowRecovery) : true + if (opened) { + if (!onOpenWorkflowParent) { + fallbackOpenParent() + } + return + } + toast.error('Parent Claude pane is no longer available.') + } + + return ( + + + + + + + + + Workflow actions + + + event.stopPropagation()} + > + + + {formatWorkflowMenuLabel( + 'Copy resume command', + workflowRecovery.actions.copyResumeCommand + )} + + + + {formatWorkflowMenuLabel('Reveal workflow script', workflowRecovery.actions.revealScript)} + + + + {formatWorkflowMenuLabel( + 'Reveal transcripts', + workflowRecovery.actions.revealTranscripts + )} + + + + + {formatWorkflowMenuLabel( + 'Open parent Claude pane', + workflowRecovery.actions.openParentPane + )} + + + + ) +} diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 3bc98eee1ae..faacbabea81 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -11,6 +11,7 @@ import { import type { Repo, Worktree, TerminalTab } from '../../../../shared/types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' +import { refreshClaudeWorkflowRecoveryStaleState } from '../../../../shared/claude-workflow-actions' // ─── Shared data types ──────────────────────────────────────────────────────── @@ -80,7 +81,12 @@ function buildAgentRowsForWorktree( (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') rows.push({ paneKey: entry.paneKey, - entry, + entry: entry.workflowRecovery + ? { + ...entry, + workflowRecovery: refreshClaudeWorkflowRecoveryStaleState(entry.workflowRecovery, now) + } + : entry, tab, agentType: entry.agentType ?? 'unknown', state: shouldDecay ? 'idle' : entry.state, diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 16fea6e98d1..807206eb68b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -8,6 +8,7 @@ import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions' import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row' import { useFocusedAgentPaneKey } from './focused-agent-row-highlight' @@ -184,6 +185,30 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [worktreeId] ) + const handleOpenWorkflowParent = useCallback( + (workflow: ClaudeWorkflowRecoveryMetadata): boolean => { + if (workflow.worktreeId && workflow.worktreeId !== worktreeId) { + return false + } + const parsed = parsePaneKey(workflow.parentPaneKey) + if (!parsed) { + return false + } + const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] + if (!tabs.some((tab) => tab.id === parsed.tabId)) { + return false + } + activateAndRevealWorktree(worktreeId) + activateTabAndFocusPane(parsed.tabId, parsed.leafId, { + ackPaneKeyOnSuccess: workflow.parentPaneKey, + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + }) + return true + }, + [worktreeId] + ) + // Why: own one 30s tick per non-empty inline list. Cards with zero agents // never mount this component (see WorktreeCardAgents), so idle worktrees // don't pay any timer cost. @@ -241,6 +266,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ agent={agent} onDismiss={handleDismissAgent} onActivate={handleActivateAgentTab} + onOpenWorkflowParent={handleOpenWorkflowParent} now={now} // Why: bold an agent row until the user has visited its tab. // useAutoAckViewedAgent acks automatically when the user diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 0396bf1f929..45f691c5226 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -14,6 +14,7 @@ import { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' +import { refreshClaudeWorkflowRecoveryStaleState } from '../../../../shared/claude-workflow-actions' // Why: stable empty-array references so narrow selectors return the same // reference when there's nothing for this worktree. Without stable empties, @@ -247,7 +248,15 @@ export function buildWorktreeAgentRows(args: { (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') rows.push({ paneKey: entry.paneKey, - entry, + entry: entry.workflowRecovery + ? { + ...entry, + workflowRecovery: refreshClaudeWorkflowRecoveryStaleState( + entry.workflowRecovery, + args.now + ) + } + : entry, tab, agentType: entry.agentType ?? 'unknown', state: shouldDecay ? 'idle' : entry.state, @@ -263,7 +272,15 @@ export function buildWorktreeAgentRows(args: { } rows.push({ paneKey: ra.entry.paneKey, - entry: ra.entry, + entry: ra.entry.workflowRecovery + ? { + ...ra.entry, + workflowRecovery: refreshClaudeWorkflowRecoveryStaleState( + ra.entry.workflowRecovery, + args.now + ) + } + : ra.entry, tab: ra.tab, agentType: ra.agentType, state: 'done', diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5424f6d696d..d51f9d997d5 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2121,9 +2121,14 @@ export function useIpcEvents(): void { ) { return 'dropped' } - const statusPayload = data.orchestration - ? { ...payload, orchestration: data.orchestration } - : payload + const statusPayload = + data.orchestration || data.workflowRecovery + ? { + ...payload, + ...(data.orchestration ? { orchestration: data.orchestration } : {}), + ...(data.workflowRecovery ? { workflowRecovery: data.workflowRecovery } : {}) + } + : payload store.setAgentStatus(data.paneKey, statusPayload, title, { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts index 628529dd007..3ca4286bc37 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts @@ -46,7 +46,11 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes // future timer: the setAgentStatus write already bumped the epoch, so // freshness-aware selectors can decay them immediately on that render. for (const entry of entries) { - const expiryAt = entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS + const workflowExpiryAt = + entry.workflowRecovery?.hasActiveChildWork === true + ? entry.workflowRecovery.updatedAt + AGENT_STATUS_STALE_AFTER_MS + : Number.POSITIVE_INFINITY + const expiryAt = Math.min(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS, workflowExpiryAt) if (expiryAt > now) { nextExpiryAt = Math.min(nextExpiryAt, expiryAt) } diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 190bcf7e1bb..9048eec69e9 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -60,6 +60,42 @@ describe('agent status freshness expiry', () => { // No additional bump since the entry was removed before the timer fires expect(store.getState().agentStatusEpoch).toBe(2) }) + + it('advances agentStatusEpoch when workflow recovery metadata crosses stale threshold', async () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + + const store = createTestStore() + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'done', + prompt: 'p', + agentType: 'claude', + workflowRecovery: { + workflowId: 'wf-1', + parentPaneKey: 'tab-1:1', + updatedAt: 10_000, + hasActiveChildWork: true, + isStale: false, + isResumable: true, + actions: { + copyResumeCommand: { available: true }, + revealScript: { available: true }, + revealTranscripts: { available: true }, + openParentPane: { available: true } + } + } + }, + 'claude', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + + await flushMicrotasks() + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1) + + expect(store.getState().agentStatusEpoch).toBe(2) + }) }) describe('agent status tool + assistant fields', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index bc69374dace..16f216d9b76 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -14,6 +14,7 @@ import { import type { TerminalTab } from '../../../../shared/types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions' /** Snapshot of a finished (or vanished) agent status entry, kept around so * the dashboard + sidebar hover can continue showing the completion until the @@ -55,7 +56,10 @@ export type AgentStatusSlice = { /** Update or insert an agent status entry from a status payload. */ setAgentStatus: ( paneKey: string, - payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext }, + payload: ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + workflowRecovery?: ClaudeWorkflowRecoveryMetadata + }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number } ) => void @@ -257,6 +261,7 @@ export const createAgentStatusSlice: StateCreator { onSet: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), + copyWorkflowResumeCommand: () => Promise.resolve({ ok: false, reason: 'stale-id' }), + revealWorkflowScript: () => Promise.resolve({ ok: false, reason: 'stale-id' }), + revealWorkflowTranscripts: () => Promise.resolve({ ok: false, reason: 'stale-id' }), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3d79cd9cd06..7a72f7651d0 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -28,6 +28,10 @@ import { import { join } from 'path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' +import { + buildClaudeWorkflowId, + type ClaudeWorkflowRecoveryHookMetadata +} from './claude-workflow-actions' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' import { parsePaneKey } from './stable-pane-id' @@ -177,6 +181,9 @@ export type AgentHookEventPayload = { toolAgentId?: string /** Agent/subagent type from the source hook payload, when present. */ toolAgentType?: string + /** Raw Claude workflow recovery fields. Main converts this into renderer-safe + * action metadata and keeps the paths in its action lookup. */ + workflowRecoveryLookup?: ClaudeWorkflowRecoveryHookMetadata /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean payload: ParsedAgentStatusPayload @@ -550,6 +557,42 @@ function readFirstString( return undefined } +function readFirstFiniteNumber( + record: Record, + keys: readonly string[] +): number | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + if (typeof value === 'string') { + const parsed = Number(value) + if (Number.isFinite(parsed) && parsed > 0) { + return parsed + } + const time = Date.parse(value) + if (Number.isFinite(time) && time > 0) { + return time + } + } + } + return undefined +} + +function readFirstBoolean( + record: Record, + keys: readonly string[] +): boolean | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'boolean') { + return value + } + } + return undefined +} + function parseJsonObjectString(value: unknown): Record | undefined { if (typeof value !== 'string' || value.trim().length === 0) { return undefined @@ -564,6 +607,165 @@ function parseJsonObjectString(value: unknown): Record | undefi } } +function collectWorkflowRecords(value: unknown, depth = 0, out: Record[] = []) { + if (depth > 5 || typeof value !== 'object' || value === null) { + return out + } + if (Array.isArray(value)) { + for (const item of value) { + collectWorkflowRecords(item, depth + 1, out) + } + return out + } + const record = value as Record + out.push(record) + for (const nested of Object.values(record)) { + if (typeof nested === 'string') { + const parsed = parseJsonObjectString(nested) + if (parsed) { + collectWorkflowRecords(parsed, depth + 1, out) + } + } else { + collectWorkflowRecords(nested, depth + 1, out) + } + } + return out +} + +function readFirstWorkflowString( + records: readonly Record[], + keys: readonly string[] +): string | undefined { + for (const record of records) { + const value = readFirstString(record, keys) + if (value) { + return value + } + } + return undefined +} + +function readFirstWorkflowBoolean( + records: readonly Record[], + keys: readonly string[] +): boolean | undefined { + for (const record of records) { + const value = readFirstBoolean(record, keys) + if (value !== undefined) { + return value + } + } + return undefined +} + +function readFirstWorkflowNumber( + records: readonly Record[], + keys: readonly string[] +): number | undefined { + for (const record of records) { + const value = readFirstFiniteNumber(record, keys) + if (value !== undefined) { + return value + } + } + return undefined +} + +function normalizeWorkflowStatus(value: string | undefined): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[-_\s]+/g, '-') ?? '' + ) +} + +function extractClaudeWorkflowRecovery( + paneKey: string, + worktreeId: string | undefined, + hookPayload: Record +): ClaudeWorkflowRecoveryHookMetadata | undefined { + const toolName = readString(hookPayload, 'tool_name') + const candidateRecords = collectWorkflowRecords({ + payload: hookPayload, + tool_input: hookPayload.tool_input, + tool_response: hookPayload.tool_response + }) + const scriptPath = readFirstWorkflowString(candidateRecords, ['scriptPath', 'script_path']) + const resumeFromRunId = readFirstWorkflowString(candidateRecords, [ + 'resumeFromRunId', + 'resume_from_run_id' + ]) + const transcriptDir = readFirstWorkflowString(candidateRecords, [ + 'transcriptDir', + 'transcript_dir', + 'transcriptsDir', + 'transcripts_dir' + ]) + const transcriptPath = readFirstWorkflowString(candidateRecords, [ + 'transcriptPath', + 'transcript_path' + ]) + const looksLikeWorkflowTool = + typeof toolName === 'string' && toolName.trim().toLowerCase() === 'workflow' + if (!scriptPath && !resumeFromRunId && !looksLikeWorkflowTool) { + return undefined + } + + const explicitActive = readFirstWorkflowBoolean(candidateRecords, [ + 'hasActiveChildWork', + 'has_active_child_work', + 'activeChildWork', + 'active_child_work', + 'childActive', + 'child_active', + 'isRunning', + 'is_running', + 'active' + ]) + const status = normalizeWorkflowStatus( + readFirstWorkflowString(candidateRecords, ['workflowStatus', 'workflow_status', 'status']) + ) + const finished = + status === 'done' || + status === 'completed' || + status === 'complete' || + status === 'succeeded' || + status === 'success' || + status === 'failed' || + status === 'cancelled' || + status === 'canceled' + const hasActiveChildWork = explicitActive ?? !finished + + return { + workflowId: + readFirstWorkflowString(candidateRecords, ['workflowId', 'workflow_id']) ?? + buildClaudeWorkflowId({ + parentPaneKey: paneKey, + scriptPath, + resumeFromRunId, + transcriptDir, + transcriptPath + }), + parentPaneKey: + readFirstWorkflowString(candidateRecords, ['parentPaneKey', 'parent_pane_key']) ?? paneKey, + worktreeId, + scriptPath, + transcriptDir, + transcriptPath, + resumeFromRunId, + hasActiveChildWork, + updatedAt: readFirstWorkflowNumber(candidateRecords, [ + 'workflowUpdatedAt', + 'workflow_updated_at', + 'scriptMtimeMs', + 'script_mtime_ms', + 'scriptModifiedAt', + 'script_modified_at' + ]) + } +} + function extractToolResponseText(toolResponse: unknown): string | undefined { if (typeof toolResponse === 'string' && toolResponse.length > 0) { return toolResponse @@ -2858,6 +3060,10 @@ export function normalizeHookPayload( toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), toolAgentType: readString(hookPayloadRecord, 'agent_type'), + workflowRecoveryLookup: + source === 'claude' + ? extractClaudeWorkflowRecovery(paneKey, worktreeId, hookPayloadRecord) + : undefined, payload } : null diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 05f0d8c6b96..f3aa24af40e 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,3 +1,5 @@ +import type { ClaudeWorkflowRecoveryMetadata } from './claude-workflow-actions' + // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // These types define the normalized status that Orca receives from Claude, // Codex, and other explicit integrations. Agent state normally comes from @@ -102,6 +104,7 @@ export type AgentStatusEntry = { * Why: parent/child agent hierarchy is pane-level state, not worktree * lineage; workers often run in the same worktree as their coordinator. */ orchestration?: AgentStatusOrchestrationContext + workflowRecovery?: ClaudeWorkflowRecoveryMetadata } export type MigrationUnsupportedPtyEntry = { @@ -160,6 +163,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Timestamp (ms) when the current state first appeared for this pane. */ stateStartedAt: number orchestration?: AgentStatusOrchestrationContext + workflowRecovery?: ClaudeWorkflowRecoveryMetadata } /** Maximum character length for the prompt field. Truncated on parse. */ diff --git a/src/shared/claude-workflow-actions.test.ts b/src/shared/claude-workflow-actions.test.ts new file mode 100644 index 00000000000..6179efc6fdc --- /dev/null +++ b/src/shared/claude-workflow-actions.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { + buildClaudeWorkflowResumeText, + deriveClaudeWorkflowRecoveryMetadata +} from './claude-workflow-actions' +import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' + +describe('Claude workflow recovery actions', () => { + it('marks active workflows stale only after the workflow update threshold', () => { + const base = { + workflowId: 'wf-1', + parentPaneKey: 'tab:leaf', + connectionId: null, + scriptPath: '/tmp/workflow.js', + resumeFromRunId: 'run-1', + hasActiveChildWork: true, + receivedAt: 1_000, + workflowUpdatedAt: 1_000 + } + + expect( + deriveClaudeWorkflowRecoveryMetadata(base, 1_000 + AGENT_STATUS_STALE_AFTER_MS).isStale + ).toBe(false) + expect( + deriveClaudeWorkflowRecoveryMetadata(base, 1_001 + AGENT_STATUS_STALE_AFTER_MS).isStale + ).toBe(true) + expect( + deriveClaudeWorkflowRecoveryMetadata({ ...base, hasActiveChildWork: false }, 99_000_000) + .isStale + ).toBe(false) + }) + + it('requires both script path and resume run id for copy availability', () => { + const metadata = deriveClaudeWorkflowRecoveryMetadata( + { + workflowId: 'wf-1', + parentPaneKey: 'tab:leaf', + connectionId: null, + resumeFromRunId: 'run-1', + hasActiveChildWork: true, + receivedAt: 1_000 + }, + 1_000 + ) + + expect(metadata.isResumable).toBe(false) + expect(metadata.actions.copyResumeCommand).toEqual({ + available: false, + disabledReason: 'missing-script' + }) + }) + + it('keeps copy available for SSH workflows while disabling local reveal actions', () => { + const metadata = deriveClaudeWorkflowRecoveryMetadata( + { + workflowId: 'wf-1', + parentPaneKey: 'tab:leaf', + connectionId: 'conn-1', + scriptPath: '/remote/workflow.js', + transcriptDir: '/remote/transcripts', + resumeFromRunId: 'run-1', + hasActiveChildWork: true, + receivedAt: 1_000 + }, + 1_000 + ) + + expect(metadata.actions.copyResumeCommand).toEqual({ available: true }) + expect(metadata.actions.revealScript).toEqual({ + available: false, + disabledReason: 'remote-path' + }) + expect(metadata.actions.revealTranscripts).toEqual({ + available: false, + disabledReason: 'remote-path' + }) + }) + + it('formats resume paste text on separate editable lines', () => { + expect( + buildClaudeWorkflowResumeText({ + scriptPath: '/tmp/workflow.js', + resumeFromRunId: 'run-1' + }) + ).toBe( + [ + 'Resume this Claude Code workflow:', + 'scriptPath: /tmp/workflow.js', + 'resumeFromRunId: run-1' + ].join('\n') + ) + }) +}) diff --git a/src/shared/claude-workflow-actions.ts b/src/shared/claude-workflow-actions.ts new file mode 100644 index 00000000000..71104bee083 --- /dev/null +++ b/src/shared/claude-workflow-actions.ts @@ -0,0 +1,164 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' + +export type ClaudeWorkflowRecoveryDisabledReason = + | 'missing-script' + | 'missing-run-id' + | 'missing-transcripts' + | 'remote-path' + | 'invalid-path' + | 'parent-pane-gone' + +export type ClaudeWorkflowRecoveryActionAvailability = + | { available: true } + | { available: false; disabledReason: ClaudeWorkflowRecoveryDisabledReason } + +export type ClaudeWorkflowRecoveryActions = { + copyResumeCommand: ClaudeWorkflowRecoveryActionAvailability + revealScript: ClaudeWorkflowRecoveryActionAvailability + revealTranscripts: ClaudeWorkflowRecoveryActionAvailability + openParentPane: ClaudeWorkflowRecoveryActionAvailability +} + +export type ClaudeWorkflowRecoveryMetadata = { + workflowId: string + parentPaneKey: string + worktreeId?: string + updatedAt: number + hasActiveChildWork: boolean + isStale: boolean + isResumable: boolean + actions: ClaudeWorkflowRecoveryActions +} + +export type ClaudeWorkflowRecoveryActionFailureReason = + | 'missing-path' + | 'remote-path' + | 'not-found' + | 'wrong-kind' + | 'stale-id' + | 'clipboard-failed' + | 'launch-failed' + | 'invalid-path' + | 'missing-run-id' + +export type ClaudeWorkflowRecoveryActionResult = + | { ok: true } + | { ok: false; reason: ClaudeWorkflowRecoveryActionFailureReason } + +export type ClaudeWorkflowRecoveryHookMetadata = { + workflowId: string + parentPaneKey: string + worktreeId?: string + scriptPath?: string + transcriptDir?: string + transcriptPath?: string + resumeFromRunId?: string + hasActiveChildWork: boolean + updatedAt?: number +} + +export type ClaudeWorkflowRecoveryLookupEntry = ClaudeWorkflowRecoveryHookMetadata & { + connectionId: string | null + receivedAt: number +} + +export type ClaudeWorkflowRecoveryMetadataInput = { + workflowId: string + parentPaneKey: string + worktreeId?: string + connectionId: string | null + scriptPath?: string + transcriptDir?: string + transcriptPath?: string + resumeFromRunId?: string + hasActiveChildWork: boolean + receivedAt: number + workflowUpdatedAt?: number +} + +export function buildClaudeWorkflowResumeText(args: { + scriptPath: string + resumeFromRunId: string +}): string { + return [ + 'Resume this Claude Code workflow:', + `scriptPath: ${args.scriptPath}`, + `resumeFromRunId: ${args.resumeFromRunId}` + ].join('\n') +} + +export function buildClaudeWorkflowId(args: { + parentPaneKey: string + scriptPath?: string + resumeFromRunId?: string + transcriptDir?: string + transcriptPath?: string +}): string { + const input = [ + args.parentPaneKey, + args.scriptPath ?? '', + args.resumeFromRunId ?? '', + args.transcriptDir ?? '', + args.transcriptPath ?? '' + ].join('\0') + let hash = 0x811c9dc5 + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return `claude-workflow:${(hash >>> 0).toString(16).padStart(8, '0')}` +} + +export function deriveClaudeWorkflowRecoveryMetadata( + input: ClaudeWorkflowRecoveryMetadataInput, + now: number +): ClaudeWorkflowRecoveryMetadata { + const workflowUpdatedAt = input.workflowUpdatedAt ?? input.receivedAt + const hasScriptPath = typeof input.scriptPath === 'string' && input.scriptPath.trim().length > 0 + const hasRunId = + typeof input.resumeFromRunId === 'string' && input.resumeFromRunId.trim().length > 0 + const hasTranscripts = + (typeof input.transcriptDir === 'string' && input.transcriptDir.trim().length > 0) || + (typeof input.transcriptPath === 'string' && input.transcriptPath.trim().length > 0) + const isRemote = input.connectionId !== null + const isResumable = hasScriptPath && hasRunId + + return { + workflowId: input.workflowId, + parentPaneKey: input.parentPaneKey, + worktreeId: input.worktreeId, + updatedAt: workflowUpdatedAt, + hasActiveChildWork: input.hasActiveChildWork, + isStale: + input.hasActiveChildWork === true && + now - Math.max(workflowUpdatedAt, input.receivedAt) > AGENT_STATUS_STALE_AFTER_MS, + isResumable, + actions: { + copyResumeCommand: !hasScriptPath + ? { available: false, disabledReason: 'missing-script' } + : !hasRunId + ? { available: false, disabledReason: 'missing-run-id' } + : { available: true }, + revealScript: !hasScriptPath + ? { available: false, disabledReason: 'missing-script' } + : isRemote + ? { available: false, disabledReason: 'remote-path' } + : { available: true }, + revealTranscripts: !hasTranscripts + ? { available: false, disabledReason: 'missing-transcripts' } + : isRemote + ? { available: false, disabledReason: 'remote-path' } + : { available: true }, + openParentPane: { available: true } + } + } +} + +export function refreshClaudeWorkflowRecoveryStaleState( + metadata: ClaudeWorkflowRecoveryMetadata, + now: number +): ClaudeWorkflowRecoveryMetadata { + const isStale = + metadata.hasActiveChildWork === true && now - metadata.updatedAt > AGENT_STATUS_STALE_AFTER_MS + return metadata.isStale === isStale ? metadata : { ...metadata, isStale } +}