Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ extension/.vscode/
extension/package-lock.json

.DS_Store
__pycache__/
validation/
8 changes: 7 additions & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { JsonlEventSource } from './event-source'
import { WebviewToExtensionMessage } from './protocol'
import { startClaudeRuntime } from './claude-runtime'
import { startCodexRuntime } from './codex-runtime'
import { startStrandsRuntime } from './strands-runtime'
import { promptHookSetupIfNeeded, configureClaudeHooks, isDisable1MContext } from './hooks-config'
import { createLogger } from './logger'
import type { AgentRuntime, AgentRuntimeMode } from './session-runtime'
Expand All @@ -17,7 +18,7 @@ let runtimes: AgentRuntime[] = []

function readConfiguredMode(): ConfiguredRuntimeMode {
const raw = vscode.workspace.getConfiguration('agentVisualizer').get<string>('runtime', 'auto')
return raw === 'claude' || raw === 'codex' ? raw : 'auto'
return raw === 'claude' || raw === 'codex' || raw === 'strands' ? raw : 'auto'
}

interface StartRuntimesResult {
Expand All @@ -41,6 +42,11 @@ async function startRuntimes(
try { runtimes.push(startCodexRuntime(context)) }
catch (err) { log.error('Codex runtime failed to start:', err); failures.push('codex') }
}
if (mode === 'strands' || mode === 'auto') {
log.info('Starting Strands runtime...')
try { runtimes.push(startStrandsRuntime(context)) }
catch (err) { log.error('Strands runtime failed to start:', err); failures.push('strands') }
}
return { runtimes, failures }
}

Expand Down
2 changes: 1 addition & 1 deletion extension/src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { VisualizerPanel } from './webview-provider'
import { SESSION_ID_DISPLAY, STATUS_MESSAGE_DURATION_MS } from './constants'
import type { TypedDisposable, TypedEvent } from './typed-event-emitter'

export type AgentRuntimeMode = 'claude' | 'codex'
export type AgentRuntimeMode = 'claude' | 'codex' | 'strands'

