diff --git a/AGENTS.md b/AGENTS.md index 6caa6619..41f55f49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ Start here: - Repo overview and commands: [README.md](README.md) - Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) - Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) +- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) For new features: - Default home for medium and large features: `src/features//` @@ -15,6 +16,7 @@ For new features: ## Review guidelines - Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority. +- For team launch hangs, OpenCode `registered`/`bootstrap unconfirmed`, missing teammate replies, or suspicious task logs, follow [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) before changing code. - Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints. - Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases. - Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`. diff --git a/CLAUDE.md b/CLAUDE.md index ed080edb..bd649074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,12 @@ Keep orphaned Task calls (no matching subagent) for visibility. Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team. Official docs: https://code.claude.com/docs/en/agent-teams +#### Debugging Team Launches And Teammates +- Use [`docs/team-management/debugging-agent-teams.md`](docs/team-management/debugging-agent-teams.md) when a team launch hangs, a teammate remains `registered`, OpenCode shows `bootstrap unconfirmed`, messages are missing, or Task Log Stream looks wrong. +- Always correlate UI diagnostics with persisted files under `~/.claude/teams//`, live process state, and runtime-specific evidence before changing code. +- For OpenCode secondary lanes, do not confuse primary filesystem readiness with lane bootstrap readiness. A missing OpenCode inbox during primary launch is not automatically a bug. +- Do not treat `member_briefing` as runtime evidence. OpenCode deliverability requires lane-scoped committed runtime evidence such as `opencode-sessions.json` plus its manifest entry. + #### Message Delivery Architecture - **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin. - **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed. diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index f48f29b7..63a6478e 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -7,6 +7,7 @@ const processes = require('./internal/processes.js'); const maintenance = require('./internal/maintenance.js'); const crossTeam = require('./internal/crossTeam.js'); const runtime = require('./internal/runtime.js'); +const workSync = require('./internal/workSync.js'); const agentBlocks = require('./internal/agentBlocks.js'); function bindModule(context, moduleApi) { @@ -31,6 +32,7 @@ function createController(options) { maintenance: bindModule(context, maintenance), crossTeam: bindModule(context, crossTeam), runtime: bindModule(context, runtime), + workSync: bindModule(context, workSync), }; } @@ -51,4 +53,5 @@ module.exports = { maintenance, crossTeam, runtime, + workSync, }; diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index ff724899..5c16676e 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -188,6 +188,63 @@ function appendRow(filePath, row) { return row; } +const RUNTIME_DELIVERY_DUPLICATE_NOTICE = + 'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.'; + +function normalizeComparableText(value) { + return String(value || '') + .trim() + .replace(/\r\n/g, '\n') + .replace(/[ \t]+/g, ' '); +} + +function normalizeComparableParticipant(value) { + return String(value || '').trim().toLowerCase(); +} + +function getRuntimeDeliveryDuplicate(list, row) { + if ( + row.source !== 'runtime_delivery' || + typeof row.relayOfMessageId !== 'string' || + row.relayOfMessageId.trim().length === 0 + ) { + return null; + } + + const relayOfMessageId = row.relayOfMessageId.trim(); + const from = normalizeComparableParticipant(row.from); + const to = normalizeComparableParticipant(row.to); + const text = normalizeComparableText(row.text); + if (!from || !to || !text) { + return null; + } + + return ( + list.find( + (candidate) => + candidate && + candidate.source === 'runtime_delivery' && + String(candidate.relayOfMessageId || '').trim() === relayOfMessageId && + normalizeComparableParticipant(candidate.from) === from && + normalizeComparableParticipant(candidate.to) === to && + normalizeComparableText(candidate.text) === text + ) || null + ); +} + +function appendInboxRow(filePath, row) { + const current = readJson(filePath, []); + const list = Array.isArray(current) ? current : []; + const duplicate = getRuntimeDeliveryDuplicate(list, row); + if (duplicate) { + return { row: duplicate, deduplicated: true }; + } + + list.push(row); + writeJson(filePath, list); + return { row, deduplicated: false }; +} + function sendInboxMessage(paths, flags) { const memberName = typeof flags.member === 'string' && flags.member.trim() @@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) { to: memberName, read: false, }); - appendRow(getInboxPath(paths, memberName), payload); + const appended = appendInboxRow(getInboxPath(paths, memberName), payload); return { deliveredToInbox: true, - messageId: payload.messageId, - message: payload, + messageId: appended.row.messageId, + message: appended.row, + ...(appended.deduplicated + ? { + deduplicated: true, + duplicateOfMessageId: appended.row.messageId, + deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE, + } + : {}), }; } diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js index a798b6bc..ed328839 100644 --- a/agent-teams-controller/src/internal/runtime.js +++ b/agent-teams-controller/src/internal/runtime.js @@ -8,6 +8,9 @@ const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const POLL_INTERVAL_MS = 1000; const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; const RETRYABLE_CONTROL_ERROR = 'retryableControlError'; +const BOOTSTRAP_CHECKIN_MAX_ATTEMPTS = 3; +const BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS = 4000; +const BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS = [300, 900]; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -67,9 +70,15 @@ function resolveControlBaseUrls(context, flags = {}) { return candidates; } -function makeRetryableControlError(message, cause) { +function makeRetryableControlError(message, cause, metadata = {}) { const error = new Error(message); error[RETRYABLE_CONTROL_ERROR] = true; + if (metadata.kind) { + error.retryableKind = metadata.kind; + } + if (metadata.statusCode) { + error.statusCode = metadata.statusCode; + } if (cause) { error.cause = cause; } @@ -114,7 +123,9 @@ async function requestJson(baseUrl, pathname, options = {}) { : `${response.status} ${response.statusText}`.trim(); if (isRetryableStatusCode(response.status)) { throw makeRetryableControlError( - `Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}` + `Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`, + undefined, + { kind: 'status', statusCode: response.status } ); } throw new Error(detail || 'Team control API request failed'); @@ -122,19 +133,24 @@ async function requestJson(baseUrl, pathname, options = {}) { if (payload == null) { throw makeRetryableControlError( - `Team control API returned empty or non-JSON response at ${baseUrl}${pathname}` + `Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`, + undefined, + { kind: 'empty' } ); } return payload; } catch (error) { if (error && error.name === 'AbortError') { - throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error); + throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error, { + kind: 'timeout', + }); } if (error && error.name === 'TypeError') { throw makeRetryableControlError( `Failed to reach team control API at ${baseUrl}: ${error.message || 'fetch failed'}`, - error + error, + { kind: 'network' } ); } throw error; @@ -161,6 +177,54 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) { throw lastError || new Error('Team control API request failed'); } +function isBootstrapCheckinRetryableControlError(error) { + if (!isRetryableControlError(error)) { + return false; + } + + if (error.retryableKind === 'timeout' || error.retryableKind === 'network') { + return true; + } + + if (error.retryableKind === 'status') { + return typeof error.statusCode === 'number' && error.statusCode >= 500; + } + + return false; +} + +async function requestJsonWithBoundedRetry(baseUrls, pathname, options = {}, retryOptions = {}) { + const maxAttempts = Math.max(1, retryOptions.maxAttempts || 1); + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const result = await requestJsonWithFallback(baseUrls, pathname, options); + if (attempt > 1 && result && typeof result === 'object' && !Array.isArray(result)) { + return { + ...result, + diagnostics: uniqueNonEmpty([ + ...(Array.isArray(result.diagnostics) ? result.diagnostics : []), + 'opencode_bootstrap_checkin_retry', + ]), + }; + } + return result; + } catch (error) { + lastError = error; + if (attempt >= maxAttempts || !isBootstrapCheckinRetryableControlError(error)) { + throw error; + } + const delayMs = retryOptions.delaysMs?.[attempt - 1] || 0; + if (delayMs > 0) { + await sleep(delayMs); + } + } + } + + throw lastError || new Error('Team control API request failed'); +} + function buildLaunchRequest(flags = {}) { const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : ''; if (!cwd) { @@ -412,18 +476,31 @@ async function getRuntimeState(context, flags = {}) { } async function runtimeBootstrapCheckin(context, flags = {}) { - return postRuntimeTool( - context, - flags, - 'bootstrap-checkin', - compactRuntimeToolBody(context, flags, [ - 'runId', - 'memberName', - 'runtimeSessionId', - 'observedAt', - 'diagnostics', - 'metadata', - ]) + const baseUrls = resolveControlBaseUrls(context, flags); + const explicitTimeoutMs = flags.waitTimeoutMs || flags['wait-timeout-ms']; + const timeoutMs = Math.min( + normalizeTimeoutMs(explicitTimeoutMs || BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS), + BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS + ); + return requestJsonWithBoundedRetry( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/bootstrap-checkin`, + { + method: 'POST', + body: compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'diagnostics', + 'metadata', + ]), + timeoutMs, + }, + { + maxAttempts: BOOTSTRAP_CHECKIN_MAX_ATTEMPTS, + delaysMs: BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS, + } ); } diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 13dea345..56551a28 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - Then run task_start only when you truly begin. - If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass. +15. MEMBER WORK SYNC REPORTING: + - member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks. + - Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment. + - When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself. + - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working", that exact agendaFingerprint, and the returned reportToken. + - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task, and include the returned reportToken. + - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint and the returned reportToken. + - Do not report more than once for the same agendaFingerprint unless your state changed. Failure to follow this protocol means the task board will show incorrect status.`); } diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js new file mode 100644 index 00000000..5ac48411 --- /dev/null +++ b/agent-teams-controller/src/internal/workSync.js @@ -0,0 +1,273 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const runtimeHelpers = require('./runtimeHelpers.js'); +const { withFileLockSync } = require('./fileLock.js'); + +const DEFAULT_WAIT_TIMEOUT_MS = 10000; +const MIN_WAIT_TIMEOUT_MS = 1000; +const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; + +function normalizeTimeoutMs(rawValue) { + const numeric = + typeof rawValue === 'number' && Number.isFinite(rawValue) + ? Math.floor(rawValue) + : DEFAULT_WAIT_TIMEOUT_MS; + return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric)); +} + +function readControlApiState(context) { + const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim() + ? parsed.baseUrl.trim() + : ''; + } catch { + return ''; + } +} + +function resolveControlBaseUrls(context, flags = {}) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + ''; + const stateFileUrl = readControlApiState(context); + const envUrl = + typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' + ? process.env.CLAUDE_TEAM_CONTROL_URL.trim() + : ''; + const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))]; + if (candidates.length === 0) { + throw new Error( + 'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.' + ); + } + return candidates; +} + +async function requestJson(baseUrl, pathname, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs)); + try { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method || 'GET', + headers: { + accept: 'application/json', + ...(options.body ? { 'content-type': 'application/json' } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = + payload && typeof payload.error === 'string' && payload.error.trim() + ? payload.error.trim() + : `${response.status} ${response.statusText}`.trim(); + const error = new Error(detail || 'Team control API request failed'); + error.controlApiStatus = response.status; + throw error; + } + return payload; + } finally { + clearTimeout(timer); + } +} + +async function requestJsonWithFallback(baseUrls, pathname, options = {}) { + let lastError = null; + for (const baseUrl of baseUrls) { + try { + return await requestJson(baseUrl, pathname, options); + } catch (error) { + if (error && error.controlApiStatus) { + throw error; + } + lastError = error; + } + } + throw lastError || new Error('Team control API request failed'); +} + +function compactReportBody(context, memberName, flags = {}) { + return { + teamName: context.teamName, + memberName, + state: flags.state, + agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], + reportToken: flags.reportToken || flags['report-token'], + ...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), + ...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}), + ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), + ...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim() + ? { reportedAt: flags.reportedAt.trim() } + : {}), + ...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}), + }; +} + +function stableStringify(value) { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; +} + +function buildPendingIntentId(body) { + const taskIds = Array.isArray(body.taskIds) + ? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort() + : []; + const payload = { + teamName: body.teamName, + memberName: String(body.memberName || '').trim().toLowerCase(), + state: body.state, + agendaFingerprint: body.agendaFingerprint, + reportToken: body.reportToken || '', + ...(taskIds.length > 0 ? { taskIds } : {}), + ...(body.note ? { note: body.note } : {}), + ...(body.leaseTtlMs ? { leaseTtlMs: body.leaseTtlMs } : {}), + ...(body.source ? { source: body.source } : {}), + }; + return `member-work-sync-intent:${crypto + .createHash('sha256') + .update(stableStringify(payload)) + .digest('hex')}`; +} + +function readPendingReportFile(filePath) { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if ( + parsed && + typeof parsed === 'object' && + parsed.schemaVersion === 1 && + parsed.intents && + typeof parsed.intents === 'object' && + !Array.isArray(parsed.intents) + ) { + return parsed; + } + } catch (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + } + return { schemaVersion: 1, intents: {} }; +} + +function writePendingReportFile(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + fs.renameSync(tempPath, filePath); +} + +function appendPendingReportIntent(context, body, reason) { + const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json'); + const { id } = withFileLockSync(filePath, () => { + const data = readPendingReportFile(filePath); + const request = { + ...body, + source: 'mcp', + }; + const intentId = buildPendingIntentId(request); + const current = data.intents[intentId]; + if (!current || current.status === 'pending') { + data.intents[intentId] = { + id: intentId, + teamName: body.teamName, + memberName: body.memberName, + request, + reason, + status: 'pending', + recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(), + }; + writePendingReportFile(filePath, data); + } + return { id: intentId }; + }); + return { + accepted: false, + pendingValidation: true, + code: 'pending_validation', + message: + 'Member work sync report was recorded for app validation. Continue concrete task work; do not treat this as a confirmed lease yet.', + intentId: id, + }; +} + +function assertReportBody(body) { + if (!body.state || !['still_working', 'blocked', 'caught_up'].includes(body.state)) { + throw new Error('state must be still_working, blocked, or caught_up'); + } + if (!body.agendaFingerprint) { + throw new Error('agendaFingerprint is required'); + } + if (!body.reportToken) { + throw new Error('reportToken is required'); + } +} + +async function memberWorkSyncStatus(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync status member' + ); + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent( + memberName + )}`, + { timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) } + ); +} + +async function memberWorkSyncReport(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync report member' + ); + const body = compactReportBody(context, memberName, flags); + assertReportBody(body); + + const pathname = `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`; + const options = { + method: 'POST', + body, + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + }; + + let baseUrls; + try { + baseUrls = resolveControlBaseUrls(context, flags); + } catch { + return appendPendingReportIntent(context, body, 'control_api_unavailable'); + } + + try { + return await requestJsonWithFallback(baseUrls, pathname, options); + } catch (error) { + if (error && error.controlApiStatus) { + throw error; + } + return appendPendingReportIntent(context, body, 'control_api_unavailable'); + } +} + +module.exports = { + memberWorkSyncStatus, + memberWorkSyncReport, +}; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index ecd74b19..1a5e3b0d 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [ 'runtime_heartbeat', ]; +const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report']; + const AGENT_TEAMS_MCP_TOOL_GROUPS = [ { id: 'team', @@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [ teammateOperational: false, toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, }, + { + id: 'workSync', + teammateOperational: true, + toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, + }, { id: 'crossTeam', teammateOperational: true, @@ -141,6 +148,7 @@ module.exports = { AGENT_TEAMS_PROCESS_TOOL_NAMES, AGENT_TEAMS_KANBAN_TOOL_NAMES, AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES, AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index b0494fd4..6ac878a4 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Task briefing for bob:'); expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.'); expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); + expect(briefing).toContain('member_work_sync_status and member_work_sync_report'); expect(briefing).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); @@ -234,6 +235,44 @@ describe('agent-teams-controller API', () => { expect(delivered.deliveredToInbox).toBe(true); }); + it('deduplicates repeated runtime_delivery replies to the same inbound message', () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const first = controller.messages.sendMessage({ + to: 'user', + from: 'bob', + text: 'Да, я здесь!', + source: 'runtime_delivery', + relayOfMessageId: 'msg-inbound-1', + }); + const second = controller.messages.sendMessage({ + to: 'user', + from: 'bob', + text: ' Да, я здесь! ', + source: 'runtime_delivery', + relayOfMessageId: 'msg-inbound-1', + }); + + const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json'); + const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8')); + expect(rows).toHaveLength(1); + expect(second).toMatchObject({ + deliveredToInbox: true, + deduplicated: true, + messageId: first.messageId, + duplicateOfMessageId: first.messageId, + deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'), + }); + }); + it('strips hallucinated zero task placeholder prefixes from visible messages', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -2250,6 +2289,301 @@ describe('agent-teams-controller API', () => { } }); + it('retries OpenCode bootstrap check-in on retryable control API failures', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (calls.length < 3) { + return { statusCode: 500, body: { error: 'temporary bootstrap failure' } }; + } + return { body: { ok: true, state: 'accepted', diagnostics: [] } }; + }); + + try { + const result = await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(result).toMatchObject({ + ok: true, + state: 'accepted', + diagnostics: expect.arrayContaining(['opencode_bootstrap_checkin_retry']), + }); + expect(calls).toHaveLength(3); + expect(calls.map((call) => call.body)).toEqual([ + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + ]); + } finally { + await server.close(); + } + }); + + it('accepts idempotent OpenCode bootstrap check-in after a timed-out committed request', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + let committed = false; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (!committed) { + committed = true; + await new Promise((resolve) => setTimeout(resolve, 1200)); + return { body: { ok: true, state: 'accepted', diagnostics: [] } }; + } + return { + body: { + ok: true, + state: 'accepted', + diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], + }, + }; + }); + + try { + const result = await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + waitTimeoutMs: 1000, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(result).toMatchObject({ + ok: true, + state: 'accepted', + diagnostics: expect.arrayContaining([ + 'opencode_bootstrap_checkin_duplicate_accepted', + 'opencode_bootstrap_checkin_retry', + ]), + }); + expect(calls).toHaveLength(2); + } finally { + await server.close(); + } + }); + + it('does not retry OpenCode bootstrap check-in on validation failures', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + return { statusCode: 400, body: { error: 'invalid bootstrap payload' } }; + }); + + try { + await expect( + controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }) + ).rejects.toThrow('invalid bootstrap payload'); + expect(calls).toHaveLength(1); + } finally { + await server.close(); + } + }); + + it('fails OpenCode bootstrap check-in clearly after bounded timeout retries', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + await new Promise((resolve) => setTimeout(resolve, 1200)); + return { body: { ok: true, state: 'accepted' } }; + }); + + try { + await expect( + controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + waitTimeoutMs: 1000, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }) + ).rejects.toThrow('Timed out calling team control API'); + expect(calls).toHaveLength(3); + } finally { + await server.close(); + } + }); + + it('forwards member work sync status and reports to the app validator', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') { + return { + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'my-team', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = await controller.workSync.memberWorkSyncStatus({ + controlUrl: server.baseUrl, + from: 'bob', + }); + const report = await controller.workSync.memberWorkSyncReport({ + controlUrl: server.baseUrl, + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }); + + expect(status.state).toBe('needs_sync'); + expect(report.accepted).toBe(true); + expect(calls).toEqual([ + { + method: 'GET', + url: '/api/teams/my-team/member-work-sync/bob', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/my-team/member-work-sync/report', + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + + it('records member work sync report intents only when the app validator is unavailable', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const pending = await controller.workSync.memberWorkSyncReport({ + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + }); + + expect(pending.pendingValidation).toBe(true); + expect(pending.accepted).toBe(false); + + const intentFile = path.join( + claudeDir, + 'teams', + 'my-team', + '.member-work-sync', + 'pending-reports.json' + ); + const intents = JSON.parse(fs.readFileSync(intentFile, 'utf8')); + expect(Object.values(intents.intents)).toEqual([ + expect.objectContaining({ + teamName: 'my-team', + memberName: 'bob', + reason: 'control_api_unavailable', + status: 'pending', + request: expect.objectContaining({ + memberName: 'bob', + source: 'mcp', + reportToken: 'wrs:v1.test.token', + }), + }), + ]); + }); + + it('does not record pending work sync intents for app-side validation rejections', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const server = await startControlServer(async () => ({ + statusCode: 400, + body: { error: 'stale_fingerprint' }, + })); + + try { + await expect( + controller.workSync.memberWorkSyncReport({ + controlUrl: server.baseUrl, + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:stale', + reportToken: 'wrs:v1.test.token', + }) + ).rejects.toThrow('stale_fingerprint'); + + expect( + fs.existsSync( + path.join(claudeDir, 'teams', 'my-team', '.member-work-sync', 'pending-reports.json') + ) + ).toBe(false); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/docs/team-management/debugging-agent-teams.md b/docs/team-management/debugging-agent-teams.md new file mode 100644 index 00000000..a6baac42 --- /dev/null +++ b/docs/team-management/debugging-agent-teams.md @@ -0,0 +1,176 @@ +# Debugging Agent Teams + +Use this runbook when a team launch hangs, a teammate is marked `registered` or `failed_to_start`, messages do not appear, or OpenCode participants look online but do not answer. + +## First Rule + +Do not guess from the UI alone. Always correlate: +- UI diagnostics copied from the launch/member detail panel +- persisted team files under `~/.claude/teams//` +- live process table +- runtime-specific evidence, especially OpenCode lane manifests + +## Key Files + +Team root: + +```bash +TEAM="" +TEAM_DIR="$HOME/.claude/teams/$TEAM" +``` + +Important files and folders: +- `config.json` - configured members, provider/model selection, project path +- `members-meta.json` - member metadata, removed members, worktree settings if present +- `launch-state.json` - current app-side truth for member launch/liveness +- `bootstrap-state.json` - bootstrap phase summary when present +- `bootstrap-journal.jsonl` - ordered bootstrap events from the CLI/runtime +- `inboxes/*.json` - durable inbox messages for user, lead, and native teammates +- `sentMessages.json` - app-side sent-message records +- `tasks/*.json` - task board state +- `.opencode-runtime/lanes.json` - OpenCode lane index +- `.opencode-runtime/lanes//manifest.json` - lane-scoped runtime store manifest +- `.opencode-runtime/lanes//opencode-sessions.json` - committed OpenCode session evidence + +Quick inspection: + +```bash +jq '.teamLaunchState, .summary, .members' "$TEAM_DIR/launch-state.json" +jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null +find "$TEAM_DIR/.opencode-runtime" -maxdepth 3 -type f | sort +tail -80 "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null +``` + +## Launch Phases + +Primary launch and OpenCode secondary lanes are different paths. + +- Primary CLI members are created by the main provisioning process. +- OpenCode secondary members are launched as side lanes after primary filesystem readiness. +- Missing `inboxes/.json` is not automatically a launch bug. OpenCode side lanes do not have to be primary inbox-created before they start. +- The UI can show the team still launching while primary members are already usable, because "all teammates joined" waits for secondary lanes too. + +When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member. + +## Member State Meanings + +Common `launch-state.json` cases: + +- `confirmed_alive` with `bootstrapConfirmed: true` - member is usable. +- `registered` / `runtime_pending_bootstrap` - process or lane exists, but bootstrap proof is not committed yet. +- `registered_only` - app has persisted metadata, but no live runtime proof. +- `runtime_process_candidate` - process/session was observed, but committed runtime evidence is incomplete or pending. +- `failed_to_start` with `runtime_process` - a process exists, but the launch gate still failed. Inspect diagnostics and runtime evidence. +- `failed_to_start` with `stale_metadata` - persisted pid/session is old or dead. + +Do not treat `member_briefing` alone as runtime evidence. For OpenCode, the authoritative proof is committed bootstrap/session evidence in the lane runtime store. + +## OpenCode Debug Flow + +For an OpenCode teammate: + +```bash +MEMBER="" +jq --arg member "$MEMBER" '.members[$member]' "$TEAM_DIR/launch-state.json" +jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null +find "$TEAM_DIR/.opencode-runtime/lanes" -maxdepth 3 -type f | sort +``` + +Expected healthy OpenCode lane: +- `lanes.json` has the lane state `active` +- lane `manifest.json` has `activeRunId` +- lane manifest has at least one runtime evidence entry, usually `opencode.sessionStore` +- lane directory has `opencode-sessions.json` +- `launch-state.json` member has `runtimeRunId`, `runtimeSessionId`, and `bootstrapConfirmed: true` + +If the bridge says bootstrap succeeded but the manifest has `entries: []`, the issue is evidence commit, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist. + +OpenCode bridge ledger, if needed: + +```bash +LEDGER="$HOME/Library/Application Support/claude-agent-teams-ui/opencode-bridge/command-ledger.json" +jq --arg team "$TEAM" '.data[] | select(.teamName == $team)' "$LEDGER" 2>/dev/null +``` + +Live process checks: + +```bash +pgrep -af "opencode serve" +ps -p -o pid,ppid,etime,command +``` + +Do not kill all OpenCode processes as a debugging shortcut. First identify whether the pid belongs to the current team/lane. Some OpenCode temp `libopentui.dylib` files are held by live `opencode serve` processes and should only be cleaned after those processes are stopped. + +## Messaging Debug Flow + +Lead and teammates use different delivery paths: + +- Lead reads stdin. Messages to lead go through `relayLeadInboxMessages()`. +- Native teammates read their inbox files directly. +- OpenCode teammates receive prompts through runtime delivery and must reply via `agent-teams_message_send`. +- Teammate-to-user replies should appear in `inboxes/user.json` or app sent-message projections. + +If a notification appears but the Messages UI does not show it: + +```bash +jq '.' "$TEAM_DIR/inboxes/user.json" 2>/dev/null +jq '.' "$TEAM_DIR/sentMessages.json" 2>/dev/null +``` + +Check `from`, `to`, `messageId`, `relayOfMessageId`, and `taskRefs`. Unknown authors should be rejected or normalized at the write boundary, not silently rendered as fake teammates. + +For OpenCode "message saved but not delivered" cases, inspect the OpenCode prompt-delivery ledger and response proof. Do not synthesize visible replies in the frontend. + +## Task And Work-Stall Debug Flow + +For task stalls: + +```bash +TASK="" +rg -n "$TASK" "$TEAM_DIR/tasks" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null +``` + +Important distinctions: +- Delivery proof means the agent received the message. +- Task progress proof means the agent made meaningful task progress. +- A weak comment like "starting work" is not strong progress. +- `task_add_comment` should be evaluated from the actual persisted comment text, not only from the tool call. + +Task-stall monitor defaults: +- General task-stall monitor is for all agents. +- OpenCode direct remediation is provider-specific and should nudge the OpenCode owner first. +- If OpenCode remediation is not accepted, fallback to lead alert. +- Watchdog/remediation must not auto-start new OpenCode processes. + +## Task Log Stream Debug Flow + +Task Log Stream is a projection, not a separate source of truth. + +For OpenCode tasks, a healthy stream should show native tool rows such as `read`, `bash`, `edit`, `write`, plus Agent Teams MCP rows. If it only shows `agent-teams_*` calls: +- confirm the task has OpenCode attribution for the member/session +- confirm the OpenCode transcript contains native tools inside the bounded task window +- check whether the task was assigned after the native work happened +- do not widen attribution so far that unrelated session work is pulled into the task + +If Changes says "No file changes recorded" while native `write`/`edit` rows exist, inspect the ledger/backfill path. Task logs can show runtime tools even when `.board-task-changes/**` was not created. + +## Safe Fix Checklist + +Before changing launch or runtime logic: +- Preserve stale-run, tombstone, stopped-team, and removed-member guards. +- Do not make `member_briefing` runtime evidence. +- Do not make delivery/watchdog auto-launch a fresh OpenCode lane. +- Keep primary launch readiness separate from secondary OpenCode lane readiness. +- Keep runtime evidence lane-scoped. Never let one OpenCode lane satisfy another lane. +- Add a regression test for the exact state shape you found in `launch-state.json`. + +Recommended verification: + +```bash +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts +pnpm vitest run test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +pnpm typecheck --pretty false +git diff --check +``` + +Use narrower test commands first when editing a focused path, then run the broader suite that covers launch, delivery, and liveness. diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index d0d4c2bf..218314d2 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -1,6 +1,6 @@ # Member Work Sync Control Plane Plan -**Status:** Proposed +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -29,6 +29,21 @@ Phase 1 does not send nudges. It computes agenda/fingerprint/status, validates ` Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint churn and false positives are low. +Current implementation note: + +- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. +- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. +- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges. +- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. +- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive. +- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident. +- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port. +- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work. +- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write. +- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. +- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint. +- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. + Patterns used: - Kubernetes-style level-triggered reconcile: recompute from current desired/current state instead of trusting events. @@ -1136,7 +1151,7 @@ Validation result contract: ```ts export type MemberWorkSyncReportValidationReason = - | 'feature_disabled' + | 'capability_unavailable' | 'team_inactive' | 'member_inactive' | 'reserved_author' @@ -1349,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`. ### 10.4 Shadow Would-Nudge Semantics -Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. +Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`. `wouldNudge` is true only when all are true: @@ -1866,26 +1881,26 @@ Rules: - In Phase 1, missing `member_work_sync_report` must not block team launch. - If the tool is missing, omit work-sync instructions from `task_briefing`/`member_briefing`. - If the tool exists but app validation bridge is unavailable, return `pending_validation`. -- If app says feature disabled, return `feature_disabled`. -- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tool is optional unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=true`. +- Do not add a runtime/env flag to require the tool in Phase 1. +- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tools remain optional compatibility capabilities. -Suggested rollout gate: +Important distinction: ```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=false +capability-gated means "use it if both sides expose it". +feature-flagged means "runtime branch changes behavior based on env/config". ``` -Default `false` until Phase 1 has shipped across both repos. +Phase 1 uses capability gating only. That avoids permanent `new vs legacy` branches while still supporting mixed repo versions during development. Compatibility matrix: | claude_team | orchestrator/controller | Expected behavior | |---|---|---| | no feature | no tool | no work-sync surface | -| feature enabled | no tool | status/reconcile only, no report instruction | -| feature enabled | tool exists, no app bridge | pending intent only | -| feature enabled | tool exists, app bridge live | full report validation | -| feature disabled | tool exists | tool returns `feature_disabled`, no writes | +| app has feature | no tool | status/reconcile only, no report instruction | +| app has feature | tool exists, no app bridge | pending intent only | +| app has feature | tool exists, app bridge live | full report validation | ### 13.1 Current Agenda Read Surface @@ -2869,8 +2884,7 @@ Details dialog can show: - missing `member_work_sync_report` does not fail OpenCode readiness in Phase 1; - work-sync instructions are omitted when the tool is unavailable; - tool available + app bridge unavailable returns `pending_validation`; -- feature disabled returns `feature_disabled` and writes no intents; -- optional require-tool gate can fail readiness when explicitly enabled. +- no runtime flag is needed to require the tool in Phase 1. ### 20.4 Controller Tests @@ -2888,7 +2902,7 @@ In `agent-teams-controller`: - returns structured stale fingerprint response; - returns `pendingValidation` instead of accepted lease when app validator is unavailable; - pending validation intent replay does not update lease until app accepts; -- disabled feature returns `feature_disabled` and does not write intents; +- capability unavailable returns `capability_unavailable` and does not write accepted reports; - exposes current fingerprint through the chosen read surface; - does not write task comments or messages. @@ -2976,6 +2990,7 @@ Check: - stale report rate; - invalid caught-up attempts; - how many nudges Phase 2 would send. +- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`. Exit criteria: @@ -2999,6 +3014,15 @@ Includes: - per-member token bucket; - shared cooldown with watchdog. +Implemented safety constraints: + +- only queued reconciles plan outbox rows; +- read-only diagnostics never plan outbox rows; +- outbox planning requires `phase2Readiness.state === "shadow_ready"`; +- dispatch revalidates lifecycle, current status, current fingerprint, readiness, busy state, rate limit, and watchdog cooldown immediately before inbox insert; +- scheduled dispatch lists lifecycle-active teams only, not all stored teams; +- undelivered `superseded` rows can be revived by a later fresh reconcile for the same fingerprint, while `delivered` rows remain one-per-fingerprint. + ### Phase 3: Provider Accelerators `🎯 8 🛡️ 8 🧠 5`, `300-600 LOC`. @@ -3012,57 +3036,59 @@ Includes: No accelerator is proof. +Current implementation: + +- tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal; +- Claude Stop hook and OpenCode turn-settled hooks are intentionally not wired yet because the current feature boundary does not expose one authoritative cross-provider "turn settled and idle" signal. Adding an adapter around prompt text, idle notifications, or provider-specific transcript heuristics would be less reliable than the current tool-finish + scheduled reconcile path; +- manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards. + --- -## 22. Feature Gates +## 22. Runtime Defaults And No Feature Flags -Phase 1: +Phase 1 shipped without feature flags. -```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=true -CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true +Reason: + +- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior. +- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about. +- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects. + +Runtime defaults: + +| Behavior | Default | Why | +|---|---:|---| +| agenda/fingerprint/status computation | on | passive, deterministic, app-owned | +| `member_work_sync_status` | on | read-only diagnostics | +| `member_work_sync_report` | on | server-validated, no board mutation | +| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries | +| outbox planning | on only for queued reconciles and only when `phase2Readiness=shadow_ready` | prevents status reads from causing side effects | +| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges | +| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write | + +Do not add: + +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` + +If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature. + +Phase 2 policy: + +- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics. +- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns `phase2_not_ready`. +- Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch. + +Phase 2 runtime constants can be normal typed defaults, not feature gates: + +```ts +const MEMBER_WORK_SYNC_QUIET_WINDOW_MS = 90_000; +const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000; +const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; ``` -Defaults: - -- enabled can default `true` only if Phase 1 is read/status-only; -- shadow-only must default `true`; -- Phase 2 nudges default `false` until explicitly validated. - -Gate behavior: - -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=false` disables queue, reconcile, status writes, and report acceptance. The MCP report tool should return `feature_disabled`. -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true` allows reconcile/status/report validation but forbids outbox and inbox writes. -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=false` is allowed only after Phase 2 implementation and metrics review. -- Report intent recording should also honor `ENABLED=false`; do not write intent files when the feature is explicitly disabled. -- Read surfaces can include `"feature": "disabled"` when disabled, but should not instruct agents to call the report tool. - -Phase 2: - -```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=false -CLAUDE_TEAM_MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR=2 -CLAUDE_TEAM_MEMBER_WORK_SYNC_QUIET_WINDOW_MS=90000 -CLAUDE_TEAM_MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS=600000 -``` - -Recommended defaults by phase: - -| Gate | Phase 1 default | Phase 2 default after metrics | -|---|---:|---:| -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` | `true` | `true` | -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` | `true` | `false` only after manual enable | -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` | `false` | `false` until explicitly flipped | -| report tool enabled | `true` when feature enabled | `true` | -| report intent fallback | `true` when feature enabled | `true` | - -Kill-switch expectations: - -- turning `ENABLED=false` should stop queue processing within one event-loop tick; -- pending outbox items must not dispatch while disabled; -- report tool should return a structured disabled response; -- status read APIs may still return last known status marked stale/disabled; -- no feature flag should change task board state directly. +If we ever need an emergency kill switch for production nudges, it must only wrap the Phase 2 dispatcher. It must not disable agenda/status/report validation. --- @@ -3401,7 +3427,7 @@ Step order: 2. Extend current agenda read surface. - Prefer `task_briefing.workSync`. - Include compact agenda preview, `agendaFingerprint`, state, and `reportToken`. - - Omit report instructions when tool is unavailable or feature disabled. + - Omit report instructions when tool or app validation capability is unavailable. - Keep old `task_briefing` fields unchanged. 3. Implement report validator. diff --git a/docs/team-management/member-work-sync-debugging.md b/docs/team-management/member-work-sync-debugging.md new file mode 100644 index 00000000..9f3d5f95 --- /dev/null +++ b/docs/team-management/member-work-sync-debugging.md @@ -0,0 +1,40 @@ +# Member Work Sync Debugging + +`member-work-sync` stores member-scoped control-plane state under each team member: + +```text +~/.claude/teams//members//.member-work-sync/ + status.json + reports.json + outbox.json + journal.jsonl +``` + +`member-key` is the normalized, percent-encoded member name. The canonical name is stored in: + +```text +~/.claude/teams//members//member.meta.json +``` + +Use the journal for local debugging: + +```bash +tail -f ~/.claude/teams//members//.member-work-sync/journal.jsonl +``` + +The journal is append-only JSONL and records sync decisions, not raw agent transcripts. Useful events: + +- `reconcile_started`, `agenda_loaded`, `decision_made`, `status_written` +- `report_received`, `report_accepted`, `report_rejected` +- `nudge_planned`, `nudge_delivered`, `nudge_skipped`, `nudge_retryable`, `nudge_superseded` +- `member_busy`, `watchdog_cooldown_active`, `team_inactive`, `legacy_fallback_used` + +Team-level shared/index state remains under: + +```text +~/.claude/teams//.member-work-sync/ + indexes/ + report-token-secret.json +``` + +The indexes are implementation details used to avoid scanning every member directory on the hot path. diff --git a/docs/team-management/member-work-sync-opencode-turn-settled-plan.md b/docs/team-management/member-work-sync-opencode-turn-settled-plan.md new file mode 100644 index 00000000..37cb7355 --- /dev/null +++ b/docs/team-management/member-work-sync-opencode-turn-settled-plan.md @@ -0,0 +1,2700 @@ +# Member Work Sync OpenCode Turn-Settled Plan + +- **Status:** implemented and live-verified in `feat/member-work-sync-opencode-turn-settled` +- **Scope:** `member-work-sync`, OpenCode runtime turn-settled signal, OpenCode SSE observer +- **Primary repo:** `claude_team` +- **Secondary repo:** `agent_teams_orchestrator` +- **Feature name:** `member-work-sync` +- **Recommended cut:** provider-neutral runtime turn-settled pipeline with OpenCode SSE adapter + +Implemented verification: + +- `claude_team`: `pnpm exec vitest run test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts` +- `claude_team`: `pnpm typecheck --pretty false` +- `agent_teams_orchestrator`: `bun test src/services/opencode/OpenCodeSseEventStream.test.ts src/services/opencode/OpenCodePreviewObserver.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` +- `agent_teams_orchestrator`: `bun run build` +- `agent_teams_orchestrator`: `OPENCODE_E2E=1 OPENCODE_TURN_SETTLED_LIVE=1 bun test src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts` +- both repos: `git diff --check` + +--- + +## 1. Summary + +Add OpenCode support to the existing `member-work-sync` runtime turn-settled pipeline. + +The goal is not to make OpenCode "answer better" directly. The goal is to make the app know when an OpenCode teammate turn has settled, so the existing `MemberWorkSyncReconciler` can re-check authoritative work state: + +```text +OpenCode prompt_async accepted +-> app-owned SSE observer watches same session +-> observer returns bounded turn evidence: idle / error / timeout / stream unavailable +-> bridge command also uses existing reconcile/preview evidence +-> OpenCode turn-settled coordinator chooses one final outcome +-> orchestrator writes one durable runtime_turn_settled event to spool +-> claude_team drains event +-> OpenCode normalizer validates payload +-> resolver validates active team/member/provider +-> existing MemberWorkSyncEventQueue enqueues reconcile +-> existing policy decides no-op / status update / future nudge outbox +``` + +Recommended implementation: + +**OpenCode SSE turn-settled observer + bounded bridge-command settlement + error-aware spool event** + +`🎯 9 🛡️ 9 🧠 6`, roughly `850-1250 LOC`. + +Why this is the right cut: + +- OpenCode already exposes reliable-enough SSE lifecycle events on `/event`. +- The current app already has a durable runtime turn-settled spool for Claude and Codex. +- OpenCode does not need user/project plugin config mutation. +- `session.idle` is a wake-up signal, not proof of success. The existing member-work-sync agenda remains authoritative. +- This integrates with watchdog by queueing the same reconcile signal, not by adding a second watchdog. +- The OpenCode bridge command is short-lived, so the observer cannot be a fire-and-forget background task. It must be awaited with a small bounded settlement budget inside the bridge command, then return `timeout` evidence if the turn is still not terminal. +- The observer must collect evidence only. A small emission coordinator writes exactly one spool event after the path has also used existing reconcile/preview evidence. This avoids premature `timeout` events when post-prompt reconcile proves activity. + +--- + +## 2. Evidence From Live Prototype + +Prototype environment: + +- installed OpenCode version: `1.14.19` +- local server: `opencode serve --hostname 127.0.0.1 --port ` +- API used: + - `POST /session` + - `GET /event` + - `POST /session/:id/prompt_async` + - `GET /session/:id/message` + +Observed with `opencode/gpt-5-nano`: + +```text +prompt_async accepted: true +session activity observed after prompt: true +idle observed: true +assistant text: OK +``` + +Observed event shape: + +```text +server.connected +message.updated user +message.part.updated user text +session.status busy +message.updated assistant +message.part.updated step-start/reasoning/text +message.part.delta text +message.part.updated step-finish +session.status idle +session.idle +``` + +Observed with `openai/gpt-5.1-codex-mini`: + +```text +prompt_async accepted: true +session.error observed: true +session.status idle observed after error: true +session.idle observed after error: true +``` + +Important conclusions: + +- The observer must subscribe before `prompt_async`, otherwise fast turns can be missed. +- OpenCode `messageID` must start with `msg`; UUID-only IDs get `400`. +- `session.idle` means "turn ended", not "turn succeeded". +- `session.error` must produce an error outcome, but should still wake member-work-sync to reconcile. +- Both `session.status idle` and `session.idle` can arrive for the same turn, so emission must be idempotent. +- The orchestrator bridge command exits after the command returns. A background observer can be killed before it writes the spool event. This invalidates a pure "start observer and return" design. + +### 2.1 External Research Notes + +Official OpenCode docs confirm the API surface this plan relies on: + +- OpenCode server docs document `opencode serve` and `GET /event` as a server-sent events stream: https://dev.opencode.ai/docs/server/ +- OpenCode SDK docs document `event.subscribe()` and `session.prompt(...)`: https://opencode.ai/docs/sdk/ +- OpenCode plugin docs list `session.idle`, `session.status`, `session.error`, `message.updated`, and `message.part.updated` event types: https://open-code.ai/en/docs/plugins +- The generated OpenCode SDK types define `EventSessionIdle`, `EventSessionStatus`, `EventSessionError`, `EventMessageUpdated`, `EventMessagePartUpdated`, and `GlobalEvent { directory, payload }`: https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/gen/types.gen.ts +- The generated OpenCode SDK types define `/session/{id}/prompt_async` with optional `body.messageID?: string` and `204 Prompt accepted`: https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/gen/types.gen.ts +- Current OpenCode prompt implementation uses `input.messageID ?? MessageID.ascending()` when creating the user message, so a custom `messageID` is a real OpenCode message identity, not opaque metadata: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt.ts +- Current OpenCode `prompt_async` handler schedules `SessionPrompt.prompt(...)` in an async runtime and returns `204` immediately; later failures are logged and published as `session.error`: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +- Current OpenCode `/event` handler sends `server.connected`, heartbeat events every 10 seconds, and no SSE `id` field for replay: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/server/routes/instance/event.ts +- Current OpenCode session status source defines `session.status` with object status and marks `session.idle` as deprecated, while still publishing idle for compatibility: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/status.ts +- Current OpenCode ID schema requires message IDs to start with `msg`: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/id/id.ts +- Current OpenCode message event schemas carry `message.updated.properties.info.role` and `message.part.updated.properties.part.messageID/sessionID`, so the observer can distinguish prompt persistence from assistant/runtime activity: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/message-v2.ts + +Design impact: + +- The observer should parse both plain events and global events wrapped under `payload`. +- For `/global/event`, the observer should ignore events whose `directory` is known and does not match the session record `projectPath`. +- The terminal state must be session-scoped and post-prompt, not host-scoped. +- Error and idle can both be emitted for the same turn, so error wins over later idle. +- `prompt_async` `204` means "scheduled/accepted by endpoint", not "assistant turn succeeded"; `session.error` after `204` must still be captured. +- `prompt_async` returns before the assistant is done, so the bridge command must wait a bounded amount for `session.status idle` / `session.idle` if it wants a reliable turn-settled file. +- `session.status idle` is the primary terminal event; deprecated `session.idle` is a compatibility fallback. +- A custom `messageID` must be generated per OpenCode prompt attempt and should not be reused as the app-level delivery retry key. +- Heartbeat and `server.connected` events are stream health, not session activity. +- `message.updated user` and user text part events prove only that the prompt was persisted. They are not assistant-turn activity and must not make observer outcome `success`. +- Assistant-turn activity should be limited to `session.status busy`, assistant `message.updated`, assistant-owned `message.part.updated` / `message.part.delta`, or tool/step/reasoning parts associated with assistant messages. +- Because `/event` has no replay IDs, missing a fast event must be handled by reconcile/preview proof rather than by reconnect replay. + +Additional source-audit notes from current docs: + +- OpenCode server docs say `/event` starts with `server.connected`, then bus events. Do not treat `server.connected` as session activity. +- OpenCode server docs expose `/global/event` separately. It is useful as fallback, but only with directory filtering. +- OpenCode SDK docs expose `event.subscribe()` as the official stream abstraction. Our fetch-based reader should stay compatible with the same event shapes, not invent a separate schema. +- OpenCode SDK generated types show `EventSessionStatus.properties.status` as a structured status object in current versions. Runtime captures can still be strings, so support both. +- OpenCode SDK generated types show `GlobalEvent.directory`. That confirms the need to pass `projectPath` into both new turn-settled observer and existing preview observer. + +### 2.2 Deep Review Corrections + +The original version of this plan had two unsafe assumptions. They are fixed below. + +1. **No unbounded background observer in orchestrator CLI.** `OpenCodeBridgeCommandClient` launches `agent_teams_orchestrator runtime opencode-command ...` as a short-lived process. If `runSendMessage()` returns immediately after `prompt_async`, a background SSE observer may be terminated with the process. The implementation must await observer settlement with a bounded timeout, keep the evidence, and only then continue to final emission. +2. **Do not mutate global `promptAsync()` semantics.** Existing OpenCode prompt callers should keep old behavior. Add an opt-in method such as `promptAsyncWithTurnSettled()` or a small wrapper service around `promptAsync()` so only launch/delivery paths that explicitly request turn-settled telemetry get message IDs and bounded observation. +3. **Do not add a second SSE stream for launch unless live evidence requires it.** `runLaunch()` already calls `observePreview()` per prompted member in the concurrent settle phase. For launch v1, derive final turn-settled outcome from existing preview + reconcile summaries instead of starting a second observer. The new observe-around-prompt path is most valuable for delivery prompts. +4. **Normalize `session.status` as object or string.** Current OpenCode SDK types model status as `{ type: "idle" | "busy" | "retry" }`, while older/live shapes can appear as strings. Shared status parsing must accept both, and `OpenCodePreviewObserver` should be updated as part of helper extraction. +5. **Do not let observation outlive the retained host scope accidentally.** `OpenCodeSessionBridge.withSessionHost()` calls `releaseHost()` in `finally`. Observed prompt APIs must either keep observe/prompt/settle inside the retained scope or explicitly hold an observation lease until `waitForSettled()`/`dispose()` completes. +6. **Emit after final local evidence, not directly from observer.** `runSendMessage()` already reconciles after prompt. If the SSE observer times out but reconcile sees new messages/tool calls, the final event should be `success`, not an already-written `timeout`. The observer returns evidence; the coordinator emits once. + +--- + +## 3. Goals + +- Emit OpenCode `runtime_turn_settled` events into the existing durable spool exactly once per observed prompt path. +- Keep OpenCode support provider-specific at the adapter boundary and provider-neutral in `member-work-sync` core. +- Make the observer fail-soft: delivery success still depends on `prompt_async`, but the bridge command waits only a bounded telemetry budget and returns `timeout` evidence rather than hanging. +- Preserve existing OpenCode delivery, watchdog, ledger, MCP readiness, and task-stall semantics. +- Avoid modifying OpenCode user config, project plugins, or profile settings. +- Avoid frontend changes in v1. +- Make live validation possible with cheap models and without long-running model matrix tests. + +--- + +## 4. Non-Goals + +This plan does not: + +- add OpenCode plugin installation; +- add a new MCP tool; +- synthesize replies; +- auto-complete tasks; +- change `TeamTaskStallMonitor` behavior; +- mark messages read; +- change OpenCode prompt text except optional deterministic message IDs; +- rely on model text like "done" as proof; +- expose new UI controls. + +--- + +## 5. Architecture Principles + +### 5.1 Clean Architecture + +Follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +In `claude_team`: + +```text +src/features/member-work-sync/ + core/domain/ + core/application/ + main/adapters/output/ + main/infrastructure/ + main/composition/ +``` + +In `agent_teams_orchestrator`: + +```text +src/services/opencode/ + OpenCodeTurnSettledObserver.ts + OpenCodeRuntimeTurnSettledEmitter.ts +``` + +The boundary is explicit: + +- orchestrator knows OpenCode SSE and session protocol; +- orchestrator writes raw provider event files; +- `claude_team` owns agenda, fingerprint, leases, queue, nudge policy, and watchdog separation. + +### 5.2 SOLID + +- **SRP:** observer watches OpenCode events; coordinator derives final outcome; emitter writes spool files; normalizer validates payload; resolver validates team/member. +- **OCP:** adding OpenCode means adding a normalizer/resolver branch and provider env support, not rewriting `RuntimeTurnSettledIngestor`. +- **LSP:** tests can substitute fake observer, fake emitter, fake resolver. +- **ISP:** ports stay small: `OpenCodeTurnSettledEmitterPort`, `RuntimeTurnSettledPayloadNormalizerPort`, `RuntimeTurnSettledTargetResolverPort`. +- **DIP:** application layer depends on ports, not `fetch`, filesystem, OpenCode client, or Electron. + +### 5.3 Watchdog Separation + +OpenCode turn-settled is a fast wake-up signal: + +```text +"a runtime turn ended, recompute current agenda" +``` + +Task stall watchdog remains semantic and delayed: + +```text +"a task has not had meaningful progress for too long" +``` + +Rules: + +- turn-settled does not directly nudge; +- turn-settled does not count as meaningful task progress; +- watchdog cooldowns still prevent duplicate nudges; +- existing `member-work-sync` nudge side-effects gate remains the only way to deliver sync nudges. + +--- + +## 6. Recommended Design + +### 6.1 Provider Signal Source + +Use OpenCode SSE, not plugin hooks. + +```text +GET /event +``` + +Fallback: + +```text +GET /global/event +``` + +Reasons: + +- no project config mutation; +- no user OpenCode plugin pollution; +- app already has the session record and host URL; +- observer can be started before `prompt_async`; +- compatible with existing `OpenCodePreviewObserver` experience. + +### 6.2 Turn Boundary + +A turn-settled observer starts immediately before the prompt is submitted and is awaited with a bounded settlement budget inside the same bridge command: + +```text +observeTurnSettled(record, context) +-> waitUntilReady(max 500ms) +-> markPromptSubmitting() +-> prompt_async(record, prompt) +-> markPromptAcceptedByEndpoint() +-> waitForSettled(max 8-12s for delivery) +-> existing post-send reconcile / response observation where already present +-> coordinator derives final outcome from observer evidence + reconcile evidence + response proof +-> coordinator emits one success/error/timeout/stream_unavailable event +-> return bridge command result +``` + +Why this is required: + +- the orchestrator OpenCode command is not a long-lived daemon; +- a fire-and-forget observer can be killed when the command exits; +- a bounded wait gives a durable signal without making delivery depend on perfect SSE behavior. +- the observer must enter `submitting` before the HTTP call, not after the accepted response, because fast OpenCode turns can emit message/activity/idle events while `prompt_async` is still in flight. +- `prompt_async` `204` only means the endpoint scheduled the turn. It does not prove the prompt finished or even that model/tool execution succeeded. Later `session.error` still wins unless reconcile/response proof upgrades the outcome. + +No-reply guard: + +- If `noReply === true`, do not emit a runtime turn-settled event. There is no assistant turn to settle. +- The command can still reconcile for delivery bookkeeping, but `member-work-sync` should not treat a no-reply prompt as an agent idle signal. +- Add a test that `noReply` delivery preserves existing behavior and does not enqueue OpenCode work sync. + +Important default: + +```ts +const OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS = 12_000; +const OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS = 2_500; +``` + +These are telemetry budgets. If they expire, delivery can still be accepted and the observer outcome is `timeout`. The emitted outcome can still become `success` if later reconcile/response evidence proves assistant-turn activity. + +Do not let the observer write the spool file directly. It should return evidence: + +```ts +type OpenCodeTurnSettledEvidence = { + readiness: 'connected' | 'fallback' | 'timeout'; + promptLifecycle: 'accepted_by_endpoint' | 'rejected_by_endpoint' | 'unknown'; + outcome: OpenCodeTurnSettledOutcome; + sawAssistantTurnActivity: boolean; + sawError: boolean; + diagnostics: string[]; +}; +``` + +Then the command path derives the final event: + +```text +response observation proves visible/tool reply -> success with diagnostic response_observation_proved_activity +reconcile cursor advanced -> success with diagnostic reconcile_advanced_after_prompt +observer error -> error unless reconcile/response proof shows a later successful turn +observer success -> success +observer idle_without_assistant_activity -> idle_without_assistant_activity unless reconcile/response proof upgrades it +observer timeout + reconcile failed/no activity -> timeout +stream unavailable + reconcile failed/no activity -> stream_unavailable +``` + +This avoids writing a premature `timeout` immediately before existing reconcile proves that the turn actually completed. + +Prompt submission race rule: + +```text +before HTTP prompt_async request -> markPromptSubmitting() +HTTP 204 returned -> markPromptAcceptedByEndpoint() +HTTP rejected/throws -> markPromptRejectedByEndpoint(), dispose observer, do not emit runtime_turn_settled +events seen after submitting are buffered as candidate evidence +candidate evidence becomes valid only after endpoint acceptance +session.error after endpoint acceptance is still a failed turn signal +``` + +This avoids both bad outcomes: + +- missing a very fast turn that finishes while the HTTP request is in flight; +- emitting a turn-settled event for a prompt that OpenCode rejected. + +There are two integration shapes: + +1. **Single prompt wrapper for delivery.** + + ```text + promptAsyncWithTurnSettled() + -> begin observation + -> prompt_async + -> bounded wait + -> return accepted + telemetry outcome + ``` + +2. **Preview-derived launch event.** + + ```text + promptAsync(record) + existing observePreview(record) in concurrent settle phase + existing reconcileSession(record) + coordinator emits turn-settled from preview + reconcile summary + ``` + +Launch should not open a second SSE stream in v1. If live tests prove preview misses too many fast bootstrap turns, add split observe-around-prompt later. + +### 6.3 Event Outcome + +Allowed OpenCode outcomes: + +```ts +export type OpenCodeTurnSettledOutcome = + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; +``` + +Interpretation: + +- `success`: assistant-turn activity was observed and no `session.error` happened before idle. +- `error`: `session.error` happened before idle or stream termination. +- `timeout`: observer connected but did not see a terminal event within budget. +- `stream_unavailable`: SSE could not be opened. +- `idle_without_assistant_activity`: an idle signal was seen after prompt submission, but no assistant-turn session/message/tool activity was observed. This still wakes reconcile, but diagnostics should flag weak correlation. + +All outcomes can still enqueue reconcile, because even an error can leave board state changed through earlier tool calls. + +### 6.4 Idempotency + +Use one deterministic source identity: + +```text +runtime-turn-settled:opencode:::no-transcript: +``` + +The file store already dedupes by source ID after normalization. + +OpenCode emission coordinator must also avoid duplicate writes for the same prompt path: + +```ts +let emitted = false; + +async function emitOnce(outcome: OpenCodeTurnSettledOutcome) { + if (emitted) return; + emitted = true; + await emitter.emit(buildEvent({ outcome })); +} +``` + +### 6.5 Message ID + +If we add explicit `messageID` to `prompt_async`, it must be OpenCode-compatible: + +```ts +function buildOpenCodePromptMessageId(input: { + teamId: string; + memberName: string; + sessionId: string; + purpose: string; + nonce: string; +}): string { + const hash = createHash('sha256') + .update(JSON.stringify(input)) + .digest('hex') + .slice(0, 32); + return `msg_${hash}`; +} +``` + +In v1, `turnId` can be generated by our observer even if `messageID` is not passed to OpenCode. However, passing a compatible `messageID` improves correlation and should be done if it does not break existing tests. + +Important source-backed constraint: + +- OpenCode SDK generated types expose `SessionPromptAsyncData.body.messageID?: string` for `/session/{id}/prompt_async`. +- OpenCode session prompt implementation uses `input.messageID ?? MessageID.ascending()` as the user message ID. + +Therefore `messageID` is not just telemetry metadata. It becomes the OpenCode user-message identifier. Treat it as a per-prompt attempt ID, not as a long-lived delivery retry key. + +Rules: + +- Generate a fresh OpenCode prompt `messageID` for each accepted `prompt_async` attempt. +- Keep Agent Teams delivery idempotency in the existing `messageId` / ledger / relay fields, not by reusing OpenCode `messageID`. +- Do not retry a failed `prompt_async` with the same text and same `messageID` unless a targeted live test proves OpenCode dedupes that exact case safely. +- Store the generated OpenCode prompt `messageID` in diagnostics and turn-settled payload as `runtimePromptMessageId` for correlation. +- If OpenCode rejects the custom ID shape, fail the prompt normally in v1. Do not silently resend without `messageID`, because that can create duplicate user messages. A later compatibility fallback can be added only with explicit single-send guarantees. + +--- + +## 7. Cross-Repo Contract + +### 7.1 Spool Environment + +Existing env variable: + +```ts +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; +``` + +Current `claude_team` behavior only returns this env for `codex`. Extend it to OpenCode: + +```ts +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex' && input.provider !== 'opencode') { + return null; + } + + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} +``` + +### 7.2 OpenCode Runtime Event Payload + +Add this orchestrator payload: + +```ts +export interface OpenCodeRuntimeTurnSettledEvent { + schemaVersion: 1; + provider: 'opencode'; + eventName: 'runtime_turn_settled'; + hookEventName: 'Stop'; + source: 'agent-teams-orchestrator-opencode'; + recordedAt: string; + sessionId: string; + turnId: string; + teamName: string; + memberName: string; + cwd?: string; + runtimePid?: number; + outcome: + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; + detail?: string; + diagnostics?: string[]; +} +``` + +Why keep `hookEventName: 'Stop'`: + +- `RuntimeTurnSettledEvent` currently models provider "turn settled" as a Stop-like lifecycle signal. +- This avoids changing core semantics. +- It does not imply OpenCode has a real Claude Stop hook. + +If desired later, rename the domain field to `eventKind`. Do not do that in this patch. + +### 7.3 File Naming + +Use the same atomic spool pattern as Codex: + +```text +/incoming/-.turn-settled.-.opencode.json +``` + +Do not append to shared JSONL. + +--- + +## 8. Orchestrator Implementation Plan + +Repo: + +```text +/Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +``` + +### 8.1 Add OpenCode Runtime Turn-Settled Emitter + +File: + +```text +src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.ts +``` + +Example: + +```ts +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; + +export type OpenCodeRuntimeTurnSettledOutcome = + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; + +export interface OpenCodeRuntimeTurnSettledEvent { + schemaVersion: 1; + provider: 'opencode'; + eventName: 'runtime_turn_settled'; + hookEventName: 'Stop'; + source: 'agent-teams-orchestrator-opencode'; + recordedAt: string; + sessionId: string; + turnId: string; + teamName: string; + memberName: string; + cwd?: string; + runtimePid?: number; + outcome: OpenCodeRuntimeTurnSettledOutcome; + detail?: string; + diagnostics?: string[]; +} + +export interface OpenCodeRuntimeTurnSettledEmitterPort { + emit(event: OpenCodeRuntimeTurnSettledEvent): Promise; +} + +export class FileOpenCodeRuntimeTurnSettledEmitter + implements OpenCodeRuntimeTurnSettledEmitterPort +{ + constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + + async emit(event: OpenCodeRuntimeTurnSettledEvent): Promise { + const spoolRoot = this.env[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]?.trim(); + if (!spoolRoot) return; + + const incomingDir = join(spoolRoot, 'incoming'); + await mkdir(incomingDir, { recursive: true }); + + const stamp = event.recordedAt.replace(/[-:.]/g, ''); + const suffix = `${process.pid}-${randomUUID()}`; + const tempPath = join(incomingDir, `.turn-settled.${suffix}`); + const finalPath = join(incomingDir, `${stamp}-${basename(tempPath)}.opencode.json`); + + await writeFile(tempPath, `${JSON.stringify(event)}\n`, 'utf8'); + await rename(tempPath, finalPath); + } +} + +export async function emitOpenCodeTurnSettledBestEffort( + event: OpenCodeRuntimeTurnSettledEvent, + emitter: OpenCodeRuntimeTurnSettledEmitterPort, +): Promise { + try { + await emitter.emit(event); + } catch { + // Runtime turn-settled telemetry must never fail OpenCode delivery. + } +} +``` + +### 8.2 Add OpenCode Turn-Settled Observer + +File: + +```text +src/services/opencode/OpenCodeTurnSettledObserver.ts +``` + +Responsibilities: + +- open SSE before prompt; +- filter events by `sessionID`; +- mark assistant-turn activity only after `start()` is called; +- capture `session.error`; +- return terminal evidence on `session.idle` or `session.status idle`; +- timeout gracefully; +- never throw into delivery path; +- never write the spool directly. + +Example interface: + +```ts +export interface OpenCodeTurnSettledObservation { + turnId: string; + waitUntilReady(input: { timeoutMs: number }): Promise<'connected' | 'fallback' | 'timeout'>; + markPromptSubmitting(): void; + markPromptAcceptedByEndpoint(): void; + markPromptRejectedByEndpoint(reason: string): void; + waitForSettled(input: { timeoutMs: number }): Promise; + dispose(): void; +} + +export interface OpenCodeTurnSettledEvidence { + readiness: 'connected' | 'fallback' | 'timeout'; + promptLifecycle: 'accepted_by_endpoint' | 'rejected_by_endpoint' | 'unknown'; + outcome: OpenCodeRuntimeTurnSettledOutcome; + sawAssistantTurnActivity: boolean; + sawError: boolean; + diagnostics: string[]; +} + +export interface OpenCodeTurnSettledObserverPort { + observe(input: OpenCodeTurnSettledObserveInput): OpenCodeTurnSettledObservation; +} + +export interface OpenCodeTurnSettledObserveInput { + baseUrl: string; + sessionId: string; + teamName: string; + memberName: string; + selectedModel: string; + projectPath?: string | null; + runtimePid?: number | null; + turnId: string; + timeoutMs?: number; +} +``` + +Status and session identity rules: + +```ts +function sessionIdFromEvent(event: OpenCodeSseEvent): string | null { + const properties = event.properties; + if (event.type === 'session.error') { + return asString(properties.sessionID); + } + return ( + asString(properties.sessionID) + ?? asString(asRecord(properties.info)?.sessionID) + ?? asString(asRecord(properties.part)?.sessionID) + ); +} + +function isCurrentSessionEvent(event: OpenCodeSseEvent, sessionId: string): boolean { + const eventSessionId = sessionIdFromEvent(event); + return eventSessionId === sessionId; +} + +function getOpenCodeSessionStatusType(value: unknown): string | null { + if (typeof value === 'string') return value; + const record = asRecord(value); + return asString(record?.type); +} +``` + +Do not infer `session.error` from `info.sessionID` or `part.sessionID`; current generated SDK shape uses `properties.sessionID?` for session error. If missing, record a diagnostic and do not classify the turn as error without later matched session evidence. + +Core event handling: + +```ts +function isRelevantDirectory(event: OpenCodeSseEvent, projectPath?: string | null): boolean { + if (!projectPath || !event.directory) return true; + return normalizePathForCompare(event.directory) === normalizePathForCompare(projectPath); +} + +function isTerminalIdle(event: OpenCodeSseEvent): boolean { + if (event.type === 'session.status') { + return getOpenCodeSessionStatusType(event.properties.status) === 'idle'; + } + return event.type === 'session.idle'; // Deprecated in OpenCode source, kept as legacy fallback. +} + +const assistantMessageIds = new Set(); + +function isAssistantTurnActivityEvent(event: OpenCodeSseEvent, runtimePromptMessageId: string): boolean { + if (event.type === 'session.status') { + return getOpenCodeSessionStatusType(event.properties.status) === 'busy'; + } + + if (event.type === 'message.updated') { + const info = asRecord(event.properties.info); + const messageId = asString(info?.id); + const role = asString(info?.role); + if (messageId && role === 'assistant') { + assistantMessageIds.add(messageId); + return true; + } + return false; // user message persistence is not assistant-turn activity. + } + + if (event.type === 'message.part.updated' || event.type === 'message.part.delta') { + const part = asRecord(event.properties.part); + const messageId = asString(part?.messageID) ?? asString(event.properties.messageID); + if (!messageId || messageId === runtimePromptMessageId) return false; + if (assistantMessageIds.has(messageId)) return true; + const partType = asString(part?.type); + return partType === 'tool' + || partType === 'step-start' + || partType === 'step-finish' + || partType === 'reasoning'; + } + + return false; +} +``` + +Prompt lifecycle behavior: + +```ts +let promptLifecycle: + | 'pending' + | 'submitting' + | 'accepted_by_endpoint' + | 'rejected_by_endpoint' = 'pending'; +let candidateAssistantTurnActivity = false; +let candidateTerminalIdle: OpenCodeSseEvent | null = null; +let candidateSessionError = false; + +function markPromptSubmitting() { + if (promptLifecycle === 'pending') { + promptLifecycle = 'submitting'; + } +} + +function markPromptAcceptedByEndpoint() { + if (promptLifecycle !== 'rejected_by_endpoint') { + promptLifecycle = 'accepted_by_endpoint'; + if (candidateAssistantTurnActivity) sawAssistantTurnActivity = true; + if (candidateSessionError) { + resolveTerminalEvidence('error'); + return; + } + if (candidateTerminalIdle) resolveFromIdle(candidateTerminalIdle); + } +} + +function markPromptRejectedByEndpoint(reason: string) { + promptLifecycle = 'rejected_by_endpoint'; + diagnostics.push(`OpenCode prompt_async rejected before turn-settled emission: ${reason}`); +} +``` + +Core event behavior: + +```ts +if (!isRelevantDirectory(event, input.projectPath)) return; + +if (event.type === 'session.error' && !sessionIdFromEvent(event)) { + diagnostics.push('OpenCode session.error observed without matching session identity'); + return; +} + +if (!isCurrentSessionEvent(event, input.sessionId)) return; + +if (isAssistantTurnActivityEvent(event, turnId)) { + if (promptLifecycle === 'submitting') { + candidateAssistantTurnActivity = true; + } else if (promptLifecycle === 'accepted_by_endpoint') { + sawAssistantTurnActivity = true; + } +} + +if (event.type === 'session.error') { + if (promptLifecycle === 'submitting' || promptLifecycle === 'accepted_by_endpoint') { + sawError = true; + diagnostics.push('OpenCode session.error observed before idle'); + if (promptLifecycle === 'submitting') { + candidateSessionError = true; + return; + } + resolveTerminalEvidence('error'); + return; + } else { + diagnostics.push('OpenCode session.error observed before prompt submit window'); + } +} + +if (isTerminalIdle(event)) { + if (promptLifecycle === 'submitting') { + candidateTerminalIdle = event; + return; + } + if (promptLifecycle !== 'accepted_by_endpoint') return; + const outcome = sawError + ? 'error' + : sawAssistantTurnActivity + ? 'success' + : 'idle_without_assistant_activity'; + resolveTerminalEvidence(outcome); +} +``` + +Timeout behavior: + +```ts +async waitForSettled({ timeoutMs }: { timeoutMs: number }) { + return await Promise.race([ + terminalEvidencePromise, + sleep(timeoutMs).then(() => buildEvidence(streamConnected ? 'timeout' : 'stream_unavailable')), + ]); +} +``` + +Readiness behavior: + +```ts +async waitUntilReady({ timeoutMs }: { timeoutMs: number }) { + return await Promise.race([ + streamConnectedPromise.then(() => 'connected' as const), + endpointFallbackPromise.then(() => 'fallback' as const), + sleep(timeoutMs).then(() => 'timeout' as const), + ]); +} +``` + +`waitUntilReady()` is advisory. If it times out, the prompt still proceeds and the final outcome can become `stream_unavailable` or `timeout`. + +### 8.2b Add Turn-Settled Emission Coordinator + +File: + +```text +src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.ts +``` + +Responsibility: + +- combine observer evidence with existing reconcile/preview evidence; +- avoid premature timeout emission when existing reconcile proves assistant-turn activity; +- build one final `OpenCodeRuntimeTurnSettledEvent`; +- call the file emitter once; +- translate outcome into existing bridge diagnostics. + +Example: + +```ts +export class OpenCodeTurnSettledEmissionCoordinator { + constructor(private readonly emitter: OpenCodeRuntimeTurnSettledEmitterPort) {} + + async emitDelivery(input: { + record: OpenCodeSessionRecord; + turnId: string; + teamName: string; + memberName: string; + observer: OpenCodeTurnSettledEvidence; + prePromptCursor: string | null; + reconcileSummary: OpenCodeSessionReconcileSummary | null; + responseObservation?: OpenCodeDeliveryResponseObservation | null; + }): Promise { + const finalOutcome = deriveDeliveryOutcome(input); + await emitOpenCodeTurnSettledBestEffort( + buildOpenCodeTurnSettledEvent({ ...input, outcome: finalOutcome.outcome }), + this.emitter, + ); + return [teamDiagnostic(finalOutcome.code, finalOutcome.message, finalOutcome.severity)]; + } +} +``` + +Derivation rules: + +```ts +function didReconcileAdvance(input: { + prePromptCursor: string | null; + summary: OpenCodeSessionReconcileSummary | null; +}): boolean { + return Boolean( + input.summary + && input.summary.lastCanonicalCursor + && input.summary.lastCanonicalCursor !== input.prePromptCursor + ); +} + +function deriveDeliveryOutcome(input: DeliveryEmissionInput): FinalOpenCodeTurnOutcome { + if (didResponseObservationProveActivity(input.responseObservation)) { + return success('response_observation_proved_activity'); + } + + if (didReconcileAdvance(input)) { + return success('reconcile_advanced_after_prompt'); + } + + if (input.observer.outcome === 'error') { + return failure('error', 'observer_session_error'); + } + + if (input.observer.outcome === 'success') { + return success('observer_idle_after_activity'); + } + + return { + outcome: input.observer.outcome, + diagnostics: input.observer.diagnostics, + }; +} +``` + +Launch derivation is similar but uses preview summary plus reconcile summary: + +```ts +function deriveLaunchOutcome(input: LaunchEmissionInput): FinalOpenCodeTurnOutcome { + if (didPreviewObserveActivity(input.preview) || didReconcileAdvance(input)) { + return success('launch_preview_or_reconcile_activity'); + } + if (input.preview?.runtimeState === 'error') { + return failure('error', 'launch_preview_session_error'); + } + return { outcome: 'timeout', diagnostics: ['launch_preview_no_activity'] }; +} + +function didPreviewObserveActivity(summary: OpenCodeSessionPreviewSummary | null): boolean { + if (!summary) return false; + return Boolean( + summary.previewOutcome === 'observed' + && ( + summary.runtimeState === 'idle' + || summary.latestEventType === 'session.idle' + || summary.latestAssistantMessageId + || summary.latestAssistantPreview + ) + ); +} +``` + +The coordinator is the only object that writes spool files for OpenCode turn-settled. The observer and preview reader return evidence only. + +Do not treat `previewOutcome === 'observed'` alone as success. The current preview observer can return `observed` after a bounded timeout once the stream was connected. The coordinator needs session activity evidence, not just stream availability. + +### 8.3 Reuse Or Extract SSE Helpers + +Existing file: + +```text +src/services/opencode/OpenCodePreviewObserver.ts +``` + +It already contains: + +- SSE parsing; +- OpenCode event normalization; +- session ID extraction logic. + +Required signature change: + +```ts +type ObserveSessionParams = { + baseUrl: string; + sessionId: string; + projectPath?: string | null; + timeoutMs?: number; + idleTimeoutMs?: number; + signal?: AbortSignal; +} +``` + +`OpenCodeSessionBridge.observePreview(record, ...)` must pass `record.projectPath` into `openCodePreviewObserver.observeSession(...)`. + +Current weak spot found in code: + +```ts +const status = asString(properties.status) +``` + +OpenCode SDK types model `session.status.properties.status` as an object: + +```ts +type SessionStatus = { type: 'idle' } | { type: 'busy' } | { type: 'retry', ... } +``` + +Older/live shapes can still be strings, so the shared helper must normalize both. This should be fixed while extracting helpers, otherwise `session.status idle` may be missed by both preview and turn-settled logic. + +Preferred low-risk approach: + +1. Extract shared pure helpers into: + +```text +src/services/opencode/OpenCodeSseEventStream.ts +``` + +2. Keep `OpenCodePreviewObserver` behavior unchanged. +3. Add tests that both preview and turn-settled observers parse the same fixture events. + +Example shared API: + +```ts +export function normalizeOpenCodeSseEvent(raw: unknown): OpenCodeSseEvent | null; +export function parseOpenCodeSseDataBlocks(input: string): string[]; +export function extractOpenCodeSseDataLines(block: string): string | null; +export function getOpenCodeSessionStatusType(value: unknown): string | null; +export async function* readOpenCodeSseEvents(input: { + fetchImpl: typeof fetch; + endpointUrl: string; + signal: AbortSignal; + projectPath?: string | null; +}): AsyncIterable; +``` + +If extraction looks risky, duplicate the small parser in v1 and document a follow-up dedupe task. However, extraction is preferred for DRY if tests stay tight. + +Current-code weak spots that this extraction must fix: + +1. `OpenCodePreviewObserver` currently reads `properties.status` as a string. It must use `getOpenCodeSessionStatusType()` so `{ type: 'idle' }` is terminal. +2. `OpenCodePreviewObserver` currently has `directory` on normalized events but no `projectPath` input, so `/global/event` fallback cannot reject foreign project events. Extend `ObserveSessionParams` with `projectPath?: string | null`, pass `record.projectPath` from `OpenCodeSessionBridge.observePreview()`, and filter before session matching. +3. `session.error` without a session identity should not mark the current session as errored. It should add a diagnostic and wait for matched session activity. This matters because current SDK types allow missing `sessionID`. +4. `server.connected` proves stream readiness only. It must not increment assistant-turn activity or launch success evidence. +5. Multiline SSE `data:` blocks and comment lines should stay supported. Do not replace the parser with a naive `split('\n')`. + +### 8.4 Integrate With OpenCodeSessionBridge + +File: + +```text +src/services/opencode/OpenCodeSessionBridge.ts +``` + +Extend deps: + +```ts +type OpenCodeSessionBridgeDeps = { + // existing deps + turnSettledObserver?: OpenCodeTurnSettledObserverPort; +} +``` + +Do not put final emission policy in `OpenCodeSessionBridge`. The bridge owns host/session IO. The command handler owns command-level evidence composition because it already has `prePromptCursor`, response observation, preview summary, and reconcile summary. + +Current code shape: + +```text +OpenCodeBridgeCommandHandler.ts +-> module-level runLaunch/runSendMessage functions +-> singleton imports: openCodeSessionBridge, openCodeSessionStore, ... +-> export executeOpenCodeBridgeCommandEnvelope(input) +``` + +Do not introduce a large class refactor in this patch. Add a small optional dependency seam: + +```ts +type OpenCodeBridgeCommandRuntimeDeps = { + sessionBridge: typeof openCodeSessionBridge; + turnSettledCoordinator?: OpenCodeTurnSettledEmissionCoordinator; +} + +const defaultBridgeCommandRuntimeDeps: OpenCodeBridgeCommandRuntimeDeps = { + sessionBridge: openCodeSessionBridge, + turnSettledCoordinator: defaultOpenCodeTurnSettledCoordinator, +}; + +export async function executeOpenCodeBridgeCommandEnvelope( + input: unknown, + deps: OpenCodeBridgeCommandRuntimeDeps = defaultBridgeCommandRuntimeDeps, +) { + // pass deps into runLaunch/runSendMessage only where needed +} +``` + +This keeps the CLI public API unchanged, improves testability, and avoids rewriting the whole command handler. + +Host lifecycle rule: + +- For delivery v1, keep observe -> prompt -> waitForSettled inside one `withSessionHost()` callback. +- Do not create an observation inside `withSessionHost()` and return it after the callback exits. +- If a future split observer is added, it must own a separate host ref and release it from `dispose()` / `waitForSettled()` finalizer. + +Future split scope shape: + +```ts +type OpenCodeObservedPromptScope = { + baseUrl: string; + runtimePid: number | null; + observation: OpenCodeTurnSettledObservation; + submit(params: OpenCodePromptParams): Promise; + dispose(): Promise; +}; +``` + +Add prompt context: + +```ts +type OpenCodePromptTurnSettledContext = { + teamName: string; + memberName: string; + purpose: 'launch' | 'delivery' | 'reminder' | 'manual'; + turnId?: string; + readyTimeoutMs?: number; + timeoutMs?: number; +}; +``` + +Do not change the behavior of existing `promptAsync()` calls. Add one public opt-in wrapper for delivery and one private helper: + +1. A convenience wrapper for single delivery prompts. +2. A private submit helper that can include `messageID` without changing public `promptAsync()` behavior. +3. Coordinator methods in the command handler that reuse existing `observePreview()` output for launch. + +Private submit helper: + +```ts +private async submitPromptAsync( + record: OpenCodeSessionRecord, + params: { + text: string; + agent?: string; + noReply?: boolean; + system?: string; + messageID?: string; + }, +): Promise +``` + +Existing public method remains a wrapper: + +```ts +async promptAsync(record, params): Promise { + await this.submitPromptAsync(record, params); +} +``` + +Single-prompt wrapper: + +```ts +async promptAsyncWithTurnSettled( + record: OpenCodeSessionRecord, + params: { + text: string; + agent?: string; + noReply?: boolean; + system?: string; + turnSettled: OpenCodePromptTurnSettledContext; + }, +): Promise +``` + +Guard: + +- `promptAsyncWithTurnSettled()` is for prompts that can produce an assistant turn. +- `runSendMessage()` must call plain `promptAsync()` when `body.noReply === true`. +- Add a defensive assertion inside `promptAsyncWithTurnSettled()` so accidental no-reply use fails in tests before production wiring. + +Do not expand bridge command public data unless needed. The final coordinator result can be converted into existing `diagnostics`: + +```ts +teamDiagnostic( + `opencode_turn_settled_${outcome}`, + `OpenCode turn-settled observer finished with outcome=${outcome}`, + outcome === 'success' ? 'info' : 'warning', +) +``` + +This keeps `OpenCodeSendMessageCommandData` and renderer IPC stable. + +Delivery wrapper implementation sketch: + +```ts +async promptAsyncWithTurnSettled(record, params): Promise { + if (params.noReply === true) { + throw new Error('OpenCode turn-settled observation does not support noReply prompts'); + } + + return await this.withSessionHost(record, async ({ baseUrl, runtimePid }) => { + const turnId = params.turnSettled.turnId ?? buildOpenCodePromptMessageId({ + teamId: record.teamId, + memberName: record.memberName, + sessionId: record.opencodeSessionId, + purpose: params.turnSettled.purpose, + nonce: new Date().toISOString(), + }); + + const observation = this.turnSettledObserver.observe({ + baseUrl, + sessionId: record.opencodeSessionId, + teamName: params.turnSettled.teamName, + memberName: params.turnSettled.memberName, + selectedModel: record.selectedModel, + projectPath: record.projectPath, + runtimePid, + turnId, + }); + + try { + observation.markPromptSubmitting(); + try { + await this.submitPromptAsync(record, { + text: params.text, + agent: params.agent, + noReply: params.noReply, + system: params.system, + messageID: turnId, + }); + observation.markPromptAcceptedByEndpoint(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + observation.markPromptRejectedByEndpoint(message); + throw error; + } + + const evidence = await observation.waitForSettled({ + timeoutMs: params.turnSettled.timeoutMs ?? OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS, + }); + + return { ok: true, turnId, readiness, evidence }; + } finally { + observation.dispose(); + } + }); +} +``` + +Launch preview-derived coordinator sketch: + +```ts +const settledMembers = await mapWithConcurrency(promptedMembers, 3, async ({ name, record }) => { + let preview: OpenCodePreviewSummary | null = null; + let reconciled: OpenCodeSessionReconcileSummary | null = null; + + try { + preview = await deps.sessionBridge.observePreview(record, { + timeoutMs: OPENCODE_LAUNCH_PREVIEW_TIMEOUT_MS, + idleTimeoutMs: OPENCODE_LAUNCH_PREVIEW_IDLE_TIMEOUT_MS, + }); + } catch (error) { + // Existing launch preview diagnostics stay unchanged. + } + + try { + reconciled = await deps.sessionBridge.reconcileSession(record, { limit: 50 }); + } catch (error) { + // Existing launch reconcile diagnostics stay unchanged. + } + + await deps.turnSettledCoordinator?.emitLaunch({ + record, + turnId: buildOpenCodeLaunchTurnId(record), + teamName: teamId, + memberName: name, + preview: preview?.summary ?? null, + reconcileSummary: reconciled, + }); + + return { name, record, reconciled }; +}); +``` + +Risk: + +- Adding `messageID` to all prompts could affect OpenCode behavior. +- Waiting for settlement can increase bridge command latency. + +Mitigation: + +- Keep `promptAsync()` unchanged for unobserved prompt paths. +- Only pass `messageID` inside `promptAsyncWithTurnSettled()`. +- Ensure generated ID starts with `msg_`. +- Keep wait bounded and return `timeout` evidence rather than waiting indefinitely. +- Test existing prompt paths remain unchanged. + +### 8.5 Add Turn-Settled Context At Prompt Sites + +Prompt sites: + +```text +src/services/opencode/OpenCodeBridgeCommandHandler.ts +``` + +Known calls: + +- launch/bootstrap prompt around `openCodeSessionBridge.promptAsync(record, ...)`; +- delivery prompt inside `runSendMessage()`. + +Launch prompt example: + +```ts +await openCodeSessionBridge.promptAsync(record, { + text: `${runtimeIdentityBlock}\n\n${prompt}`, + agent: 'teammate', +}); +promptedMembers.push({ name, record }); +``` + +Launch settle phase example: + +```ts +const preview = await safeObservePreview(record); +const reconciled = await safeReconcileSession(record, { limit: 50 }); +const diagnostics = await deps.turnSettledCoordinator?.emitLaunch({ + record, + teamName: teamId, + memberName: name, + turnId: buildOpenCodeLaunchTurnId(record), + preview: preview?.summary ?? null, + reconcileSummary: reconciled, +}); +``` + +Delivery prompt example: + +```ts +const promptText = identityReminder ? `${identityReminder}\n\n${text}` : text; + +const turnSettled = body.noReply === true + ? null + : await deps.sessionBridge.promptAsyncWithTurnSettled(deliveryRecord, { + text: promptText, + agent: asString(body.agent) ?? 'teammate', + turnSettled: { + teamName: teamId, + memberName, + purpose: 'delivery', + timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS, + }, + }); + +if (body.noReply === true) { + await deps.sessionBridge.promptAsync(deliveryRecord, { + text: promptText, + agent: asString(body.agent) ?? 'teammate', + noReply: true, + }); +} + +const reconcileSummary = await safeReconcileSession(deliveryRecord, { limit: 50 }); +const responseObservation = observeOpenCodeDeliveryResponse(...); +const diagnostics = turnSettled + ? await deps.turnSettledCoordinator?.emitDelivery({ + record: deliveryRecord, + teamName: teamId, + memberName, + turnId: turnSettled.turnId, + observer: turnSettled.evidence, + prePromptCursor, + reconcileSummary, + responseObservation, + }) + : []; +``` + +Suggested defaults: + +```ts +const OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS = 12_000; +const OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS = 2_500; +``` + +Keep these bounded. They are bridge-command telemetry budgets, not model behavior guarantees. + +### 8.6 Bounded Wait, Not Background Fire-And-Forget + +Do not leave the observer running in the background after the bridge command returns. + +Bad: + +```ts +void observation.waitForSettled({ timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS }); +return accepted; +``` + +Good: + +```ts +const outcome = await observation.waitForSettled({ timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS }); +const reconcileSummary = await reconcileSession(record); +await deps.turnSettledCoordinator?.emitDelivery({ observer: outcome, reconcileSummary, ... }); +return { accepted, diagnostics }; +``` + +Acceptance semantics remain: + +- `prompt_async` accepted means delivery accepted; +- observer timeout does not turn accepted delivery into failed delivery; +- post-send reconcile can still warn; +- turn-settled event is extra input for member-work-sync. + +Practical tradeoff: + +- The bridge command can take up to the telemetry budget longer. +- This is acceptable because OpenCode bridge calls already wait for delivery observation/reconcile in several paths, and durability matters more than a fire-and-forget signal that may never be written. + +--- + +## 9. claude_team Implementation Plan + +Repo: + +```text +/Users/belief/dev/projects/claude/_worktrees/claude_team_member_work_sync_opencode +``` + +### 9.1 Extend Provider Type + +File: + +```text +src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts +``` + +Change: + +```ts +export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode'; + +export function isRuntimeTurnSettledProvider( + value: unknown +): value is RuntimeTurnSettledProvider { + return value === 'claude' || value === 'codex' || value === 'opencode'; +} +``` + +### 9.2 Add OpenCode Payload Normalizer + +File: + +```text +src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts +``` + +Example: + +```ts +export class OpenCodeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'opencode') { + return { ok: false, reason: 'unsupported_provider' }; + } + + const payload = parseObject(input.raw); + if (!payload.ok) { + return { ok: false, reason: payload.reason }; + } + + if (getString(payload.value, 'provider') !== 'opencode') { + return { ok: false, reason: 'provider_mismatch' }; + } + if (getString(payload.value, 'source') !== 'agent-teams-orchestrator-opencode') { + return { ok: false, reason: 'source_mismatch' }; + } + if (getString(payload.value, 'eventName', 'event_name') !== 'runtime_turn_settled') { + return { ok: false, reason: 'not_turn_settled_event' }; + } + + const sessionId = getString(payload.value, 'sessionId', 'session_id'); + const teamName = getString(payload.value, 'teamName', 'team_name'); + const memberName = getString(payload.value, 'memberName', 'member_name'); + if (!sessionId) return { ok: false, reason: 'missing_session_identity' }; + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const turnId = getString(payload.value, 'turnId', 'turn_id'); + const outcome = getString(payload.value, 'outcome'); + + return { + ok: true, + event: { + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + payloadHash, + recordedAt: getString(payload.value, 'recordedAt', 'recorded_at') ?? input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'opencode', + sessionId, + turnId, + payloadHash, + }), + sessionId, + ...(turnId ? { turnId } : {}), + teamName, + memberName, + ...(outcome ? { outcome } : {}), + }, + }; + } +} +``` + +Validation rules: + +- reject invalid JSON; +- reject source mismatch; +- require session ID; +- require team and member identity; +- accept known outcomes but do not fail if outcome is unknown, because event still wakes reconcile. + +### 9.3 Add Normalizer To Composition + +File: + +```text +src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +``` + +Change: + +```ts +const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([ + new ClaudeStopHookPayloadNormalizer(hash), + new CodexNativeTurnSettledPayloadNormalizer(hash), + new OpenCodeTurnSettledPayloadNormalizer(hash), +]); +``` + +### 9.4 Extend Target Resolver + +File: + +```text +src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +``` + +Add OpenCode branch matching Codex style: + +```ts +async resolve(event: RuntimeTurnSettledEvent): Promise { + if (event.provider === 'codex') { + return this.resolveExplicitProviderEvent(event, 'codex'); + } + if (event.provider === 'opencode') { + return this.resolveExplicitProviderEvent(event, 'opencode'); + } + // existing Claude transcript/session scan +} +``` + +Shared helper: + +```ts +private async resolveExplicitProviderEvent( + event: RuntimeTurnSettledEvent, + expectedProviderId: 'codex' | 'opencode' +): Promise { + const teamName = event.teamName?.trim(); + const memberName = event.memberName?.trim(); + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const member = await this.resolveActiveMember(teamName, memberName); + if (!member) { + return { ok: false, reason: 'member_not_active' }; + } + if (isReservedMemberName(member.name)) { + return { ok: false, reason: 'reserved_member' }; + } + + const providerId = providerForMember(member); + if (providerId && providerId !== expectedProviderId) { + return { ok: false, reason: 'provider_mismatch' }; + } + + return { + ok: true, + teamName, + memberName: normalizeMemberName(member.name), + }; +} +``` + +This reduces duplication and keeps Codex/OpenCode explicit identity resolution consistent. + +### 9.5 Split Spool Initialization From Shell Hook Installation + +Current weak spot: + +`ShellRuntimeTurnSettledHookScriptInstaller` both creates the spool root and installs the Claude shell hook script. Reusing it for OpenCode works accidentally but is confusing and can become wrong as more provider-native emitters are added. + +Add a provider-neutral initializer: + +```text +src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts +``` + +Example: + +```ts +export interface RuntimeTurnSettledSpoolInitializerPort { + ensure(): Promise<{ spoolRoot: string }>; +} + +export class RuntimeTurnSettledSpoolInitializer + implements RuntimeTurnSettledSpoolInitializerPort +{ + constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {} + + async ensure(): Promise<{ spoolRoot: string }> { + const root = this.paths.getSpoolRoot(); + await Promise.all([ + mkdir(join(root, 'incoming'), { recursive: true }), + mkdir(join(root, 'processing'), { recursive: true }), + mkdir(join(root, 'processed'), { recursive: true }), + mkdir(join(root, 'invalid'), { recursive: true }), + ]); + return { spoolRoot: root }; + } +} +``` + +Then: + +- Claude hook settings still use `ShellRuntimeTurnSettledHookScriptInstaller`. +- Codex and OpenCode runtime env use `RuntimeTurnSettledSpoolInitializer`. +- This keeps shell-hook concerns out of provider-native turn-settled emitters. + +Extend environment builder: + +```text +src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts +``` + +```ts +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex' && input.provider !== 'opencode') { + return null; + } + + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} +``` + +### 9.6 Extend File Store Provider Parsing + +File: + +```text +src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +``` + +Current weak spot: + +```ts +function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null +``` + +This currently extracts the provider token from the second-to-last filename segment and validates it through `isRuntimeTurnSettledProvider(provider)`. After the provider union is extended, the runtime behavior is almost correct, but the explicit return type would still make TypeScript reject `opencode` and can hide future provider additions. + +Change to: + +```ts +function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null { + const parts = fileName.split('.'); + const provider = parts.length >= 3 ? parts[parts.length - 2] : null; + return isRuntimeTurnSettledProvider(provider) ? provider : null; +} +``` + +Add a test that a valid `.opencode.json` file reaches the normalizer instead of invalid quarantine. + +### 9.7 Pass Env To OpenCode Bridge Launch + +Find where `claude_team` invokes `agent_teams_orchestrator` OpenCode bridge commands. + +Expected service area: + +```text +src/main/services/team/ +src/features/runtime-provider-management/ +``` + +Concrete path found in current code: + +```text +src/main/index.ts +createOpenCodeRuntimeAdapterRegistry() +``` + +The important detail: `OpenCodeBridgeCommandClient` captures `env` in its constructor. Adding env after the client is constructed is too late. + +Second important detail found in current composition order: + +```text +teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()) +... +memberWorkSyncFeature = createMemberWorkSyncFeature(...) +``` + +So `createOpenCodeRuntimeAdapterRegistry()` currently runs before `memberWorkSyncFeature` exists. A naive call to `memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' })` inside the registry factory would always see `null`. + +Related code path: + +```text +TeamProvisioningService.buildRuntimeTurnSettledEnvironment(providerId) +``` + +currently returns env only for `codex`. That path is for native provider process launches. OpenCode secondary teammates use the OpenCode runtime adapter bridge, whose env is captured by `OpenCodeBridgeCommandClient` in `src/main/index.ts`. Therefore the v1 OpenCode wiring must target the bridge client env, not only the generic provisioning env helper. + +Preferred composition fix: + +```text +create TeamDataService +create TeamProvisioningService +create memberWorkSyncFeature +register runtimeTurnSettled providers on TeamProvisioningService +create OpenCode runtime adapter registry with memberWorkSyncFeature available +``` + +Keep delayed side effects where they are: + +- startup replay/scan still runs after service wiring; +- IPC registration still runs after window/service setup; +- `memberWorkSyncFeature.noteTeamChange(...)` remains guarded by nullable access in emitters. + +The runtime launch/handoff path must merge: + +```ts +const openCodeTurnSettledEnv = + await memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); +``` + +into the environment used for the OpenCode bridge process. + +Important: + +- do not overwrite existing env; +- merge before constructing `OpenCodeBridgeCommandClient`; +- do not expose this env to unrelated user project scripts; +- missing env means telemetry disabled, not runtime failure. +- keep `TeamProvisioningService.buildRuntimeTurnSettledEnvironment()` codex-only unless a real OpenCode path later launches a provider process directly through that generic helper. + +Example: + +```ts +const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); +const turnSettledEnv = memberWorkSyncFeature + ? await memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }) + : null; +Object.assign(bridgeEnv, turnSettledEnv ?? {}); + +const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath, + tempDirectory, + env: bridgeEnv, +}); +``` + +--- + +## 10. Event Flow Details + +### 10.1 Launch Bootstrap + +OpenCode launch currently prompts teammates with runtime identity and briefing instructions. + +New behavior: + +```text +launch prompt accepted +-> existing preview/reconcile settle phase collects evidence +-> coordinator writes one spool event +-> app reconciles member agenda +``` + +Expected practical value: + +- if launch finished and teammate has tasks, work-sync quickly re-evaluates; +- if launch errored, app still gets a signal and diagnostics; +- no direct user-visible UI changes. + +### 10.2 User Delivery + +OpenCode user-to-member message currently goes through `runSendMessage()`. + +New behavior: + +```text +delivery prompt accepted +-> observer tracks same session +-> post-send reconcile/response observation contributes evidence +-> coordinator writes one turn-settled event +-> member-work-sync recomputes whether member agenda is known/current +``` + +It does not replace: + +- OpenCode delivery ledger; +- response observation; +- visible reply correlation; +- MCP readiness repair. + +### 10.3 Watchdog Interaction + +If OpenCode agent stalls after weak start: + +1. delivery ledger/watchdog keeps existing behavior; +2. OpenCode turn-settled signal wakes member-work-sync after each turn; +3. member-work-sync may decide status is `needs_sync`; +4. future nudge outbox remains rate-limited; +5. task-stall monitor remains responsible for semantic no-progress. + +No conflict because each layer has a different proof model. + +--- + +## 11. Risks And Mitigations + +### Risk 1: SSE Misses Fast Turns + +Problem: + +```text +prompt_async can return and OpenCode can finish very quickly. +``` + +Mitigation: + +- start observer before `prompt_async`; +- call `markPromptSubmitting()` immediately before the HTTP request; +- buffer same-session activity and terminal idle seen while the request is in flight; +- validate buffered evidence only after `markPromptAcceptedByEndpoint()`; +- if `prompt_async` rejects, call `markPromptRejectedByEndpoint()` and do not emit a runtime turn-settled file. + +Residual risk: + +- if OpenCode emits a complete turn before SSE stream connects, v1 can miss it. + +Fallback: + +- bounded polling can be added if live tests show misses. + +### Risk 2: Idle After Error Looks Like Success + +Problem: + +OpenCode emits `session.idle` after `session.error`. + +Mitigation: + +- preserve `sawError` state; +- treat matched `session.error` as terminal error evidence immediately; +- if matched `session.error` arrives while `prompt_async` is still in flight, buffer it and promote it after endpoint acceptance; +- return observer `outcome: 'error'`; +- let coordinator upgrade to success only if response/reconcile evidence proves later successful activity; +- include short diagnostic. + +### Risk 3: Duplicate Idle Events + +Problem: + +Both `session.status idle` and `session.idle` can arrive. + +Mitigation: + +- observer-level `resolveOnce`; +- coordinator-level `emitOnce`; +- sourceId-level dedupe in `RuntimeTurnSettledIngestor`. + +### Risk 4: Misattributing Member + +Problem: + +OpenCode session ID alone can be stale or reused in corrupted metadata. + +Mitigation: + +- payload includes explicit `teamName` and `memberName` from `OpenCodeSessionRecord`; +- resolver validates active team config/meta; +- resolver validates provider is `opencode`; +- reserved names rejected. + +### Risk 5: Stale Removed Teammate Emits Event + +Problem: + +Old OpenCode process can still emit after member removal. + +Mitigation: + +- resolver checks config is not deleted and member has no `removedAt`; +- unresolved event is archived, not enqueued. + +### Risk 6: Telemetry Breaks Delivery + +Problem: + +If observer or spool fails, message delivery should still work. + +Mitigation: + +- all turn-settled emission is best-effort; +- observer errors are diagnostics only; +- `prompt_async` acceptance remains the delivery acceptance boundary; +- `waitForSettled()` is bounded and returns `timeout`/`stream_unavailable`, not an exception that fails delivery. + +Non-negotiable: + +- do not use unbounded await; +- do not run the observer fire-and-forget after bridge command return. + +### Risk 7: Message ID Changes Existing Behavior + +Problem: + +Passing `messageID` to `prompt_async` may alter idempotency in OpenCode. + +Mitigation: + +- only pass messageID when observer is enabled; +- generate valid `msg_...` ID; +- use unique turn ID, not deterministic retry ID, unless we explicitly want OpenCode-side idempotency later; +- tests verify existing no-observer prompt path sends no `messageID`. + +### Risk 8: OpenCode Event Schema Changes + +Problem: + +OpenCode event properties use `sessionID`, `info.sessionID`, or `part.sessionID`. + +Mitigation: + +- reuse existing preview observer extraction logic; +- tolerate unknown event types; +- only rely on `session.status`, `session.idle`, `session.error`. + +### Risk 8b: `session.error` Without Session Identity + +Problem: + +OpenCode SDK types allow `session.error.properties.sessionID` to be missing. Treating all host-level errors as the current session could misattribute errors if a host ever serves multiple sessions. + +Mitigation: + +- if `session.error` has the expected session ID, set `sawError = true`; +- if `session.error` has no session ID, record a diagnostic but do not mark error unless later matched activity/idle confirms the session; +- only add a stronger host-level fallback if the session bridge can prove the host is dedicated to this `OpenCodeSessionRecord`; +- tests cover sessionless error followed by matched idle and sessionless error with no matched activity. + +### Risk 8c: Global Event Cross-Project Noise + +Problem: + +`/global/event` wraps events with a `directory` field. If `/event` fails and observer falls back to `/global/event`, unrelated project events can be visible on the same server stream. + +Mitigation: + +- pass `projectPath` from `OpenCodeSessionRecord` into observation input; +- when event has `directory`, compare it with normalized `projectPath`; +- ignore mismatched directory events before session matching; +- keep direct `/event` behavior unchanged when no directory is present; +- tests cover global event with matching directory and foreign directory. + +### Risk 9: Long-Lived Background Observers Leak + +Problem: + +Many OpenCode messages can start many observers. + +Mitigation: + +- bounded timeout per observer; +- abort controller cleanup; +- no global listener per host in v1; +- unit test verifies `dispose()` aborts stream. + +Additional constraint: + +- observer lifetime must be scoped to one bridge command. If a future long-lived host-level observer is added, it should be a separate adapter with explicit lifecycle ownership and not hidden inside `promptAsyncWithTurnSettled()`. + +### Risk 10: Nudges Become More Frequent + +Problem: + +More reconcile triggers could expose existing Phase 2 nudges. + +Mitigation: + +- current nudge side effects remain gated by `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`; +- queue quiet window debounces events; +- outbox has one item per fingerprint; +- dispatcher revalidates busy/watchdog cooldown before delivery. + +### Risk 11: Bridge Command Timeout Budget + +Problem: + +`claude_team` currently runs `opencode.sendMessage` through `OpenCodeReadinessBridge`, whose default send timeout is `30_000ms`. `runSendMessage()` already does MCP readiness repair and a post-prompt reconcile with `OPENCODE_SEND_RECONCILE_TIMEOUT_MS = 5_000ms`. Adding a turn-settled wait can consume that budget and accidentally turn accepted prompts into bridge timeouts. + +Mitigation: + +- keep `OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS` below the send command budget, implemented default `12_000ms`; +- keep `OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS` small, implemented default `2_500ms`; +- do not nest another long response-observation wait inside the same critical path without reviewing total timeout; +- if the bridge envelope exposes remaining time, cap observer timeout to `min(configuredTurnSettledTimeout, remainingBudget - safetyMargin)`; +- compute a static fallback cap when remaining time is unavailable: + + ```ts + const telemetryBudgetMs = Math.min( + configuredTurnSettledTimeoutMs, + Math.max(1_000, envelope.timeoutMs - 12_000), + ); + ``` + +- add a test where `waitForSettled()` times out and the command still returns accepted before the bridge timeout. + +### Risk 12: Launch Fan-Out Becomes Serial Or Opens Duplicate Streams + +Problem: + +`runLaunch()` currently submits bootstrap prompts for all members first, then observes/reconciles with `mapWithConcurrency(promptedMembers, 3, ...)`. If launch uses the single-prompt wrapper and waits during the prompt loop, a 4-member OpenCode team can pay the observer timeout 4 times before all members even receive bootstrap. If it starts a second SSE observer in addition to `observePreview()`, launch does duplicate stream work. + +Mitigation: + +- launch v1 keeps current prompt submission path; +- launch v1 emits turn-settled from existing `observePreview()` summary in the concurrent settle phase; +- no second launch SSE stream unless live tests prove preview-derived signal is insufficient; +- tests assert prompt submission is not delayed by per-member observer timeout. + +### Risk 13: Status Shape Drift + +Problem: + +OpenCode SDK types model `session.status.properties.status` as an object with `type`, while earlier/live event captures may expose a string. Code that only checks one shape can silently miss terminal idle. + +Mitigation: + +- centralize `getOpenCodeSessionStatusType(value)` in shared SSE helpers; +- support both string and object status; +- update `OpenCodePreviewObserver` and the new turn-settled observer to use the helper; +- tests cover both shapes. + +### Risk 14: Composition Order Drops OpenCode Spool Env + +Problem: + +`createOpenCodeRuntimeAdapterRegistry()` currently runs before `memberWorkSyncFeature` is created. Since `OpenCodeBridgeCommandClient` captures env in its constructor, late wiring cannot fix the bridge env. + +Mitigation: + +- move `createMemberWorkSyncFeature(...)` earlier in `src/main/index.ts`, before OpenCode registry construction; +- keep effectful startup replay/scan and IPC registration in their current later positions; +- add a composition test or safe integration test proving `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT` is present in the env passed to `OpenCodeBridgeCommandClient`; +- if reordering causes a cycle, extract a small public member-work-sync factory for runtime-turn-settled env creation rather than reaching into infrastructure directly. + +### Risk 15: Observation Outlives Host Reference + +Problem: + +`OpenCodeSessionBridge.withSessionHost()` retains a host for callback duration and releases it in `finally`. Any API that returns an observation after `withSessionHost()` exits can leave the SSE stream attached to a host whose in-process ref was already released. + +Mitigation: + +- v1 delivery wrapper keeps observe, prompt, and `waitForSettled()` inside one `withSessionHost()` callback; +- launch v1 reuses `observePreview()`, so it does not introduce a second returned observation; +- if future split observer is needed, introduce explicit observed prompt scope ownership; +- tests assert delivery wrapper does not release host before observer settle/dispose. + +### Risk 16: IPC/Data Contract Churn + +Problem: + +Adding `turnSettledOutcome` to bridge command data could force renderer/shared contract changes for a telemetry-only feature. + +Mitigation: + +- keep public command data shape stable in v1; +- surface observer outcome through existing `diagnostics`; +- only expand contract later if UI needs to display turn-settled telemetry directly. + +### Risk 17: Premature Timeout Before Reconcile Proves Activity + +Problem: + +The SSE observer can time out because of stream delay, but the existing post-send reconcile can still see the assistant message, tool call, or cursor advance a few milliseconds later. If the observer writes the spool file directly, `member-work-sync` receives a false `timeout` even though the turn completed. + +Mitigation: + +- observer never writes spool files; +- observer returns evidence only; +- command handler runs the same reconcile/preview logic it already owns; +- coordinator emits exactly one final event after all local evidence has been collected; +- tests cover timeout evidence upgraded to success by reconcile and assert no duplicate timeout+success files. + +### Risk 18: Cross-Repo Contract Drift + +Problem: + +The orchestrator writes `.opencode.json` payloads, while `claude_team` normalizes and resolves them. If the two repos drift, events can be silently quarantined or ignored. + +Mitigation: + +- keep payload minimal and versioned with `schemaVersion: 1`; +- add an orchestrator fixture event generated by `buildOpenCodeTurnSettledEvent(...)`; +- import that fixture into `claude_team` tests or duplicate it as a contract fixture with an explicit comment; +- test malformed provider/source/session/team/member cases; +- do not rely on TypeScript shared imports across repos for runtime compatibility. + +### Risk 19: Optional Coordinator Missing + +Problem: + +If turn-settled env is not present or coordinator construction fails, OpenCode delivery must not fail. + +Mitigation: + +- default coordinator no-ops when spool env is missing; +- command handler treats missing `turnSettledCoordinator` as telemetry disabled; +- diagnostics can include `opencode_turn_settled_disabled`, but delivery acceptance remains unchanged; +- tests run send-message with `turnSettledCoordinator: undefined`. + +### Risk 20: Prompt Request Race And Rejected Prompt Events + +Problem: + +`prompt_async` returns `204` after OpenCode accepts the prompt, but the runtime can start and finish a short turn while the HTTP request is still in flight. If observation only starts counting after the response, the app can miss the whole turn. If observation emits before the response, it can create a false turn-settled event for a rejected prompt. + +Mitigation: + +- start SSE before the request; +- call `markPromptSubmitting()` immediately before `submitPromptAsync()`; +- buffer same-session activity and idle seen during `submitting`; +- after `204`, call `markPromptAcceptedByEndpoint()` and promote buffered evidence; +- if the HTTP request throws, call `markPromptRejectedByEndpoint()`, dispose the observer, and emit no runtime turn-settled file; +- tests cover fast idle during in-flight submit, rejected prompt with buffered idle, and normal slow idle after accepted. + +### Risk 21: `204 Prompt Accepted` Is Not Turn Success + +Problem: + +Current OpenCode `prompt_async` handler returns `204` after scheduling the prompt run. The actual prompt can still fail later and publish `session.error`. Treating `204` as success would hide model/provider/tool startup failures and make member-work-sync reconcile too optimistically. + +Mitigation: + +- name the lifecycle state `accepted_by_endpoint`, not just `accepted`; +- still observe `session.error` after `204`; +- derive final success only from response proof, reconcile cursor/message proof, or observer idle after post-submit activity with no error; +- tests cover `204 -> session.error -> session.status idle` returning final `error` unless response/reconcile proof upgrades it. + +### Risk 22: SSE Has Heartbeats But No Replay + +Problem: + +Current OpenCode `/event` sends `server.connected` and `server.heartbeat`, but no SSE `id` for replay. If the stream connects late, reconnects, or closes before terminal idle, the observer cannot recover missed events from Last-Event-ID. + +Mitigation: + +- ignore `server.connected` and `server.heartbeat` as activity; +- do not implement reconnect replay in v1 because the server does not expose replay IDs; +- map premature stream EOF to `stream_unavailable` with diagnostic `stream_closed_before_terminal_event`; +- let coordinator upgrade stream failure from existing reconcile/response proof; +- tests cover heartbeat-only stream, stream EOF before idle, and stream failure upgraded by reconcile. + +### Risk 23: `noReply` Prompts Are Not Agent Turns + +Problem: + +OpenCode `SessionPrompt.prompt()` returns after creating the user message when `noReply === true`; it does not enter the assistant loop that sets busy/idle. Observing such prompts would produce false timeouts or fake work-sync signals. + +Mitigation: + +- do not request OpenCode turn-settled observation when `noReply === true`; +- keep existing delivery/reconcile behavior for no-reply bookkeeping; +- do not write runtime-turn-settled spool files for no-reply prompts; +- tests cover `noReply` delivery with no observer, no coordinator emission, and stable command response. + +### Risk 24: OpenCode Version Drift + +Problem: + +The plan references current OpenCode source and docs, but users can run different installed OpenCode versions. Event payload shapes can drift while still staying compatible at the HTTP level. + +Mitigation: + +- implement tolerant parsing for known string/object status variants and unknown event types; +- record OpenCode version, stream endpoint, and observed event type histogram in live test artifacts; +- avoid exact full-event snapshots in unit tests. Use targeted fixtures for session identity, status shape, error, heartbeat, and global payload wrapper; +- add a live smoke that dumps a compact compatibility report when `OPENCODE_TURN_SETTLED_LIVE=1`; +- if a future version drops `session.idle`, v1 still works through `session.status idle`. + +### Risk 25: User Prompt Persistence Looks Like Assistant Work + +Problem: + +OpenCode emits `message.updated user` and user text part events when it stores the prompt. If the observer treats those as activity, a prompt that only persisted user input and then idled or errored could be reported as a successful assistant turn. + +Mitigation: + +- ignore `message.updated` where `info.role === 'user'`; +- ignore parts whose `messageID` equals the generated OpenCode prompt `messageID`; +- track assistant message IDs from `message.updated assistant`; +- count assistant parts only when their message ID is known assistant, or when the part type is assistant-only such as `tool`, `step-start`, `step-finish`, or `reasoning`; +- count `session.status busy` as turn-start activity, but keep final success gated by later idle and no `session.error`; +- tests cover user-only prompt events followed by idle returning `idle_without_assistant_activity`. + +### 11.1 Highest-Risk Implementation Checks + +These are the checks to do first during implementation, before expanding tests broadly: + +1. **Command timeout budget.** Confirm the actual `OpenCodeBridgeCommandClient` timeout for `send-message` and `launch`. The sum of `waitUntilReady`, `waitForSettled`, response observation, and reconcile must stay below that budget with a safety margin. If not, cap the turn-settled wait dynamically. +2. **Host retention.** Add a test fake host manager that records retain/release order. The sequence must be `retain -> observe -> prompt -> waitForSettled -> dispose -> release`. +3. **Outcome derivation from real types.** Use actual `OpenCodeSessionPreviewSummary` and `OpenCodeSessionReconcileSummary` fields, not invented observer fields. Outcome helpers should be pure functions with fixture tests. +4. **Composition order.** Write a test or narrow integration seam proving OpenCode bridge env receives `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT` before `OpenCodeBridgeCommandClient` construction. +5. **SSE schema drift.** Shared event helpers must parse `session.status` as string and object, and must unwrap `/global/event` payload events without trusting unrelated directories. +6. **Best-effort boundary.** Make emitter failure impossible to surface as delivery failure. The only accepted-prompt failure path should remain existing OpenCode delivery/reconcile logic. +7. **Prompt submit race.** Unit-test `submitting -> accepted` promotion and `submitting -> rejected` discard before wiring the observer into delivery. This is the highest-risk race in the design. +8. **No-reply bypass.** Confirm no-reply OpenCode prompts do not start the observer and do not emit runtime-turn-settled files. +9. **No replay assumption.** Treat SSE as best-effort current stream only. Reconcile remains the safety net for missed events. +10. **User prompt is not work.** Ensure `message.updated user` and prompt text parts do not satisfy `sawAssistantTurnActivity`. + +--- + +## 12. Alternatives Considered + +### Option 1: Bounded SSE Observer Per Prompt + +`🎯 9 🛡️ 9 🧠 6`, roughly `850-1250 LOC`. + +Pros: + +- no OpenCode config mutation; +- exact prompt boundary; +- easy to test; +- works for launch and delivery; +- provider-specific logic stays in orchestrator. +- durable in the current short-lived bridge-command architecture because the command waits for a bounded outcome. +- preserves launch fan-out by reusing existing preview/reconcile settle lifecycle. +- avoids false timeout events by emitting only after observer + reconcile/preview evidence is merged. + +Cons: + +- one observer per prompt; +- needs careful timeout cleanup; +- can miss events if stream connection is delayed. +- adds bounded latency to OpenCode bridge command completion. + +Decision: choose this. + +### Option 2: Long-Lived Host-Level SSE Observer + +`🎯 8 🛡️ 9 🧠 7`, roughly `650-1000 LOC`. + +Pros: + +- lower chance of missing fast events; +- one connection per host; +- can collect richer runtime diagnostics. + +Cons: + +- more lifecycle complexity; +- host lease cleanup risk; +- needs session-to-member registry updates; +- harder to prove no leaks. + +Decision: defer. Consider if per-prompt observer misses events in live validation. + +### Option 3: OpenCode Plugin `session.idle` + +`🎯 6 🛡️ 6 🧠 5`, roughly `300-600 LOC`. + +Pros: + +- OpenCode officially documents `session.idle` plugin event; +- event is naturally emitted by OpenCode runtime. + +Cons: + +- requires plugin install/config mutation; +- risks user/project config conflict; +- harder to keep app-owned and reversible; +- less aligned with current OpenCode serve bridge. + +Decision: reject for v1. + +### Option 4: Poll `/session/status` + +`🎯 6 🛡️ 7 🧠 3`, roughly `180-350 LOC`. + +Pros: + +- simple; +- no SSE parser. + +Cons: + +- less precise; +- more load; +- cannot distinguish error path as cleanly; +- slower reaction. + +Decision: use only as fallback if SSE has real misses. + +--- + +## 13. Test Plan + +### 13.1 Orchestrator Unit Tests + +Files: + +```text +src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts +src/services/opencode/OpenCodeTurnSettledObserver.test.ts +src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts +src/services/opencode/OpenCodeSessionBridge.test.ts +src/services/opencode/OpenCodeBridgeCommandHandler.test.ts +``` + +Cases: + +- emitter writes atomic `.opencode.json` file when env is present; +- emitter no-ops when env missing; +- command handler accepts missing coordinator and preserves delivery behavior; +- observer returns success evidence on post-prompt `session.status idle`; +- observer handles `session.status` where status is `{ type: 'idle' }`; +- preview observer still handles status string and status object after helper extraction; +- observer returns success evidence on post-prompt `session.idle`; +- observer returns error evidence when `session.error` precedes idle; +- observer does not misattribute sessionless `session.error` to a foreign session; +- observer records sessionless `session.error` diagnostic; +- observer resolves only once when both idle events arrive; +- observer ignores foreign session events; +- observer ignores `/global/event` events from a foreign directory; +- observer ignores `server.connected` and `server.heartbeat` as session activity; +- observer ignores `message.updated user` and user text prompt parts as assistant-turn activity; +- observer tracks assistant message IDs and counts assistant parts for those messages; +- observer treats prompt message parts with `messageID === runtimePromptMessageId` as prompt persistence, not assistant-turn activity; +- observer times out and returns timeout evidence; +- stream unavailable returns `stream_unavailable` evidence; +- premature stream EOF before terminal idle returns `stream_unavailable` with diagnostic `stream_closed_before_terminal_event`; +- observer buffers same-session activity and idle during `submitting`; +- observer buffers same-session `session.error` during `submitting` and promotes it to terminal error after endpoint acceptance; +- `markPromptAcceptedByEndpoint()` promotes buffered in-flight evidence into success; +- `markPromptRejectedByEndpoint()` discards buffered evidence and no emitter call happens; +- prompt failure after buffered idle does not produce a runtime turn-settled file; +- `204 -> session.error -> session.status idle` returns error unless response/reconcile proof upgrades it; +- `session.error` without later idle still returns error, not timeout; +- user-only prompt persistence followed by idle returns `idle_without_assistant_activity`, not `success`; +- coordinator writes one event per delivery emission; +- coordinator upgrades observer timeout to success when reconcile cursor advanced; +- coordinator upgrades stream unavailable to success when response observation proves visible/tool reply; +- coordinator keeps observer error when reconcile/response evidence does not prove activity; +- coordinator does not write both timeout and success for one prompt; +- coordinator emits launch success from preview activity; +- coordinator emits launch success from reconcile cursor advance when preview missed activity; +- orchestrator contract fixture is accepted by `claude_team` OpenCode normalizer; +- `promptAsync()` remains unchanged and sends no `messageID`; +- `promptAsyncWithTurnSettled()` starts observer before `promptSessionAsync`; +- `promptAsyncWithTurnSettled()` calls `waitUntilReady()` before prompt; +- `promptAsyncWithTurnSettled()` calls `markPromptSubmitting()` immediately before `submitPromptAsync()`; +- `promptAsyncWithTurnSettled()` calls `markPromptAcceptedByEndpoint()` only after `promptSessionAsync` resolves; +- `promptAsyncWithTurnSettled()` calls `markPromptRejectedByEndpoint()` when `promptSessionAsync` rejects; +- `promptAsyncWithTurnSettled()` calls `waitForSettled()` after prompt and before command return; +- `promptAsyncWithTurnSettled()` disposes observer when prompt request fails; +- delivery wrapper keeps host retained until settle/dispose; +- delivery wrapper releases host after settle/dispose; +- bounded observer timeout returns accepted delivery with internal observer outcome `timeout`; +- `noReply: true` delivery does not start turn-settled observation and writes no OpenCode runtime-turn-settled file; +- launch path reuses existing preview observation and does not open a second SSE stream; +- launch with 4 members does not add `4 * turnSettledTimeoutMs` to prompt submission time; +- delivery prompt passes turnSettled context; +- no observer context preserves old request body shape. +- bridge command data shape remains stable and final coordinator outcome appears in diagnostics. + +### 13.2 claude_team Unit Tests + +Files: + +```text +test/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.test.ts +test/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.test.ts +test/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.test.ts +test/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.test.ts +``` + +Cases: + +- OpenCode payload normalizes valid event; +- invalid JSON rejected; +- source mismatch rejected; +- missing session identity rejected; +- missing team/member rejected; +- error outcome preserved; +- resolver accepts active OpenCode member; +- resolver rejects non-OpenCode provider member; +- resolver rejects removed member; +- resolver rejects reserved member; +- env builder returns spool env for OpenCode; +- composition creates member-work-sync env provider before OpenCode bridge client captures env; +- file event store routes `.opencode.json` files to the OpenCode normalizer; +- ingestor enqueues OpenCode event once. +- orchestrator-generated contract fixture normalizes successfully. + +### 13.3 Integration Tests + +Add or update: + +```text +test/features/member-work-sync/MemberWorkSyncRuntimeTurnSettled.opencode.test.ts +``` + +Scenario: + +```text +given team with OpenCode member +and one actionable task +and a valid OpenCode turn-settled event file +when drainRuntimeTurnSettledEvents runs +then queue receives member-turn-settled +and member-work-sync status is recomputed +and no direct nudge is sent unless existing nudge side-effects are enabled +``` + +### 13.4 Live E2E Prototype Test + +Add opt-in test: + +```text +src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts +``` + +Gate: + +```text +OPENCODE_E2E=1 +OPENCODE_TURN_SETTLED_LIVE=1 +``` + +Default models: + +```text +opencode/gpt-5-nano +opencode/minimax-m2.5-free +``` + +If OpenAI model is used: + +```text +openai/gpt-5.4-mini-fast +``` + +Assertions: + +- server starts and cleans up; +- session is created; +- SSE stream connects; +- `prompt_async` accepted; +- observer returns terminal evidence before bridge command exits; +- coordinator writes one event after final evidence is derived; +- event has provider `opencode`; +- event has outcome `success` or `error`; +- error outcome includes diagnostic; +- all spawned `opencode serve` processes are killed by test cleanup. + +Live test must use a short prompt: + +```text +Reply with exactly OK. +``` + +No model matrix in this patch. + +### 13.5 Verification Commands + +In orchestrator worktree: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +bun test src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts +bun run build:dev +git diff --check +``` + +In `claude_team` worktree: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/claude_team_member_work_sync_opencode +pnpm vitest run test/features/member-work-sync +pnpm typecheck --pretty false +git diff --check +``` + +Opt-in live: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +OPENCODE_E2E=1 OPENCODE_TURN_SETTLED_LIVE=1 bun test src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts +``` + +--- + +## 14. Implementation Sequence + +### Cut 1: claude_team Contract Support + +`🎯 9 🛡️ 9 🧠 4`, roughly `180-300 LOC`. + +Steps: + +1. Extend provider union with `opencode`. +2. Add provider-neutral `RuntimeTurnSettledSpoolInitializer`. +3. Extend runtime env builder. +4. Extend file store provider parsing for `.opencode.json`. +5. Add OpenCode normalizer. +6. Extend resolver through shared explicit-provider helper. +7. Add contract fixture test using the orchestrator payload shape. +8. Add tests. +9. Commit: + +```text +feat(member-work-sync): accept opencode turn-settled events +``` + +### Cut 2: Orchestrator Emitter And Observer + +`🎯 9 🛡️ 8 🧠 5`, roughly `320-520 LOC`. + +Steps: + +1. Add OpenCode emitter. +2. Add OpenCode SSE observer. +3. Add OpenCode turn-settled emission coordinator. +4. Extract or duplicate SSE helpers safely. +5. Add generated contract fixture for `claude_team` normalizer tests. +6. Add observer/coordinator unit tests. +7. Add live e2e gate. +8. Commit: + +```text +feat(opencode): emit runtime turn-settled events +``` + +### Cut 3: Prompt Path Integration + +`🎯 8 🛡️ 9 🧠 5`, roughly `240-420 LOC`. + +Steps: + +1. Keep `OpenCodeSessionBridge.promptAsync()` unchanged. +2. Add `promptAsyncWithTurnSettled()` as the delivery convenience wrapper. +3. Add coordinator usage to send-message after post-send reconcile/response observation. +4. Add coordinator usage to launch after existing preview + reconcile settle phase. +5. Keep launch prompt submission unchanged and avoid a second launch SSE stream. +6. Ensure failure path disposes observer. +7. Ensure observer wait is bounded and returns timeout evidence without failing accepted delivery. +8. Add regression tests for prompt request bodies, bounded wait behavior, and launch no-duplicate-stream behavior. +9. Commit: + +```text +feat(opencode): observe launch and delivery turn settlement +``` + +### Cut 4: App Launch Env Wiring + +`🎯 8 🛡️ 9 🧠 5`, roughly `120-240 LOC`. + +Steps: + +1. Move `createMemberWorkSyncFeature(...)` before `createOpenCodeRuntimeAdapterRegistry()` call, without moving startup replay/scan side effects. +2. Register runtime turn-settled providers before OpenCode registry construction. +3. Wire env in `src/main/index.ts` before `OpenCodeBridgeCommandClient` construction. +4. Merge `memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' })`. +5. Add integration or safe e2e test proving env reaches bridge command constructor. +6. Commit: + +```text +feat(member-work-sync): pass opencode turn-settled spool env +``` + +### Cut 5: Full Verification + +`🎯 9 🛡️ 9 🧠 3`, roughly test-only cleanup. + +Steps: + +1. Run targeted suites in both repos. +2. Run one OpenCode live e2e with cheap model. +3. Confirm no stray `opencode serve` process from tests. +4. Confirm no untracked temp artifacts. +5. Commit test fixture or doc updates if needed. + +--- + +## 15. Definition Of Done + +The implementation is done when: + +- `claude_team` accepts OpenCode runtime turn-settled spool events. +- OpenCode bridge process receives `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT`. +- `promptAsync()` remains backward-compatible. +- Observed OpenCode delivery paths use `promptAsyncWithTurnSettled()`. +- Observed OpenCode launch paths derive final outcome from existing `observePreview()` + reconcile summaries without opening a second SSE stream. +- Observed OpenCode delivery paths start an observer before `prompt_async`. +- Observed OpenCode delivery paths wait a bounded telemetry budget before command return. +- `session.error` before idle produces `outcome: 'error'`. +- observer does not write spool files directly. +- coordinator emits one spool event after final local evidence is collected. +- observer timeout can be upgraded to success by response/reconcile proof. +- duplicate idle events produce one final spool event. +- missing spool env never breaks OpenCode delivery. +- `.opencode.json` spool files are accepted by the file event store. +- removed or non-OpenCode member events are rejected by resolver. +- `member-work-sync` reconciles from OpenCode event without direct frontend changes. +- tests pass in both repos. +- one opt-in live test proves actual `opencode serve` emits the expected lifecycle. + +--- + +## 16. Open Questions + +No blocker questions before implementation. + +Non-blocking decisions: + +- Whether to extract SSE helpers from `OpenCodePreviewObserver` immediately or duplicate in v1. Preferred: extract if tests remain small. +- Whether to pass explicit `messageID` on all observed prompts. Preferred: yes, only when `turnSettled` context is present. +- Whether to add polling fallback in v1. Preferred: no, add only if live e2e shows missed idle events. +- Whether to use a long-lived host-level observer later. Preferred: no for v1 because current bridge process lifecycle is command-scoped; revisit only if bounded per-prompt observer misses events in live tests. + +--- + +## 17. Final Recommendation + +Implement Option 1 in the cuts above. + +This gives OpenCode the same architectural role as Claude Stop hook and Codex native turn-settled: + +```text +provider-specific runtime signal +-> durable spool +-> provider normalizer +-> active team/member resolver +-> MemberWorkSync reconciler +``` + +It is more robust than a plain "ping after idle" loop and less invasive than OpenCode plugin hooks. It also keeps `member-work-sync` scalable for future providers. diff --git a/docs/team-management/member-work-sync-runtime-stop-hook-plan.md b/docs/team-management/member-work-sync-runtime-stop-hook-plan.md new file mode 100644 index 00000000..a15dc7c4 --- /dev/null +++ b/docs/team-management/member-work-sync-runtime-stop-hook-plan.md @@ -0,0 +1,1767 @@ +# Member Work Sync Runtime Stop Hook Plan + +**Status:** design ready, not implemented +**Scope:** `member-work-sync`, Claude runtime hook integration, future Codex hook adapter +**Primary repo:** `claude_team` +**Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract +**Feature name:** `member-work-sync` +**Recommended cut:** provider-neutral turn-settled control plane with Claude Stop hook adapter first + +--- + +## 1. Summary + +Add a provider-neutral runtime turn-settled signal for `member-work-sync`. + +The goal is not to ping agents directly from a hook. The hook only records that a runtime turn ended. The app then performs the same level-triggered reconcile that `member-work-sync` already owns: + +```text +runtime Stop hook +-> durable raw event spool +-> app-side drain and provider normalization +-> team/member resolver +-> MemberWorkSyncEventQueue +-> MemberWorkSyncReconciler +-> optional durable nudge outbox, if policy allows +``` + +Recommended approach: + +**Provider-neutral Stop hook event pipeline, Claude adapter first** +`🎯 9 🛡️ 9 🧠 6`, roughly `650-1000 LOC`. + +Why this is the safest direction: + +- `Stop` is a useful "turn settled" signal, but not proof that work is complete. +- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to future Codex runtime hooks. +- Hook execution must be fast, non-blocking, and fail-open. +- The existing `member-work-sync` agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative. +- Codex can be added later by implementing a second provider adapter that emits the same normalized event contract. + +Architecture checkpoint: + +- No blocker question is required before implementation. The safest defaults are clear. +- The only intentionally deferred decision is production Codex installation. Codex receives a tested adapter seam, but no production launch behavior until its hook payload/config contract is verified. +- The implementation should be done as an extension of `member-work-sync`, not as ad hoc logic inside `TeamProvisioningService`. +- The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector. + +Implementation summary: + +```text +Launch path asks member-work-sync for provider settings patch +-> settings patch appends one Stop hook command +-> Stop hook writes raw provider payload to spool +-> app drainer normalizes payload +-> resolver proves active team/member +-> router enqueues turn_settled reconcile +-> existing reconciler/outbox policy decides whether to do nothing or nudge +``` + +Architecture answer: + +- The durable Stop hook ingestion belongs in `claude_team`, not in `agent_teams_orchestrator`, because `member-work-sync` agenda, leases, nudge outbox, cooldowns, and watchdog separation are app-owned policy. +- The orchestrator should only remain a runtime launcher/bridge. It can help with `--settings` materialization, but it should not decide member agenda sync. +- Frontend changes are not needed in v1. This is a control-plane signal, not a new UI workflow. +- The only cross-repo risk is `--settings` merge behavior. If `claude_team` can guarantee a single already-merged inline settings object, no orchestrator change is required. If multiple app-owned inline `--settings` values can still cross the boundary, the orchestrator merge helper must become hook-aware too. + +--- + +## 2. Key Design Decisions + +### 2.1 Use `Stop` Only As A Wake-Up Signal + +The Stop hook must not: + +- decide whether the agent is done; +- write tasks or comments; +- send inbox nudges directly; +- call the app HTTP API; +- block the runtime with `decision: "block"`; +- inject more prompt context. + +The Stop hook only writes raw event payload to durable storage and exits `0`. + +This keeps the model turn lifecycle independent from app policy. It also prevents expensive or looping "keep working" prompts from hook code. + +### 2.2 Do Not Mutate Project `.claude/settings.local.json` + +Do not install this hook by editing a user's project-local Claude settings. + +Reasons: + +- Project settings may already contain user hooks. +- Invalid JSON in project settings should not become our problem. +- Worktrees would each need careful mutation and cleanup. +- A team launch should not permanently alter a customer repo. +- Future Codex support needs a provider adapter, not a Claude-only project settings hack. + +Preferred installation is a managed `--settings` fragment added at launch time. + +### 2.3 Hook Command Must Be Generic + +Do not bake `teamName`, `memberName`, or `runId` into the settings hook command. + +Reason: Claude teammate subprocesses inherit `--settings` from the parent. A hook setting created for the lead can run inside a teammate process. A hook setting created for one teammate can also be copied through restart paths. Identity must therefore be resolved after the event is recorded. + +Allowed identity hints: + +- process environment values, if present; +- hook payload `session_id`; +- hook payload `transcript_path`; +- hook payload `cwd`; +- runtime launch state and log source attribution. + +Hints are never accepted as final truth until validated against active team/member state. + +### 2.4 Use A POSIX Shell Writer, Not Node + +The hook command should invoke a tiny app-owned POSIX shell script: + +```text +/bin/sh "/member-work-sync/hooks/bin/turn-settled-hook-v1.sh" "" "claude" +``` + +Why shell is better than Node here: + +- No dependency on `node` being available in the user's shell PATH. +- No dependency on Electron internals from an external hook process. +- Very small failure surface. +- Works for Claude now and can be reused for Codex later. + +The shell script should not parse JSON. It writes raw stdin to an atomic event file. The app process parses and validates later. + +### 2.5 Spool Files Instead Of Shared JSONL Append + +Use one atomic file per hook event, not one append-only JSONL file. + +Reason: many agents can stop at nearly the same time. Atomic file writes avoid append interleaving, file lock contention, and partial JSONL line corruption. + +Recommended layout: + +```text +/.member-work-sync/runtime-hooks/ + bin/ + turn-settled-hook-v1.sh + incoming/ + 20260429T120102Z-12345-a1b2c3.json + processing/ + processed/ + invalid/ +``` + +The hook script writes a hidden temp file under `incoming/`, then `mv`s it to a final `.json` filename. + +### 2.6 Drain In The App Process + +The Electron main process owns: + +- reading incoming hook files; +- parsing raw payloads; +- normalizing provider-specific payloads; +- resolving team/member; +- emitting `member-turn-settled` events to `member-work-sync`. + +The hook process must stay dumb. + +### 2.7 No New Feature Flag For Claude Stop Hook + +Do not add a user-facing feature flag for the Claude Stop hook in v1. + +Reason: + +- The hook is fail-open and only enqueues an existing `member-work-sync` reconcile. +- It does not directly send nudges. +- Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior. +- A flag would add another state combination without reducing the main risk, which is attribution correctness. + +If an emergency kill switch is needed later, prefer an internal config/env-only disable around hook settings generation, not branching inside the core policy. + +--- + +## 3. Alternatives Considered + +### Option 1: Provider-Neutral Stop Hook Spool + +`🎯 9 🛡️ 9 🧠 6`, roughly `650-1000 LOC`. + +Add provider-neutral infrastructure now. Implement Claude adapter first. Add Codex adapter later by plugging into the same raw-event contract. + +Pros: + +- clean provider boundary; +- minimal model interruption; +- no user settings mutation; +- safe under high agent count; +- aligns with existing `member-work-sync` control plane. + +Cons: + +- more code than direct hook-to-HTTP; +- requires resolver tests for session and transcript attribution. + +Decision: choose this. + +### Option 2: Claude-Only Stop Hook Directly Enqueues Member + +`🎯 7 🛡️ 7 🧠 4`, roughly `350-600 LOC`. + +Install a Claude Stop hook that writes `{ teamName, memberName }` directly and bypasses provider-neutral normalization. + +Pros: + +- faster to build; +- simpler tests. + +Cons: + +- hard to extend to Codex; +- unsafe if `--settings` is inherited across processes; +- more likely to misattribute lead vs teammate; +- encourages Claude-specific logic inside the feature. + +Decision: reject. + +### Option 3: Gastown-Style Blocking Stop Hook + +`🎯 5 🛡️ 5 🧠 6`, roughly `400-750 LOC`. + +Return `decision: "block"` from the Stop hook when the app thinks there is work. + +Pros: + +- can force an immediate continuation. + +Cons: + +- risks loops; +- risks repeated acknowledgement-only turns; +- increases token usage; +- bypasses durable outbox and cooldown policy; +- conflicts with watchdog and `member-work-sync` separation. + +Decision: reject for this app. + +### Option 4: Use `TeammateIdle` + +`🎯 6 🛡️ 7 🧠 4`, roughly `250-450 LOC`. + +Use Claude Code's team-specific `TeammateIdle` event. + +Pros: + +- gives direct `team_name` and `teammate_name` for Claude teammates; +- simpler resolver. + +Cons: + +- not provider-neutral; +- not available for Codex; +- does not cover lead/non-team sessions; +- makes future runtime support harder. + +Decision: do not use as v1 base. + +--- + +## 3.1 External Pattern Takeaways + +From the prior Gastown/GoClaw comparison and production control-plane patterns: + +- Keep Gastown's strongest idea: use provider Stop hooks as a low-latency wake-up signal. +- Do not copy Gastown's blocking Stop hook behavior. It is too easy to create loops and token burn in this product. +- Keep GoClaw-style simplicity only at adapter boundaries. The app still needs durable state because team membership, worktrees, task boards, and provider runtimes are richer here. +- Use Kubernetes-style level-triggered reconciliation: the event only says "recheck now", the app recomputes current work state. +- Use SQS/BullMQ-style lease semantics already present in `member-work-sync`: a report/lease is temporary and tied to current agenda fingerprint. +- Use GitHub Actions-style concurrency key behavior: one in-flight reconcile/nudge per `team/member/fingerprint`, not one per hook event. + +This means the architecture should be better suited to this app than a direct Gastown copy: provider hooks are adapters, durable reconciliation remains in the feature core, and watchdog stays a separate slow semantic layer. + +--- + +## 4. Clean Architecture Shape + +The feature remains under: + +```text +src/features/member-work-sync/ +``` + +New code should follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +### 4.0 Current Code Integration Map + +The implementation should touch these current seams: + +```text +src/features/member-work-sync/ + contracts/ + types.ts # add runtime turn-settled diagnostics DTOs only if UI/API needs them + core/ + domain/ + RuntimeTurnSettledEvent.ts # normalized event value objects + RuntimeTurnSettledProvider.ts # 'claude' | 'codex' + application/ + RuntimeTurnSettledIngestor.ts # drain, normalize, resolve, enqueue + runtimeTurnSettledPorts.ts # narrow ports + ports.ts # re-export or colocate new ports + main/ + adapters/ + input/ + MemberWorkSyncTeamChangeRouter.ts # route member-turn-settled + RuntimeTurnSettledMemberQueue.ts # small queue adapter, if keeping router thin + output/ + TeamRuntimeTurnSettledTargetResolver.ts + composition/ + createMemberWorkSyncFeature.ts # wire installer/store/normalizers/scheduler/ingestor + infrastructure/ + RuntimeTurnSettledSpoolPaths.ts + ShellRuntimeTurnSettledHookScriptInstaller.ts + FileRuntimeTurnSettledEventStore.ts + RuntimeTurnSettledDrainScheduler.ts + ClaudeStopHookPayloadNormalizer.ts + CodexStopHookPayloadNormalizer.ts # tests only first + runtimeTurnSettledHookSettings.ts # settings patch builder and hook-aware merge +``` + +Launch integration should be intentionally small: + +```text +src/main/services/team/TeamProvisioningService.ts + # call public member-work-sync main helper/facade to get a settings patch + # pass patch into provider args/settings merge + # do not import member-work-sync infrastructure internals + +src/main/services/runtime/cliSettingsArgs.ts + # replace or extend current generic --settings JSON merge so hooks arrays are additive +``` + +Shared event type touch: + +```text +src/shared/types/team.ts + # add TeamChangeEvent type 'member-turn-settled' + +src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts + # add trigger reason 'turn_settled' +``` + +Tests should live with the feature: + +```text +test/features/member-work-sync/main/ + RuntimeTurnSettledHookSettings.test.ts + RuntimeTurnSettledSpool.test.ts + RuntimeTurnSettledIngestor.test.ts + RuntimeTurnSettledTargetResolver.test.ts + createMemberWorkSyncFeature.test.ts +``` + +Do not add runtime Stop hook code to: + +- renderer components; +- `TeamTaskStallMonitor`; +- OpenCode delivery ledger; +- task log stream services; +- direct IPC handlers outside the feature facade. + +Those layers can observe outcomes later, but they should not own ingestion or policy. This keeps SRP clean: runtime hook ingestion is an input adapter, `member-work-sync` is policy, watchdog is semantic stall policy, and runtime adapters only launch processes. + +### 4.1 Core Domain + +`core/domain` remains provider-agnostic. + +Possible additions: + +```text +core/domain/RuntimeTurnSettledEvent.ts +core/domain/RuntimeTurnSettledProvider.ts +``` + +Domain responsibilities: + +- define normalized event value objects; +- validate basic invariants; +- no filesystem; +- no Electron; +- no Claude/Codex CLI details. + +Example: + +```ts +export type RuntimeTurnSettledProvider = 'claude' | 'codex'; + +export interface RuntimeTurnSettledEvent { + schemaVersion: 1; + provider: RuntimeTurnSettledProvider; + hookEventName: 'Stop'; + sourceId: string; + payloadHash: string; + recordedAt: string; + sessionId?: string; + turnId?: string; + transcriptPath?: string; + cwd?: string; + hints?: { + teamName?: string; + memberName?: string; + runId?: string; + }; +} +``` + +`sourceId` should be deterministic for storage/debugging but not trusted for dedupe alone: + +```ts +export function buildRuntimeTurnSettledSourceId(input: { + provider: RuntimeTurnSettledProvider; + sessionId?: string; + turnId?: string; + payloadHash: string; +}): string { + return [ + 'runtime-turn-settled', + input.provider, + input.sessionId || 'no-session', + input.turnId || 'no-turn', + input.payloadHash, + ].join(':'); +} +``` + +Domain invariants: + +- `provider` is known. +- `hookEventName` is `Stop`. +- `payloadHash` is non-empty. +- `recordedAt` is valid ISO or normalized by infrastructure before domain construction. +- `sourceId` contains no filesystem path and no message text. + +### 4.2 Core Application + +`core/application` owns use cases and ports. + +Possible additions: + +```text +core/application/RuntimeTurnSettledIngestor.ts +core/application/RuntimeTurnSettledResolver.ts +core/application/runtimeTurnSettledPorts.ts +``` + +Ports: + +```ts +export interface RuntimeTurnSettledEventStorePort { + claimPending(limit: number): Promise; + markProcessed(input: { id: string; resolved: boolean; reason?: string }): Promise; + markInvalid(input: { id: string; reason: string }): Promise; + release(input: { id: string; reason: string }): Promise; +} + +export interface RuntimeTurnSettledNormalizerPort { + provider: RuntimeTurnSettledProvider; + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null; +} + +export interface RuntimeTurnSettledTargetResolverPort { + resolve(event: RuntimeTurnSettledEvent): Promise< + | { ok: true; teamName: string; memberName: string; runId?: string } + | { ok: false; reason: string } + >; +} + +export interface RuntimeTurnSettledQueuePort { + enqueue(input: { + teamName: string; + memberName: string; + triggerReason: 'turn_settled'; + runAfterMs?: number; + }): void; +} +``` + +The use case: + +```ts +export class RuntimeTurnSettledIngestor { + constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {} + + async drain(): Promise { + const claims = await this.deps.store.claimPending(this.deps.batchSize); + const summary = createEmptyDrainSummary(); + + for (const claim of claims) { + try { + const normalizer = this.deps.normalizers.get(claim.provider); + if (!normalizer) { + await this.deps.store.markInvalid({ + id: claim.id, + reason: 'unsupported_provider', + }); + summary.invalid += 1; + continue; + } + + const normalized = normalizer.normalize(claim.raw); + if (!normalized) { + await this.deps.store.markInvalid({ + id: claim.id, + reason: 'invalid_payload', + }); + summary.invalid += 1; + continue; + } + + const target = await this.deps.targetResolver.resolve(normalized); + if (!target.ok) { + await this.deps.store.markProcessed({ + id: claim.id, + resolved: false, + reason: target.reason, + }); + summary.unresolved += 1; + continue; + } + + this.deps.queue.enqueue({ + teamName: target.teamName, + memberName: target.memberName, + triggerReason: 'turn_settled', + runAfterMs: this.deps.turnSettledDelayMs, + }); + await this.deps.store.markProcessed({ id: claim.id, resolved: true }); + summary.enqueued += 1; + } catch (error) { + await this.deps.store.release({ + id: claim.id, + reason: error instanceof Error ? error.message : String(error), + }); + summary.released += 1; + } + } + + return summary; + } +} +``` + +The use case depends only on ports. It should not import `fs`, `path`, Electron, `TeamProvisioningService`, or concrete stores. + +### 4.3 Main Infrastructure + +`main/infrastructure` owns technical details. + +Possible additions: + +```text +main/infrastructure/RuntimeTurnSettledSpoolPaths.ts +main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts +main/infrastructure/FileRuntimeTurnSettledEventStore.ts +main/infrastructure/RuntimeTurnSettledDrainScheduler.ts +main/infrastructure/ClaudeStopHookPayloadNormalizer.ts +main/infrastructure/CodexStopHookPayloadNormalizer.ts +``` + +Responsibilities: + +- write shell hook script; +- manage spool directories; +- claim event files with atomic rename to `processing/`; +- parse JSON with bounded size; +- move invalid files to `invalid/`; +- delete or archive processed files with bounded retention; +- schedule drain with bounded concurrency and `unref()` timers. + +### 4.4 Main Adapters + +Input adapter: + +```text +main/adapters/input/RuntimeTurnSettledMemberWorkSyncAdapter.ts +``` + +It translates resolved runtime turn events into `MemberWorkSyncEventQueue.enqueue`. + +Output adapters: + +```text +main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +``` + +It resolves team/member through existing team stores and runtime metadata. + +### 4.5 Composition + +`createMemberWorkSyncFeature()` wires: + +- spool paths; +- hook script installer; +- event store; +- provider normalizers; +- target resolver; +- drain scheduler; +- ingestor. + +Facade additions: + +```ts +export interface MemberWorkSyncFeatureFacade { + buildRuntimeTurnSettledHookSettings(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null>; + + drainRuntimeTurnSettledEvents(): Promise; +} +``` + +Launch code should call only the facade or a public helper from `@features/member-work-sync/main`. It should not deep-import infrastructure. + +--- + +## 5. Hook Installation Details + +### 5.1 Managed Settings Fragment + +For Claude: + +```ts +const settingsPatch = { + hooks: { + Stop: [ + { + matcher: '', + hooks: [ + { + type: 'command', + command: buildTurnSettledHookCommand({ + scriptPath, + spoolPath, + provider: 'claude', + }), + }, + ], + }, + ], + }, +}; +``` + +The command must be deterministic and idempotent. + +### 5.2 Hook Marker And Deduplication + +Claude hook settings do not have a formal `id` field in the command schema. Deduplication should use a stable command marker: + +```text +# agent-teams:member-work-sync-turn-settled:v1 +``` + +Example command: + +```sh +/bin/sh '/.../turn-settled-hook-v1.sh' '/.../runtime-hooks' 'claude' # agent-teams:member-work-sync-turn-settled:v1 +``` + +Settings merge should: + +- preserve existing user hooks; +- append our hook only if marker is absent; +- not reorder unrelated hooks; +- not duplicate our hook across repeated launch/restart. + +### 5.3 Settings Merge Must Be Hook-Aware + +Current generic deep merge replaces arrays. That is unsafe for `hooks.Stop`. + +Add a hook-aware merge helper. It can live in `src/main/services/runtime/cliSettingsArgs.ts` if it must merge all launch `--settings`, or in `src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts` if the launch path only needs to build a single already-merged fragment. Prefer a shared runtime helper if multiple launch fragments already use `--settings`. + +```ts +type JsonObject = Record; + +const MEMBER_WORK_SYNC_HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1'; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function mergeHooksConfig(target: unknown, source: unknown): unknown { + if (!isJsonObject(target) && !isJsonObject(source)) { + return source; + } + + const merged: JsonObject = isJsonObject(target) ? { ...target } : {}; + if (!isJsonObject(source)) { + return merged; + } + + for (const [eventName, sourceEntries] of Object.entries(source)) { + if (!Array.isArray(sourceEntries)) { + merged[eventName] = sourceEntries; + continue; + } + + const currentEntries = Array.isArray(merged[eventName]) + ? [...(merged[eventName] as unknown[])] + : []; + for (const entry of sourceEntries) { + if (isMemberWorkSyncHookEntry(entry)) { + const alreadyPresent = currentEntries.some(isMemberWorkSyncHookEntry); + if (alreadyPresent) { + continue; + } + } + currentEntries.push(entry); + } + merged[eventName] = currentEntries; + } + + return merged; +} + +function isMemberWorkSyncHookEntry(entry: unknown): boolean { + if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) { + return false; + } + + return entry.hooks.some((hook) => { + if (!isJsonObject(hook) || typeof hook.command !== 'string') { + return false; + } + return hook.command.includes(MEMBER_WORK_SYNC_HOOK_MARKER); + }); +} + +export function mergeRuntimeSettingsFragments(fragments: JsonObject[]): JsonObject { + let merged: JsonObject = {}; + for (const fragment of fragments) { + merged = mergeRuntimeSettingsObject(merged, fragment); + } + return merged; +} + +function mergeRuntimeSettingsObject(target: JsonObject, source: JsonObject): JsonObject { + const output: JsonObject = { ...target }; + for (const [key, value] of Object.entries(source)) { + if (key === 'hooks') { + output.hooks = mergeHooksConfig(output.hooks, value); + continue; + } + + const current = output[key]; + if (isJsonObject(current) && isJsonObject(value)) { + output[key] = mergeRuntimeSettingsObject(current, value); + continue; + } + + output[key] = value; + } + return output; +} +``` + +Test cases: + +- fastMode settings + Stop hook settings both survive; +- two Stop hook fragments dedupe our marker; +- user Stop hook and our Stop hook both survive; +- non-hook arrays keep existing generic replace behavior unless explicitly supported. +- malformed `hooks.Stop` from another fragment is not "fixed" silently. Preserve it if it is a non-array value and let Claude validation handle it. +- if any `--settings` value is a file path, do not try to parse or rewrite it in `claude_team`; preserve existing behavior and append our own inline settings fragment separately. + +Important implementation note: + +The current `mergeJsonObjectCliFlagValues()` in `agent_teams_orchestrator` also deep-merges `--settings` values. If multiple inline JSON settings are passed to Claude, arrays can still be overwritten there. Either: + +- upstream the same hook-aware merge into `agent_teams_orchestrator/src/utils/cliArgs.ts`, or +- ensure `claude_team` passes exactly one already-merged inline JSON settings fragment. + +Recommended v1: pass exactly one already-merged inline JSON settings fragment from `claude_team` for app-owned settings, and add an orchestrator test proving `fastMode + hooks.Stop` survive eager settings load. + +### 5.4 Do Not Fail Team Launch If Hook Install Fails + +If hook script installation or settings patch generation fails: + +- log a warning; +- continue launch without Stop hook; +- `member-work-sync` still works from startup scans, task events, inbox events, tool activity, and watchdog. + +Reason: Stop hook is an optimization signal, not a hard runtime dependency. + +### 5.5 Launch Integration Shape + +Launch code should ask the feature for an optional settings patch and merge it with existing app-owned Claude settings before args are finalized. + +Sketch: + +```ts +const appSettingsFragments: JsonObject[] = []; + +const fastModeSettings = buildAnthropicFastModeSettings(...); +if (fastModeSettings) { + appSettingsFragments.push(fastModeSettings); +} + +const turnSettledHookSettings = + await memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings({ + provider: 'claude', + }).catch((error) => { + logger.warn('member-work-sync Stop hook unavailable', { error }); + return null; + }); + +if (turnSettledHookSettings) { + appSettingsFragments.push(turnSettledHookSettings); +} + +const appSettings = mergeRuntimeSettingsFragments(appSettingsFragments); +args = mergeJsonSettingsArgs(args, JSON.stringify(appSettings)); +``` + +Rules: + +- existing user-supplied config/settings stays user-owned and is not rewritten; +- app-owned settings should be merged before crossing process boundaries; +- hook settings generation failure logs warning only; +- tests must prove no duplicate hook command after restart/relaunch. + +--- + +## 6. Hook Writer Script + +### 6.1 Requirements + +The shell script must: + +- use `/bin/sh`; +- accept `spoolRoot` and `provider`; +- create `incoming/`; +- read stdin to a temp file with size bounded by shell-independent outer command where possible; +- atomically rename temp to final `.json`; +- add no stdout; +- keep stderr quiet unless debugging is explicitly enabled; +- exit `0` even when it cannot write. + +### 6.2 Example Script Shape + +```sh +#!/bin/sh +set +e + +spool_root="$1" +provider="$2" +max_bytes="${3:-262144}" + +if [ -z "$spool_root" ] || [ -z "$provider" ]; then + exit 0 +fi + +case "$provider" in + claude|codex) ;; + *) exit 0 ;; +esac + +incoming="$spool_root/incoming" +mkdir -p "$incoming" 2>/dev/null || exit 0 + +stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)" +tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0 +suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')" +final="$incoming/$stamp-$$-$suffix.$provider.json" + +# Use dd to bound the file even if a provider accidentally sends a huge payload. +# dd is POSIX. If it fails, fall back to fail-open cleanup. +dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +# Empty payloads are not useful. They are dropped here to reduce invalid spool noise. +if [ ! -s "$tmp" ]; then + rm -f "$tmp" 2>/dev/null + exit 0 +fi + +mv "$tmp" "$final" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +exit 0 +``` + +The file content is raw provider hook payload. The provider is also encoded in the filename. The drainer validates both. + +### 6.3 Payload Size Guard + +Claude Stop payload can include `last_assistant_message`. To avoid huge files: + +- the shell writer limits stdin to `256KB` by default; +- app-side reader still refuses files over the same fixed limit; +- oversized payloads are treated as invalid, never as resolved events. + +For v1, app-side read limit is mandatory. + +### 6.4 Hook Command Quoting + +Build the hook command with shell-safe single-quote escaping: + +```ts +const HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1'; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildTurnSettledHookCommand(input: { + scriptPath: string; + spoolRoot: string; + provider: RuntimeTurnSettledProvider; + maxBytes?: number; +}): string { + return [ + '/bin/sh', + shellQuote(input.scriptPath), + shellQuote(input.spoolRoot), + shellQuote(input.provider), + shellQuote(String(input.maxBytes ?? 262_144)), + '#', + HOOK_MARKER, + ].join(' '); +} +``` + +Rules: + +- never interpolate unquoted paths; +- never include team/member identity in the command; +- never include secrets or auth paths; +- keep the marker at the end so it is easy to detect without parsing shell syntax. + +--- + +## 7. Event Store And Drain Semantics + +### 7.1 Claiming Files + +`FileRuntimeTurnSettledEventStore.claimPending(limit)`: + +1. list `incoming/*.json`; +2. sort by filename for deterministic order; +3. for each file, atomic rename to `processing/`; +4. if rename fails, skip because another process may have claimed it; +5. return claims with file path and provider inferred from extension. + +Code sketch: + +```ts +export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort { + constructor(private readonly deps: { + paths: RuntimeTurnSettledSpoolPaths; + maxBytes: number; + clock: MemberWorkSyncClockPort; + }) {} + + async claimPending(limit: number): Promise { + await this.recoverStaleProcessingFiles(); + const incoming = this.deps.paths.incomingDir(); + const processing = this.deps.paths.processingDir(); + await fs.promises.mkdir(processing, { recursive: true }); + + const names = (await fs.promises.readdir(incoming).catch(() => [])) + .filter((name) => providerFromFileName(name) !== null) + .sort() + .slice(0, Math.max(0, limit)); + + const claims: RuntimeTurnSettledClaim[] = []; + for (const name of names) { + const sourcePath = path.join(incoming, name); + const claimedPath = path.join(processing, name); + const provider = providerFromFileName(name); + if (!provider) { + continue; + } + try { + await fs.promises.rename(sourcePath, claimedPath); + } catch { + continue; + } + + let stat: Awaited>; + try { + stat = await fs.promises.stat(claimedPath); + } catch (error) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + transientError: error instanceof Error ? error.message : String(error), + }); + continue; + } + if (stat.size > this.deps.maxBytes) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + invalidReason: 'payload_too_large', + }); + continue; + } + + let raw: string; + try { + raw = await fs.promises.readFile(claimedPath, 'utf8'); + } catch (error) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + transientError: error instanceof Error ? error.message : String(error), + }); + continue; + } + claims.push({ + id: name, + provider, + raw, + claimedPath, + }); + } + return claims; + } +} +``` + +`claimPending()` should not throw for one bad file. Bad files become claims with `invalidReason`, so the ingestor can quarantine them and continue. Transient read/stat errors become claims with `transientError`, so the ingestor can release them back for retry. + +File name validation is part of the store, not the normalizer: + +```ts +const EVENT_FILE_PATTERN = + /^\d{8}T\d{6}Z-\d+-[A-Za-z0-9._-]+\.(claude|codex)\.json$/; + +function providerFromFileName(name: string): RuntimeTurnSettledProvider | null { + const match = EVENT_FILE_PATTERN.exec(name); + if (!match) { + return null; + } + return match[1] as RuntimeTurnSettledProvider; +} +``` + +Rules: + +- ignore hidden temp files; +- reject names with path separators; +- reject unknown provider suffixes; +- never derive any target identity from the filename. + +### 7.2 Processing Outcome + +For each claim: + +- processed and resolved: move to `processed/` or delete after metrics are recorded; +- parsed but unresolved: move to `processed/` with unresolved metric, not `invalid`; +- invalid JSON or too large: move to `invalid/`; +- transient read error: release back to `incoming/` or leave in `processing/` with recovery scan. + +Important distinction: + +```text +invalid = payload cannot be trusted or parsed +unresolved = payload is valid, but no active team/member target was proven +``` + +Unresolved events are not errors. They are expected when a lead or non-team Claude session stops. + +### 7.3 Recovery From Crashes + +On app startup: + +- move stale `processing/` files older than e.g. `5 minutes` back to `incoming/`; +- then drain normally. + +This prevents losing events if the app crashes mid-drain. + +Recovery should run before every scheduled drain as well, not only at startup. This handles app crashes and abrupt process kills without requiring a full app restart. + +Recovery should use file age, not process liveness. The app can crash while a process that wrote the file is still alive, and checking arbitrary PIDs here would couple the store to runtime process management. + +### 7.4 Retention + +Keep processed/invalid files only for a short developer/debug window: + +- processed: max `1000` files or `24h`; +- invalid: max `100` files or `72h`. + +These are not user-facing logs. + +### 7.5 Drain Scheduler + +The drain scheduler should be lightweight and bounded: + +```ts +export class RuntimeTurnSettledDrainScheduler { + private timer: ReturnType | null = null; + private running = false; + + start(): void { + if (this.timer) { + return; + } + this.timer = setInterval(() => void this.tick(), 10_000); + this.timer.unref?.(); + void this.tick(); + } + + private async tick(): Promise { + if (this.running) { + return; + } + this.running = true; + try { + await this.ingestor.drain(); + } finally { + this.running = false; + } + } +} +``` + +Rules: + +- one drain at a time; +- bounded batch size, recommended `50`; +- `unref()` timer; +- no UI dependency; +- `dispose()` stops timer and waits for current drain; +- scheduler errors are logged once per tick and never crash the app; +- if one drain finds a full batch, schedule an immediate follow-up tick to clear backlog without waiting the full interval. + +--- + +## 8. Provider Normalizers + +### 8.1 Claude Stop Normalizer + +Input fields expected from Claude hook payload: + +- `hook_event_name`; +- `session_id`; +- `transcript_path`; +- `cwd`; +- possibly `turn_id`; +- possibly `last_assistant_message`. + +Rules: + +- accept only `hook_event_name === "Stop"`; +- do not persist full `last_assistant_message`; +- compute `payloadHash` from raw payload; +- trim and normalize paths but do not require them to exist at normalization time; +- produce `RuntimeTurnSettledEvent`. + +Implementation sketch: + +```ts +export class ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort { + readonly provider = 'claude' as const; + + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null { + if (raw.provider !== 'claude' || typeof raw.text !== 'string') { + return null; + } + + const parsed = parseJsonObject(raw.text); + if (!parsed || parsed.hook_event_name !== 'Stop') { + return null; + } + + const payloadHash = sha256(raw.text); + const sessionId = readOptionalString(parsed.session_id); + const turnId = readOptionalString(parsed.turn_id); + + return { + schemaVersion: 1, + provider: 'claude', + hookEventName: 'Stop', + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'claude', + sessionId, + turnId, + payloadHash, + }), + payloadHash, + recordedAt: raw.recordedAt, + sessionId, + turnId, + transcriptPath: normalizeOptionalPath(parsed.transcript_path), + cwd: normalizeOptionalPath(parsed.cwd), + }; + } +} +``` + +`parseJsonObject()` must reject arrays and primitives. Do not pass raw parsed provider payload outside the normalizer. + +Normalizer tests should cover: + +- valid Stop payload returns normalized event; +- non-Stop event returns `null`; +- malformed JSON returns `null`; +- `last_assistant_message` is not copied; +- missing `session_id` is allowed but resolver may later ignore the event; +- huge payload never reaches normalizer because the store quarantines it first. + +### 8.2 Codex Stop Normalizer, Future + +Codex support should be added by implementing the same interface: + +```ts +class CodexStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort { + provider = 'codex' as const; + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null { + // Codex payload mapping + } +} +``` + +Do not add Codex launch behavior until there is a contract test with a captured or synthetic Codex Stop payload. + +--- + +## 9. Target Resolution + +`TeamRuntimeTurnSettledTargetResolver` should resolve conservatively. + +Resolution order: + +1. Runtime launch state by `sessionId`. +2. Transcript path attribution. +3. Validated explicit hints, if event contains them. +4. CWD/team metadata match, only if it maps to exactly one active team/member. + +Session and transcript evidence should beat hints because hints can be inherited through `--settings`. + +If resolution is ambiguous: + +- do not enqueue; +- record metric `unresolved_ambiguous_target`; +- leave debug diagnostics. + +If team is stopped/offline: + +- do not enqueue; +- record metric `ignored_inactive_team`. + +If member is removed/inactive: + +- do not enqueue; +- record metric `ignored_inactive_member`. + +Implementation sketch: + +```ts +export class TeamRuntimeTurnSettledTargetResolver + implements RuntimeTurnSettledTargetResolverPort { + constructor(private readonly deps: { + teamReader: TeamConfigReaderPort; + launchStateReader: RuntimeLaunchStateReaderPort; + transcriptAttribution: RuntimeTranscriptAttributionPort; + teamStatusReader: TeamStatusReaderPort; + }) {} + + async resolve(event: RuntimeTurnSettledEvent): Promise { + const candidates = compact([ + await this.fromSessionId(event), + await this.fromTranscriptPath(event), + await this.fromValidatedHints(event), + await this.fromUniqueCwd(event), + ]); + + const active = []; + for (const candidate of candidates) { + const verified = await this.verifyCandidate(event, candidate); + if (verified.ok) { + active.push(verified.target); + } + } + + const unique = uniqueTargets(active); + if (unique.length === 1) { + return { ok: true, ...unique[0] }; + } + if (unique.length > 1) { + return { ok: false, reason: 'unresolved_ambiguous_target' }; + } + return { ok: false, reason: 'unresolved_no_target' }; + } +} +``` + +`verifyCandidate()` must check: + +- team exists and is not stopped/cancelled; +- member exists in current config or members-meta; +- member is active, not removed; +- provider matches `claude` for Claude Stop events; +- if `sessionId` exists, it belongs to the same member or is absent from known runtime state; +- if `transcriptPath` exists, it is under the expected team/member transcript root or matches known attribution. + +Never enqueue based only on `cwd` when more than one team can match the same project path. Worktrees and restarted teams make CWD ambiguous. + +### 9.1 Explicit Hints Are Not Trusted Alone + +If a hook event includes hints: + +```ts +{ + hints: { + teamName: 'atlas', + memberName: 'bob' + } +} +``` + +The resolver must still verify: + +- team exists; +- member exists in config/meta; +- member is active; +- event session/transcript is compatible if those fields are present. + +### 9.2 Lead Sessions + +Lead sessions may emit Stop events too. + +Policy: + +- Ignore lead Stop for member-specific work sync in v1, because task/inbox events already cover most team-wide changes. +- Do not fan out a lead Stop to all active members unless a later phase explicitly adds a separate team-level policy with its own anti-spam tests. + +Recommended v1 default: ignore unresolved lead events. + +If a lead event is strongly resolved as lead, record `ignored_lead_turn_settled` and do not enqueue all members in v1. Team-wide task/inbox changes already trigger member sync, and fan-out from lead Stop could create noisy false positives. + +### 9.3 OpenCode Sessions + +This plan is not for OpenCode. OpenCode runtime already has separate delivery/watchdog mechanisms. + +Do not infer OpenCode member work sync from Claude Stop events. + +--- + +## 10. Member Work Sync Integration + +Add trigger reason: + +```ts +export type MemberWorkSyncTriggerReason = + | ... + | 'turn_settled'; +``` + +Add event type: + +```ts +export interface TeamChangeEvent { + type: + | ... + | 'member-turn-settled'; + teamName: string; + runId?: string; + detail?: string; + taskId?: string; +} +``` + +Current `TeamChangeEvent.detail` is a string used broadly across preload, renderer store, and log/task panels. Do not change it to `unknown` in this cut. Encode a small JSON detail string and validate it at the router boundary: + +```ts +export interface MemberTurnSettledTeamChangeDetail { + memberName: string; + provider: 'claude' | 'codex'; + sourceId: string; +} + +function serializeMemberTurnSettledDetail( + detail: MemberTurnSettledTeamChangeDetail +): string { + return JSON.stringify(detail); +} +``` + +Router behavior: + +```ts +if (event.type === 'member-turn-settled') { + const detail = parseMemberTurnSettledDetail(event.detail); + if (!detail) { + return; + } + + queue.enqueue({ + teamName: event.teamName, + memberName: detail.memberName, + triggerReason: 'turn_settled', + runAfterMs: 15_000, + }); + return; +} +``` + +Parsing detail explicitly avoids fragile string conventions and gives future Codex/Claude diagnostics without changing the queue contract again. `sourceId` is intentionally not added to the queue item in v1, because queue coalescing is keyed by `team/member`. Keep `sourceId` in drain/router debug logs only. + +`parseMemberTurnSettledDetail()` should reject missing JSON, non-object JSON, empty `memberName`, and unknown provider values. Invalid detail is ignored, not routed as team-wide. + +Why `15_000` instead of immediate: + +- allows logs/task writes from the just-finished turn to land; +- coalesces repeated hook events; +- avoids racing with task/comment writes emitted near turn end. + +The existing queue still applies normal quiet/coalescing behavior. + +--- + +## 11. Watchdog Interaction + +Stop hook events must not conflict with `TeamTaskStallMonitor`. + +Rules: + +- Stop hook event is a fast consistency trigger. +- It does not count as progress proof. +- It does not suppress watchdog by itself. +- If `member-work-sync` sends a nudge, the existing watchdog cooldown adapter can suppress near-duplicate task-stall nudges. +- If watchdog sends a nudge first, `member-work-sync` dispatcher must see that cooldown and avoid duplicate member sync pings. + +This keeps responsibilities separate: + +```text +member-work-sync: does the member know the current actionable agenda? +watchdog: has meaningful task progress stalled for too long? +delivery ledger: did a specific message reach a runtime? +runtime liveness: is a process/session alive? +``` + +Required anti-spam contract: + +- A `turn_settled` enqueue may cause at most one `member-work-sync` reconciliation for `(teamName, memberName)` after coalescing. +- It must not create a nudge if the agenda fingerprint is already caught up or covered by a valid lease/report. +- If a watchdog nudge was recently sent for the same member/task, `member-work-sync` must respect the existing cooldown adapter. +- If `member-work-sync` sends a nudge first, watchdog must see the same cooldown signal through the already-existing `TeamTaskStallJournalWorkSyncCooldown`. +- Stop hook events do not reset leases and do not extend leases. Only explicit report/tool/task state can do that. + +--- + +## 12. Security And Reliability + +### 12.1 No Network In Hook + +The hook must not call local HTTP control endpoints. Reasons: + +- app may be down; +- hook could hang; +- local ports can change; +- network calls increase Stop latency. + +File spool is safer. + +### 12.2 No Secrets In Spool + +Do not persist: + +- API keys; +- full env; +- full assistant messages; +- user prompt content beyond what Claude already puts in raw Stop payload. + +If raw payload includes `last_assistant_message`, the app normalizer should not copy it into normalized event. Optionally hash it for dedupe. + +### 12.3 Bounded IO + +Every read/list operation must be bounded: + +- max event file size; +- max files per drain tick; +- bounded retention cleanup; +- no recursive scanning outside known spool dirs. + +### 12.4 Fail Open + +If hook settings cannot be installed, launch continues. + +If hook command cannot write, it exits `0`. + +If event cannot resolve, no nudge is sent. + +--- + +## 13. Implementation Phases + +Commit order should follow dependency direction. Each commit must leave tests for touched layer green. + +```text +commit 1: domain ports + hook settings merge + script installer +commit 2: file spool store + normalizers + ingestor +commit 3: target resolver + router integration +commit 4: launch integration + composition wiring +commit 5: docs/test hardening and optional Codex contract adapter +``` + +Do not wire launch integration before `RuntimeTurnSettledHookSettings.test.ts` and store/ingestor tests exist. Otherwise a bad hook command could silently ship into every launched Claude teammate. + +### Phase 0: Preflight And Existing-Code Assertions + +`🎯 10 🛡️ 9 🧠 2`, roughly `40-90 LOC`. + +Tasks: + +- add characterization tests for current `mergeJsonSettingsArgs` or equivalent `--settings` helper; +- add characterization test that `createMemberWorkSyncFeature()` exposes stable startup/dispose lifecycle; +- add a small fixture for a Claude Stop payload with `session_id`, `transcript_path`, and `cwd`; +- confirm no renderer import path is needed. + +Why this phase exists: it locks current behavior before adding hook-aware merge and prevents accidental breakage of existing Claude fast mode settings. + +### Phase 1: Hook Settings And Spool Foundation + +`🎯 9 🛡️ 9 🧠 5`, roughly `300-450 LOC`. + +Tasks: + +- add `RuntimeTurnSettledSpoolPaths`; +- add shell hook script installer; +- add hook-aware settings merge helper; +- add Claude Stop settings patch builder; +- wire launch path to include patch through public member-work-sync main helper/facade; +- keep failure non-fatal. +- if the orchestrator can still receive multiple inline app-owned settings fragments, add the same hook-aware merge test/fix there before wiring production launch. + +Tests: + +- script installer writes executable content; +- settings merge preserves existing user hooks; +- settings merge dedupes our hook marker; +- fastMode settings and hook settings both survive. + +### Phase 2: Drainer And Resolver + +`🎯 9 🛡️ 9 🧠 6`, roughly `350-600 LOC`. + +Tasks: + +- add file event store; +- add Claude payload normalizer; +- add target resolver; +- add drain scheduler; +- add startup recovery for stale processing files; +- emit `member-turn-settled` only for resolved active member. +- add structured drain summary for diagnostics but do not expose it to UI yet. + +Tests: + +- concurrent file claims are safe; +- malformed and oversized payloads are quarantined; +- unresolved events do not enqueue; +- sessionId and transcriptPath resolution works; +- stopped team and removed member are ignored. + +### Phase 3: Member Work Sync Router Integration + +`🎯 10 🛡️ 9 🧠 3`, roughly `100-180 LOC`. + +Tasks: + +- add `turn_settled` trigger reason; +- add `member-turn-settled` event type; +- route to one member with a short delay; +- include trigger reason in diagnostics. +- verify watchdog cooldown path is unchanged. + +Tests: + +- router enqueues only target member; +- missing detail is ignored; +- coalescing works with existing queue. + +### Phase 4: Codex Contract-Ready Adapter, No Production Launch Yet + +`🎯 7 🛡️ 8 🧠 5`, roughly `150-300 LOC`. + +Tasks: + +- add `CodexStopHookPayloadNormalizer` behind tests only; +- add synthetic captured-payload tests; +- do not install Codex Stop hook in production until runtime launch and config format are verified. + +Tests: + +- Codex payload maps into same normalized event; +- unsupported event names are ignored; +- missing identity fields stay unresolved. + +--- + +## 14. Testing Plan + +### Unit + +```bash +pnpm vitest run \ + test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledSpool.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledTargetResolver.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts +``` + +### Existing Regression + +```bash +pnpm vitest run \ + test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts +``` + +### Broader Regression + +```bash +pnpm typecheck --pretty false +git diff --check +``` + +### Optional Live Claude Hook Probe + +Only if token/cost is acceptable: + +```bash +CLAUDE_TEAM_LIVE_HOOK_PROBE=1 pnpm vitest run test/features/member-work-sync/live/ClaudeStopHook.live.test.ts +``` + +The live probe should: + +- create temp settings with only our Stop hook; +- launch a minimal Claude runtime turn; +- verify one spool event; +- not assert any model behavior. + +Do not run this in normal CI. + +--- + +## 15. Edge Cases + +### 15.1 App Closed While Agent Stops + +Hook still writes event file. On next app startup, drainer processes it if still relevant. If team/member is no longer active, event is ignored. + +### 15.2 Many Agents Stop Together + +Each hook writes a separate file. Drainer claims a bounded batch. Queue coalesces by team/member. + +### 15.3 Hook Script Missing After App Update + +Launch calls installer before creating settings patch. If script path changes, new settings patch uses new path. Old running agents may keep old hook command. If old script is gone, hook exits fail-open or shell fails. No runtime break. + +Recommended improvement: keep versioned scripts for at least one app session after update. + +### 15.4 `--settings` Inheritance + +Inherited settings are expected. The hook is generic. Resolver validates identity post-fact. + +### 15.5 User Has Their Own Stop Hook + +Hook-aware merge appends our hook and preserves the user's hook. If the user hook fails or blocks, that is outside our control, but our hook should be independent. + +### 15.6 Invalid User Project Settings + +Because we do not mutate project settings, invalid project `.claude/settings.local.json` does not affect hook installation beyond normal Claude settings loading behavior. + +### 15.7 Duplicate Events + +Dedupe can use: + +- provider; +- sessionId; +- turnId if present; +- payloadHash; +- recordedAt bucket. + +Even without perfect dedupe, queue coalescing and outbox one-per-fingerprint prevent duplicate nudges. + +### 15.8 Runtime Payload Has No Session ID + +Resolver may use transcript path. If both are missing, event is unresolved and ignored. + +### 15.9 Codex Payload Differs From Claude + +Codex normalizer is separate. It must not force Claude assumptions into the core event model. + +--- + +## 16. Open Questions + +No blocker questions for v1. + +Non-blocking decisions with recommended defaults: + +1. Processed event retention + Recommended: keep `24h` or `1000` files. + `🎯 9 🛡️ 9 🧠 2`, roughly `20-40 LOC`. + This gives enough debugging signal without making hook files a growing log store. + +2. Turn-settled queue delay + Recommended: `15s`. + `🎯 8 🛡️ 9 🧠 1`, roughly `5-15 LOC`. + Short enough to feel responsive, long enough for task/comment writes from the just-finished turn to appear. + +3. Max event file size + Recommended: `256KB`. + `🎯 8 🛡️ 9 🧠 1`, roughly `10-20 LOC`. + Claude Stop payloads should be small for our use case. Large payloads likely contain message text we do not need. + +4. Lead Stop events + Recommended: ignore in v1. + `🎯 8 🛡️ 9 🧠 2`, roughly `10-30 LOC`. + Fan-out from lead Stop is the highest-noise path and current task/inbox events already cover team-wide changes. + +5. Codex production hook installation + Recommended: not in v1. + `🎯 7 🛡️ 8 🧠 5`, roughly `150-300 LOC` later. + Keep the adapter seam and tests, but do not install until Codex hook payload and config semantics are proven. + +The only question worth escalating before implementation is this: + +```text +Should processed hook payload files be retained for 24h for debugging, or deleted immediately after metrics? +``` + +Default if no answer: retain short-term with bounded cleanup. + +--- + +## 17. Acceptance Criteria + +Implementation is accepted when: + +- Claude launches include exactly one managed member-work-sync Stop hook. +- Existing user `hooks.Stop` entries are preserved. +- Existing app inline settings such as fast mode are preserved. +- Hook command writes raw payload files atomically. +- App drains payloads without blocking launch or UI. +- Resolved Claude Stop event enqueues one `turn_settled` reconcile for the correct active member. +- Unresolved, inactive, oversized, and malformed events never send nudges. +- Existing watchdog tests remain green. +- Codex support can be added by implementing provider adapter only, without rewriting core or Claude adapter. +- `member-work-sync` does not nudge if current agenda fingerprint is already caught up or covered by valid lease/report. +- A Stop hook event alone is not counted as task progress. +- Stopped teams, removed members, stale run ids, unresolved lead events, and ambiguous CWD matches are ignored. +- No renderer code is required for v1. +- No user project settings file is mutated. diff --git a/docs/team-management/task-log-stream-candidate-selector-plan.md b/docs/team-management/task-log-stream-candidate-selector-plan.md new file mode 100644 index 00000000..2eb22f4b --- /dev/null +++ b/docs/team-management/task-log-stream-candidate-selector-plan.md @@ -0,0 +1,1919 @@ +# Task Log Stream Candidate Selector Plan + +## Summary + +`Task Log Stream` can currently strict-parse hundreds of transcript files for a single task. The most visible failure mode is: + +```text +Slow exact-log parse: files=876 messages=61654 elapsedMs=153048 +Slow task-log stream layout: team=vector-room-131313 task=... elapsedMs=377395 +``` + +The root cause is not the transcript discovery cache. Discovery intentionally finds all known session/root/subagent transcript files for a team. The bug is that task-scoped stream construction sometimes passes that full team-wide file list into `BoardTaskExactLogStrictParser`. + +This plan introduces a task-scoped transcript candidate selection layer before strict parsing. + +Chosen direction: + +```text +TaskLogTranscriptCandidateSelector + HistoricalBoardMcpRawProbe +🎯 9 🛡️ 9 🧠 6 +Estimated size: 550-850 LOC including tests +``` + +## Decision Matrix + +### Option 1 - Selector + Raw Probe + +```text +🎯 9 🛡️ 9 🧠 6 +Estimated size: 550-850 LOC +``` + +This is the recommended option. It fixes the root cause by preventing broad strict parser input. It keeps existing renderer and response semantics. It is more code than a hard cap, but the behavior is explainable and testable. + +Best fit when: + +- we need to preserve historical task logs; +- we need inferred native tools to keep working; +- we want a reusable policy for stream and diagnostics; +- we want to avoid future hidden perf regressions. + +### Option 2 - Inferred Path Only + +```text +🎯 7 🛡️ 8 🧠 4 +Estimated size: 250-450 LOC +``` + +This fixes the currently confirmed `records exist but execution links are missing` path, but leaves `recoverHistoricalBoardMcpRecords()` capable of full strict parsing. It is not enough if a user opens an old task with no activity records. + +Best fit only if we need an emergency patch and explicitly accept a known remaining full-parse path. + +### Option 3 - Hard Budget Or File Cap + +```text +🎯 6 🛡️ 7 🧠 3 +Estimated size: 120-260 LOC +``` + +This prevents UI hangs by refusing to parse too many files, but it can hide valid logs. It is operationally safe but semantically weaker. + +Use only as a secondary safety rail after selector diagnostics prove a real need. + +### Option 4 - Rewrite Activity Indexing First + +```text +🎯 6 🛡️ 6 🧠 8 +Estimated size: 900-1600 LOC +``` + +This could improve `BoardTaskActivityTranscriptReader` and task-activity indexing, but it is too broad for the confirmed strict-parse bug. It touches more persistence/cache behavior and increases regression risk. + +Keep as a separate follow-up if activity transcript reads remain slow after strict parser input is bounded. + +Goals: + +- Never strict-parse all transcript files for inferred native task logs. +- Keep historical board MCP recovery, but prefilter files cheaply before strict parsing. +- Preserve renderer/API contracts and exact-log rendering semantics. +- Preserve old/historical logs where task evidence exists. +- Keep OpenCode runtime fallback behavior unchanged. +- Add diagnostics for future tuning without exposing debug noise in UI. +- Keep implementation aligned with `docs/FEATURE_ARCHITECTURE_STANDARD.md`: policy in small isolated classes, IO in adapters/helpers, service as orchestration. + +Non-goals: + +- Do not rewrite transcript discovery. +- Do not change IPC/preload/renderer response shapes. +- Do not add backend cancellation tokens in this pass. +- Do not hard-cap logs in a way that silently hides valid evidence. +- Do not change `BoardTaskActivityRecordSource` indexing in this pass. +- Do not optimize `BoardTaskLogDiagnosticsService` in the first cut unless stream fix is green. +- Do not change task ownership, runtime fallback, watchdog, member-work-sync, or delivery semantics. + +## Current Evidence + +Real team checked: + +```text +team: vector-room-131313 +discovered sessions: 93 +discovered transcript files: 880 +root jsonl files: 33 +subagent jsonl files: 847 +context size: ~166 MB +``` + +Risky tasks found: + +```text +task #9c1a9dce +records: 3 +execution links: 0 +record files: 1 +non-read sessions: 1 +current inferred parse: 879 extra files +safe candidate set: 1 file + +task #e6d65b6d +records: 3 +execution links: 0 +record files: 1 +non-read sessions: 1 +current inferred parse: 879 extra files +safe candidate set: 1 file +``` + +Raw prefilter check on `#9c1a9dce`: + +```text +input files: 880 +raw hits for task id/display id + board MCP marker: 2 +elapsed: ~462ms +``` + +This means historical recovery can be reduced from strict parsing hundreds of JSONL files to strict parsing a few raw hits. + +## Additional Code Research + +### Transcript Discovery Is Intentionally Broad + +`TeamTranscriptProjectResolver.discoverSessionIds()` combines: + +- known session ids from `config.leadSessionId` and `sessionHistory`; +- root JSONL files that appear to belong to the team; +- every session directory under the transcript project directory. + +`TeamTranscriptSourceLocator` then expands each session id into: + +```text +/.jsonl +//subagents/agent-*.jsonl +``` + +This is why big teams can have hundreds of transcript files. Narrowing discovery globally is risky because other features depend on broad historical discovery. + +### Some Sessions Are Subagent-Heavy + +Real data showed sessions with: + +```text +72 files in one session +58 files in one session +55 files in one session +``` + +Session-bound selection is still a major improvement over 880 files, but it can still be broad in subagent-heavy cases. The first cut should warn on broad same-session candidate sets, not hard-drop them. + +### Diagnostics Service Has A Separate Full-Parse Path + +`BoardTaskLogDiagnosticsService.diagnose()` currently does: + +```ts +const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); +``` + +This can reproduce the same IO spike in live diagnostic tests. It is not the main renderer stream path, but live smoke tests call diagnostics before rendering. After the stream path is fixed, diagnostics should reuse the selector/probe or clearly mark itself as an expensive debug-only operation. + +### Strict Parser Cache Has Retain-Only Semantics + +`BoardTaskExactLogStrictParser.parseFiles()` calls: + +```ts +this.cache.retainOnly(new Set(uniquePaths)); +``` + +This means smaller candidate sets reduce current request work, but can evict cache entries from a previous request. Do not change this in the same patch. Instead: + +- keep selector output deterministic; +- rely on layout in-flight coalescing for same task; +- measure after the input-size fix; +- consider an explicit `retainOnly` option later if needed. + +### Existing Detail Selection Is Already Correctly Scoped + +`BoardTaskExactLogSummarySelector` groups by explicit records and `BoardTaskExactLogDetailSelector` filters by explicit message/tool anchors. Those paths should not be rewritten. The fix should only control which extra files are parsed for inferred and historical fallback paths. + +### Activity Reader Keeps Actor Context Separately From Board Links + +`BoardTaskActivityTranscriptReader` intentionally parses lines containing `"agentName"` or `"agentId"` so `TranscriptSessionActorContextTracker` can remember actor context, but only emits records for lines containing `"boardTaskLinks"`. + +Implication: + +- candidate selector must use emitted `BoardTaskActivityRecord.actor.sessionId` and `record.source.filePath` as the trusted task evidence; +- raw historical probe must not try to replace the activity reader; +- if actor context is absent, selector should not invent member/session attribution from task owner. + +### Exact Detail Service Should Stay Untouched + +`BoardTaskExactLogDetailService.getTaskExactLogDetail()` already parses a single candidate file: + +```ts +const parsedMessagesByFile = await this.strictParser.parseFiles([candidate.source.filePath]); +``` + +Do not route detail expansion through the new selector. Detail requests are already scoped by exact log id and source generation. + +### Log Source Tracker Invalidation Still Applies + +`TeamLogSourceTracker.onLogSourceChange(teamName)` invalidates shared `TeamTranscriptSourceLocator` discovery cache. The selector should not add a separate generation system. It should rely on the transcript context it receives from the locator. + +If layout generation changes during a long build, the existing stale layout guard remains the cache-safety layer. + +### OpenCode Runtime Fallback Is A Separate Source + +`OpenCodeTaskLogStreamSource` has its own task-marker and attribution logic, its own limits, and its own cache. The selector must not try to recreate OpenCode projection rules. + +Current merge behavior: + +```text +if transcript layout has no visible slices: + load runtime fallback + +if transcript layout has slices and shouldMergeRuntimeFallback: + merge runtime fallback response +``` + +Implication: + +- empty inferred candidates should not disable fallback; +- no selector code should import `OpenCodeTaskLogStreamSource`; +- OpenCode-specific task-log bugs should remain in the runtime fallback source or attribution service, not in the generic transcript selector. + +### Task Display Id Matching Is Short And Collision-Prone + +`getTaskDisplayId(task)` usually returns the first 8 chars of a UUID. Raw historical probe can match short display ids. This is acceptable only because strict historical validation still checks structured tool input/result payloads. + +Do not make raw display-id hits directly visible in UI. + +## Root Cause + +The dangerous path is in `BoardTaskLogStreamService.buildInferredExecutionSlices()`: + +```ts +const transcriptFiles = transcriptContext?.transcriptFiles ?? []; +const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); +const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); +``` + +If a task has board records but no explicit `execution` links, the service parses every discovered transcript file not already parsed for explicit board records. + +The second dangerous path is in `recoverHistoricalBoardMcpRecords()`: + +```ts +const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); +``` + +If activity records are missing, historical recovery strict-parses the whole team transcript context. + +## Design Principles + +### Keep Discovery Broad, Keep Parsing Narrow + +`TeamTranscriptSourceLocator` should keep discovering all team-relevant transcript files. It serves multiple consumers and historical workflows. The fix should not make discovery less complete. + +Instead, task log stream must narrow files before strict parsing. + +### Evidence Before Expansion + +Native tool rows can be inferred only from files with task-related evidence: + +- explicit board activity record source files; +- same session as non-read/non-board task activity; +- historical raw file that mentions the task and a recoverable board MCP marker. + +Do not expand by: + +- owner name alone; +- current lead session alone; +- read-only `task_get` or `task_get_comment`; +- broad work interval alone; +- all team transcript files. + +### Strict Parser Stays Authoritative + +Raw prefilter only chooses candidates. It does not create records. Existing strict parser and existing historical task-reference validation remain authoritative. + +False positives are acceptable. False negatives are risky and should be avoided by conservative raw matching. + +### Deterministic Output + +Candidate file lists must be sorted and deterministic. This matters because: + +- stream layout cache keys are task-level; +- `BoardTaskExactLogStrictParser.parseFiles()` currently calls `retainOnly()`; +- nondeterministic file order makes tests and diagnostics harder. + +### Fail Narrow, Not Broad + +If evidence is missing or ambiguous, the system should prefer a smaller candidate set and visible diagnostics over parsing the full team context. + +Allowed fallback: + +- direct record files; +- runtime fallback for OpenCode when existing conditions allow it; +- empty stream with diagnostics. + +Disallowed fallback: + +- "no candidates, therefore parse all transcripts"; +- "owner is known, therefore parse all owner-looking sessions"; +- "work interval exists, therefore parse every file in the interval". + +### Keep Time Filtering After File Filtering + +Time windows are useful for filtering messages inside candidate files, but they are not safe enough to select files from the whole project. + +Reason: + +- open work intervals can extend to now; +- transcript files do not expose cheap precise min/max timestamps today; +- native tool calls often do not mention the task id; +- broad time windows over all files recreate the original bug. + +Therefore: + +```text +file evidence first +then strict parse +then timestamp/member/tool filtering inside parsed messages +``` + +### Keep Raw Probe Non-Authoritative + +Raw text scanning can only decide "worth strict parsing". It must not decide "this is task activity". + +The authoritative checks remain: + +- strict JSONL parsing; +- tool call/result matching; +- `historicalBoardToolReferencesTask()`; +- existing task id/display id resolution. + +## Proposed Components + +### `TaskLogTranscriptCandidateSelector` + +Location: + +```text +src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector.ts +``` + +Responsibility: + +- Pure-ish selection policy. +- No file IO. +- Knows how to map discovered transcript file paths to session ids. +- Knows which board tools are read-only. +- Produces explainable candidate sets. + +Suggested public API: + +```ts +export interface TranscriptFileSessionIndex { + projectDir: string; + filesBySessionId: Map; + sessionIdByFilePath: Map; + rootFilesBySessionId: Map; + subagentFilesBySessionId: Map; +} + +export interface CandidateSelectionDiagnostics { + recordFileCount: number; + nonReadSessionCount: number; + sameSessionFileCount: number; + excludedReadOnlySessionCount: number; + finalCandidateCount: number; + reason: string; +} + +export interface CandidateSelectionResult { + filePaths: string[]; + diagnostics: CandidateSelectionDiagnostics; +} + +export class TaskLogTranscriptCandidateSelector { + buildSessionIndex(args: { + projectDir: string; + transcriptFiles: string[]; + }): TranscriptFileSessionIndex; + + selectInferredNativeFiles(args: { + records: BoardTaskActivityRecord[]; + transcriptFiles: string[]; + projectDir?: string; + alreadyParsedFilePaths?: Set; + }): CandidateSelectionResult; + + selectExplicitRecordFiles(args: { + records: BoardTaskActivityRecord[]; + }): CandidateSelectionResult; +} +``` + +### `HistoricalBoardMcpRawProbe` + +Location: + +```text +src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe.ts +``` + +Responsibility: + +- File IO adapter. +- Cheap raw text scan with bounded concurrency. +- Finds files that might contain recoverable historical board MCP task activity. +- Does not parse JSON. +- Does not create stream records. + +Suggested public API: + +```ts +export interface HistoricalBoardMcpRawProbeResult { + filePaths: string[]; + scannedFileCount: number; + hitCount: number; + elapsedMs: number; +} + +export class HistoricalBoardMcpRawProbe { + async findCandidateFiles(args: { + task: TeamTask; + transcriptFiles: string[]; + }): Promise; +} +``` + +### Why Two Classes + +`TaskLogTranscriptCandidateSelector` is policy. `HistoricalBoardMcpRawProbe` is IO. + +This keeps SRP clean: + +- selection rules are unit-testable without the filesystem; +- raw scan can be tested separately with temp files; +- `BoardTaskLogStreamService` remains an orchestrator; +- future diagnostics service can reuse both. + +## Layering And Dependency Direction + +This is not a new cross-process feature, so it should stay under `src/main/services/team/taskLogs`. Still, the same clean architecture rules apply locally: + +```text +stream service + depends on selector policy + depends on raw probe adapter + depends on strict parser + +selector policy + depends on task activity record types + no filesystem + no logger required for core decisions + +raw probe + depends on filesystem + no stream rendering + no task activity record building + +strict parser + unchanged +``` + +Do not let `TaskLogTranscriptCandidateSelector` import: + +- `fs`; +- `readline`; +- renderer types; +- IPC contracts; +- runtime/provider services. + +Do not let `HistoricalBoardMcpRawProbe` know about: + +- stream segments; +- participants; +- exact-log chunk rendering; +- OpenCode fallback. + +This keeps the service open for future selectors without changing rendering code. + +### Shared Tool Name Normalization + +Use the existing helper: + +```ts +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; +``` + +Do not duplicate board-tool normalization in the selector or raw probe. The same canonicalization is already used by: + +- `BoardTaskLogStreamService`; +- `OpenCodeTaskLogStreamSource`; +- task boundary parsing; +- stall monitor evidence. + +Duplicating it creates a risk where one subsystem treats `mcp__agent-teams__task_get` as read-only while another treats it as work evidence. + +Recommended shared helper extraction: + +```ts +export function canonicalizeBoardTaskLogToolName(toolName: string | undefined): string | null { + if (!toolName) return null; + const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} +``` + +If this helper is added, put it in a small local file such as: + +```text +src/main/services/team/taskLogs/stream/boardTaskLogToolNames.ts +``` + +Then import it from `BoardTaskLogStreamService`, `TaskLogTranscriptCandidateSelector`, and `HistoricalBoardMcpRawProbe`. + +## Data Flow + +Before: + +```mermaid +flowchart LR + A["Task records"] --> B["BoardTaskLogStreamService"] + C["All transcript files"] --> B + B --> D["Strict parse all missing files"] + D --> E["Filter by time/member/tool"] +``` + +After: + +```mermaid +flowchart LR + A["Task records"] --> B["Candidate selector"] + C["All transcript files"] --> B + B --> D["Small candidate file set"] + D --> E["Strict parser"] + E --> F["Existing time/member/tool filters"] +``` + +Historical path: + +```mermaid +flowchart LR + A["No task records"] --> B["Historical raw probe"] + C["All transcript files"] --> B + B --> D["Raw hits only"] + D --> E["Strict parser"] + E --> F["Existing historical validation"] +``` + +## Candidate Reason Model + +The selector should return why every file was selected. This is more verbose internally, but it makes diagnostics and tests much stronger. + +Suggested internal model: + +```ts +export type TaskLogCandidateReason = + | 'direct_record_file' + | 'same_session_non_read_record' + | 'historical_raw_task_ref_and_board_marker'; + +export interface TaskLogCandidateFile { + filePath: string; + reason: TaskLogCandidateReason; + sessionId?: string; + sourceRecordIds?: string[]; +} + +export interface CandidateSelectionResult { + filePaths: string[]; + candidates: TaskLogCandidateFile[]; + diagnostics: CandidateSelectionDiagnostics; +} +``` + +Rules: + +- `filePaths` is sorted and deduped. +- `candidates` can contain merged reasons for the same file, or one canonical highest-priority reason. +- Tests should assert both final file list and reason counts. +- Do not expose `candidates` through public IPC or renderer APIs. + +Priority if a file has multiple reasons: + +```text +direct_record_file > same_session_non_read_record > historical_raw_task_ref_and_board_marker +``` + +## Candidate Selection Details + +### Session Id Extraction + +Known transcript shapes: + +```text +/.jsonl +//subagents/agent-.jsonl +``` + +Implementation sketch: + +```ts +function extractTranscriptSessionId(projectDir: string, filePath: string): string | null { + const relative = path.relative(projectDir, filePath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + + const parts = relative.split(path.sep).filter(Boolean); + if (parts.length === 1 && parts[0].endsWith('.jsonl')) { + return parts[0].slice(0, -'.jsonl'.length); + } + + if ( + parts.length === 3 && + parts[1] === 'subagents' && + parts[2].startsWith('agent-') && + parts[2].endsWith('.jsonl') + ) { + return parts[0]; + } + + return null; +} +``` + +Edge cases: + +- Path outside `projectDir`: ignore. +- Unknown shape: ignore for session expansion, but keep if it is already a direct record file. +- Windows separators: use `path.relative` and `path.sep`. +- Symlinks: do not resolve realpath in first pass; current code operates on paths as discovered. + +### Non-Read Session Evidence + +Read-only board tools are not task work evidence: + +```ts +const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); +``` + +Do not expand sessions from records where: + +- `record.action.category === 'read'`; +- canonical tool name is read-only; +- no actor session id. + +Implementation sketch: + +```ts +function isReadOnlyRecord(record: BoardTaskActivityRecord): boolean { + const toolName = canonicalizeAgentTeamsToolName(record.action?.canonicalToolName ?? ''); + return record.action?.category === 'read' || READ_ONLY_BOARD_TOOL_NAMES.has(toolName); +} + +function collectNonReadSessionIds(records: BoardTaskActivityRecord[]): Set { + const sessionIds = new Set(); + for (const record of records) { + if (isReadOnlyRecord(record)) continue; + const sessionId = record.actor.sessionId?.trim(); + if (sessionId) { + sessionIds.add(sessionId); + } + } + return sessionIds; +} +``` + +### Inferred Native Candidate Set + +Input: + +- all records for the task; +- transcript context; +- already parsed files from explicit record details. + +Output: + +- direct record source files; +- same-session root and subagent files for non-read task activity sessions; +- minus files already parsed, if caller only needs missing files. + +Selection levels: + +```text +Level 0: direct record source files +Level 1: same-session root/subagent files from non-read evidence +Level 2: future optional same-session refinement if Level 1 is too broad +Never: full team transcript file list +``` + +Implementation sketch: + +```ts +selectInferredNativeFiles(args): CandidateSelectionResult { + const directFiles = new Set(args.records.map((record) => record.source.filePath)); + const nonReadSessions = collectNonReadSessionIds(args.records); + const index = args.projectDir + ? this.buildSessionIndex({ projectDir: args.projectDir, transcriptFiles: args.transcriptFiles }) + : null; + + const candidates = new Set(directFiles); + if (index) { + for (const sessionId of nonReadSessions) { + for (const filePath of index.filesBySessionId.get(sessionId) ?? []) { + candidates.add(filePath); + } + } + } + + for (const alreadyParsed of args.alreadyParsedFilePaths ?? []) { + candidates.delete(alreadyParsed); + } + + return { + filePaths: [...candidates].sort((a, b) => a.localeCompare(b)), + diagnostics: { + recordFileCount: directFiles.size, + nonReadSessionCount: nonReadSessions.size, + sameSessionFileCount: candidates.size, + excludedReadOnlySessionCount: countReadOnlySessions(args.records), + finalCandidateCount: candidates.size, + reason: nonReadSessions.size > 0 ? 'same_session_evidence' : 'record_files_only', + }, + }; +} +``` + +Important nuance: + +The direct record file should always be considered evidence, but the caller may exclude it from `missingFiles` if it was already parsed during explicit detail loading. This avoids duplicate parse calls while preserving the file in diagnostics. + +### Heavy Same-Session Handling + +Same-session expansion is safe semantically, but can still be expensive when a session has many subagent files. + +First cut behavior: + +```text +include all same-session files +warn if final candidates > 100 or same-session candidates > 50 +do not hard cap +``` + +Reason: + +- subagent files can contain real work without task id in their native tool calls; +- dropping them by file name or size can hide valid work; +- a warning gives us production evidence for a second-stage index. + +Future optional Level 2 if needed: + +```text +For subagent-heavy sessions, build a cheap file envelope index: +- file path +- mtime/size +- first timestamp if cheaply discoverable +- last timestamp if cheaply discoverable +- contains native tool marker + +Then include: +- root session file always; +- subagent file if timestamp envelope overlaps task window; +- subagent file if envelope missing but file contains native tool marker and candidate count is still below budget. +``` + +Do not add Level 2 in the initial patch. It requires timestamp-envelope parsing and more cache invalidation decisions. + +### Native Tool Marker Probe Is Not Enough For Inferred Path + +It is tempting to raw-scan same-session files for native tool names like `Bash`, `Read`, `Edit`, `Write` before strict parsing. This can help later, but it is not enough as a primary selector because: + +- tool names vary by provider and formatter; +- user/assistant text can contain those words; +- some provider tools are lower-case or different names; +- OpenCode tools may be projected differently; +- the existing strict parser already normalizes parsed tool calls. + +For the first cut, use task/session evidence for file selection and keep native-tool classification after parsing. + +### Historical Raw Probe + +Historical recovery should not strict-parse all files. + +Raw candidate condition: + +```text +file contains task canonical id OR task display id +AND +file contains at least one recoverable board MCP marker +``` + +Recoverable markers: + +```ts +const HISTORICAL_RECOVERABLE_MARKERS = [ + 'mcp__agent-teams__task_start', + 'mcp__agent-teams__task_complete', + 'mcp__agent-teams__task_set_status', + 'mcp__agent-teams__task_add_comment', + 'mcp__agent-teams__task_attach_file', + 'mcp__agent-teams__task_attach_comment_file', + 'mcp__agent-teams__task_set_owner', + 'mcp__agent-teams__task_set_clarification', + 'mcp__agent-teams__task_link', + 'mcp__agent-teams__task_unlink', + 'mcp__agent-teams__review_start', + 'mcp__agent-teams__review_request', + 'mcp__agent-teams__review_approve', + 'mcp__agent-teams__review_request_changes', + 'agent-teams_task_', + 'agent-teams_review_', +]; +``` + +Implementation sketch: + +```ts +async function fileMayContainHistoricalBoardTaskActivity(args: { + filePath: string; + taskRefs: string[]; +}): Promise { + let hasTaskRef = false; + let hasBoardMarker = false; + for await (const line of readline.createInterface({ input: createReadStream(args.filePath) })) { + const lowerLine = line.toLowerCase(); + hasTaskRef ||= args.taskRefs.some((ref) => lowerLine.includes(ref)); + hasBoardMarker ||= HISTORICAL_RECOVERABLE_MARKERS.some((marker) => + lowerLine.includes(marker) + ); + if (hasTaskRef && hasBoardMarker) return true; + } + return false; +} +``` + +Why line-oriented instead of `readFile`: + +- it preserves the cheap prefilter property without building a second JSON parser; +- it does not keep large transcript files in memory; +- it can stop early once both a task ref and a board MCP marker are present; +- it still allows task ref and marker on different lines via two booleans. + +If file sizes above 25 MB appear in diagnostics, revisit this with a bounded rolling-window scanner. + +Bounded concurrency: + +```ts +const HISTORICAL_RAW_PROBE_CONCURRENCY = process.platform === 'win32' ? 4 : 8; +``` + +Concurrency rule: + +```text +raw probe concurrency <= strict parser concurrency +``` + +Reason: + +- raw probe should reduce pressure, not create a parallel IO storm before strict parsing. +- Keep it at 8 on macOS/Linux and 4 on Windows unless logs prove otherwise. + +Why raw read is acceptable: + +- It avoids JSON parse and object conversion. +- It yields a small strict-parse candidate set. +- It is only used when normal activity records are absent. +- It preserves historical recovery behavior without full strict parse. + +Potential future optimization: + +- read chunks instead of full file for very large files; +- add a tiny file content cache keyed by `mtimeMs/size`; +- not needed in first pass because tested files are under ~4 MB and raw probe was ~462ms for 880 files. + +### Historical Marker False Positives + +Raw probe can return files that mention the task but do not represent task work, for example: + +- lead task creation transcript; +- inbox delivery prompt containing task instructions; +- task context embedded in a different tool result. + +This is acceptable because strict historical recovery still checks: + +```ts +historicalBoardToolReferencesTask({ + canonicalToolName, + input, + resultPayload, + taskRefs, +}); +``` + +The raw probe must be broad enough to include true positives. It does not need to exclude every false positive. + +### Historical Marker False Negatives + +False negatives are more dangerous. Avoid them by: + +- matching both canonical task id and display id; +- matching raw task refs with both bare and `#` forms for display ids; +- using broad marker variants for `mcp__agent-teams__`, `mcp__agent_teams__`, `agent-teams_task_`, and `agent-teams_review_`; +- not requiring `boardTaskLinks`, because historical rows specifically lack them. + +If old transcripts use a marker format not covered here, add that marker to `HISTORICAL_RECOVERABLE_MARKERS` with a fixture before rollout. + +Suggested task ref builder: + +```ts +function buildRawHistoricalTaskRefs(task: TeamTask): string[] { + const canonicalId = task.id.trim(); + const displayId = getTaskDisplayId(task).trim(); + return [ + canonicalId, + displayId, + displayId ? `#${displayId}` : '', + ].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); +} +``` + +Do not include task subject/description in raw refs. Those are too broad and can pull unrelated task-context files. + +## Changes To `BoardTaskLogStreamService` + +### Constructor + +Add optional dependencies after existing dependencies, or as an options object if changing signature is too noisy. + +Prefer minimal constructor churn: + +```ts +constructor( + private readonly recordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector = new BoardTaskExactLogSummarySelector(), + private readonly strictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(), + private readonly membersMetaStore = new TeamMembersMetaStore(), + private readonly configReader = new TeamConfigReader(), + private readonly transcriptCandidateSelector = new TaskLogTranscriptCandidateSelector(), + private readonly historicalRawProbe = new HistoricalBoardMcpRawProbe() +) {} +``` + +Risk: + +- There are many tests constructing this service with positional args. +- Appending optional dependencies at the end is safer than inserting dependencies earlier. + +Safer alternative if constructor becomes too long: + +```ts +interface BoardTaskLogStreamServiceDependencies { + recordSource?: BoardTaskActivityRecordSource; + summarySelector?: BoardTaskExactLogSummarySelector; + strictParser?: BoardTaskExactLogStrictParser; + detailSelector?: BoardTaskExactLogDetailSelector; + chunkBuilder?: BoardTaskExactLogChunkBuilder; + taskReader?: TeamTaskReader; + transcriptSourceLocator?: TeamTranscriptSourceLocator; + runtimeFallbackSource?: TaskLogRuntimeStreamSource; + membersMetaStore?: TeamMembersMetaStore; + configReader?: TeamConfigReader; + transcriptCandidateSelector?: TaskLogTranscriptCandidateSelector; + historicalRawProbe?: HistoricalBoardMcpRawProbe; +} +``` + +Do not switch to this object in the same patch unless positional constructor changes become unmanageable. A constructor refactor would touch many tests and can obscure the actual perf fix. + +### Imports And Cycles + +Avoid cycles: + +```text +BoardTaskLogStreamService -> TaskLogTranscriptCandidateSelector +TaskLogTranscriptCandidateSelector -> BoardTaskActivityRecord types + tool name helper +TaskLogTranscriptCandidateSelector must not import BoardTaskLogStreamService +``` + +Keep shared constants either: + +- in a small local helper file; or +- duplicated only if they are test-local, not production logic. + +### Inferred Native Path + +Current broad path: + +```ts +const transcriptFiles = transcriptContext?.transcriptFiles ?? []; +const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); +``` + +Replace with: + +```ts +const transcriptFiles = transcriptContext?.transcriptFiles ?? []; +const selected = this.transcriptCandidateSelector.selectInferredNativeFiles({ + records, + transcriptFiles, + projectDir: transcriptContext?.projectDir, + alreadyParsedFilePaths: new Set(parsedMessagesByFile.keys()), +}); +const missingFiles = selected.filePaths; + +this.logCandidateSelectionIfLarge(teamName, taskId, 'inferred_native', selected.diagnostics); +``` + +Keep the rest of inferred message filtering: + +- time windows; +- explicit message/tool dedupe; +- allowed member filtering; +- `messageHasNonBoardToolActivity`; +- `sanitizeJsonLikeToolResultPayloads`; +- `pruneEmptyInternalToolResultMessages`. + +### Historical Recovery Path + +Current broad path: + +```ts +const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); +``` + +Replace with: + +```ts +const rawProbe = await this.historicalRawProbe.findCandidateFiles({ + task, + transcriptFiles, +}); + +if (rawProbe.filePaths.length === 0) { + return { + task, + parsedMessagesByFile: new Map(), + records: [], + }; +} + +const parsedMessagesByFile = await this.strictParser.parseFiles(rawProbe.filePaths); +``` + +Diagnostics: + +```ts +if (rawProbe.scannedFileCount >= 500 || rawProbe.elapsedMs >= 3_000) { + logger.warn( + `Task-log historical raw probe: team=${teamName} task=${taskId} scanned=${rawProbe.scannedFileCount} hits=${rawProbe.hitCount} elapsedMs=${rawProbe.elapsedMs}` + ); +} +``` + +### Parser Call Invariants + +After the change, these should be true: + +```text +Explicit board details: + parseFiles(candidate.source.filePath only) + +Inferred native: + parseFiles(selected missing same-session candidate files only) + +Historical recovery: + parseFiles(raw probe hit files only) + +Never: + parseFiles(transcriptContext.transcriptFiles) from stream layout path +``` + +Add test assertions directly on `strictParser.parseFiles.mock.calls`. + +Anti-patterns to reject in review: + +```ts +// Bad: reconstructs the original bug under another name. +await this.strictParser.parseFiles(transcriptContext.transcriptFiles); + +// Bad: owner is not file evidence. +const ownerFiles = transcriptFiles.filter((file) => file.includes(task.owner)); + +// Bad: time window is not file evidence. +const allFilesInTaskTimeRange = await scanAllFilesForTimestamps(transcriptFiles, task); + +// Bad: read-only task_get should not authorize native inference. +const sessions = records.map((record) => record.actor.sessionId); +``` + +### Layout Cache Interaction + +Do not change layout cache keys in this patch: + +```text +teamName::taskId +``` + +The candidate selector must be deterministic so the same task request produces the same layout input while transcript discovery generation is unchanged. + +If `TeamTranscriptSourceLocator` generation changes during a long build, the existing stale layout guard should still prevent caching stale layouts. The selector does not need its own generation tracking. + +### OpenCode Runtime Fallback Interaction + +Keep existing behavior: + +- if explicit execution links exist, runtime fallback is not merged; +- if owner provider is OpenCode and stream lacks explicit execution, fallback may still merge; +- selector failure or empty inferred candidates must not disable runtime fallback. + +Do not make the selector OpenCode-aware. Provider-specific logic belongs in existing runtime fallback path. + +## Diagnostics Strategy + +Add developer-only logs when selection is unusually broad. + +Suggested thresholds: + +```ts +const TASK_LOG_CANDIDATE_SELECTION_WARN_FILES = 100; +const TASK_LOG_CANDIDATE_SELECTION_WARN_RATIO = 0.5; +``` + +Log example: + +```text +Task-log inferred candidate selection broad: +team=vector-room-131313 task=... mode=inferred_native +recordFiles=1 nonReadSessions=1 sameSessionFiles=72 finalCandidates=72 transcriptFiles=880 +``` + +Do not show this in UI. + +### Diagnostic Fields + +Log only structured counts and reason codes: + +```ts +logger.warn('Task-log candidate selection broad', { + teamName, + taskId, + mode: 'inferred_native', + transcriptFileCount, + recordFileCount, + nonReadSessionCount, + sameSessionFileCount, + finalCandidateCount, + excludedReadOnlySessionCount, + reason: diagnostics.reason, +}); +``` + +Avoid logging: + +- raw prompt text; +- tool result payloads; +- full file contents; +- API keys; +- full task descriptions. + +File paths are already present in existing developer diagnostics, but do not include long lists in warning logs. Use counts plus first 3 paths only in debug logs if needed. + +### Metrics To Watch Manually + +After implementation, these log patterns should drop: + +```text +Slow exact-log parse: files=8xx +Slow task-log stream layout: ... elapsedMs=1xxxxx +``` + +Acceptable post-fix logs: + +```text +Task-log inferred candidate selection broad: ... finalCandidateCount=72 +Slow exact-log parse: files=72 ... +``` + +The second case means the selector worked but same-session workload is still heavy. That becomes a Level 2 optimization, not the original bug. + +## Risk Register + +| Risk | Severity | Likelihood | Mitigation | +| --- | --- | --- | --- | +| Native subagent tools disappear because selector only includes root transcript | High | Medium | Include all same-session root and `subagents/agent-*.jsonl` files for non-read evidence. Add integration test. | +| Read-only task lookup pulls unrelated native work into stream | High | High | Exclude `task_get` and `task_get_comment` sessions from expansion. Existing read-only test plus new parser-input test. | +| Historical old logs stop working because raw probe misses old marker format | Medium | Medium | Match broad marker variants. Keep historical fixture tests. Add marker only with fixture. | +| Parser cache reuse worsens because candidate sets are smaller | Low | Medium | Do not change cache semantics. Keep deterministic candidate ordering. Measure after fix. | +| Same-session candidate set is still large for subagent-heavy sessions | Medium | Medium | Warn with counts. Do not hard cap. Consider Level 2 timestamp envelope later. | +| Raw probe reads too much text under heavy load | Medium | Low | Bounded concurrency. Observed files under ~4 MB. Add slow diagnostics. Chunked scan is follow-up. | +| Lead task creation false positive creates fake history | High | Low | Raw probe only selects files. Strict historical validation remains authoritative and excludes non-recoverable tools. | +| OpenCode fallback stops showing logs | High | Low | Keep provider fallback unchanged and selector provider-agnostic. Add regression around fallback merge. | +| Live diagnostics still cause full parse and look like stream bug | Medium | High | Document as Cut 4. Update diagnostics after stream fix or mark as expensive. | +| New helper duplicates board tool normalization and drifts from runtime code | Medium | Medium | Reuse `canonicalizeAgentTeamsToolName` through a shared local helper. | +| Constructor refactor causes broad test churn unrelated to perf fix | Medium | Medium | Append optional deps or keep positional constructor; avoid dependency-object migration unless necessary. | +| Raw probe full-file reads compete with strict parser under load | Medium | Low | Bounded concurrency <= strict parser concurrency, no extra parallel probe once strict parse starts. | +| Candidate diagnostics become noisy in dev logs | Low | Medium | Warn only on broad candidates/slow probe, keep normal selections silent. | + +## Implementation Invariants + +These invariants should be enforced by tests: + +- Selector never returns the full transcript list unless every file is directly evidenced by records or same-session evidence. +- Direct record source files are always preserved. +- Read-only records never create same-session expansion. +- Non-read records may expand by session id, not by owner name. +- Historical raw probe never creates `BoardTaskActivityRecord` directly. +- Strict parser receives a sorted, deduped file list. +- Empty candidate selection never falls back to all files. +- OpenCode runtime fallback remains independent from selector decisions. +- No public IPC, preload, renderer, or DTO shape changes. + +## Failure Behavior + +When candidate selection cannot confidently find files: + +```text +records exist, no non-read session evidence: + parse direct record files only + +records absent, raw probe has no hits: + return no recovered historical records + +transcript context unavailable: + skip inferred expansion and keep explicit slices/runtime fallback + +projectDir unavailable: + parse direct record files only, because session mapping cannot be trusted +``` + +Do not throw for these cases. They are data-shape limitations, not user-visible fatal errors. + +## Rollback Strategy + +If the implementation causes missing logs or unexpected regressions: + +1. Revert the service integration commits first, leaving pure selector/probe tests if useful. +2. Do not revert unrelated task-log rendering fixes. +3. Confirm `BoardTaskLogStreamService` returns to previous behavior by checking parser calls in the synthetic test. +4. If rollback is needed because historical raw probe missed old logs, keep inferred-path selector and revert only historical integration. + +Suggested commit split supports this: + +```text +test(task-logs): cover transcript candidate selection +fix(task-logs): bound inferred native transcript parsing +fix(task-logs): prefilter historical board recovery files +test(task-logs): add live candidate smoke coverage +``` + +This lets us revert historical prefilter independently from inferred native selection. + +## Benchmark Method + +Use instrumentation that wraps `BoardTaskExactLogStrictParser.prototype.parseFiles` and records: + +```ts +type ParseCallMetric = { + count: number; + unique: number; + elapsedMs: number; + sample: string[]; +}; +``` + +Before/after command shape: + +```bash +LIVE_TASK_LOG_TEAM=vector-room-131313 \ +LIVE_TASK_LOG_TASK=9c1a9dce-ecdf-4923-8ec6-6e9521534739 \ +pnpm exec tsx scripts/task-log-stream-candidate-smoke.ts +``` + +Expected before: + +```text +parseFiles unique=1 +parseFiles unique=879 +``` + +Expected after: + +```text +parseFiles unique=1 +parseFiles unique=0-1 +``` + +The smoke script should be read-only and should not launch agents or runtimes. + +## Test Plan + +### Pure Selector Tests + +File: + +```text +test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts +``` + +Cases: + +- Extract root session id from `/.jsonl`. +- Extract subagent session id from `//subagents/agent-abc.jsonl`. +- Ignore unknown file shapes for session expansion. +- Direct record file is always included. +- Non-read task activity expands same-session files. +- Read-only `task_get` does not expand same-session files. +- Mixed read and non-read records expand only non-read sessions. +- `alreadyParsedFilePaths` are excluded from missing candidate output. +- Output order is deterministic. +- Path traversal/outside-project files do not create session expansion. +- Same-session expansion includes subagent files. +- Missing `projectDir` falls back to direct record files only. +- Unknown transcript file shape does not crash selection. + +Example: + +```ts +it('does not expand candidates from read-only task_get records', () => { + const selector = new TaskLogTranscriptCandidateSelector(); + const result = selector.selectInferredNativeFiles({ + records: [makeReadOnlyRecord('/tmp/project/session-a.jsonl', 'session-a')], + projectDir: '/tmp/project', + transcriptFiles: [ + '/tmp/project/session-a.jsonl', + '/tmp/project/session-a/subagents/agent-work.jsonl', + ], + }); + + expect(result.filePaths).toEqual(['/tmp/project/session-a.jsonl']); + expect(result.diagnostics.excludedReadOnlySessionCount).toBe(1); +}); +``` + +### Raw Probe Tests + +File: + +```text +test/main/services/team/HistoricalBoardMcpRawProbe.test.ts +``` + +Cases: + +- Finds file with task id and board MCP marker. +- Finds file with display id and board MCP marker. +- Ignores file with task id but no recoverable marker. +- Ignores file with marker but no task ref. +- Ignores unreadable files without throwing. +- Uses bounded concurrency. +- Output order is deterministic. + +Example: + +```ts +it('prefilters historical recovery files by task ref and board marker', async () => { + const probe = new HistoricalBoardMcpRawProbe(); + const result = await probe.findCandidateFiles({ + task: makeTask({ id: TASK_ID, displayId: 'c414cd52' }), + transcriptFiles: [unrelatedPath, leadCreatePath, historicalPath], + }); + + expect(result.filePaths).toEqual([historicalPath]); +}); +``` + +### Stream Service Tests + +Existing file: + +```text +test/main/services/team/BoardTaskLogStreamService.test.ts +``` + +New cases: + +- Task has records but no `execution` links: strict parser receives only related/same-session files, not all transcript files. +- `task_get` record plus nearby same-session native tool: native tool is not inferred. +- Non-read record plus same-session subagent `bash`: native tool is inferred. +- Foreign session native tool inside same time window: excluded. +- Historical recovery with many unrelated files: strict parser receives only raw hits. +- Raw false-positive lead task creation file does not produce recovered stream rows. +- Empty raw hits do not call strict parser with the full transcript list. +- Broad same-session selection logs diagnostics but still returns logs. +- Runtime fallback merge conditions are unchanged for OpenCode owners. + +Example for the critical regression: + +```ts +it('bounds inferred native parsing to task-evidence sessions', async () => { + const strictParser = { + parseFiles: vi.fn(async (filePaths: string[]) => { + return new Map(filePaths.map((filePath) => [filePath, messagesFor(filePath)])); + }), + }; + + const transcriptSourceLocator = { + getContext: vi.fn(async () => ({ + projectDir: '/tmp/project', + transcriptFiles: [ + '/tmp/project/session-a.jsonl', + '/tmp/project/session-a/subagents/agent-work.jsonl', + ...Array.from({ length: 200 }, (_, index) => `/tmp/project/other-${index}.jsonl`), + ], + config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] }, + })), + }; + + const response = await service.getTaskLogStream('demo', 'task-a'); + + const parsedFileArgs = strictParser.parseFiles.mock.calls.flatMap(([files]) => files); + expect(parsedFileArgs).not.toContain('/tmp/project/other-0.jsonl'); + expect(parsedFileArgs).toContain('/tmp/project/session-a/subagents/agent-work.jsonl'); +}); +``` + +### Integration Tests + +Existing file: + +```text +test/main/services/team/BoardTaskLogStreamIntegration.test.ts +``` + +Keep these green: + +- explicit execution links show worker tool logs; +- historical board MCP rows are reconstructed; +- inferred time-window worker logs are shown when execution links are missing; +- annotated multi-task fixture does not leak unrelated task activity. + +Add: + +- fixture with `300` unrelated transcript files and one related file; +- assert response still includes expected native tool; +- assert strict parser did not receive unrelated files. + +### Diagnostics Tests + +Cut 4 should update: + +```text +test/main/services/team/BoardTaskLogDiagnosticsService.test.ts +test/main/services/team/BoardTaskLogStream.live.test.ts +test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts +``` + +Required behavior after Cut 4: + +- diagnostics no longer strict-parses the full transcript file list for normal task checks; +- diagnostics report includes candidate counts or mentions limited candidates; +- live smoke does not call diagnostics in a way that reintroduces 800-file strict parse before stream rendering. + +If Cut 4 is not implemented immediately, live smoke should be run with stream service instrumentation rather than diagnostics-first instrumentation. + +### Performance Regression Tests + +Add a synthetic test with hundreds of unrelated files: + +```ts +it('does not strict-parse unrelated transcripts for inferred native stream', async () => { + const unrelatedFiles = Array.from({ length: 300 }, (_, index) => + `/tmp/project/unrelated-${index}.jsonl` + ); + + const strictParser = { + parseFiles: vi.fn(async (filePaths: string[]) => { + expect(filePaths).not.toEqual(expect.arrayContaining(unrelatedFiles)); + return new Map(filePaths.map((filePath) => [filePath, []])); + }), + }; + + await service.getTaskLogStream('demo-team', 'task-a'); + + const allParsedFiles = strictParser.parseFiles.mock.calls.flatMap(([files]) => files); + expect(allParsedFiles).not.toContain('/tmp/project/unrelated-0.jsonl'); +}); +``` + +Add a real-shape fixture: + +```text +project/ + session-a.jsonl + session-a/subagents/agent-work.jsonl + session-b.jsonl + session-c/subagents/agent-noise.jsonl +``` + +Expected: + +- `session-a` and `agent-work` included when non-read record has `session-a`; +- `session-b` and `session-c` excluded even if timestamps overlap. + +### Mutation-Style Negative Tests + +Add tests that would fail if a future change reintroduces broad parsing: + +```ts +expect(strictParser.parseFiles).not.toHaveBeenCalledWith( + expect.arrayContaining(['/tmp/project/unrelated-199.jsonl']) +); +``` + +And tests that verify no owner-based expansion: + +```ts +it('does not select files just because file path or actor text matches task owner', async () => { + const result = selector.selectInferredNativeFiles({ + records: [makeRecord({ actor: { memberName: 'alice' }, sessionId: undefined })], + projectDir: '/tmp/project', + transcriptFiles: ['/tmp/project/alice-looking-file.jsonl'], + }); + + expect(result.filePaths).not.toContain('/tmp/project/alice-looking-file.jsonl'); +}); +``` + +And tests that verify no task-subject raw matching: + +```ts +it('does not use task subject as a raw historical ref', async () => { + const task = makeTask({ subject: 'calculator' }); + const result = await probe.findCandidateFiles({ + task, + transcriptFiles: [fileContainingOnlySubject], + }); + + expect(result.filePaths).toEqual([]); +}); +``` + +### Live Smoke + +Use read-only instrumentation, not a permanent test unless stable enough: + +```bash +pnpm exec tsx scripts/task-log-stream-smoke.ts \ + --team vector-room-131313 \ + --task 9c1a9dce-ecdf-4923-8ec6-6e9521534739 +``` + +Expected: + +```text +strictParser parse calls: + explicit: 1 unique file + inferred: 0-1 additional unique files +not expected: + parse call with 879 files +``` + +## Rollout Plan + +### Cut 1 - Selector And Tests + +Implement: + +- `TaskLogTranscriptCandidateSelector`; +- pure tests. + +No service behavior change yet. + +Risk: + +- Low. Pure functions only. + +### Cut 2 - Inferred Native Path + +Implement: + +- inject selector into `BoardTaskLogStreamService`; +- replace broad inferred `missingFiles`; +- add stream service regression tests; +- run real smoke on risky tasks. + +Risk: + +- Medium. Could hide inferred native tools if session mapping is wrong. + +Mitigation: + +- Include root and subagent same-session files. +- Direct record file always included. +- Keep existing time/member filters unchanged. + +### Cut 3 - Historical Raw Probe + +Implement: + +- `HistoricalBoardMcpRawProbe`; +- replace broad historical strict parse; +- add historical tests. + +Risk: + +- Medium. Raw prefilter could miss an old format. + +Mitigation: + +- Match both canonical id and display id. +- Use broad recoverable board MCP markers. +- Keep strict validation authoritative. +- If no raw hits, return empty instead of parsing all. This is intentional. + +### Cut 4 - Diagnostics Service Follow-up + +After stream path is stable, update `BoardTaskLogDiagnosticsService` to use the same selector/probe or explicitly mark full parse as debug-only expensive. + +Risk: + +- Low for production UI, but useful for live tests. + +### Cut 5 - Optional Same-Session Envelope Index + +Only do this if logs after Cuts 1-4 still show slow strict parses with candidate counts like `50-100` from one session. + +Potential implementation: + +- in-memory short TTL envelope cache keyed by file path + mtime + size; +- first/last timestamp by scanning first/last valid timestamp lines; +- `hasNativeToolMarker`; +- no persistence initially. + +Risk: + +- Medium. Adds another cache and partial parsing semantics. + +Do not implement before measuring post-selector behavior. + +### Cut 6 - Optional Activity Reader Index + +Only do this if `Slow task-activity transcript read` remains a top bottleneck after strict parser is fixed. + +Potential implementation: + +- persistent task activity index by task id/display id; +- mtime/size invalidation; +- team-level rebuild on discovery generation change; +- separate from stream candidate selector. + +Risk: + +- High. This touches source-of-truth activity records and should be its own design. + +## Verification Commands + +Targeted: + +```bash +pnpm exec vitest run \ + test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts \ + test/main/services/team/HistoricalBoardMcpRawProbe.test.ts \ + test/main/services/team/BoardTaskLogStreamService.test.ts \ + --reporter=dot +``` + +Integration: + +```bash +pnpm exec vitest run \ + test/main/services/team/BoardTaskLogStreamIntegration.test.ts \ + test/main/services/team/BoardTaskLogStreamSource.fixture-e2e.test.ts \ + --reporter=dot +``` + +Regression: + +```bash +pnpm exec vitest run test/main/services/team/stallMonitor --reporter=dot +pnpm typecheck --pretty false +git diff --check +``` + +Live read-only smoke: + +```bash +LIVE_TASK_LOG_TEAM=vector-room-131313 \ +LIVE_TASK_LOG_TASK=9c1a9dce-ecdf-4923-8ec6-6e9521534739 \ +pnpm exec vitest run test/main/services/team/BoardTaskLogStream.live.test.ts --reporter=dot +``` + +If diagnostics is not updated yet, prefer stream-only smoke instrumentation over `BoardTaskLogStream.live.test.ts`, because that test currently calls `BoardTaskLogDiagnosticsService` first. + +Example ad-hoc stream-only smoke: + +```bash +pnpm exec tsx -e ' +import { BoardTaskLogStreamService } from "./src/main/services/team/taskLogs/stream/BoardTaskLogStreamService"; +import { BoardTaskExactLogStrictParser } from "./src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser"; + +const original = BoardTaskExactLogStrictParser.prototype.parseFiles; +const calls = []; +BoardTaskExactLogStrictParser.prototype.parseFiles = async function(filePaths) { + const started = performance.now(); + const result = await original.call(this, filePaths); + calls.push({ unique: new Set(filePaths).size, elapsedMs: Math.round(performance.now() - started) }); + return result; +}; + +await new BoardTaskLogStreamService().getTaskLogStream( + "vector-room-131313", + "9c1a9dce-ecdf-4923-8ec6-6e9521534739" +); +console.log(calls); +' +``` + +## Unknowns And Open Questions + +### Can Same-Session Expansion Still Be Too Broad? + +Yes. Real data has sessions with 72 files. This is still much smaller than 880, but can be ~20 MB for a single session. + +Do not optimize this in the first cut unless tests prove it is still slow. If needed, add a second-stage same-session raw probe: + +```text +include root session file always +include subagent files only if timestamp range overlaps task window OR file contains native tool marker +``` + +This is riskier because JSONL file timestamp range requires reading/parsing lines or maintaining an index. + +Decision for first implementation: + +```text +Do not add timestamp envelope. +Do add diagnostics and tests proving we reduced full-team parse. +``` + +### Should We Cache Raw Probe Results? + +Not in first cut. + +Reason: + +- raw probe only runs when records are absent; +- context cache already short-circuits discovery; +- adding another cache increases invalidation risk. + +Future cache key: + +```text +teamName + taskId + transcript file mtime/size hash +``` + +### Should We Change `parseFiles().retainOnly()`? + +Not in this patch. + +It can reduce cross-task cache reuse when candidate sets differ, but it is existing behavior. Changing it may increase memory usage. Keep candidate sets deterministic first. + +Potential future fix: + +```ts +parseFiles(filePaths, { retainOnly: false }) +``` + +Only after measuring memory and cache behavior. + +### Should We Hard Limit Candidate Count? + +Not as primary behavior. + +Hard limit can hide valid logs. Prefer: + +- warn when candidate count is high; +- keep response correct; +- later add degraded UI semantics if needed. + +### Could Raw Probe Leak Sensitive Content? + +It reads local transcript files but does not log raw content. Diagnostics must include only counts, file counts, task id/display id and timings. No prompts, tool results, API keys or file contents. + +### Should Diagnostics Be Fixed In The Same PR? + +Recommended: + +```text +Stream path first, diagnostics second if tests stay manageable. +``` + +Reason: + +- stream path is user-facing and confirmed slow; +- diagnostics service has a different report model and examples; +- changing both at once makes regressions harder to localize. + +If live tests still call diagnostics first, either: + +- update diagnostics in the same branch after stream fix is green; or +- change live smoke to measure `BoardTaskLogStreamService` directly for this perf check. + +### Should Activity Transcript Reader Be Optimized Too? + +Not in this plan. + +`BoardTaskActivityRecordSource` still reads transcript files to build task activity records. That can also be slow, but it is a separate layer. The confirmed multi-minute spike is strict parsing and rendering layout over hundreds of files. + +Follow-up candidates: + +```text +activity index by task id/display id +mtime/size task activity cache +persistent activity index under team task-log cache +``` + +Do not mix those with this patch. + +### Should We Add A File Envelope Index Now? + +Not yet. + +A file envelope index would store first/last timestamp and tool markers per transcript file. It can further reduce same-session subagent-heavy scans, but it introduces cache invalidation complexity and another persistence surface. + +Possible future interface: + +```ts +interface TranscriptFileEnvelope { + filePath: string; + mtimeMs: number; + size: number; + firstTimestamp?: string; + lastTimestamp?: string; + hasNativeToolMarker?: boolean; + hasBoardMcpMarker?: boolean; +} +``` + +Only add this if post-selector logs still show slow same-session candidates. + +## Acceptance Criteria + +The implementation is acceptable when: + +- `vector-room-131313` risky tasks no longer invoke strict parser with hundreds of files. +- `#9c1a9dce` and `#e6d65b6d` candidate parse count drops from `879` to `1` on current artifacts. +- Existing stream integration tests stay green. +- Historical recovery still reconstructs known historical fixtures. +- Inferred native logs still appear for missing execution-link fixtures. +- Read-only task records do not cause native inference. +- No renderer/IPС contract changes. +- Typecheck passes. +- `BoardTaskLogStreamService` no longer contains direct `strictParser.parseFiles(transcriptFiles)` calls. +- Any remaining full transcript parse is explicitly debug-only or moved behind candidate selector. + +## Pre-Implementation Checklist + +Before coding: + +- Add failing tests for current behavior: inferred path parses unrelated transcript files. +- Add failing tests for historical path parsing all transcript files. +- Confirm test fixtures include both root and subagent transcript shapes. +- Confirm constructor changes do not break existing positional test setup. +- Confirm no public API changes are needed. + +## Code Review Checklist + +Use this checklist when reviewing the implementation: + +- No new `strictParser.parseFiles(transcriptContext.transcriptFiles)` in stream or diagnostics paths. +- Existing `BoardTaskExactLogDetailService` remains single-file scoped. +- No selector import cycle back into `BoardTaskLogStreamService`. +- No owner-name-only selection. +- No task subject/description raw matching. +- No read-only session expansion. +- Root and subagent transcript shapes both covered. +- Raw probe does not parse JSON and does not create records. +- Historical strict validation remains unchanged or only receives narrower input. +- Runtime fallback code paths are not changed. +- `canonicalizeAgentTeamsToolName` is reused for board/read-only tool decisions. +- Diagnostics are count/reason based and do not log payloads. +- Tests assert parser input file paths, not only rendered output. +- Live smoke uses stream instrumentation and does not accidentally measure diagnostics full parse unless Cut 4 is included. + +Before merging: + +- Run synthetic tests with hundreds of unrelated files. +- Run real smoke on `vector-room-131313` risky tasks. +- Inspect logs for absence of `Slow exact-log parse: files=8xx`. +- Keep any new diagnostics short and developer-only. + +## Recommended Next Action + +Implement Cuts 1-3 together in one feature branch/worktree, but commit them separately: + +```text +test(task-logs): cover transcript candidate selection +fix(task-logs): bound inferred native transcript parsing +fix(task-logs): prefilter historical board recovery files +``` + +Do not combine this with unrelated perf work such as advisory scans, config reads, or Codex account lifecycle. Those are separate bottlenecks and should not obscure this fix. diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 79693520..9f7c9b31 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -102,6 +102,11 @@ declare module 'agent-teams-controller' { runtimeHeartbeat(flags: Record): Promise; } + export interface ControllerWorkSyncApi { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; @@ -111,6 +116,7 @@ declare module 'agent-teams-controller' { maintenance: ControllerMaintenanceApi; crossTeam: ControllerCrossTeamApi; runtime: ControllerRuntimeApi; + workSync: ControllerWorkSyncApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; @@ -143,6 +149,7 @@ declare module 'agent-teams-controller' { | 'message' | 'process' | 'runtime' + | 'workSync' | 'crossTeam'; export interface AgentTeamsMcpToolGroup { @@ -159,6 +166,7 @@ declare module 'agent-teams-controller' { export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 72c8d6b4..98d725dc 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -10,10 +10,17 @@ const { createController } = controllerModule; const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; +type WorkSyncCapableController = ReturnType & { + workSync: { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + }; +}; + /** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */ export const agentBlocks = controllerModule.agentBlocks; -export function getController(teamName: string, claudeDir?: string) { +export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController { const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim(); let resolvedClaudeDir = claudeDir; if (forcedClaudeDir) { @@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) { teamName, ...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}), allowUserMessageSender: false, - }); + }) as WorkSyncCapableController; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index c9937293..fa1f23d4 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; import { registerTeamTools } from './teamTools'; +import { registerWorkSyncTools } from './workSyncTools'; const REGISTRATION_BY_GROUP = { team: registerTeamTools, @@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = { message: registerMessageTools, process: registerProcessTools, runtime: registerRuntimeTools, + workSync: registerWorkSyncTools, crossTeam: registerCrossTeamTools, } as const; diff --git a/mcp-server/src/tools/workSyncTools.ts b/mcp-server/src/tools/workSyncTools.ts new file mode 100644 index 00000000..b3b8d122 --- /dev/null +++ b/mcp-server/src/tools/workSyncTools.ts @@ -0,0 +1,86 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; + +import { getController } from '../controller'; +import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; + +const controlContextSchema = { + teamName: z.string().min(1), + claudeDir: z.string().min(1).optional(), + controlUrl: z.string().optional(), + waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), +}; + +const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']); + +export function registerWorkSyncTools(server: Pick) { + server.addTool({ + name: 'member_work_sync_status', + description: + 'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + }), + execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); + + server.addTool({ + name: 'member_work_sync_report', + description: + 'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + state: reportStateSchema, + agendaFingerprint: z.string().min(1), + reportToken: z.string().min(1), + taskIds: z.array(z.string().min(1)).optional(), + note: z.string().optional(), + leaseTtlMs: z.number().int().min(60000).max(3600000).optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + memberName, + from, + state, + agendaFingerprint, + reportToken, + taskIds, + note, + leaseTtlMs, + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncReport({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + state, + agendaFingerprint, + reportToken, + ...(taskIds ? { taskIds } : {}), + ...(note ? { note } : {}), + ...(leaseTtlMs ? { leaseTtlMs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); +} diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index b9ea1d2f..df98328f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -400,6 +400,97 @@ describe('agent-teams-mcp tools', () => { } }); + it('forwards member work sync MCP tools through the app validator bridge', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') { + return { + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'needs_sync', + agenda: { + teamName: 'alpha', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = parseJsonToolResult( + await getTool('member_work_sync_status').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + from: 'alice', + }) + ); + expect(status.state).toBe('needs_sync'); + + const report = parseJsonToolResult( + await getTool('member_work_sync_report').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }) + ); + expect(report.accepted).toBe(true); + + expect(calls).toEqual([ + { + method: 'GET', + url: '/api/teams/alpha/member-work-sync/alice', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/alpha/member-work-sync/report', + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { diff --git a/resources/pricing.json b/resources/pricing.json index 534f0611..b8e4c05a 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -18,6 +18,7 @@ }, "anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -41,6 +42,7 @@ }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -261,6 +263,7 @@ }, "anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -288,6 +291,7 @@ }, "anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -317,6 +321,7 @@ }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -346,6 +351,7 @@ }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -433,6 +439,7 @@ }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -477,6 +484,7 @@ }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -507,6 +515,7 @@ }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -597,6 +606,7 @@ }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "bedrock_converse", @@ -625,6 +635,7 @@ }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "bedrock_converse", @@ -653,6 +664,7 @@ }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "litellm_provider": "bedrock_converse", @@ -767,11 +779,13 @@ }, "anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, "output_cost_per_token_above_200k_tokens": 0.0000225, "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost_above_200k_tokens": 6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -1963,12 +1977,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { @@ -1997,12 +2011,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { @@ -2523,6 +2537,11 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gemini-2.5-flash-lite": { @@ -2569,6 +2588,11 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gemini-2.5-pro": { @@ -2615,6 +2639,11 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gmi/anthropic/claude-opus-4.5": { @@ -2663,11 +2692,13 @@ }, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, "output_cost_per_token_above_200k_tokens": 0.0000225, "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost_above_200k_tokens": 6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -2724,6 +2755,7 @@ }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -3036,7 +3068,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.5-haiku": { "input_cost_per_token": 8e-7, @@ -3050,7 +3084,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.5-sonnet": { "input_cost_per_token": 0.000003, @@ -3064,7 +3100,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.7-sonnet": { "input_cost_per_token": 0.000003, @@ -3078,7 +3116,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, @@ -3138,12 +3178,14 @@ "input_cost_per_image": 0.0004, "input_cost_per_token": 2.5e-7, "litellm_provider": "openrouter", - "max_tokens": 200000, + "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00000125, "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "max_input_tokens": 200000, + "max_output_tokens": 4096 }, "openrouter/anthropic/claude-3.5-sonnet": { "input_cost_per_token": 0.000003, @@ -3468,6 +3510,7 @@ }, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, + "cache_creation_input_token_cost_above_1hr": 0.0000022, "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "bedrock_converse", @@ -3619,11 +3662,13 @@ }, "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "input_cost_per_token_above_200k_tokens": 0.0000066, "output_cost_per_token_above_200k_tokens": 0.00002475, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132, "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -3724,6 +3769,7 @@ }, "us.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -3751,6 +3797,7 @@ }, "global.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", diff --git a/runtime.lock.json b/runtime.lock.json index 56440b59..05d7d288 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.13", - "sourceRef": "v0.0.13", + "version": "0.0.15", + "sourceRef": "v0.0.15", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.13.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.15.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.15.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.15.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.13.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.15.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx index 3daa41d8..7c61db12 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { api } from '@renderer/api'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts index 92dcf194..11ea2c25 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts @@ -6,29 +6,53 @@ import { } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; +import type { AppState } from '@renderer/store/types'; -export function useGraphMemberPopoverContext(teamName: string, memberName: string) { +interface GraphMemberPopoverContext { + teamData: + | (NonNullable> & { + members: ReturnType; + messageFeed: []; + }) + | null; + teamMembers: ReturnType; + spawnEntry: AppState['memberSpawnStatusesByTeam'][string][string] | undefined; + leadActivity: AppState['leadActivityByTeam'][string] | undefined; + progress: ReturnType | null; + memberSpawnSnapshot: AppState['memberSpawnSnapshotsByTeam'][string] | undefined; + memberSpawnStatuses: AppState['memberSpawnStatusesByTeam'][string] | undefined; +} + +function selectGraphMemberPopoverContext( + state: AppState, + teamName: string, + memberName: string +): GraphMemberPopoverContext { + const snapshot = teamName ? selectTeamDataForName(state, teamName) : null; + const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : []; + + return { + teamData: snapshot + ? { + ...snapshot, + members: teamMembers, + messageFeed: [], + } + : null, + teamMembers, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + }; +} + +export function useGraphMemberPopoverContext( + teamName: string, + memberName: string +): ReturnType { return useStore( - useShallow((state) => { - const snapshot = teamName ? selectTeamDataForName(state, teamName) : null; - const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : []; - - return { - teamData: snapshot - ? { - ...snapshot, - members: teamMembers, - messageFeed: [], - } - : null, - teamMembers, - spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, - leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, - progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, - memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, - memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, - }; - }) + useShallow((state) => selectGraphMemberPopoverContext(state, teamName, memberName)) ); } diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 4e31fec3..0f7fc916 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -289,6 +289,15 @@ export const GraphActivityHud = ({ const handleMessageClick = useCallback((item: TimelineItem) => { setExpandedItem(item); }, []); + const handleMessageKeyDown = useCallback( + (event: React.KeyboardEvent, item: TimelineItem): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(item); + } + }, + [handleMessageClick] + ); const handleMemberClick = useCallback( (member: ResolvedTeamMember) => { @@ -360,6 +369,52 @@ export const GraphActivityHud = ({ }; }, [enabled, forwardWheelToGraph, visibleLanes]); + const renderLaneEntry = useCallback( + (entry: InlineActivityEntry, index: number): React.JSX.Element => { + const messageKey = toMessageKey(entry.message); + const timelineItem: TimelineItem = { + type: 'message', + message: entry.message, + }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => handleMessageKeyDown(event, timelineItem)} + > + handleMessageClick(timelineItem)} + onOpenTaskDetail={onOpenTaskDetail} + onOpenMemberProfile={onOpenMemberProfile} + /> +
+ ); + }, + [ + handleMessageClick, + handleMessageKeyDown, + messageContext, + onOpenMemberProfile, + onOpenTaskDetail, + readSet, + teamColorByName, + teamName, + teamNames, + ] + ); + if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } @@ -421,43 +476,7 @@ export const GraphActivityHud = ({ No recent activity ) : null} - {lane.entries.map((entry, index) => { - const messageKey = toMessageKey(entry.message); - const timelineItem: TimelineItem = { - type: 'message', - message: entry.message, - }; - const isUnread = !entry.message.read && !readSet.has(messageKey); - - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} - > - handleMessageClick(timelineItem)} - onOpenTaskDetail={onOpenTaskDetail} - onOpenMemberProfile={onOpenMemberProfile} - /> -
- ); - })} + {lane.entries.map(renderLaneEntry)} {lane.overflowCount > 0 ? (