From ece956c2f6fc95a8ec5866e907ef32b5eb3cdc0c Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 13:54:39 +0200 Subject: [PATCH] Surface live worktree agents in the VS Code companion The extension now falls back to root-level AGENT.lock telemetry when no launcher session JSON exists, merges those synthetic rows with normal active-session records, and watches AGENT.lock changes so SCM stays live for direct gx worktree lanes. Constraint: Launcher session JSON is optional for direct gx branch start lanes Rejected: Require every live lane to run through codex-agent wrapper | misses existing worktree telemetry and keeps SCM blind Confidence: high Scope-risk: moderate Directive: Keep AGENT.lock fallback secondary to launcher-backed session rows and never duplicate the same worktree in SCM Tested: node --test test/vscode-active-agents-session-state.test.js Tested: openspec validate agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43 --type change --strict Tested: openspec validate --specs Not-tested: Manual VS Code window verification against a live workspace --- .../proposal.md | 17 ++ .../vscode-active-agents-extension/spec.md | 20 ++ .../tasks.md | 31 +++ .../vscode/guardex-active-agents/README.md | 2 +- .../vscode/guardex-active-agents/extension.js | 45 ++- .../vscode/guardex-active-agents/package.json | 2 + .../guardex-active-agents/session-schema.js | 262 ++++++++++++++++-- ...vscode-active-agents-session-state.test.js | 161 ++++++++++- vscode/guardex-active-agents/README.md | 2 +- vscode/guardex-active-agents/extension.js | 45 ++- vscode/guardex-active-agents/package.json | 2 + .../guardex-active-agents/session-schema.js | 262 ++++++++++++++++-- 12 files changed, 775 insertions(+), 76 deletions(-) create mode 100644 openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/proposal.md create mode 100644 openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/tasks.md diff --git a/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/proposal.md b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/proposal.md new file mode 100644 index 0000000..30af720 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/proposal.md @@ -0,0 +1,17 @@ +## Why + +- The GitGuardEx VS Code companion currently depends on `.omx/state/active-sessions/*.json`. +- That misses real live sandboxes when operators start a worktree with `gx branch start` and the live source is only the worktree-root `AGENT.lock` marker. +- Result: the Source Control view can look empty or stale even while a managed worktree is actively owned and changing. + +## What Changes + +- Teach the Active Agents companion to discover live managed worktrees from root-level `AGENT.lock` markers in `.omx/agent-worktrees/**` and `.omc/agent-worktrees/**`. +- Merge those synthetic live rows with the existing `.omx/state/active-sessions/*.json` records, preferring the richer session file when both exist for the same worktree. +- Keep the current SCM affordances, lock decorations, and change nesting behavior, but make refresh/watch logic observe worktree-root `AGENT.lock` files too. +- Document the fallback behavior in the extension README. + +## Risks + +- `AGENT.lock` is optional telemetry, so parsing must degrade safely and ignore invalid or stale files instead of crashing the view. +- Synthetic rows have no launcher PID, so stop/dead semantics must remain tied to wrapper session records only. diff --git a/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..8faeef5 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Active Agents view falls back to live managed worktree telemetry +The GitGuardEx Active Agents VS Code companion SHALL surface managed worktrees that expose a live root-level `AGENT.lock` marker, even when no `.omx/state/active-sessions/*.json` launcher record exists for that worktree. + +#### Scenario: Managed worktree lock creates a synthetic live row +- **WHEN** a managed Guardex worktree under `.omx/agent-worktrees/` or `.omc/agent-worktrees/` contains a valid `AGENT.lock` +- **AND** no `.omx/state/active-sessions/*.json` record exists for that same worktree +- **THEN** the Active Agents SCM view shows a live row for that worktree +- **AND** the row still derives `thinking` versus `working` from the worktree git state. + +#### Scenario: Wrapper session record wins over lock fallback +- **WHEN** both a valid `.omx/state/active-sessions/*.json` record and a valid root `AGENT.lock` exist for the same managed worktree +- **THEN** the companion renders a single row for that worktree +- **AND** it prefers the launcher-backed session metadata instead of duplicating the row. + +#### Scenario: Lock fallback refreshes with worktree telemetry updates +- **WHEN** a managed worktree `AGENT.lock` file is created, changed, or deleted +- **THEN** the Active Agents companion refreshes the affected SCM rows +- **AND** invalid lock payloads are ignored without crashing the view. diff --git a/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/tasks.md b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/tasks.md new file mode 100644 index 0000000..6e6b5c0 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43/tasks.md @@ -0,0 +1,31 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## 1. Specification + +- [x] 1.1 Capture the live-worktree telemetry problem and acceptance criteria in this change. +- [x] 1.2 Define normative requirements for `AGENT.lock` fallback discovery in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add a session-schema fallback that can synthesize live Active Agents rows from managed worktree `AGENT.lock` markers. +- [x] 2.2 Merge fallback worktree rows with existing `.omx/state/active-sessions/*.json` rows, preferring wrapper session data when both point at the same worktree. +- [x] 2.3 Refresh/watch the SCM companion on worktree-root `AGENT.lock` changes and keep current commit/change affordances working for synthetic rows. +- [x] 2.4 Update the extension README to describe the live worktree fallback. + +## 3. Verification + +- [x] 3.1 Run focused extension/session-state regression coverage. +- [x] 3.2 Run `openspec validate agent-codex-vscode-active-agents-live-worktree-telem-2026-04-22-13-43 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/vscode-active-agents-live-worktree-telem-2026-04-22-13-43 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 0cd0b42..9d74245 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -24,4 +24,4 @@ What it does: - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. - Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. -- Reads repo-local presence files from `.omx/state/active-sessions/`. +- Reads repo-local presence files from `.omx/state/active-sessions/` and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 9a8d554..4718b6c 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -15,7 +15,9 @@ const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; const SESSION_ACTIVITY_GROUPS = [ @@ -187,14 +189,23 @@ class SessionItem extends vscode.TreeItem { const tooltipLines = [ session.branch, `${session.agentName} 路 ${session.taskName}`, + session.latestTaskPreview && session.latestTaskPreview !== session.taskName + ? `Live task ${session.latestTaskPreview}` + : '', `Status ${this.description}`, session.changeCount > 0 ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, - session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + Number.isInteger(session.pid) && session.pid > 0 + ? session.pidAlive === false + ? `PID ${session.pid} not alive` + : `PID ${session.pid} alive` + : '', session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', - `Started ${session.startedAt}`, + session.sourceKind === 'worktree-lock' + ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` + : `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); @@ -365,6 +376,10 @@ function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromWorktreeLockFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..', '..'); +} + function repoRootFromLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..'); } @@ -479,16 +494,29 @@ function localizeChangeForSession(session, change) { } async function findRepoSessionEntries() { - const sessionFiles = await vscode.workspace.findFiles( - ACTIVE_SESSION_FILES_GLOB, - SESSION_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ); + const [sessionFiles, worktreeLockFiles] = await Promise.all([ + vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), + vscode.workspace.findFiles( + WORKTREE_AGENT_LOCKS_GLOB, + WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), + ]); const repoRoots = new Set(); for (const uri of sessionFiles) { repoRoots.add(repoRootFromSessionFile(uri.fsPath)); } + for (const uri of worktreeLockFiles) { + if (path.basename(uri.fsPath) !== 'AGENT.lock') { + continue; + } + repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath)); + } if (repoRoots.size === 0) { for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { @@ -1057,6 +1085,7 @@ function activate(context) { const refresh = () => void refreshController.refreshNow(); const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); + const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -1151,12 +1180,14 @@ function activate(context) { vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), activeSessionsWatcher, lockWatcher, + worktreeLockWatcher, { dispose: () => clearInterval(interval) }, ); context.subscriptions.push( ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), ); void refreshController.refreshNow(); } diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 8352ac8..3b5fa18 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -14,6 +14,8 @@ ], "activationEvents": [ "workspaceContains:.omx/state/active-sessions", + "workspaceContains:.omx/agent-worktrees", + "workspaceContains:.omc/agent-worktrees", "onView:gitguardex.activeAgents" ], "main": "./extension.js", diff --git a/templates/vscode/guardex-active-agents/session-schema.js b/templates/vscode/guardex-active-agents/session-schema.js index fd4feca..4d8ae5f 100644 --- a/templates/vscode/guardex-active-agents/session-schema.js +++ b/templates/vscode/guardex-active-agents/session-schema.js @@ -5,6 +5,11 @@ const cp = require('node:child_process'); const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions'); const SESSION_SCHEMA_VERSION = 1; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock'; +const MANAGED_WORKTREE_ROOTS = [ + path.join('.omx', 'agent-worktrees'), + path.join('.omc', 'agent-worktrees'), +]; const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); @@ -62,6 +67,10 @@ function sessionFilePathForBranch(repoRoot, branch) { return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch)); } +function resolveManagedWorktreeRoots(repoRoot) { + return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot)); +} + function splitOutputLines(output) { if (typeof output !== 'string') { return null; @@ -76,6 +85,24 @@ function normalizeRelativePath(value) { return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); } +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + return null; + } +} + +function normalizeIsoString(value, fallback = '') { + const normalized = toNonEmptyString(value); + if (!normalized) { + return fallback; + } + + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback; +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -200,8 +227,8 @@ function parseRepoChangeLine(repoRoot, line) { function collectWorktreeChangedPaths(worktreePath) { const changedGroups = [ - runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]), - runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]), + runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), + runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']), ]; @@ -210,7 +237,11 @@ function collectWorktreeChangedPaths(worktreePath) { } return [...new Set(changedGroups.flat())] - .filter((relativePath) => relativePath && relativePath !== LOCK_FILE_RELATIVE) + .filter((relativePath) => ( + relativePath + && relativePath !== LOCK_FILE_RELATIVE + && relativePath !== AGENT_WORKTREE_LOCK_FILE + )) .sort((left, right) => left.localeCompare(right)); } @@ -290,6 +321,8 @@ function deriveLatestWorktreeFileActivity(worktreePath) { function deriveSessionActivity(session, options = {}) { const now = Number.isFinite(options.now) ? options.now : Date.now(); + const pid = toPositiveInteger(session?.pid); + const pidAlive = pid ? isPidAlive(pid) : null; const blockingLabel = deriveBlockingGitLabel(session.worktreePath); if (blockingLabel) { return { @@ -299,14 +332,13 @@ function deriveSessionActivity(session, options = {}) { activitySummary: blockingLabel, changeCount: 0, changedPaths: [], - pidAlive: isPidAlive(session.pid), + pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', }; } - const pidAlive = isPidAlive(session.pid); - if (!pidAlive) { + if (pid && !pidAlive) { return { activityKind: 'dead', activityLabel: 'dead', @@ -426,6 +458,7 @@ function buildSessionRecord(input) { repoRoot, branch, taskName: toNonEmptyString(input.taskName, 'task'), + latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), worktreePath, pid, @@ -465,6 +498,7 @@ function normalizeSessionRecord(input, options = {}) { repoRoot: path.resolve(repoRoot), branch, taskName: toNonEmptyString(input.taskName, 'task'), + latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), worktreePath: path.resolve(worktreePath), pid, @@ -476,6 +510,12 @@ function normalizeSessionRecord(input, options = {}) { filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), changedPaths: [], + sourceKind: 'active-session', + telemetryUpdatedAt: '', + telemetrySource: '', + lockSnapshotCount: 0, + lockSessionCount: 0, + collaboration: false, }; } @@ -517,49 +557,212 @@ function isPidAlive(pid) { } } -function readActiveSessions(repoRoot, options = {}) { - const activeSessionsDir = activeSessionsDirForRepo(repoRoot); - if (!fs.existsSync(activeSessionsDir)) { - return []; +function readWorktreeBranch(worktreePath) { + const lines = runGitLines(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']); + return Array.isArray(lines) && typeof lines[0] === 'string' ? lines[0].trim() : ''; +} + +function deriveAgentNameFromBranch(branch) { + const parts = toNonEmptyString(branch).split('/').filter(Boolean); + if (parts.length >= 2 && parts[0] === 'agent') { + return parts[1]; + } + return 'agent'; +} + +function flattenTelemetrySnapshotSessions(lockPayload) { + const flattened = []; + const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; + for (const snapshot of snapshots) { + const snapshotSessions = Array.isArray(snapshot?.sessions) ? snapshot.sessions : []; + for (const session of snapshotSessions) { + flattened.push({ + taskPreview: toNonEmptyString(session?.taskPreview), + taskUpdatedAt: normalizeIsoString(session?.taskUpdatedAt), + projectName: toNonEmptyString(session?.projectName), + projectPath: toNonEmptyString(session?.projectPath), + snapshotName: toNonEmptyString(snapshot?.snapshotName), + email: toNonEmptyString(snapshot?.email), + }); + } } + return flattened; +} + +function sortSessionsByTimestamp(sessions) { + sessions.sort((left, right) => { + const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); + if (timeDelta !== 0) { + return timeDelta; + } + return left.label.localeCompare(right.label); + }); + return sessions; +} +function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { + const sortedEntries = [...entries].sort((left, right) => { + const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); + if (timeDelta !== 0) { + return timeDelta; + } + if (Boolean(right.taskPreview) !== Boolean(left.taskPreview)) { + return Number(Boolean(right.taskPreview)) - Number(Boolean(left.taskPreview)); + } + return (right.projectPath || '').localeCompare(left.projectPath || ''); + }); + + const latestEntry = sortedEntries[0] || null; + return { + taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', + latestTaskPreview: latestEntry?.taskPreview || '', + timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + }; +} + +function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = {}) { const now = options.now || Date.now(); + const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); + const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt); + const branch = readWorktreeBranch(worktreePath); + const effectiveBranch = branch && branch !== 'HEAD' + ? branch + : `agent/telemetry/${path.basename(worktreePath)}`; + const label = deriveSessionLabel(effectiveBranch, worktreePath); + const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); + const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); + + const session = { + schemaVersion: toPositiveInteger(lockPayload?.schemaVersion) || SESSION_SCHEMA_VERSION, + repoRoot: path.resolve(repoRoot), + branch: effectiveBranch, + taskName: taskAnchor.taskName, + latestTaskPreview: taskAnchor.latestTaskPreview, + agentName: deriveAgentNameFromBranch(effectiveBranch), + worktreePath: path.resolve(worktreePath), + pid: null, + cliName: 'codex', + taskMode: '', + openspecTier: '', + taskRoutingReason: '', + startedAt, + filePath: path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE), + label, + changedPaths: [], + sourceKind: 'worktree-lock', + telemetryUpdatedAt: telemetryUpdatedAt || startedAt, + telemetrySource: toNonEmptyString(lockPayload?.source, 'worktree-lock'), + lockSnapshotCount: toPositiveInteger(lockPayload?.snapshotCount) || 0, + lockSessionCount: toPositiveInteger(lockPayload?.sessionCount) || telemetryEntries.length, + collaboration: Boolean(lockPayload?.collaboration), + }; + + session.elapsedLabel = formatElapsedFrom(session.startedAt, now); + Object.assign(session, deriveSessionActivity(session, { now })); + return session; +} + +function readWorktreeLockSessions(repoRoot, options = {}) { const sessions = []; - for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { + for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { + if (!fs.existsSync(managedRoot)) { continue; } - const filePath = path.join(activeSessionsDir, entry.name); - let parsed; + let entries; try { - parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + entries = fs.readdirSync(managedRoot, { withFileTypes: true }); } catch (_error) { continue; } - const normalized = normalizeSessionRecord(parsed, { filePath }); - if (!normalized) { - continue; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const worktreePath = path.join(managedRoot, entry.name); + const lockPath = path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE); + if (!fs.existsSync(lockPath)) { + continue; + } + + const lockPayload = readJsonFile(lockPath); + if (!lockPayload || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) { + continue; + } + + const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); + if (telemetryEntries.length === 0 && !toPositiveInteger(lockPayload.sessionCount)) { + continue; + } + + sessions.push(buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options)); } - if (!options.includeStale && !isPidAlive(normalized.pid)) { + } + + return sortSessionsByTimestamp(sessions); +} + +function mergeSessionSources(primarySessions, lockSessions) { + const lockSessionsByWorktree = new Map( + lockSessions.map((session) => [path.resolve(session.worktreePath), session]), + ); + const consumedLockWorktrees = new Set(); + const merged = []; + + for (const session of primarySessions) { + const worktreeKey = path.resolve(session.worktreePath); + const lockSession = lockSessionsByWorktree.get(worktreeKey); + if (lockSession && session.activityKind === 'dead') { continue; } + if (lockSession) { + consumedLockWorktrees.add(worktreeKey); + } + merged.push(session); + } - normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized, { now })); - sessions.push(normalized); + for (const lockSession of lockSessions) { + const worktreeKey = path.resolve(lockSession.worktreePath); + if (!consumedLockWorktrees.has(worktreeKey)) { + merged.push(lockSession); + } } - sessions.sort((left, right) => { - const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); - if (timeDelta !== 0) { - return timeDelta; + return sortSessionsByTimestamp(merged); +} + +function readActiveSessions(repoRoot, options = {}) { + const activeSessionsDir = activeSessionsDirForRepo(repoRoot); + const now = options.now || Date.now(); + const sessionFileSessions = []; + if (fs.existsSync(activeSessionsDir)) { + for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + + const filePath = path.join(activeSessionsDir, entry.name); + const parsed = readJsonFile(filePath); + const normalized = normalizeSessionRecord(parsed, { filePath }); + if (!normalized) { + continue; + } + if (!options.includeStale && !isPidAlive(normalized.pid)) { + continue; + } + + normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); + sessionFileSessions.push(normalized); } - return left.label.localeCompare(right.label); - }); + } - return sessions; + return mergeSessionSources( + sortSessionsByTimestamp(sessionFileSessions), + readWorktreeLockSessions(repoRoot, { now }), + ); } function readRepoChanges(repoRoot) { @@ -592,6 +795,7 @@ module.exports = { parseRepoChangeLine, previewChangedPaths, readActiveSessions, + readWorktreeLockSessions, readRepoChanges, deriveRepoChangeStatus, resolveWorktreeGitDir, diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 2d4c517..7f14b3b 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -52,6 +52,50 @@ function writeSessionRecord(repoRoot, record) { return sessionPath; } +function buildWorktreeLockPayload(worktreePath, overrides = {}) { + return { + schemaVersion: 1, + source: 'recodee-live-telemetry', + updatedAt: '2026-04-22T08:56:00.000Z', + worktreePath, + worktreeName: path.basename(worktreePath), + collaboration: false, + snapshotCount: 1, + sessionCount: 1, + snapshots: [ + { + snapshotName: 'snapshot-a', + accountId: 'acct-1', + email: 'agent@example.com', + liveSessionCount: 1, + trackedSessionCount: 1, + compatSessionCount: 1, + sessions: [ + { + sessionKey: 'pid:101', + taskPreview: 'Implement live worktree telemetry', + taskUpdatedAt: '2026-04-22T08:55:00.000Z', + projectName: 'gitguardex', + projectPath: worktreePath, + }, + ], + }, + ], + ...overrides, + }; +} + +function writeWorktreeLock(worktreePath, overrides = {}) { + const lockPath = path.join(worktreePath, 'AGENT.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify(buildWorktreeLockPayload(worktreePath, overrides), null, 2)}\n`, + 'utf8', + ); + return lockPath; +} + function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { const Module = require('node:module'); const originalLoad = Module._load; @@ -470,6 +514,62 @@ test('session-schema ignores stale or invalid session records', () => { ); }); +test('session-schema falls back to managed worktree AGENT.lock telemetry when launcher state is absent', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-fallback-')); + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__live-lock-task', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/live-lock-task']); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + writeWorktreeLock(worktreePath); + + const [session] = sessionSchema.readActiveSessions(tempRoot); + assert.equal(session.sourceKind, 'worktree-lock'); + assert.equal(session.branch, 'agent/codex/live-lock-task'); + assert.equal(session.agentName, 'codex'); + assert.equal(session.taskName, 'Implement live worktree telemetry'); + assert.equal(session.activityKind, 'idle'); + assert.equal(session.telemetrySource, 'recodee-live-telemetry'); + assert.equal(session.telemetryUpdatedAt, '2026-04-22T08:56:00.000Z'); +}); + +test('session-schema prefers live worktree telemetry over a dead launcher record for the same worktree', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-prefer-')); + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__replace-dead-session', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/replace-dead-session']); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + writeWorktreeLock(worktreePath, { + updatedAt: '2026-04-22T08:57:00.000Z', + }); + writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/replace-dead-session', + taskName: 'replace-dead-session', + agentName: 'codex', + worktreePath, + pid: 999999, + cliName: 'codex', + })); + + const [session] = sessionSchema.readActiveSessions(tempRoot, { includeStale: true }); + assert.equal(session.sourceKind, 'worktree-lock'); + assert.equal(session.activityKind, 'idle'); +}); + test('session-schema derives working activity from dirty sandbox worktrees', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-working-')); const worktreePath = path.join(tempRoot, 'sandbox'); @@ -640,12 +740,13 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.decorationProviders.length, 1); - assert.equal(registrations.fileWatchers.length, 2); + assert.equal(registrations.fileWatchers.length, 3); assert.deepEqual( registrations.fileWatchers.map((watcher) => watcher.pattern), [ '**/.omx/state/active-sessions/*.json', '**/.omx/state/agent-file-locks.json', + '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', ], ); assert.equal(registrations.workspaceFolderListeners.length, 1); @@ -998,6 +1099,60 @@ test('active-agents extension shows grouped repo changes beside active agents', } }); +test('active-agents extension surfaces live managed worktrees from AGENT.lock fallback', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-worktree-lock-view-')); + initGitRepo(tempRoot); + + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__lock-visible-task', + ); + initGitRepo(worktreePath); + runGit(worktreePath, ['checkout', '-b', 'agent/codex/lock-visible-task']); + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'src/live.js']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\nchanged\n', 'utf8'); + const lockPath = writeWorktreeLock(worktreePath, { + updatedAt: '2026-04-22T09:01:00.000Z', + }); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async (pattern) => { + if (pattern === '**/.omx/state/active-sessions/*.json') { + return []; + } + if (pattern === '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock') { + return [{ fsPath: lockPath }]; + } + return []; + }; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + assert.equal(repoItem.description, '1 active 路 1 working 路 1 changed'); + + const [agentsSection] = await provider.getChildren(repoItem); + const [workingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(workingSection); + assert.equal(workingSection.label, 'WORKING NOW'); + assert.equal(sessionItem.label, `${path.basename(worktreePath)} 馃敀 0`); + assert.match(sessionItem.description, /^working 路 1 file 路 /); + assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); initGitRepo(tempRoot); @@ -1339,6 +1494,7 @@ test('active-agents extension watches active sessions, lock files, and session g [ '**/.omx/state/active-sessions/*.json', '**/.omx/state/agent-file-locks.json', + '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', path.join(worktreePath, '.git', 'index'), ], ); @@ -1349,7 +1505,7 @@ test('active-agents extension watches active sessions, lock files, and session g await new Promise((resolve) => setTimeout(resolve, 350)); await flushAsyncWork(); - assert.equal(registrations.fileWatchers[2].disposed, true); + assert.equal(registrations.fileWatchers[3].disposed, true); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -1370,6 +1526,7 @@ test('active-agents extension debounces refresh events with a trailing 250ms tim registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') }); registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') }); + registrations.fileWatchers[2].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', 'AGENT.lock') }); assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0); await new Promise((resolve) => setTimeout(resolve, 300)); diff --git a/vscode/guardex-active-agents/README.md b/vscode/guardex-active-agents/README.md index 0cd0b42..9d74245 100644 --- a/vscode/guardex-active-agents/README.md +++ b/vscode/guardex-active-agents/README.md @@ -24,4 +24,4 @@ What it does: - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. - Uses distinct VS Code codicons for each session state: `warning`, `edit`, `loading~spin`, `clock`, and `error`. -- Reads repo-local presence files from `.omx/state/active-sessions/`. +- Reads repo-local presence files from `.omx/state/active-sessions/` and falls back to managed worktree-root `AGENT.lock` telemetry when the launcher session file is absent. diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 9a8d554..4718b6c 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -15,7 +15,9 @@ const IDLE_ERROR_MS = 30 * 60 * 1000; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; +const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; const SESSION_ACTIVITY_GROUPS = [ @@ -187,14 +189,23 @@ class SessionItem extends vscode.TreeItem { const tooltipLines = [ session.branch, `${session.agentName} 路 ${session.taskName}`, + session.latestTaskPreview && session.latestTaskPreview !== session.taskName + ? `Live task ${session.latestTaskPreview}` + : '', `Status ${this.description}`, session.changeCount > 0 ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` : session.activitySummary, `Locks ${lockCount}`, - session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`, + Number.isInteger(session.pid) && session.pid > 0 + ? session.pidAlive === false + ? `PID ${session.pid} not alive` + : `PID ${session.pid} alive` + : '', session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', - `Started ${session.startedAt}`, + session.sourceKind === 'worktree-lock' + ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` + : `Started ${session.startedAt}`, session.worktreePath, ]; this.tooltip = tooltipLines.filter(Boolean).join('\n'); @@ -365,6 +376,10 @@ function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromWorktreeLockFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..', '..'); +} + function repoRootFromLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..'); } @@ -479,16 +494,29 @@ function localizeChangeForSession(session, change) { } async function findRepoSessionEntries() { - const sessionFiles = await vscode.workspace.findFiles( - ACTIVE_SESSION_FILES_GLOB, - SESSION_SCAN_EXCLUDE_GLOB, - SESSION_SCAN_LIMIT, - ); + const [sessionFiles, worktreeLockFiles] = await Promise.all([ + vscode.workspace.findFiles( + ACTIVE_SESSION_FILES_GLOB, + SESSION_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), + vscode.workspace.findFiles( + WORKTREE_AGENT_LOCKS_GLOB, + WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), + ]); const repoRoots = new Set(); for (const uri of sessionFiles) { repoRoots.add(repoRootFromSessionFile(uri.fsPath)); } + for (const uri of worktreeLockFiles) { + if (path.basename(uri.fsPath) !== 'AGENT.lock') { + continue; + } + repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath)); + } if (repoRoots.size === 0) { for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { @@ -1057,6 +1085,7 @@ function activate(context) { const refresh = () => void refreshController.refreshNow(); const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); + const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; @@ -1151,12 +1180,14 @@ function activate(context) { vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), activeSessionsWatcher, lockWatcher, + worktreeLockWatcher, { dispose: () => clearInterval(interval) }, ); context.subscriptions.push( ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), + ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), ); void refreshController.refreshNow(); } diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 8352ac8..3b5fa18 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -14,6 +14,8 @@ ], "activationEvents": [ "workspaceContains:.omx/state/active-sessions", + "workspaceContains:.omx/agent-worktrees", + "workspaceContains:.omc/agent-worktrees", "onView:gitguardex.activeAgents" ], "main": "./extension.js", diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index fd4feca..4d8ae5f 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -5,6 +5,11 @@ const cp = require('node:child_process'); const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions'); const SESSION_SCHEMA_VERSION = 1; const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); +const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock'; +const MANAGED_WORKTREE_ROOTS = [ + path.join('.omx', 'agent-worktrees'), + path.join('.omc', 'agent-worktrees'), +]; const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); @@ -62,6 +67,10 @@ function sessionFilePathForBranch(repoRoot, branch) { return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch)); } +function resolveManagedWorktreeRoots(repoRoot) { + return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot)); +} + function splitOutputLines(output) { if (typeof output !== 'string') { return null; @@ -76,6 +85,24 @@ function normalizeRelativePath(value) { return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, ''); } +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + return null; + } +} + +function normalizeIsoString(value, fallback = '') { + const normalized = toNonEmptyString(value); + if (!normalized) { + return fallback; + } + + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback; +} + function runGitLines(worktreePath, args) { try { const output = cp.execFileSync('git', ['-C', worktreePath, ...args], { @@ -200,8 +227,8 @@ function parseRepoChangeLine(repoRoot, line) { function collectWorktreeChangedPaths(worktreePath) { const changedGroups = [ - runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]), - runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]), + runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), + runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']), ]; @@ -210,7 +237,11 @@ function collectWorktreeChangedPaths(worktreePath) { } return [...new Set(changedGroups.flat())] - .filter((relativePath) => relativePath && relativePath !== LOCK_FILE_RELATIVE) + .filter((relativePath) => ( + relativePath + && relativePath !== LOCK_FILE_RELATIVE + && relativePath !== AGENT_WORKTREE_LOCK_FILE + )) .sort((left, right) => left.localeCompare(right)); } @@ -290,6 +321,8 @@ function deriveLatestWorktreeFileActivity(worktreePath) { function deriveSessionActivity(session, options = {}) { const now = Number.isFinite(options.now) ? options.now : Date.now(); + const pid = toPositiveInteger(session?.pid); + const pidAlive = pid ? isPidAlive(pid) : null; const blockingLabel = deriveBlockingGitLabel(session.worktreePath); if (blockingLabel) { return { @@ -299,14 +332,13 @@ function deriveSessionActivity(session, options = {}) { activitySummary: blockingLabel, changeCount: 0, changedPaths: [], - pidAlive: isPidAlive(session.pid), + pidAlive, lastFileActivityAt: '', lastFileActivityLabel: '', }; } - const pidAlive = isPidAlive(session.pid); - if (!pidAlive) { + if (pid && !pidAlive) { return { activityKind: 'dead', activityLabel: 'dead', @@ -426,6 +458,7 @@ function buildSessionRecord(input) { repoRoot, branch, taskName: toNonEmptyString(input.taskName, 'task'), + latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), worktreePath, pid, @@ -465,6 +498,7 @@ function normalizeSessionRecord(input, options = {}) { repoRoot: path.resolve(repoRoot), branch, taskName: toNonEmptyString(input.taskName, 'task'), + latestTaskPreview: '', agentName: toNonEmptyString(input.agentName, 'agent'), worktreePath: path.resolve(worktreePath), pid, @@ -476,6 +510,12 @@ function normalizeSessionRecord(input, options = {}) { filePath: toNonEmptyString(options.filePath), label: deriveSessionLabel(branch, worktreePath), changedPaths: [], + sourceKind: 'active-session', + telemetryUpdatedAt: '', + telemetrySource: '', + lockSnapshotCount: 0, + lockSessionCount: 0, + collaboration: false, }; } @@ -517,49 +557,212 @@ function isPidAlive(pid) { } } -function readActiveSessions(repoRoot, options = {}) { - const activeSessionsDir = activeSessionsDirForRepo(repoRoot); - if (!fs.existsSync(activeSessionsDir)) { - return []; +function readWorktreeBranch(worktreePath) { + const lines = runGitLines(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']); + return Array.isArray(lines) && typeof lines[0] === 'string' ? lines[0].trim() : ''; +} + +function deriveAgentNameFromBranch(branch) { + const parts = toNonEmptyString(branch).split('/').filter(Boolean); + if (parts.length >= 2 && parts[0] === 'agent') { + return parts[1]; + } + return 'agent'; +} + +function flattenTelemetrySnapshotSessions(lockPayload) { + const flattened = []; + const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : []; + for (const snapshot of snapshots) { + const snapshotSessions = Array.isArray(snapshot?.sessions) ? snapshot.sessions : []; + for (const session of snapshotSessions) { + flattened.push({ + taskPreview: toNonEmptyString(session?.taskPreview), + taskUpdatedAt: normalizeIsoString(session?.taskUpdatedAt), + projectName: toNonEmptyString(session?.projectName), + projectPath: toNonEmptyString(session?.projectPath), + snapshotName: toNonEmptyString(snapshot?.snapshotName), + email: toNonEmptyString(snapshot?.email), + }); + } } + return flattened; +} + +function sortSessionsByTimestamp(sessions) { + sessions.sort((left, right) => { + const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); + if (timeDelta !== 0) { + return timeDelta; + } + return left.label.localeCompare(right.label); + }); + return sessions; +} +function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) { + const sortedEntries = [...entries].sort((left, right) => { + const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || ''); + if (timeDelta !== 0) { + return timeDelta; + } + if (Boolean(right.taskPreview) !== Boolean(left.taskPreview)) { + return Number(Boolean(right.taskPreview)) - Number(Boolean(left.taskPreview)); + } + return (right.projectPath || '').localeCompare(left.projectPath || ''); + }); + + const latestEntry = sortedEntries[0] || null; + return { + taskName: latestEntry?.taskPreview || fallbackTaskName || 'task', + latestTaskPreview: latestEntry?.taskPreview || '', + timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '', + }; +} + +function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = {}) { const now = options.now || Date.now(); + const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); + const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt); + const branch = readWorktreeBranch(worktreePath); + const effectiveBranch = branch && branch !== 'HEAD' + ? branch + : `agent/telemetry/${path.basename(worktreePath)}`; + const label = deriveSessionLabel(effectiveBranch, worktreePath); + const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); + const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString(); + + const session = { + schemaVersion: toPositiveInteger(lockPayload?.schemaVersion) || SESSION_SCHEMA_VERSION, + repoRoot: path.resolve(repoRoot), + branch: effectiveBranch, + taskName: taskAnchor.taskName, + latestTaskPreview: taskAnchor.latestTaskPreview, + agentName: deriveAgentNameFromBranch(effectiveBranch), + worktreePath: path.resolve(worktreePath), + pid: null, + cliName: 'codex', + taskMode: '', + openspecTier: '', + taskRoutingReason: '', + startedAt, + filePath: path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE), + label, + changedPaths: [], + sourceKind: 'worktree-lock', + telemetryUpdatedAt: telemetryUpdatedAt || startedAt, + telemetrySource: toNonEmptyString(lockPayload?.source, 'worktree-lock'), + lockSnapshotCount: toPositiveInteger(lockPayload?.snapshotCount) || 0, + lockSessionCount: toPositiveInteger(lockPayload?.sessionCount) || telemetryEntries.length, + collaboration: Boolean(lockPayload?.collaboration), + }; + + session.elapsedLabel = formatElapsedFrom(session.startedAt, now); + Object.assign(session, deriveSessionActivity(session, { now })); + return session; +} + +function readWorktreeLockSessions(repoRoot, options = {}) { const sessions = []; - for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { + for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) { + if (!fs.existsSync(managedRoot)) { continue; } - const filePath = path.join(activeSessionsDir, entry.name); - let parsed; + let entries; try { - parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + entries = fs.readdirSync(managedRoot, { withFileTypes: true }); } catch (_error) { continue; } - const normalized = normalizeSessionRecord(parsed, { filePath }); - if (!normalized) { - continue; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const worktreePath = path.join(managedRoot, entry.name); + const lockPath = path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE); + if (!fs.existsSync(lockPath)) { + continue; + } + + const lockPayload = readJsonFile(lockPath); + if (!lockPayload || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) { + continue; + } + + const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); + if (telemetryEntries.length === 0 && !toPositiveInteger(lockPayload.sessionCount)) { + continue; + } + + sessions.push(buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options)); } - if (!options.includeStale && !isPidAlive(normalized.pid)) { + } + + return sortSessionsByTimestamp(sessions); +} + +function mergeSessionSources(primarySessions, lockSessions) { + const lockSessionsByWorktree = new Map( + lockSessions.map((session) => [path.resolve(session.worktreePath), session]), + ); + const consumedLockWorktrees = new Set(); + const merged = []; + + for (const session of primarySessions) { + const worktreeKey = path.resolve(session.worktreePath); + const lockSession = lockSessionsByWorktree.get(worktreeKey); + if (lockSession && session.activityKind === 'dead') { continue; } + if (lockSession) { + consumedLockWorktrees.add(worktreeKey); + } + merged.push(session); + } - normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); - Object.assign(normalized, deriveSessionActivity(normalized, { now })); - sessions.push(normalized); + for (const lockSession of lockSessions) { + const worktreeKey = path.resolve(lockSession.worktreePath); + if (!consumedLockWorktrees.has(worktreeKey)) { + merged.push(lockSession); + } } - sessions.sort((left, right) => { - const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt); - if (timeDelta !== 0) { - return timeDelta; + return sortSessionsByTimestamp(merged); +} + +function readActiveSessions(repoRoot, options = {}) { + const activeSessionsDir = activeSessionsDirForRepo(repoRoot); + const now = options.now || Date.now(); + const sessionFileSessions = []; + if (fs.existsSync(activeSessionsDir)) { + for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + + const filePath = path.join(activeSessionsDir, entry.name); + const parsed = readJsonFile(filePath); + const normalized = normalizeSessionRecord(parsed, { filePath }); + if (!normalized) { + continue; + } + if (!options.includeStale && !isPidAlive(normalized.pid)) { + continue; + } + + normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now); + Object.assign(normalized, deriveSessionActivity(normalized, { now })); + sessionFileSessions.push(normalized); } - return left.label.localeCompare(right.label); - }); + } - return sessions; + return mergeSessionSources( + sortSessionsByTimestamp(sessionFileSessions), + readWorktreeLockSessions(repoRoot, { now }), + ); } function readRepoChanges(repoRoot) { @@ -592,6 +795,7 @@ module.exports = { parseRepoChangeLine, previewChangedPaths, readActiveSessions, + readWorktreeLockSessions, readRepoChanges, deriveRepoChangeStatus, resolveWorktreeGitDir,