export interface SessionLifecycleEvent {
type: 'started' | 'ended' | 'updated'
Expand Down
40 changes: 40 additions & 0 deletions extension/src/strands-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Strands runtime.
*
* Strands agents emit agent-flow events via the AgentFlowHookProvider, which
* writes native AgentEvent JSONL to ~/.strands/agent-flow/. This runtime wires
* StrandsSessionWatcher to the visualizer panel.
*/

import * as vscode from 'vscode'
import * as os from 'os'
import { StrandsSessionWatcher } from './strands-session-watcher'
import { createLogger } from './logger'
import { wireWatcherToPanel } from './session-runtime'
import type { AgentRuntime } from './session-runtime'

const log = createLogger('StrandsRuntime')

export function startStrandsRuntime(context: vscode.ExtensionContext): AgentRuntime {
const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null
const watcher = new StrandsSessionWatcher(workspace)
context.subscriptions.push(watcher)

const wiring = wireWatcherToPanel(watcher, {
sessionLabelPrefix: 'Strands',
})

watcher.start()

const homeLabel = process.env.STRANDS_HOME
? process.env.STRANDS_HOME.replace(os.homedir(), '~')
: '~/.strands'

const connectionStatus = (): string => `Strands session watcher (${homeLabel}/agent-flow)`

const dispose = (): void => { wiring.dispose(); watcher.dispose() }

log.info(`Strands runtime started (home: ${homeLabel})`)

return { mode: 'strands', watcher, connectionStatus, dispose }
}
294 changes: 294 additions & 0 deletions extension/src/strands-session-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
/**
* Watches Strands agent-flow JSONL files at ~/.strands/agent-flow/<session-id>.jsonl
*
* The Strands AgentFlowHookProvider writes one JSONL file per agent session.
* Each line is a complete AgentEvent in agent-flow's native schema, so no
* parser/translation layer is needed — just JSON.parse + validate.
*
* Discovery scans the agent-flow directory for recent .jsonl files, optionally
* filtering by workspace cwd (read from the first agent_spawn payload).
* Respects STRANDS_HOME for non-default installs.
*/

import * as fs from 'fs'
import * as path from 'path'
import * as os from 'os'
import { AgentEvent, SessionInfo } from './protocol'
import {
ACTIVE_SESSION_AGE_S, INACTIVITY_TIMEOUT_MS, ORCHESTRATOR_NAME,
POLL_FALLBACK_MS, SCAN_INTERVAL_MS, SESSION_ID_DISPLAY,
} from './constants'
import { readNewFileLines } from './fs-utils'
import { createLogger } from './logger'
import type { AgentSessionWatcher, SessionLifecycleEvent } from './session-runtime'
import { TypedEventEmitter } from './typed-event-emitter'

const log = createLogger('StrandsSessionWatcher')

/** Extract session ID from filename: <uuid>.jsonl */
const SESSION_ID_FROM_FILENAME = /^([0-9a-f-]{36})\.jsonl$/

interface WatchedStrandsSession {
sessionId: string
filePath: string
fileWatcher: fs.FSWatcher | null
pollTimer: NodeJS.Timeout | null
inactivityTimer: NodeJS.Timeout | null
fileSize: number
fileTail: string
sessionStartTime: number
lastActivityTime: number
sessionDetected: boolean
sessionCompleted: boolean
label: string
}

function strandsHome(): string {
return process.env.STRANDS_HOME || path.join(os.homedir(), '.strands')
}

function agentFlowDir(): string {
return path.join(strandsHome(), 'agent-flow')
}

/** Read the first line of a session file to extract cwd from agent_spawn payload. */
function readSessionCwd(filePath: string): string | null {
try {
const fd = fs.openSync(filePath, 'r')
try {
const buf = Buffer.alloc(4096)
const read = fs.readSync(fd, buf, 0, buf.length, 0)
const firstNewline = buf.subarray(0, read).indexOf(0x0a)
const end = firstNewline >= 0 ? firstNewline : read
const line = buf.slice(0, end).toString('utf-8')
const parsed = JSON.parse(line) as { type?: string; payload?: { cwd?: string } }
if (parsed.type !== 'agent_spawn') return null
return typeof parsed.payload?.cwd === 'string' ? parsed.payload.cwd : null
} finally { fs.closeSync(fd) }
} catch { return null }
}

export class StrandsSessionWatcher implements AgentSessionWatcher {
private dirWatcher: fs.FSWatcher | null = null
private sessions = new Map<string, WatchedStrandsSession>()
private workspacePath: string | null = null
private scanInterval: NodeJS.Timeout | null = null

private readonly _onEvent = new TypedEventEmitter<AgentEvent>()
private readonly _onSessionDetected = new TypedEventEmitter<string>()
private readonly _onSessionLifecycle = new TypedEventEmitter<SessionLifecycleEvent>()

readonly onEvent = this._onEvent.event
readonly onSessionDetected = this._onSessionDetected.event
readonly onSessionLifecycle = this._onSessionLifecycle.event

constructor(private readonly workspace?: string | null) {}

isActive(): boolean {
for (const s of this.sessions.values()) {
if (s.sessionDetected && !s.sessionCompleted) return true
}
return false
}

isSessionActive(sessionId: string): boolean {
const s = this.sessions.get(sessionId)
return !!s && s.sessionDetected && !s.sessionCompleted
}

getActiveSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map(s => ({
id: s.sessionId,
label: s.label,
status: s.sessionCompleted ? 'completed' : 'active',
startTime: s.sessionStartTime,
lastActivityTime: s.lastActivityTime,
}))
}

replaySessionStart(sessionIds?: string[]): void {
for (const [id, session] of this.sessions) {
if (!session.sessionDetected) continue
if (sessionIds && !sessionIds.includes(id)) continue
this._onSessionLifecycle.fire({ type: 'started', sessionId: id, label: session.label })
}
}

start(): void {
if (this.workspace) {
try { this.workspacePath = fs.realpathSync(this.workspace) }
catch { this.workspacePath = this.workspace }
}

const dir = agentFlowDir()
if (!fs.existsSync(dir)) {
try { fs.mkdirSync(dir, { recursive: true }) }
catch (err) { log.debug('Failed to create agent-flow dir:', err) }
}

this.scanForSessions()
this.scanInterval = setInterval(() => this.scanForSessions(), SCAN_INTERVAL_MS)

if (fs.existsSync(dir)) {
try {
this.dirWatcher = fs.watch(dir, () => this.scanForSessions())
} catch (err) { log.debug('Dir watch failed:', err) }
}

log.info(`Watching ${dir} for workspace ${this.workspacePath ?? '<any>'}`)
}

private scanForSessions(): void {
const dir = agentFlowDir()
if (!fs.existsSync(dir)) return

let entries: string[]
try { entries = fs.readdirSync(dir) }
catch { return }

for (const name of entries) {
if (!name.endsWith('.jsonl')) continue
const m = name.match(SESSION_ID_FROM_FILENAME)
const sessionId = m ? m[1] : name.replace('.jsonl', '')
if (this.sessions.has(sessionId)) continue

const filePath = path.join(dir, name)

let stat: fs.Stats
try { stat = fs.statSync(filePath) } catch { continue }
if (stat.size === 0) continue
const ageS = (Date.now() - stat.mtimeMs) / 1000
if (ageS > ACTIVE_SESSION_AGE_S) continue

if (this.workspacePath) {
const cwd = readSessionCwd(filePath)
if (cwd !== null) {
const resolvedCwd = this.resolvePath(cwd)
if (resolvedCwd && !this.pathMatchesWorkspace(resolvedCwd)) continue
}
}

this.attachSession(sessionId, filePath, stat)
}
}

private resolvePath(p: string): string | null {
try { return fs.realpathSync(p) } catch { return p }
}

private pathMatchesWorkspace(p: string): boolean {
if (!this.workspacePath) return true
if (p === this.workspacePath) return true
return p.startsWith(this.workspacePath + path.sep)
}

private attachSession(sessionId: string, filePath: string, stat: fs.Stats): void {
const label = `Strands ${sessionId.slice(0, SESSION_ID_DISPLAY)}`

const session: WatchedStrandsSession = {
sessionId,
filePath,
fileWatcher: null,
pollTimer: null,
inactivityTimer: null,
fileSize: 0,
fileTail: '',
sessionStartTime: stat.birthtimeMs || stat.mtimeMs,
lastActivityTime: stat.mtimeMs,
sessionDetected: false,
sessionCompleted: false,
label,
}
this.sessions.set(sessionId, session)

this.readNewLines(sessionId)

session.sessionDetected = true
this._onSessionDetected.fire(sessionId)
this._onSessionLifecycle.fire({ type: 'started', sessionId, label })

try {
session.fileWatcher = fs.watch(filePath, () => this.readNewLines(sessionId))
} catch (err) { log.debug('File watch failed:', filePath, err) }

session.pollTimer = setInterval(() => this.readNewLines(sessionId), POLL_FALLBACK_MS)
this.resetInactivityTimer(sessionId)

log.info(`Attached to session ${sessionId.slice(0, SESSION_ID_DISPLAY)} at ${filePath}`)
}

private readNewLines(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session) return

const result = readNewFileLines(session.filePath, session.fileSize, session.fileTail)
if (!result) return
session.fileSize = result.newSize
session.fileTail = result.tail
session.lastActivityTime = Date.now()

if (session.sessionCompleted) {
session.sessionCompleted = false
this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label })
log.info(`Session ${sessionId.slice(0, SESSION_ID_DISPLAY)} re-activated after idle`)
}

for (const line of result.lines) {
const event = this.parseLine(line)
if (!event) continue
this._onEvent.fire({ ...event, sessionId })

// Update label from first user message
if (event.type === 'message' && event.payload?.role === 'user' && session.label.startsWith('Strands ')) {
const content = String(event.payload.content || '').slice(0, 40)
if (content) {
session.label = content
this._onSessionLifecycle.fire({ type: 'updated', sessionId, label: content })
}
}
}

this.resetInactivityTimer(sessionId)
}

private parseLine(line: string): AgentEvent | null {
try {
const parsed = JSON.parse(line.trim())
if (parsed && typeof parsed.type === 'string' && typeof parsed.time === 'number') {
return parsed as AgentEvent
}
return null
} catch { return null }
}

private resetInactivityTimer(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session) return
if (session.inactivityTimer) { clearTimeout(session.inactivityTimer) }
session.inactivityTimer = setTimeout(() => {
if (session.sessionCompleted) return
session.sessionCompleted = true
this._onEvent.fire({
time: (Date.now() - session.sessionStartTime) / 1000,
type: 'agent_complete',
payload: { name: ORCHESTRATOR_NAME, sessionEnd: true },
sessionId,
})
this._onSessionLifecycle.fire({ type: 'ended', sessionId, label: session.label })
}, INACTIVITY_TIMEOUT_MS)
}

dispose(): void {
if (this.scanInterval) { clearInterval(this.scanInterval) }
this.dirWatcher?.close()
for (const s of this.sessions.values()) {
s.fileWatcher?.close()
if (s.pollTimer) clearInterval(s.pollTimer)
if (s.inactivityTimer) clearTimeout(s.inactivityTimer)
}
this.sessions.clear()
this._onEvent.dispose()
this._onSessionDetected.dispose()
this._onSessionLifecycle.dispose()
}
}
10 changes: 10 additions & 0 deletions extension/test/fixtures/strands-session-sample.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{"time":0,"type":"agent_spawn","payload":{"name":"orchestrator","isMain":true,"task":"Strands session","runtime":"strands","cwd":"/tmp/test-workspace"},"sessionId":"test-strands-session"}
{"time":0.5,"type":"agent_idle","payload":{"name":"orchestrator"},"sessionId":"test-strands-session"}
{"time":2.1,"type":"tool_call_start","payload":{"agent":"orchestrator","tool":"list_files","args":".","preview":"list_files: .","inputData":{"directory":"."}},"sessionId":"test-strands-session"}
{"time":2.6,"type":"tool_call_end","payload":{"agent":"orchestrator","tool":"list_files","result":"file1.py\nfile2.py","tokenCost":0,"discovery":{"id":"list_files-.","type":"file","label":".","content":"file1.py\nfile2.py"}},"sessionId":"test-strands-session"}
{"time":2.8,"type":"agent_idle","payload":{"name":"orchestrator"},"sessionId":"test-strands-session"}
{"time":4.5,"type":"tool_call_start","payload":{"agent":"orchestrator","tool":"read_file","args":"file1.py","preview":"read_file: file1.py","inputData":{"file_path":"file1.py"},"filePath":"file1.py"},"sessionId":"test-strands-session"}
{"time":5.0,"type":"tool_call_end","payload":{"agent":"orchestrator","tool":"read_file","result":"print('hello')","tokenCost":0,"discovery":{"id":"read_file-file1.py","type":"file","label":"file1.py","content":"print('hello')"}},"sessionId":"test-strands-session"}
{"time":5.2,"type":"context_update","payload":{"agent":"orchestrator","tokens":15000,"breakdown":{"systemPrompt":0,"userMessages":0,"toolResults":0,"reasoning":0,"subagentResults":0},"tokensMax":200000,"isAuthoritative":true},"sessionId":"test-strands-session"}
{"time":5.4,"type":"message","payload":{"agent":"orchestrator","role":"user","content":"List and read files"},"sessionId":"test-strands-session"}
{"time":8.0,"type":"agent_complete","payload":{"name":"orchestrator","sessionEnd":true},"sessionId":"test-strands-session"}
Loading