From 7f3ea39cd356f692e77456c992dde0186f680fed Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Sat, 24 Oct 2026 19:30:52 +0800 Subject: [PATCH 1/2] feat(ai-capture): durable checkpoints, persisted pause, optional AI packages Work from a stopped Codex session's working tree, built on 52b80c80 and carried onto the branch as it was left. See specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md. - Progress is a checkpoint beside each sidecar: source offset and saved bytes, written atomically after a unit is processed and appended. A failed write leaves the unit at the source, across a restart too. - Pause keeps no source bytes; the boundary is persisted, so a restart during a pause does not replay it. Sessions found after resume are bounded by it. - Per-session stop and reallow from the existing source rows. - The three AI adapters move to optional-plugins/ and are installed as managed packages, with tamper refusal and rollback. Merging it with #441 (already on this branch): tailer-host.ts takes this version, which supersedes #441's in-memory queue and filler newlines. bundle-export.ts goes back to before #441, since no pause-tail marker or filler exists any more. The #441 tests move to the new adapter path; the mock-based per-message suite and two tests of behaviour this design drops (give up after the retries; retry a pre-pause failure after a pause) go. Seven of the remaining #441 tests fail here; the next commit fixes them. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- docs/agent-integration.md | 25 +- docs/domain/SPEC-capture-source-lifecycle.md | 31 + e2e/capture-settings-interaction.spec.ts | 28 +- e2e/claude-code-producer.spec.ts | 63 ++ e2e/helpers.ts | 2 + e2e/official-agent-capture.spec.ts | 90 ++- e2e/official-agent-installation.spec.ts | 2 +- electron-builder.yml | 4 + .../claude-code/adapter.ts | 11 +- optional-plugins/claude-code/plugin.json | 19 + .../codex/adapter.ts | 10 +- optional-plugins/codex/plugin.json | 19 + .../opencode/adapter.ts | 24 +- optional-plugins/opencode/plugin.json | 17 + package-lock.json | 1 + package.json | 9 +- scripts/build-ai-plugins.mjs | 28 + scripts/verify-architecture.mjs | 2 +- scripts/verify-packaged-resources.mjs | 19 +- .../ai-durability-and-session-boundaries.md | 82 +++ .../ai-installation-plan.md | 4 + .../plan.md | 14 + ...source-placement-and-ui-review-20261024.md | 101 +++ .../spec.md | 10 + .../tasks.md | 13 + .../verification.md | 145 ++++ src/core/agent-capture-policy.ts | 20 +- src/core/bundle-export.ts | 5 +- src/core/capture-health.ts | 21 +- src/core/capture-packs.ts | 8 - src/core/plugins/official-agents.ts | 232 ++++-- src/main/index.ts | 28 +- src/main/ipc/plugins.ts | 11 + src/main/services/agent-tailer.ts | 99 ++- src/main/services/tailer-host.ts | 689 ++++++++---------- src/preload/index.ts | 3 +- .../src/components/settings/AgentsPanel.tsx | 151 ++-- .../settings/CaptureControlPage.tsx | 143 ++-- .../settings/ExternalCaptureGroup.tsx | 23 +- .../src/components/settings/PluginsPanel.tsx | 5 +- src/renderer/src/env.d.ts | 3 +- src/renderer/src/i18n/en.json | 42 +- src/renderer/src/i18n/zh-TW.json | 42 +- src/renderer/src/lib/settingsSearchIndex.ts | 33 +- test/agent-capture-policy.test.ts | 23 +- test/agent-source-lifecycle.test.ts | 2 +- test/agent-sources-panel.test.tsx | 66 +- test/agent-sources.test.ts | 10 +- test/agent-tailer.test.ts | 22 +- test/capture-settings-interaction.test.tsx | 4 +- test/codex-adapter.test.ts | 2 +- test/integrity-verify.test.tsx | 3 + test/official-agent-plugins-ipc.test.ts | 17 +- test/official-agent-plugins.test.ts | 84 ++- test/opencode-adapter.test.ts | 2 +- test/plugins-panel-truth.test.tsx | 12 +- test/tailer-durability.test.ts | 206 ++++++ test/tailer-jsonl-durability.test.ts | 31 +- test/tailer-naming.test.ts | 6 +- test/tailer-pause-sidecar.test.ts | 8 +- test/tailer-per-message-durability.test.ts | 212 ------ tsconfig.check.json | 2 +- 63 files changed, 1935 insertions(+), 1114 deletions(-) create mode 100644 e2e/claude-code-producer.spec.ts rename src/main/services/adapters/claude-code.ts => optional-plugins/claude-code/adapter.ts (96%) create mode 100644 optional-plugins/claude-code/plugin.json rename src/main/services/adapters/codex.ts => optional-plugins/codex/adapter.ts (96%) create mode 100644 optional-plugins/codex/plugin.json rename src/main/services/adapters/opencode.ts => optional-plugins/opencode/adapter.ts (95%) create mode 100644 optional-plugins/opencode/plugin.json create mode 100644 scripts/build-ai-plugins.mjs create mode 100644 specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md create mode 100644 specs/062-recording-workflow-consolidation/source-placement-and-ui-review-20261024.md create mode 100644 test/tailer-durability.test.ts delete mode 100644 test/tailer-per-message-durability.test.ts diff --git a/README.md b/README.md index d4bd9298..4ba42858 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Built-in English and Traditional Chinese (zh-TW). Locale files in `src/renderer/ ## AI Agent Integration -Claude Code is the first conversation workflow, followed by Codex. Install the selected RedLog capture integration in **Settings ▸ Other capture sources ▸ AI Agents**, select its project working directories, then enable new conversations. Installation alone does not start capture or import history. Supported local transcripts supply user messages, AI replies and tool calls/results. This does not cover Claude desktop/web conversations or promise hidden reasoning and image attachments. +Claude Code is the first conversation workflow, followed by Codex. Install the selected RedLog capture integration in **Settings ▸ Other capture sources ▸ AI conversations**, select its project working directories, then enable new conversations. Installation alone does not start capture or import history. Supported local transcripts supply user messages, AI replies and tool calls/results. This does not cover Claude desktop/web conversations or promise hidden reasoning and image attachments. Shell hooks record terminal commands separately; a shell hook alone does not capture the AI conversation. The API supplies explicit events when needed. See [Capture priority](docs/agent-integration.md#how-ai-conversations-are-recorded). @@ -240,7 +240,7 @@ Shell hooks record terminal commands separately; a shell hook alone does not cap Connect your interactive terminal to record its commands and supported output. Non-interactive shells spawned by AI tools may not load shell hooks; use the optional AI transcript integration for their conversation and source-reported tool results. -> **RedLog captures nothing until a source is wired up — being open is not enough.** Install the adapter for each interactive shell. AI capture integrations for Claude Code, Codex and OpenCode are individually optional and uninstalled by default. Install the desired integration in Settings ▸ Other capture sources ▸ AI Agents, add project working directories, then explicitly enable it to record new supported messages and tool calls/results. The Dashboard's **Capture Health** card warns you when nothing is feeding. See [Set up capture](docs/agent-integration.md#set-up-capture--do-this-first). +> **RedLog captures nothing until a source is wired up — being open is not enough.** Install the adapter for each interactive shell. AI capture integrations for Claude Code, Codex and OpenCode are individually optional and uninstalled by default. Parser packages ship as inert offline resources; only the chosen package is installed and loaded after project authorization. Install the desired integration in Settings ▸ Other capture sources ▸ AI conversations, add project working directories, then explicitly enable it to record new supported messages and tool calls/results. The Dashboard's **Capture Health** card warns you when nothing is feeding. See [Set up capture](docs/agent-integration.md#set-up-capture--do-this-first). **Any agent via shell hook (zsh/bash):** @@ -411,7 +411,7 @@ hooks/ command-class.json relayed / pty / native, one list for both readers plugins/ bundled non-AI capture packs src/core/plugins/ - official-agents.ts optional AI capture catalog, installed only on request + official-agents.ts validates and installs optional offline AI packages cli/ redlog-cli.js CLI tool for external integration shell/ diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 66f2db55..9a14fa91 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -32,7 +32,7 @@ RedLog captures **nothing** until a source is wired up. Being open is not enough | Source | Covers | How | Only while RedLog open? | |---|---|---|---| -| **Agent transcript tailer** *(optional)* | New supported Claude Code, Codex or OpenCode transcript content: user/assistant messages and tool calls/results. Thinking is optional and only when present in the source. | Settings ▸ Other capture sources ▸ AI Agents: install one integration, add allowed working directories, then enable it for this project. Each tool is uninstalled by default. | Yes | +| **Agent transcript tailer** *(optional)* | New supported Claude Code, Codex or OpenCode transcript content: user/assistant messages and tool calls/results. Thinking is optional and only when present in the source. | Settings ▸ Other capture sources ▸ AI conversations: install one integration, add allowed working directories, then enable it for this project. Each tool is uninstalled by default. | Yes | | **Shell hook** | Every command in *your own* terminal (nmap, ffuf, nuclei…): command, exit code, duration, cwd; ordinary Bash/Zsh commands also have bounded output, with native/interactive exceptions | Dashboard **Capture Health ▸ Install shell hook** (or Settings ▸ Commands & terminal), then open a new terminal | Yes — it no-ops when RedLog is closed | | **mitmproxy (HTTP)** | HTTP/S traffic (the main source for web bounties) | `mitmdump -s /path/to/redlog/hooks/mitmproxy-addon.py` and route your browser through it (or use the one-click Proxied Browser) | Yes | | **mitmproxy (DNS)** | DNS queries + responses; useful when target resolution matters (subdomain takeover, DoH bypass checks) | `mitmdump --mode dns@5353 -s /path/to/redlog/hooks/mitmproxy-addon.py` — point the target at 127.0.0.1:5353. Root required for port 53. | Yes | @@ -51,7 +51,7 @@ RedLog captures **nothing** until a source is wired up. Being open is not enough ### AI project working directories -Each tool has an independent capture integration. In **Settings ▸ Other capture sources ▸ AI Agents**: +Each tool has an independent capture integration. In **Settings ▸ Other capture sources ▸ AI conversations**: 1. Install the capture integration for the desired tool. This does not install the AI tool or contact a model. 2. Add the engagement's working directories with the folder picker. @@ -69,7 +69,7 @@ Source locations supported by the current adapters: | Codex | `~/.codex/sessions/` JSONL | | OpenCode | `~/.local/share/opencode/storage/` message/part/session files | -These are on-disk source formats, not a guarantee of every vendor version, web/desktop conversation or hidden reasoning. A missing transcript directory requires starting the AI tool and reopening the project. Agent tool results are source-reported evidence; they do not prove that an arbitrary command was independently observed by the shell recorder. +These are on-disk source formats, not a guarantee of every vendor version, web/desktop conversation or hidden reasoning. Source settings can select a nonstandard transcript directory. Defaults honor absolute CLAUDE_CONFIG_DIR (projects/) and CODEX_HOME (sessions/); an explicit project source directory takes precedence. Changing it stops capture until the project is explicitly enabled again. The source directory is separate from allowed working directories. Agent tool results are source-reported evidence; they do not prove that an arbitrary command was independently observed by the shell recorder. ### What records output @@ -98,7 +98,7 @@ the ones the engagement uses; neither silently enables the other. The optional transcript integrations capture newly written supported messages and tool calls/results from Claude Code, Codex and OpenCode. Only source-provided content is available; thinking blocks require a separate opt-in. They are not a substitute for shell output capture. -**Setup:** Install a per-tool integration in Settings ▸ Other capture sources ▸ AI Agents, select allowed working directories, then explicitly enable it for the project. No shell wrapper is required. +**Setup:** Install a per-tool integration in Settings ▸ Other capture sources ▸ AI conversations, select allowed working directories, then explicitly enable it for the project. No shell wrapper is required. **What it captures:** @@ -111,7 +111,7 @@ The optional transcript integrations capture newly written supported messages an | `transcript_snapshot` | Cumulative SHA-256 of the sidecar file (emitted on 15 s idle or session close) | | `transcript_compacted` | Emitted when Claude Code `/compact` runs | -**Sidecar files:** The tailer writes a copy of each session's events to `/agent-transcripts/--.jsonl`. A separate start-offset checkpoint plus the sidecar size locate the read offset; restarting the same activation does not re-import history. +**Sidecar files:** The tailer writes a copy of each session's events to `/agent-transcripts/--.jsonl`. A separate durable source-offset checkpoint locates the read position independently of retained sidecar bytes; restarting the same activation does not re-import history. **Redaction:** `deepRedactStrings()` walks every string value; paths matching `.ssh/`, `.env`, `.aws/` suppress the output field entirely. @@ -358,3 +358,18 @@ This gives you: - Agent-initiated markers and scope checks (API / shell functions) - Team-shared scope configuration (profiles) - Per-operator attribution + Bitcoin-backed integrity anchoring + + +Pause omits new AI originals from both the event log and raw sidecar; resume +skips bytes already present at that boundary. A metadata-only capture gap +records the omission. In AI source settings, expand Conversations to stop one +session or explicitly allow new content. Exclusions persist per project and +provider; allowing again does not import the excluded period. Working-directory +authorization does not classify prompts or hide a tool's reads outside that +directory. + +After an app update, an intact older optional package offers Update integration +in the source row; it cannot execute until updated. Unknown or modified files +remain refused and available for inspection. Installing/updating never grants +a new project authorization. The separate installation records live alongside +the user plugin directories and are not captured evidence. diff --git a/docs/domain/SPEC-capture-source-lifecycle.md b/docs/domain/SPEC-capture-source-lifecycle.md index f5e500d4..981d57e5 100644 --- a/docs/domain/SPEC-capture-source-lifecycle.md +++ b/docs/domain/SPEC-capture-source-lifecycle.md @@ -218,3 +218,34 @@ The runtime and health projection use `resolveAgentCapture`. The settings source Adapter initialization, main/auxiliary watchers and delayed callbacks share activation/project boundaries. Disable, uninstall and project switch stop those watchers before replacing project identity. Rejected sessions create no sidecar. Existing JSONL sessions begin at the EOF observed on connection; the checkpoint records skipped source bytes and requested activation time. New source files created after activation may begin at byte zero. Recorded evidence is retained on disable/uninstall. Only data exposed by the supported local transcript format is captured. Source-reported tool calls/results are distinct from independently observed shell execution and output. No promise is made to recover hidden reasoning, all historical sessions, or every vendor storage version. + +## Optional AI code packages + +Provider parsing code is an optional package, separate from the shared capture host. The shipped catalog is inert: listing sources or installing a package MUST NOT execute its parser or grant project access. Only a valid, active installation with explicit working-directory permission and activation boundary may load a provider module. Stop, uninstall and project switch MUST stop all provider watchers and discard its registered adapter/module instance. Captured evidence and original vendor transcripts survive removal. + +Source identity and default/environment directory mappings come from package metadata. Runtime, source detection and health MUST resolve the same source location. An explicitly selected source directory is not an allowed cwd; changing either permission range or source location stops recording pending confirmation, preserving from-now behavior. Unknown or modified executable files MUST NOT execute merely because a manifest has an official-looking ID. This release accepts only exact app-supplied package bytes; it does not authorize arbitrary third-party in-process parsers. + + +AI pause is a preservation boundary: new original conversation bytes must not +be written to events or sidecars while paused. Resume skips the then-existing +source range (including delayed file notifications and crossing partial +lines); durable offset/disposition metadata and capture_gap may describe the +omission without storing its text. A persisted paused boundary fails closed +across reopening. Event writes, raw append and progress commit are separate; +failed units remain retryable, and restart dedup keys use AI data.session_id, +not RedLog's envelope session ID. Pending-parent replay uses retained raw +bytes, not conversation copies in checkpoint metadata. + +Individual AI exclusions are keyed by project, provider and session. They do +not widen working-directory authorization. Reauthorizing a conversation or +source begins at the later applicable activation boundary. The registry's +empty default still permits cwd-authorized sessions; it is not per-conversation +consent. Cwd authorization includes the conversation's tool content even if a +tool reads outside that cwd; no content classifier claims to identify private +prompts. Source UI exposes observed sessions and their explicit exclusions. + +Official AI package installations have separate machine installation records. +Intact recorded packages can be removed or explicitly updated after an app +update; only the current app-supplied bytes may load. Unknown/modified packages +are preserved and refused. Updating preserves prior installation identity but +does not create or widen project recording permission. diff --git a/e2e/capture-settings-interaction.spec.ts b/e2e/capture-settings-interaction.spec.ts index 1478e049..73b06ed5 100644 --- a/e2e/capture-settings-interaction.spec.ts +++ b/e2e/capture-settings-interaction.spec.ts @@ -177,7 +177,7 @@ test('external-source running and failed IPC reads remain distinct in settings', } finally { await app.close() } }) -test('other-source settings link to disabled plugins and require explicit AI scope activation', async () => { +test('optional AI recovers in its source entry and requires explicit project authorization', async () => { const { app, page, tmpHome } = await launchWithTempHome() try { const first = join(tmpHome, 'client-work') @@ -189,7 +189,14 @@ test('other-source settings link to disabled plugins and require explicit AI sco localStorage.setItem('redlog-locale', 'en') const result = await window.redlog.plugins.installOfficialAgent('codex') if (!result.ok) throw new Error(result.error) - await window.redlog.plugins.setEnabled('official-ai-codex', false) + }) + await app.evaluate((_electron, home) => { + const fs = process.getBuiltinModule('fs')! + const path = process.getBuiltinModule('path')! + fs.writeFileSync(path.join(home, '.redlog', 'plugins', 'state.json'), JSON.stringify({ disabled: ['official-ai-codex'] })) + }, tmpHome) + await page.evaluate(async () => { + await window.redlog.plugins.reload() }) await page.reload() await openView(page, 'settings') @@ -198,13 +205,12 @@ test('other-source settings link to disabled plugins and require explicit AI sco await other.click() const before = await page.evaluate(() => window.redlog.config.get()) const row = page.getByTestId('agent-source-codex') - await row.getByRole('button', { name: 'Go to plugin settings' }).click() - await expect(page.locator('[data-settings-page="plugins"]')).toHaveAttribute('aria-current', 'page') - expect((await page.evaluate(() => window.redlog.plugins.list())).find(p => p.id === 'official-ai-codex')?.status).toBe('disabled') - expect(await page.evaluate(() => window.redlog.config.get())).toEqual(before) - // Explicitly enable the integration through the existing plugin control. - await page.getByRole('button', { name: 'Enable', exact: true }).click() + await row.getByTestId('agent-restore-codex').click() await expect.poll(async () => (await page.evaluate(() => window.redlog.plugins.list())).find(p => p.id === 'official-ai-codex')?.status).toBe('active') + await expect(page.locator('[data-settings-page="captureControl"]')).toHaveAttribute('aria-current', 'page') + expect(await page.evaluate(() => window.redlog.config.get())).toEqual(before) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect(page.getByText('Codex CLI', { exact: true })).toHaveCount(0) await other.click() const setPicker = async (path: string) => app.evaluate(({ ipcMain }, next) => { ipcMain.removeHandler('hookConfig:pickPath') @@ -212,18 +218,16 @@ test('other-source settings link to disabled plugins and require explicit AI sco }, path) await setPicker(first) await row.getByTestId('agent-add-path-codex').click() - const hint = row.getByText('These working directories are not being recorded yet.', { exact: false }) - await expect(hint).toBeVisible() const checkbox = row.getByTestId('agent-enabled-codex') + await expect(checkbox).toBeEnabled() await expect(checkbox).not.toBeChecked() await checkbox.check() - await expect(hint).toHaveCount(0) await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get())).agentTailer?.sources?.codex?.enabled).toBe(true) await setPicker(second) await row.getByTestId('agent-add-path-codex').click() // The scope moved under a recording source: the row says so, and the one // control that resolves it is the same checkbox, relabelled. - const scopeChanged = row.getByText('Scope changed', { exact: false }) + const scopeChanged = row.getByTestId('agent-scope-changed-codex') await expect(scopeChanged).toBeVisible() await expect(row.getByText('Confirm this range and record')).toBeVisible() await expect(checkbox).not.toBeChecked() diff --git a/e2e/claude-code-producer.spec.ts b/e2e/claude-code-producer.spec.ts new file mode 100644 index 00000000..a6696fe0 --- /dev/null +++ b/e2e/claude-code-producer.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test' +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { launchWithTempHome, openTestProject, openView, openSettingsPage } from './helpers' + +// Opt-in vendor serializer acceptance. All model responses come from loopback; +// no real account, key, external model or operator transcript is used. +for (const precreated of [false, true]) test(`installed Claude Code preserves prompt/tool/result with ${precreated ? 'existing' : 'new'} source directory`, async () => { + test.skip(!process.env.REDLOG_CLAUDE_BIN, 'Requires an explicitly selected installed Claude Code binary') + test.setTimeout(60_000) + const { app, page, tmpHome } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + const body = JSON.parse(raw || '{}') + if (!req.url?.includes('messages')) { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); return } + const finished = body.messages?.some((m: { content?: unknown }) => Array.isArray(m.content) && m.content.some((c: { type?: string }) => c.type === 'tool_result')) + const content = finished ? { type: 'text', text: 'REAL_CLAUDE_REPLY' } + : { type: 'tool_use', id: 'redlog_local_call', name: 'Bash', input: { command: 'printf REAL_CLAUDE_TOOL_OUTPUT', description: 'Local RedLog acceptance' } } + const message = { id: 'msg_redlog_local', type: 'message', role: 'assistant', model: 'redlog-local-fixture', content: [content], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null, usage: { input_tokens: 100, output_tokens: 10 } } + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const [event, data] of [ + ['message_start', { type: 'message_start', message: { ...message, content: [], stop_reason: null } }], + ['content_block_start', { type: 'content_block_start', index: 0, content_block: finished ? { type: 'text', text: '' } : { ...content, input: {} } }], + ['content_block_delta', { type: 'content_block_delta', index: 0, delta: finished ? { type: 'text_delta', text: content.text } : { type: 'input_json_delta', partial_json: JSON.stringify(content.input) } }], + ['content_block_stop', { type: 'content_block_stop', index: 0 }], + ['message_delta', { type: 'message_delta', delta: { stop_reason: message.stop_reason }, usage: { output_tokens: 10 } }], + ['message_stop', { type: 'message_stop' }] + ]) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + res.end() + }) + try { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const work = join(tmpHome, 'client-work'); mkdirSync(work) + if (precreated) mkdirSync(join(tmpHome, '.claude', 'projects'), { recursive: true }) + const project = await openTestProject(page, 'Real-Claude-Code-serializer') + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')); await page.reload() + await openView(page, 'settings'); await openSettingsPage(page, 'captureControl') + await page.getByTestId('agent-install-claude-code').click() + await app.evaluate(({ dialog }, dir) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [dir] }) }, work) + await page.getByTestId('agent-add-path-claude-code').click(); await page.getByTestId('agent-enabled-claude-code').check() + await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get())).agentTailer?.sources?.['claude-code']?.enabled).toBe(true) + // Deliberately do not inherit OAuth, nested-agent flags, real homes or keys. + const env = { PATH: process.env.PATH!, TMPDIR: process.env.TMPDIR ?? tmpHome, HOME: tmpHome, USERPROFILE: tmpHome, CLAUDE_CONFIG_DIR: join(tmpHome, '.claude'), ANTHROPIC_API_KEY: 'redlog-local-fixture', ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_AUTOUPDATER: '1', DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } + const child = spawn(process.env.REDLOG_CLAUDE_BIN!, ['-p', 'REAL_CLAUDE_PROMPT', '--session-id', randomUUID(), '--model', 'redlog-local-fixture', '--output-format', 'json', '--tools', 'Bash', '--allowedTools', 'Bash', '--max-turns', '3', '--setting-sources', '', '--disable-slash-commands', '--no-chrome', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'], { cwd: work, env }) + let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk }); child.stdout.resume() + const timer = setTimeout(() => child.kill(), 30_000) + try { const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }); expect(code, stderr).toBe(0) } + finally { clearTimeout(timer); if (child.exitCode === null) child.kill() } + const events = () => page.evaluate(() => window.redlog.events.query({ agentType: 'agent', limit: 1000 })) + await expect.poll(async () => JSON.stringify(await events())).toContain('REAL_CLAUDE_REPLY') + const saved = await events() + expect(saved.some(e => e.data.full === 'REAL_CLAUDE_PROMPT')).toBe(true) + expect(saved.some(e => e.data.subtype === 'tool_call' && JSON.stringify(e.data.tool_input).includes('printf REAL_CLAUDE_TOOL_OUTPUT'))).toBe(true) + expect(saved.some(e => e.data.subtype === 'tool_result' && e.data.output === 'REAL_CLAUDE_TOOL_OUTPUT')).toBe(true) + const info = (await page.evaluate(() => window.redlog.project.list())).find(p => p.id === project.id)! + const raw = readdirSync(join(info.path, 'agent-transcripts')).filter(p => p.endsWith('.jsonl')).map(p => readFileSync(join(info.path, 'agent-transcripts', p), 'utf8')).join('') + expect(raw).toContain('REAL_CLAUDE_PROMPT'); expect(raw).toContain('REAL_CLAUDE_TOOL_OUTPUT') + } finally { server.closeAllConnections(); server.close(); await app.close() } +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 7b17461b..2f14b259 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -65,6 +65,8 @@ export async function launchWithTempHome(executablePath?: string): Promise<{ NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, + CLAUDE_CONFIG_DIR: join(tmpHome, '.claude'), + CODEX_HOME: join(tmpHome, '.codex'), // Enables dev-only IPC endpoints used by E2E specs. The main process // refuses those endpoints when this flag is unset. REDLOG_E2E: '1' diff --git a/e2e/official-agent-capture.spec.ts b/e2e/official-agent-capture.spec.ts index 3d9b825b..39016310 100644 --- a/e2e/official-agent-capture.spec.ts +++ b/e2e/official-agent-capture.spec.ts @@ -8,7 +8,7 @@ type AgentKind = 'claude-code' | 'codex' function transcriptFixture(kind: AgentKind, home: string) { const root = kind === 'claude-code' ? join(home, '.claude', 'projects', 'fixture') - : join(home, '.codex', 'sessions', '2026', '10', '24') + : join(home, 'custom-codex-團隊 [redlog]') const line = (value: unknown): string => JSON.stringify(value) + '\n' const message = (role: 'user' | 'assistant', text: string): string => kind === 'claude-code' ? line({ type: role, uuid: randomUUID(), message: { role, content: [{ type: 'text', text }] } }) @@ -17,7 +17,7 @@ function transcriptFixture(kind: AgentKind, home: string) { root, filename: kind === 'claude-code' ? 'client.jsonl' : 'rollout-client.jsonl', privateFilename: kind === 'claude-code' ? 'private.jsonl' : 'rollout-private.jsonl', - pathPart: kind === 'claude-code' ? '/.claude/projects/' : '/.codex/sessions/', + pathPart: kind === 'claude-code' ? '/.claude/projects/' : '/custom-codex-團隊 [redlog]/', header: (cwd: string): string => kind === 'claude-code' ? line({ type: 'user', uuid: randomUUID(), cwd, message: { role: 'user', content: [] } }) : line({ type: 'session_meta', payload: { cwd } }), @@ -68,7 +68,7 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori const original = fs[method] observedFs[method] = function(this: unknown, ...args: unknown[]) { const file = String(args[0]).replaceAll('\\', '/') - if (['/.codex/sessions/', '/.claude/projects/', '/opencode/storage/'].some(part => file.includes(part))) state.aiSourceReads.push(file) + if (['/.codex/sessions/', '/.claude/projects/', '/custom-codex-團隊 [redlog]/', '/opencode/storage/'].some(part => file.includes(part))) state.aiSourceReads.push(file) return Reflect.apply(original, this, args) } } @@ -79,8 +79,15 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori const active = await window.redlog.project.active() return (await window.redlog.project.list()).find(p => p.id === active?.id)! }) + const installedModule = join(tmpHome, '.redlog', 'plugins', `official-ai-${kind}`, 'adapter.cjs') + const parserLoaded = () => app.evaluate((_electron, file) => { + const fs = process.getBuiltinModule('fs')! + if (!fs.existsSync(file)) return false + return !!process.getBuiltinModule('module')!.createRequire(process.execPath).cache[fs.realpathSync(file)] + }, installedModule) const rows = () => page.evaluate(() => window.redlog.events.query({ agentType: 'agent', limit: 1000 })) expect(await rows()).toEqual([]) + expect(await parserLoaded()).toBe(false) expect(await reads()).toEqual([]) await openView(page, 'settings'); await openSettingsPage(page, 'captureControl') await expect(page.getByTestId(`agent-install-${kind}`)).toBeVisible() @@ -92,11 +99,22 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [dir] })) as typeof dialog.showOpenDialog }, work) await page.getByTestId(`agent-add-path-${kind}`).click() + if (kind === 'codex') { + await page.getByTestId(`agent-details-${kind}`).locator('summary').click() + await app.evaluate(({ dialog }, root) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [root] }) }, fixture.root) + await page.getByTestId(`agent-source-path-${kind}`).click() + await expect(page.getByTestId(`agent-root-${kind}`)).toHaveText(fixture.root) + await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get()))?.agentTailer?.sources?.codex?.sourceRoot).toBe(fixture.root) + expect((await page.evaluate(() => window.redlog.agents.sources())).find(s => s.id === kind)).toMatchObject({ root: fixture.root, detected: true }) + } + expect(existsSync(installedModule)).toBe(true) + expect(await parserLoaded()).toBe(false) await expect(page.getByTestId(`agent-enabled-${kind}`)).toBeEnabled() await page.getByTestId(`agent-enabled-${kind}`).check() await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get()))?.agentTailer?.sources?.[kind]?.enabled).toBe(true) // Registration is the observed connection boundary for existing files. await expect.poll(async () => (await rows()).some(e => e.data.subtype === 'transcript_capture_started'), { timeout: 10_000 }).toBe(true) + expect(await parserLoaded()).toBe(true) const output = 'AI_OUTPUT_SENTINEL\n' + 'ordinary tool output\n'.repeat(50) + 'AI_OUTPUT_TAIL_SENTINEL' appendFileSync(source, user('NEW_AUTHORIZED_SENTINEL') + fixture.reply('AI_REPLY_SENTINEL') + fixture.tools(output)) appendFileSync(privateSource, user('NEW_PRIVATE_SENTINEL')) @@ -154,6 +172,7 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori expect(bytes.toString()).not.toContain('OLD_HISTORY_SENTINEL') await page.getByTestId(`agent-enabled-${kind}`).uncheck() await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get()))?.agentTailer?.sources?.[kind]?.enabled).toBe(false) + expect(await parserLoaded()).toBe(false) await app.evaluate(() => { (globalThis as unknown as { aiSourceReads: string[] }).aiSourceReads = [] }) const count = (await rows()).length appendFileSync(source, user('WHILE_DISABLED_SENTINEL')) @@ -164,6 +183,7 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori expect(readFileSync(file).equals(bytes)).toBe(true) // A local removal is machine-wide, so it asks first and names what stops // and what stays. + await page.getByTestId(`agent-details-${kind}`).locator('summary').click() await page.getByTestId(`agent-remove-${kind}`).click() const removal = page.getByRole('dialog', { name: 'Remove capture integration' }) await expect(removal).toBeVisible() @@ -186,10 +206,74 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori expect(JSON.stringify(await rows())).not.toMatch(/WHILE_DISABLED_SENTINEL|AFTER_REINSTALL_SENTINEL/) expect(readFileSync(file).equals(bytes)).toBe(true) expect((await page.evaluate(() => window.redlog.plugins.officialAgents())).map(p => p.state)).toEqual(kind === 'claude-code' ? ['installed', 'not-installed', 'not-installed'] : ['not-installed', 'installed', 'not-installed']) + // Uninstall directly while active, rather than relying on a prior Stop. + await page.getByTestId(`agent-enabled-${kind}`).check() + await expect.poll(parserLoaded).toBe(true) + const starts = captured.filter(e => e.data.subtype === 'transcript_capture_started').length + await expect.poll(async () => (await rows()).filter(e => e.data.subtype === 'transcript_capture_started').length).toBeGreaterThan(starts) + appendFileSync(source, user('REACTIVATED_SENTINEL')) + await expect.poll(async () => JSON.stringify(await rows())).toContain('REACTIVATED_SENTINEL') + await page.getByTestId(`agent-details-${kind}`).locator('summary').click() + await page.getByTestId(`agent-remove-${kind}`).click() + await page.getByRole('dialog', { name: 'Remove capture integration' }).getByRole('button', { name: 'Remove', exact: true }).click() + await expect(page.getByTestId(`agent-install-${kind}`)).toBeVisible() + expect(await parserLoaded()).toBe(false) + expect(existsSync(installedModule)).toBe(false) + appendFileSync(source, user('AFTER_ACTIVE_UNINSTALL_SENTINEL')) + await page.waitForTimeout(500) + expect(JSON.stringify(await rows())).not.toContain('AFTER_ACTIVE_UNINSTALL_SENTINEL') + expect(readFileSync(file).equals(bytes)).toBe(true) await openTestProject(page, 'AI-other-project') + if (kind === 'codex') expect((await page.evaluate(() => window.redlog.agents.sources())).find(s => s.id === 'codex')?.root).toBe(join(tmpHome, '.codex', 'sessions')) appendFileSync(source, user('AFTER_PROJECT_SWITCH_SENTINEL')) await page.waitForTimeout(500) expect(await rows()).toEqual([]) expect(readFileSync(file).equals(bytes)).toBe(true) } finally { await app.close() } }) + +test('Claude source pause and individual exclusions omit raw content without stopping another conversation', async () => { + const { app, page, tmpHome } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) + try { + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')); await page.reload() + const work = join(tmpHome, 'client-work'); mkdirSync(work) + const fixture = transcriptFixture('claude-code', tmpHome); mkdirSync(fixture.root, { recursive: true }) + const first = join(fixture.root, 'first.jsonl'), second = join(fixture.root, 'second.jsonl') + writeFileSync(first, fixture.header(work)); writeFileSync(second, fixture.header(work)) + const project = await openTestProject(page, 'AI-session-privacy') + await openView(page, 'settings'); await openSettingsPage(page, 'captureControl') + await page.getByTestId('agent-install-claude-code').click() + await app.evaluate(({ dialog }, dir) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [dir] }) }, work) + await page.getByTestId('agent-add-path-claude-code').click(); await page.getByTestId('agent-enabled-claude-code').check() + await expect.poll(async () => (await page.evaluate(() => window.redlog.agents.sessions())).filter(s => s.recording).length).toBe(2) + const rows = () => page.evaluate(() => window.redlog.events.query({ agentType: 'agent', limit: 1000 })) + appendFileSync(first, fixture.user('PUBLIC_FIRST')); appendFileSync(second, fixture.user('PUBLIC_SECOND')) + await expect.poll(async () => JSON.stringify(await rows())).toContain('PUBLIC_FIRST') + expect(await page.evaluate(() => window.redlog.recording.toggle())).toBe(false) + appendFileSync(first, fixture.user('PRIVATE_PAUSE_SENTINEL')) + // No notification wait: resume must skip what exists now. + expect(await page.evaluate(() => window.redlog.recording.toggle())).toBe(true) + appendFileSync(second, fixture.user('PUBLIC_AFTER_PAUSE')) + await expect.poll(async () => JSON.stringify(await rows())).toContain('PUBLIC_AFTER_PAUSE') + const source = page.getByTestId('agent-source-claude-code') + await source.locator('summary').filter({ hasText: 'Conversations' }).click() + await source.getByTestId('agent-session-first').click() + await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get())).agentTailer?.sources?.['claude-code']?.excludedSessions).toContain('first') + appendFileSync(first, fixture.user('PRIVATE_EXCLUDED_SENTINEL')); appendFileSync(second, fixture.user('PUBLIC_OTHER_CONTINUES')) + await expect.poll(async () => JSON.stringify(await rows())).toContain('PUBLIC_OTHER_CONTINUES') + await page.reload(); await openView(page, 'settings'); await openSettingsPage(page, 'captureControl') + appendFileSync(first, fixture.user('PRIVATE_AFTER_RELOAD_SENTINEL')) + await source.locator('summary').filter({ hasText: 'Conversations' }).click() + await expect(source.getByTestId('agent-session-first')).toHaveText('Record new content') + await source.getByTestId('agent-session-first').click() + await expect.poll(async () => (await page.evaluate(() => window.redlog.agents.sessions())).find(s => s.sessionId === 'first')?.recording).toBe(true) + appendFileSync(first, fixture.user('PUBLIC_REAUTHORIZED')) + await expect.poll(async () => JSON.stringify(await rows())).toContain('PUBLIC_REAUTHORIZED') + const projectInfo = (await page.evaluate(() => window.redlog.project.list())).find(p => p.id === project.id)! + const sidecarDir = join(projectInfo.path, 'agent-transcripts') + const saved = readdirSync(sidecarDir).filter(p => p.endsWith('.jsonl')).map(p => readFileSync(join(sidecarDir, p), 'utf8')).join('') + expect(saved).not.toMatch(/PRIVATE_PAUSE_SENTINEL|PRIVATE_EXCLUDED_SENTINEL|PRIVATE_AFTER_RELOAD_SENTINEL/) + expect(JSON.stringify(await rows())).not.toMatch(/PRIVATE_PAUSE_SENTINEL|PRIVATE_EXCLUDED_SENTINEL|PRIVATE_AFTER_RELOAD_SENTINEL/) + expect((await page.evaluate(() => window.redlog.config.get())).agentTailer?.sources?.['claude-code']?.enabled).toBe(true) + } finally { await app.close() } +}) diff --git a/e2e/official-agent-installation.spec.ts b/e2e/official-agent-installation.spec.ts index 671d0645..44a99dea 100644 --- a/e2e/official-agent-installation.spec.ts +++ b/e2e/official-agent-installation.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { launchWithTempHome, openTestProject } from './helpers' test('official AI installation is explicit, local and independent of project activation', async () => { - const { app, page, tmpHome } = await launchWithTempHome() + const { app, page, tmpHome } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) try { const catalog = await page.evaluate(() => window.redlog.plugins.officialAgents()) expect(catalog.map(p => p.agentKind)).toEqual(['claude-code', 'codex', 'opencode']) diff --git a/electron-builder.yml b/electron-builder.yml index 4c06c821..aa5b0e5f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -5,12 +5,16 @@ directories: output: dist files: - out/**/* + - "!out/optional-ai/**/*" - resources/**/* # Ship the hook scripts alongside the app so the packaged build can point at # them when it installs a hook. (The stdio MCP bridge that used to ride along # here was removed in 2026-08 — `mcp/` no longer exists and every build was # printing "file source doesn't exist" for it.) extraResources: + # Optional AI packages are inert here; install copies only the selected package. + - from: out/optional-ai + to: optional-ai - from: hooks to: hooks # Bundled plugin packs (loader.ts bundledRoot() reads /plugins). diff --git a/src/main/services/adapters/claude-code.ts b/optional-plugins/claude-code/adapter.ts similarity index 96% rename from src/main/services/adapters/claude-code.ts rename to optional-plugins/claude-code/adapter.ts index 6f32de3b..8b0fe157 100644 --- a/src/main/services/adapters/claude-code.ts +++ b/optional-plugins/claude-code/adapter.ts @@ -8,7 +8,7 @@ import * as fs from 'fs' import * as path from 'path' -import type { TailerAdapter, ParsedTurn } from '../tailer-host' +import type { TailerAdapter, ParsedTurn } from '../../src/main/services/tailer-host' // ─── Claude-Code-specific constants ───────────────────────────────────────── @@ -315,6 +315,7 @@ export const claudeCodeAdapter: TailerAdapter = { parseUnit(rawContent: string): ParsedTurn | ParsedTurn[] | null { try { const obj = JSON.parse(rawContent) as Record + if (KNOWN_IGNORED_TYPES.has(String(obj.type)) && typeof obj.uuid === 'string') return { uuid: obj.uuid, parentUuid: typeof obj.parentUuid === 'string' ? obj.parentUuid : null, type: String(obj.type) } return parseTranscriptLine(obj) } catch { return null } }, @@ -325,6 +326,14 @@ export const claudeCodeAdapter: TailerAdapter = { /** Tests and cross-platform setups point the adapter at another root. */ export function overrideClaudeProjectsDir(root: string): void { + claudeCodeAdapter.transcriptRoot = root ;(claudeCodeAdapter as { transcriptGlob: string }).transcriptGlob = path.join(root, '**', '*.jsonl') } + +/** The optional plugin contract. Loaded only after installation and project authorization. */ +export function createAdapter(root: string): TailerAdapter { + overrideClaudeProjectsDir(root) + claudeCodeAdapter.transcriptRoot = root + return claudeCodeAdapter +} diff --git a/optional-plugins/claude-code/plugin.json b/optional-plugins/claude-code/plugin.json new file mode 100644 index 00000000..98ce8e0f --- /dev/null +++ b/optional-plugins/claude-code/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "official-ai-claude-code", + "name": "Claude Code", + "version": "1.0.0", + "redlogApi": 1, + "kind": "pack", + "author": "RedLog", + "contributes": {}, + "redlogAgent": { + "agentKind": "claude-code", + "order": 0, + "entry": "adapter.cjs", + "source": { + "defaultPath": ".claude/projects", + "configEnv": "CLAUDE_CONFIG_DIR", + "envSuffix": "projects" + } + } +} diff --git a/src/main/services/adapters/codex.ts b/optional-plugins/codex/adapter.ts similarity index 96% rename from src/main/services/adapters/codex.ts rename to optional-plugins/codex/adapter.ts index 621fe499..23c3a8e7 100644 --- a/src/main/services/adapters/codex.ts +++ b/optional-plugins/codex/adapter.ts @@ -23,7 +23,7 @@ import * as fs from 'fs' import * as path from 'path' import * as crypto from 'crypto' -import type { TailerAdapter, ParsedTurn } from '../tailer-host' +import type { TailerAdapter, ParsedTurn } from '../../src/main/services/tailer-host' // Payload types we ingest as per-turn events. export const CODEX_INGEST_TYPES = new Set([ @@ -259,6 +259,14 @@ export const codexAdapter: TailerAdapter = { /** Test-only: overridable transcript root. Mutates the adapter's glob * so tests can point at a scratch dir. */ export function overrideCodexTranscriptRoot(root: string): void { + codexAdapter.transcriptRoot = root ;(codexAdapter as { transcriptGlob: string }).transcriptGlob = path.join(root, '**', 'rollout-*.jsonl') } + +/** The optional plugin contract. Loaded only after installation and project authorization. */ +export function createAdapter(root: string): TailerAdapter { + overrideCodexTranscriptRoot(root) + codexAdapter.transcriptRoot = root + return codexAdapter +} diff --git a/optional-plugins/codex/plugin.json b/optional-plugins/codex/plugin.json new file mode 100644 index 00000000..d30c9fe8 --- /dev/null +++ b/optional-plugins/codex/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "official-ai-codex", + "name": "Codex CLI", + "version": "1.0.0", + "redlogApi": 1, + "kind": "pack", + "author": "RedLog", + "contributes": {}, + "redlogAgent": { + "agentKind": "codex", + "order": 1, + "entry": "adapter.cjs", + "source": { + "defaultPath": ".codex/sessions", + "configEnv": "CODEX_HOME", + "envSuffix": "sessions" + } + } +} diff --git a/src/main/services/adapters/opencode.ts b/optional-plugins/opencode/adapter.ts similarity index 95% rename from src/main/services/adapters/opencode.ts rename to optional-plugins/opencode/adapter.ts index 281ae2d5..3f49fb0c 100644 --- a/src/main/services/adapters/opencode.ts +++ b/optional-plugins/opencode/adapter.ts @@ -29,8 +29,9 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import chokidar, { FSWatcher } from 'chokidar' -import type { TailerAdapter, ParsedTurn, HostControlSurface } from '../tailer-host' +import type { FSWatcher } from 'chokidar' +let watch: typeof import('chokidar').watch +import type { TailerAdapter, ParsedTurn, HostControlSurface } from '../../src/main/services/tailer-host' // Part types we care about. `step-start` / `step-finish` are boundary // markers OpenCode uses internally — they carry snapshot ids and token @@ -313,7 +314,7 @@ function startSecondaryWatcher(host: HostControlSurface): void { // operator installs OpenCode later, a restart of the app picks up. return } - const watcher = chokidar.watch(partsRoot, { + const watcher = watch(partsRoot, { persistent: true, ignoreInitial: true, depth: 3, @@ -322,7 +323,9 @@ function startSecondaryWatcher(host: HostControlSurface): void { const emitFromPartFile = (p: string): void => { if (!p.endsWith('.json')) return const messageId = path.basename(path.dirname(p)) - if (!host.acceptsMessage('opencode', messageId)) return + let changedAt: number + try { changedAt = fs.statSync(p).mtimeMs } catch { return } + if (!host.acceptsMessage('opencode', messageId, changedAt)) return let raw: string try { raw = fs.readFileSync(p, 'utf-8') } catch { return } let part: OpencodePart @@ -355,10 +358,7 @@ export const opencodeAdapter: TailerAdapter = { }, parseUnit(rawContent: string, sourcePath?: string): ParsedTurn | ParsedTurn[] | null { if (!sourcePath) return null - // A stub that does not parse is usually one OpenCode has not finished - // writing. Thrown rather than returned empty: empty tells the host the - // file is handled, and it would never be read again. - try { JSON.parse(rawContent) } catch { throw new Error('OpenCode message file is not complete JSON yet') } + JSON.parse(rawContent) // A partial write is retryable, not a completed empty message. const arr = parseOpencodeMessage(rawContent, sourcePath) return arr.length ? arr : null }, @@ -383,3 +383,11 @@ export function _stopOpencodeSecondaryWatcherForTest(): void { stopSecondaryWatcher() secondaryHost = null } + +/** The optional plugin contract. Loaded only after installation and project authorization. */ +export function createAdapter(root: string, services: { watch: typeof import('chokidar').watch }): TailerAdapter { + watch = services.watch + overrideOpencodeStorageRoot(path.dirname(root)) + opencodeAdapter.transcriptRoot = root + return opencodeAdapter +} diff --git a/optional-plugins/opencode/plugin.json b/optional-plugins/opencode/plugin.json new file mode 100644 index 00000000..3b85410a --- /dev/null +++ b/optional-plugins/opencode/plugin.json @@ -0,0 +1,17 @@ +{ + "id": "official-ai-opencode", + "name": "OpenCode", + "version": "1.0.0", + "redlogApi": 1, + "kind": "pack", + "author": "RedLog", + "contributes": {}, + "redlogAgent": { + "agentKind": "opencode", + "order": 2, + "entry": "adapter.cjs", + "source": { + "defaultPath": ".local/share/opencode/storage/message" + } + } +} diff --git a/package-lock.json b/package-lock.json index 18a1605b..4c47c184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3108,6 +3108,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/package.json b/package.json index c0432d59..eb520cd1 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,14 @@ "redlog-cli": "./cli/redlog-cli.js" }, "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", + "dev": "npm run build:ai-plugins && electron-vite dev", + "build": "npm run build:ai-plugins && electron-vite build", "verify:package-resources": "node scripts/verify-packaged-resources.mjs", "verify:specs": "node scripts/verify-specs.mjs", "typecheck": "tsc -p tsconfig.check.json", "preview": "electron-vite preview", "rebuild": "electron-rebuild -f", - "pretest": "npm rebuild better-sqlite3", + "pretest": "npm rebuild better-sqlite3 && npm run build:ai-plugins", "test": "vitest run", "test:watch": "vitest", "pree2e": "electron-rebuild -f -o better-sqlite3", @@ -31,7 +31,8 @@ "e2e:ui": "playwright test --ui", "gen:settings-search": "node scripts/gen-settings-search.mjs", "verify:architecture": "node scripts/verify-architecture.mjs", - "verify:i18n": "node scripts/verify-i18n-assertions.mjs" + "verify:i18n": "node scripts/verify-i18n-assertions.mjs", + "build:ai-plugins": "node scripts/build-ai-plugins.mjs" }, "dependencies": { "@dagrejs/dagre": "^3.1.1", diff --git a/scripts/build-ai-plugins.mjs b/scripts/build-ai-plugins.mjs new file mode 100644 index 00000000..a1e0d16e --- /dev/null +++ b/scripts/build-ai-plugins.mjs @@ -0,0 +1,28 @@ +// Build each optional package independently. Nothing imports these entrypoints +// into Electron's main bundle, and discovery reads only their manifests. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { builtinModules } from 'node:module' +import { build } from 'vite' +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const source = path.join(root, 'optional-plugins') +const output = path.join(root, 'out', 'optional-ai') +fs.mkdirSync(output, { recursive: true }) +const names = fs.readdirSync(source).filter(name => fs.existsSync(path.join(source, name, 'plugin.json'))) +for (const stale of fs.readdirSync(output)) if (!names.includes(stale)) fs.rmSync(path.join(output, stale), { recursive: true, force: true }) +for (const name of names) { + const dir = path.join(output, name) + await build({ + configFile: false, logLevel: 'error', + build: { ssr: true, target: 'node22', outDir: dir, emptyOutDir: true, minify: false, + rollupOptions: { input: path.join(source, name, 'adapter.ts'), external: [...builtinModules, ...builtinModules.map(name => `node:${name}`)], + output: { format: 'cjs', entryFileNames: 'adapter.cjs', inlineDynamicImports: true } } }, + ssr: { noExternal: true } + }) + const manifest = JSON.parse(fs.readFileSync(path.join(source, name, 'plugin.json'), 'utf8')) + manifest.redlogAgent.sha256 = createHash('sha256').update(fs.readFileSync(path.join(dir, 'adapter.cjs'))).digest('hex') + fs.writeFileSync(path.join(dir, 'plugin.json'), JSON.stringify(manifest, null, 2) + '\n') +} +console.log(`Built ${names.length} optional AI packages`) diff --git a/scripts/verify-architecture.mjs b/scripts/verify-architecture.mjs index 0a3cab96..c07e4168 100644 --- a/scripts/verify-architecture.mjs +++ b/scripts/verify-architecture.mjs @@ -40,7 +40,7 @@ function walk(dir, out = []) { return out } -const srcFiles = walk(path.join(root, 'src')) +const srcFiles = [...walk(path.join(root, 'src')), ...walk(path.join(root, 'optional-plugins'))] const testFiles = [...walk(path.join(root, 'test')), ...walk(path.join(root, 'e2e'))] const parsed = new Map() diff --git a/scripts/verify-packaged-resources.mjs b/scripts/verify-packaged-resources.mjs index 592d2548..1bb4d3f0 100644 --- a/scripts/verify-packaged-resources.mjs +++ b/scripts/verify-packaged-resources.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import fs from 'node:fs' +import { createHash } from 'node:crypto' import path from 'node:path' import { spawnSync } from 'node:child_process' @@ -10,7 +11,7 @@ const roots = [ 'dist/linux-unpacked/resources', 'dist/linux-arm64-unpacked/resources' ] -const root = roots.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) +const root = process.argv[2] ? path.resolve(process.argv[2]) : roots.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) if (!root) throw new Error(`No unpacked package resources found under dist/ (${roots.join(', ')})`) const required = [ @@ -32,8 +33,22 @@ const required = [ 'plugins/starter-pack/plugin.json', // Spec 035: a capture pack whose manifest is missing does not run. 'plugins/pack-host-monitors/plugin.json', - 'plugins/pack-windows-output/plugin.json' + 'plugins/pack-windows-output/plugin.json', + ] +// Every optional source package must be present but outside automatic discovery. +for (const provider of fs.readdirSync('optional-plugins', { withFileTypes: true }).filter(e => e.isDirectory())) { + const prefix = `optional-ai/${provider.name}` + required.push(`${prefix}/plugin.json`, `${prefix}/adapter.cjs`) + const manifestPath = path.join(root, prefix, 'plugin.json') + const codePath = path.join(root, prefix, 'adapter.cjs') + if (fs.existsSync(manifestPath) && fs.existsSync(codePath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + const hash = createHash('sha256').update(fs.readFileSync(codePath)).digest('hex') + if (hash !== manifest.redlogAgent?.sha256) throw new Error(`Optional AI checksum mismatch: ${provider.name}`) + if (fs.existsSync(path.join(root, 'plugins', manifest.id))) throw new Error(`Optional AI package is auto-discoverable: ${manifest.id}`) + } +} const forbidden = [ 'hooks/shell-preexec-hook.sh', 'hooks/redlog-hook.zsh', diff --git a/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md b/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md new file mode 100644 index 00000000..ea997d16 --- /dev/null +++ b/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md @@ -0,0 +1,82 @@ +# AI capture durability, privacy and package lifecycle — 2026-10-24 + +Status: implementation in progress. Follows the user-approved capture-trust direction in PR #439 (read-only reference, not merged here). + +## Observable requirements (Specify / Clarify) + +- A failed event/raw-copy/checkpoint write cannot mark an AI unit consumed. Retrying or reopening preserves committed events without duplication and can recover the failed unit. +- Pause saves no new AI original content, including sidecars. Resume skips everything present at the resume boundary, including writes whose watcher notification was delayed. A line crossing the boundary is discarded. Store only gap metadata; no paused text. +- Per-message files follow the same write-success rule. Supplemental provider streams must respect pause and individual-session exclusion. +- Source authorization remains installation + explicit project permission + allowed cwd. Exclusion is provider/project/session-specific. Missing cwd remains refused; no semantic classifier, no claim that authorized cwd makes every tool read private-safe. +- Existing source rows show observed sessions and allow stopping one session. Explicit reauthorization starts from now; old excluded content cannot be replayed. No new page. +- An app-supplied package update is recoverable in-app; modified/foreign files remain refused and preserved. A fresh install/update does not itself authorize project capture. + +## Plan / invariants + +The host owns durable progress, transition boundaries and session gates. Provider parsers cannot advance host progress. Separate the source offset from saved-sidecar length, since intentionally omitted ranges cannot be derived from evidence bytes. Checkpoints carry offsets/dispositions, never raw conversation text. Commit complete units after successful processing and raw append; rollback partial raw appends on failure. Preserve independent DB UUID deduplication for event-success/raw-copy-failure retries. + +Project source config owns excluded session IDs and per-session activation boundaries, passed to the canonical host. Renderer uses the same source lifecycle and config save path. Unknown provider files, directories and extra content are not overwritten/deleted by package repair. + +## Checklist / Analyze + +Constitution: evidence integrity, explicit failure, truthful health, privacy omissions with provenance, canonical policy and restrained architecture apply. Tests must distinguish source offset from retained bytes, DB failure from append failure, pause from filtering, and installation from authorization. No PowerShell redesign, provider model invocation, hidden-reasoning capture promise, new navigation or external communication. + +Dependency order: failure regressions → host commits/pause → session gates/UI → managed update lifecycle → integration/native validation → verification/domain updates. Cross-platform and live vendor-version acceptance remain separate. T068–T071 in tasks.md track this bounded increment. + + +## Findings and implementation + +- Fault injection reproduced silent JSONL progress after a failed event write, + per-message index-before-event, paused raw retention/replay, partial-unit + acknowledgement and new-session discovery after resume. DB-success/raw-failure + retry also exposed the old seed query using RedLog envelope session_id; it + now uses the AI session ID in data while retaining one project-wide scan. +- Raw files remain append-only across source shrink/reset. Independent atomic + checkpoints carry committed source offset and saved byte count; retries trim + only uncommitted append residue. Complete newline-delimited UTF-8 units are + decoded; incomplete lines stay at source and parse/write failures do not + advance progress. Changed per-message files are reconsidered. Provider parsers + cannot acknowledge host progress. +- Project recording-boundary metadata handles sessions discovered after resume + and a restart from persisted pause. Existing bytes and cross-boundary units + are omitted. Same-millisecond timestamp ambiguity is handled conservatively + on the omitted side. Per-message omission indexes hold filenames/dispositions, + never paused message bodies. Auxiliary file reads respect pause/session gates. +- Gap metadata describes omissions. Missing parent after a privacy boundary + does not block subsequent turns or invent a cause. Pending children survive + ordinary restart through a retained-raw replay offset. No conversation text + is copied into checkpoints. +- Real Claude Code 2.1.310 serializer tests (local fake API only) initially + failed: source directories not yet created were not watched, and ignored + attachment metadata UUIDs held later assistant/tool events in the pending + parent queue. The watcher now follows only the authorized subtree through + existing ancestors and canonical paths. Claude returns ignored parent + metadata to the host; child relations traverse it to a real recorded ancestor + and retain transcript_parent_uuid. Both new/existing directory journeys pass. +- Existing AI source rows expose an expandable Conversations list. Exclusions + persist per provider/project; explicit Record new content establishes a new + session boundary, bounded below by the source activation. The source remains + enabled so another conversation continues recording. A short authorization + warning states that tool content from outside the chosen cwd is included. +- Machine installation records distinguish intact managed older packages from + modified files. Update only copies current app-supplied code; stale packages + cannot execute. A failed installation-record commit rolls the old package + back. Receipt/project grants remain unchanged on update; reinstall gets a + new receipt. This adds no compatibility shim for old development receipts. + +## Acceptance checklist + +- [x] Event write failure leaves the unit retryable across restart. +- [x] Raw append and checkpoint failures retain committed bytes and dedup events. +- [x] Per-message DB/index/partial-file cases retry instead of disappearing. +- [x] Pause omits raw content, including partial lines and delayed new sessions. +- [x] Persisted pause skips original bytes on reopening; gap contains no prompt. +- [x] Ignored metadata and ordinary pending parents preserve real provenance. +- [x] Per-session stop survives reload, another session continues, reallow starts now. +- [x] Managed update, tamper refusal, rollback and receipt preservation have tests. +- [ ] Final full/package/native evidence recorded below at closeout. + +These are bounded contracts. A cwd whitelist cannot detect private prompts or +private tool-read content inside an authorized session; no semantic classifier +was added. Images/hidden reasoning/all vendor versions/Windows/Linux/IDE client +variants are not certified by a single local CLI serializer test. diff --git a/specs/062-recording-workflow-consolidation/ai-installation-plan.md b/specs/062-recording-workflow-consolidation/ai-installation-plan.md index 969e616e..0b411c9f 100644 --- a/specs/062-recording-workflow-consolidation/ai-installation-plan.md +++ b/specs/062-recording-workflow-consolidation/ai-installation-plan.md @@ -50,3 +50,7 @@ T019 後續:Mac 解鎖後的實際點選、原生資料夾選擇器、鍵盤停用、重裝不自動啟用與保存 bytes 核對均通過,見 [Mac AI 驗收](ai-mac-acceptance-20261022.md)。這是 packaged candidate 驗收,未將其他平台或 DMG 安裝算成通過。 + +## 2026-10-24 clarification — actual optional code packages + +The earlier T014–T019 receipt-only installer is superseded by T065. The user selected offline packages supplied with the app. Both metadata and parser are now in optional-plugins//, independently built and copied only when installed. The host has no direct provider imports or fixed provider list; the package manager validates installed bytes before returning a module path. Activation loads that module; stop/removal unloads it. Installation/removal owns the manifest and parser, never original conversation files or project evidence. See source-placement-and-ui-review-20261024.md for current verification. Historical receipt-only acceptance above is not evidence for the new package loader. diff --git a/specs/062-recording-workflow-consolidation/plan.md b/specs/062-recording-workflow-consolidation/plan.md index 747482bf..3e81ecd1 100644 --- a/specs/062-recording-workflow-consolidation/plan.md +++ b/specs/062-recording-workflow-consolidation/plan.md @@ -246,3 +246,17 @@ only its first line (`{`). Flatten formatting line breaks for the existing 200-character summary; retain the full input/output on expansion. The shared Claude/Codex journey must read the command before expanding, and fail first against the previous UI. No new component, state or capture mode is needed. + +## Source placement implementation (T065–T067) + +Use the existing AgentsPanel and other-sources route. Official integrations are excluded from the generic plugin UI and mutation endpoint; source installation can explicitly recover a previously disabled integration, without changing project authorization. Manifest metadata identifies official packages; there is no static provider list in the renderer or host. Keep the existing machine-wide flag respected until explicit recovery. + +Extend AgentCaptureSourceConfig with optional sourceRoot; agentTranscriptRoot owns project/environment/default precedence and rejects invalid explicit roots at the activation gate. Configure every adapter root on project application to avoid carrying a previous project's override. Source discovery accepts the current config and uses the same resolver as runtime and health. Parser tests import optional package sources directly; runtime accepts validated installed module paths only. UI source-root changes reuse the work-directory authorization reset. No extra dependencies or filesystem migration. + +Clarify: user already selected optional local integrations, Claude then Codex, default-off recording, layout over prose. No unresolved question blocks these tasks. Checklist/analysis: installation and project permission remain separate; source path is not an authorized cwd; changing either resets activation; no historical import; original sources survive removal; no hidden global recovery switch. Existing source/IPC/real-file tests are extended before behavior changes. + +### User clarification: real optional packages + +On 2026-10-24 the user chose app-supplied offline packages, not registration-only installation. Provider code and source-location metadata live under optional-plugins//; existing Vite tooling builds each independently into out/optional-ai. Packaging places that directory outside the auto-discovered plugins root. The manager discovers manifests, validates API/identity/path/checksum, copies only the chosen manifest/parser under the user plugin directory and returns a module path only for unchanged managed files. Host initialization loads only installed, active, explicitly authorized sources; stop/uninstall/project switch tears down watchers and drops module instances. Shared filesystem watching is supplied by the host so optional packages need no native dependency resolution outside the app. Generic third-party executable plugins remain refused. No marketplace, network download or arbitrary code grant is introduced. + +Acceptance additions: no parser in the main bundle; no installed parser before user installation; no module execution on listing/install; require-cache entry appears only after project activation and disappears after stop; changed code cannot be loaded; a fourth fixture package is discoverable without editing core. Existing source evidence survives uninstall. diff --git a/specs/062-recording-workflow-consolidation/source-placement-and-ui-review-20261024.md b/specs/062-recording-workflow-consolidation/source-placement-and-ui-review-20261024.md new file mode 100644 index 00000000..46c21dac --- /dev/null +++ b/specs/062-recording-workflow-consolidation/source-placement-and-ui-review-20261024.md @@ -0,0 +1,101 @@ +# Optional AI packages and UI review — 2026-10-24 + +Status: bounded implementation and Mac review completed; Spec 062 remains Draft. This record distinguishes source/unit/Electron/native evidence; it does not certify every platform or every error state. + +## Accepted design + +- Official AI capture is optional. The app includes inert offline packages; installing copies only the chosen package. A valid installation alone never grants project permission. +- Provider name, source-directory defaults/environment mapping and parser live in the package. Core owns authorization, from-now boundaries, sidecars, ingestion, health and evidence preservation. +- One source entry owns install/remove/recovery, authorized working directories and project recording. Generic Plugins does not expose a second official AI switch. +- Source path, authorized cwd and copied project evidence remain separate. Originals are read-only; uninstall never deletes original transcripts or captured evidence. +- Claude Code is first, Codex second. Existing OpenCode support is retained without extending its format coverage. +- Ordinary workflow uses names, controls and state. Paths/removal are expanded on demand; required scope-change and removal consequences remain explicit. + +## File ownership + +| Location | Owner and lifecycle | +| --- | --- | +| `optional-plugins//` | Maintained package source: manifest and parser. Not a runtime installation. | +| `out/optional-ai//` | Independent build output; copied to installed app `resources/optional-ai/`. Never auto-discovered as an active plugin. | +| `~/.redlog/plugins//` | Explicit installation: managed manifest/receipt and checksum-matching `adapter.cjs`. Loaded only for an authorized source. | +| Vendor transcript directory | Vendor-owned original content. Explicit project override → absolute vendor environment root → package default. | +| Project `config.yaml` | Allowed working directories, optional source directory, installation receipt and activation boundary. | +| Project `agent-transcripts/` | Captured evidence; governed by retention/export, not by plugin uninstall. | +| Installed app resources | Immutable application assets, hooks and inert optional packages. | +| `~/.redlog/` runtime files | Local hook connection/identity/spool/runtime state. No filesystem migration in this change. | +| Electron `userData` | App preferences/update state/cache, separate from engagement evidence. | +| `~/.mitmproxy/` | Machine CA/tool state, separate from project evidence. Private keys are not project exports. | + +Source-position references: [Claude directory](https://code.claude.com/docs/en/claude-directory), [Codex configuration](https://developers.openai.com/codex/config-basic/). GUI launches may not inherit environment variables exported only in a shell; source settings therefore retain a native folder picker. + +## Build and loading contract + +Existing Vite tooling builds each provider independently, with no new runtime dependency. The package manager validates identity/API, relative default paths, expected managed files and parser bytes against the app-supplied package. It does not execute packages during discovery/install. The host supplies shared filesystem watching; a package cannot acquire recording permission by declaring a cwd or source path. Generic third-party in-process executable plugins remain refused. + +Verification must cover uninstalled/installed-but-off/active/stopped/removed states, modified code, missing packages, path reset between projects, original-byte preservation, and new package discovery without a core provider list. App updates and separate plugin distribution are not implemented as a new marketplace/update service. + +## Latest user updates preserved + +Final inspection HEAD: `52b80c80`. The user’s Transcript incremental merge/anchor changes, moved navigation/export journeys, merge of main’s PowerShell durability and HTTP start-failure repair, and fractional-width geometry assertions are preserved. Only the AI recovery scenario in the shared capture-settings test is changed by this work; its updated geometry assertions remain intact. Unrelated `package-lock.json` and `mitm_mcp_traffic.db` are not part of this work. + +## Native page review + +Native review uses `/private/tmp/redlog-ui-placement-20261024/` and actual packaged `RedLog.app`. No real AI transcript or operator project is read. Fresh final project: `UI-review-final-20261024`. CUA keyboard, native folder pickers, menus and clicks were used; this is separate from the automated Electron journeys below. + +Wide: 1400×900 logical (2800×1800 raster). Native Window → Move & Resize produced 875×1095 and 875×548 logical windows. Corner-drag attempts did not reliably resize and are not counted. This is representative desktop coverage, not every page at every size or a mobile-browser claim. Screenshots were inspected in the tool session. + +| Surface | Observed coverage | Result / limit | +| --- | --- | --- | +| Project picker | Wide empty/create | Created the isolated project manually. Empty helper repeats the form’s purpose. | +| Dashboard / initial setup | Wide unverified setup; narrow after evidence | Core source state, setup recovery, unverified HTTPS and work evidence remain reachable. Health actions remain distinct from first-run setup. | +| Command/terminal settings | Wide and 875×548 | Cards wrap without lost actions; foreign platforms stay folded. Duplicate mitm setup entry and unequal shell-description detail remain recommendations. No real profile installed. | +| Browser/HTTP settings | Narrow top and bottom, disclosures | Controls reachable; browser/verification still deserve priority above binding/CDP tuning. No CA trust/removal or network changes executed. | +| Other capture sources / AI | Wide and narrow, install/active/reset/remove | Claude alone installed; real native picker selects authorized cwd and a separate custom transcript root. New prompt/reply/tool I/O received. Reset stops capture pending reconfirmation. Removal preserves captured conversations. | +| Screenshot source controls | Same source page | Scheduling separate from manual capture, selected intervals exposed, image tuning folded. No real desktop capture performed. | +| Plugins | Narrow empty | Official AI has no duplicate machine switch. Third-party sample tester still occupies initial empty-page space. | +| Project/scope settings | Narrow top and bottom | Project/identity/profile fields reachable, independent category/content scrolling. No scope semantics changed. | +| IP/network settings | Narrow top | VPN rows wrap, known security boundary remains. Real network configuration unchanged. | +| Loot settings | Narrow top | Rule/status controls fit. Builtin English IDs/confidence remain unnecessarily technical. | +| Retention | Narrow | Numeric controls bounded; destructive retention consequence retained. No cleanup executed. | +| Integrity | Narrow populated, real verify | Full chain of 24 events verified. Found misleading anchor status copy: backend checks covered prefix, not current head. Corrected locales without changing crypto. | +| Appearance/language | Narrow | Field groups readable; no operator profile changed. | +| HUD settings and window | Wide expanded HUD; narrow settings | Existing scale stops align; default remains enabled. Transient resize screenshot was not a persistent clipping defect. HUD hidden only in disposable profile for main-window inspection. Global shortcut delivery not retested. | +| About/updates | Narrow | State/version/debug location visible. No real update/defer/restart flow executed. | +| Timeline | Narrow populated | Toolbar accessible and timeline canvas intentionally pans horizontally; list below stays readable. Did not exhaust large dataset/focus states. | +| Search | Narrow empty, results, no-match | Native search found four AI records including output tail. No-match input remained usable. Internal `agent`/`system` facet names remain a recommendation. | +| Transcript | Wide and narrow populated/expanded | Three exchanges; prompt/reply and full tool input/output readable. Removing provider does not remove prior conversation evidence. | +| HTTP history | Narrow empty | Heading/filter/status controls fit; source-settings recovery available. Populated body/error states covered by prior records, not counted as this native pass. | +| Built-in terminal | Narrow populated | Entered `printf` manually; expected output visible and present in exported evidence. External terminal/profile installation not exercised. | +| Screenshots | Narrow empty | Direct action available; duplicate action and long empty paragraph remain a recommendation. No private screen image captured. | +| Targets | Narrow empty | Filter controls fit; title and paragraph duplicate how discovery works. Populated host/pagination not exercised natively here. | +| Scope/evidence view | Narrow unconfigured | Correct unconfigured state and settings recovery; evidence count visible. | +| Loot view | Narrow empty | Filters remain available; empty copy could be shorter. No actual credential generated. | +| Bookmarks | Narrow empty | CDP disconnected state explicit. Non-evidence boundary retained; no CDP session started. | +| Export | Narrow dialog top/bottom; actual JSON | Scroll area and fixed actions reachable. Scope-unset warning visible before export. Artifact read-back contains prompt/reply/full tool I/O and native terminal output, excludes preactivation history. | +| Marker | Narrow open/cancel | Focus at title; inputs, severity, immutable-field warning and action fit. Canceled; no screen capture. | + +Native JSON: `native-final-home/.redlog/projects/ui-review-final-20261024-mvm4g2pi/exports/redlog-2026-10-24T08-34-17.json` under the isolated review root. Contains 27 selected events. `NATIVE_PLUGIN_NEW_MESSAGE`, `NATIVE_PLUGIN_INPUT`, `NATIVE_PLUGIN_OUTPUT` and `NATIVE_UI_TERMINAL_OK` present; `OLD_NATIVE_HISTORY` absent. After uninstall, zero `official-ai-*/adapter.cjs` remain in that profile; the captured Claude sidecar remains (695 bytes, SHA256 `d29b56a869fc31875c0e29bc4ab41026cd7449c94ac7d89e5e7a6e1408eb778d`). Original/sidecar byte preservation across removal is asserted by installed journeys. The native app was quit normally. + +## Remaining recommendations, ordered by operator friction + +These are findings, not silently implemented features or reasons to block optional AI capture. + +1. **One web setup entry.** Terminal settings still include mitmproxy even though browser/HTTP has a dedicated page. Keep web lifecycle and verification there; terminal source rows should use the same operator-level install/start/verify language for Bash/Zsh/PowerShell, while implementations stay separate. +2. **Common web actions first.** Show source state, start/browser choice and HTTPS verification before executable/bind/port/CDP tuning. Keep CA trust/removal consequences explicit; do not auto-change trust as a layout fix. +3. **Shorter empty states.** Screenshots, Targets and Loot repeat their heading/state in paragraphs. Use one short next action or state. Bookmarks must still say they are editable and not evidence; compress this boundary rather than deleting it. No new hint boxes. +4. **Human-readable domain labels.** Search facets show `agent`/`system`; builtin loot rules show technical IDs plus untranslated explanations. Localize visible category/confidence names, retain canonical IDs in evidence/details. +5. **Disclosure for plugin sample testing.** It occupies the empty third-party Plugins page even without installed rules. Fold the existing tester rather than creating another tester page or moving its mixed contribution behavior into Loot. +6. **Elapsed-time semantics.** Current tooltip truthfully describes project age. A session timer needs a real session boundary; do not merely rename that number. + +No per-provider AI workspace, plugin store, LLM classifier, data-folder migration or new navigation layer added. Custom project storage placement and separate plugin update distribution remain future decisions. The new package format deliberately does not adopt older receipt-only development installations; conflicts preserve files and expose folder recovery. + +## Verification and limits + +- RED: resolver ignored vendor roots/invalid explicit roots (2 failures); generic Plugins exposed official AI switches (1 failure). New fixture tests also cover an additional package without core catalog changes, modified code refusal and literal custom paths. +- Ordered final implementation gates: typecheck, specs, architecture, i18n, unit all passed. **4,188 passed / 28 existing skips; 418 test files passed / 5 skipped** (117.84s). Log `/private/tmp/redlog-placement-unit-final.log`. Initial sandbox workers stalled; that interrupted environment run is not counted as success. Initial stale source-layout assertions were corrected and the normal full run then passed. +- Source journeys: 10 distinct cases passed across the affected AI/installation/capture-settings/Transcript files. One initial recovery test expected the deleted generic official switch; its updated source-owned recovery scenario passed on rerun. Do not describe this as an uninterrupted 10/10 run. +- Actual packaged app: **3/3 AI journeys passed** (25.3s), including Claude/Codex full tool I/O/search/JSON, no parser load before authorization, unload at stop/removal, receipt/reinstall boundaries, project-root reset and preserved originals/sidecars. Log `/private/tmp/redlog-placement-installed.log`. +- Main bundle excludes provider parser symbols. Optional package resources, checksum validation and strict/deep ad-hoc signature verified on the packaged candidate. The app is a local Mac arm64 candidate, not a notarized/released installer. +- Native review discovered only an anchor status wording defect in this bounded implementation. After its locale fix, typecheck/spec/architecture/i18n and **23 focused integrity/copy/settings/i18n tests passed**. Final closeout package passed resource/signature verification and 3/3 installed AI journeys (26.6s); latest user geometry and related capture settings passed 5/5 (15.1s). Native final package verified 32 events with the correct older-anchor wording. Logs and exact candidate path are in verification.md. +- No live vendor model, paid/free external model, real account or private conversation used. Synthetic format coverage does not certify every current vendor JSONL variant, desktop/web client, images/attachments, hidden reasoning, source rotation or provider update. +- Windows/Linux installers, real external shell profiles, global HUD delivery and update/defer/restart remain the separate T008–T013 acceptance work. Spec 062 is not Verified by these results. diff --git a/specs/062-recording-workflow-consolidation/spec.md b/specs/062-recording-workflow-consolidation/spec.md index 8357e2d2..e4cea587 100644 --- a/specs/062-recording-workflow-consolidation/spec.md +++ b/specs/062-recording-workflow-consolidation/spec.md @@ -159,3 +159,13 @@ - 沿用 Constitution;相關既有規格為 050、051、052、056、057、059、060、061。AI 真正可選安裝比 050 個別開關更進一步,實作前須同步其契約,不能以本草案宣稱已完成。 - 領域契約:`docs/domain/SPEC-capture-source-lifecycle.md`、`SPEC-export-event-selection.md`、`SPEC-scope-evaluation.md`、`SPEC-search-query-semantics.md`、`SPEC-target-identity.md`。從專案根目錄讀取;本機舊 checkout 缺少的文件以稽核基準 commit 為準。 - 來源、程式核對、待合分支與限制見 [audit.md](audit.md);完整人工驗收見 [manual-acceptance.md](manual-acceptance.md)。本規格不代表功能已通過驗收。 + +## Source placement follow-up (2026-10-24) + +Official AI integrations have one actionable source entry: install/remove, choose allowed working directories, record new content, and recover a disabled integration without visiting generic plugin management. Third-party plugin controls remain machine-wide. Source installation never opts a project into capture. Source details use expandable controls; headings, buttons and state carry the ordinary workflow without repeated descriptions. + +Claude/Codex source directories resolve in order: an explicit absolute project source directory, the corresponding absolute vendor environment directory (CLAUDE_CONFIG_DIR/projects or CODEX_HOME/sessions), then the vendor default. Working-directory permission remains independent. Changing the source directory stops recording until explicit reauthorization; defaults and source changes cannot import old conversation content. Detection, health and runtime must name the same directory. Original vendor transcripts are never moved or deleted. + +Review all existing work/settings pages at representative desktop and narrow window sizes. This is a desktop Electron app: narrow-window coverage does not claim mobile application support. No marketplace, new AI workspace, project-data relocation or whole-app redesign is added. + +User clarification (2026-10-24): AI integrations are actual optional code packages supplied offline with the app. Uninstalled providers are neither registered nor executed; installation copies only that provider. Provider identity, default/source environment paths and parsing belong to its package, not a fixed core provider switch. Loading requires valid installed bytes and explicit project permission; removal stops/unloads it and preserves all captured evidence. This does not authorize executing arbitrary third-party parser code in-process. diff --git a/specs/062-recording-workflow-consolidation/tasks.md b/specs/062-recording-workflow-consolidation/tasks.md index f1a77870..df6a4c80 100644 --- a/specs/062-recording-workflow-consolidation/tasks.md +++ b/specs/062-recording-workflow-consolidation/tasks.md @@ -160,3 +160,16 @@ T042–T045 complete the seven recorded layout recommendations, not installer or - [x] T063 [US2] Complete the bounded UI/CSS consolidation: classify project/appearance settings without adding pages, align field search, use available width for source/evidence lists, unify ordinary compact actions with accessible wrapping/selection and density, replace the stale loading grid, and verify long content in both locales across narrow/wide/zoomed windows plus native manual checks. - [x] T064 [US5] Prioritize Claude Code terminal/IDE transcript capture, then Codex, using one shared installed/source journey. Verify explicit installation and directory authorization, new user/assistant text and paired complete tool I/O, search and actual JSON delivery, reload deduplication, stop/uninstall/reinstall/project isolation, and byte preservation. Repair the reproduced multiline JSON search-token defect and unreadable collapsed tool-input summary without changing evidence or adding another AI settings flow; record synthetic/native/provider-version limits. + +- [x] T065 [US5] Package provider parsers as inert, separately built optional plugins; copy and validate only the selected package and load/unload it only for an authorized project. Discover provider metadata from package manifests without importing parsers into main. Consolidate official AI installation/recovery and project recording in the source entry, remove its generic plugin switch, preserve explicit opt-in and uninstall boundaries, and reduce repeated source copy using a responsive list with expandable settings. +- [x] T066 [US5] Resolve Claude/Codex transcript roots consistently for capture, detection and health (explicit project directory, absolute vendor environment root, then default). Changing the source directory stops capture pending explicit confirmation; verify real-file capture, switching and from-now semantics. +- [x] T067 [US2] Group optional sources by operator task without new navigation layers; manually inspect every work/settings page, including representative wide/narrow journeys, repair observed layout/accessibility friction, and record unexercised states and remaining design decisions. + +T065–T067 complete the bounded optional-package/source-placement increment. Exact per-page state/size coverage, package/native evidence and remaining recommendations are in source-placement-and-ui-review-20261024.md; T008–T013 remain open. + +- [ ] T068 [US5] Reproduce and repair JSONL/per-message progress on DB/raw/checkpoint failures, preserving event UUID deduplication and complete raw units across retries/restart. +- [ ] T069 [US5] Make global pause omit new AI sidecar content and establish durable resume boundaries, including delayed notifications and partial lines; retain metadata-only capture gaps. +- [ ] T070 [US5] Add project/provider/session exclusion and explicit from-now reauthorization through existing AI source rows; show observed sessions without a new page or semantic classifier. +- [ ] T071 [US5] Repair official managed-package upgrade/removal without executing stale/modified code or adopting foreign files; test and record exact acceptance/platform limits. + +T068–T071 follow ai-durability-and-session-boundaries.md; they do not complete T008–T013. diff --git a/specs/062-recording-workflow-consolidation/verification.md b/specs/062-recording-workflow-consolidation/verification.md index 5bfdd997..ced5d0db 100644 --- a/specs/062-recording-workflow-consolidation/verification.md +++ b/specs/062-recording-workflow-consolidation/verification.md @@ -1191,3 +1191,148 @@ T063's pending manual remainder is superseded by the successful d3387008 Settings inspection above; T064's bounded AI contract/UX acceptance is complete. The entire feature stays Draft: T008–T013/platform/update gates are still open. Current candidate/hashes: `dist/internal-20261024-8a8fc093/`. No push/release. + + +## Optional offline AI packages and source/UI consolidation / T065–T067(2026-10-24) + +User clarification: AI must be genuinely optional plugin code, not a receipt +which unlocks parsers already loaded in core. User selected app-supplied inert +offline packages, copied/loaded only after explicit installation and project +authorization. This supersedes the earlier receipt-only installation design. + +Clarify: selected offline package distribution; source/work/evidence paths kept +separate. Checklist: default absence, install-without-read, authorized/from-now +capture, stop/unload, remove/preserve, roots/project isolation, generic plugin +switch prohibition, real packaged resources. Analysis: plan/domain/spec/tasks +agree on those boundaries. Converge: T065–T067 complete; detailed manual states, +remaining recommendations and platform gaps are recorded in +source-placement-and-ui-review-20261024.md. The entire Spec stays Draft, with +T008–T013 open. No claim of running slash commands that were not invoked. + +Implementation moves parser code/manifests into optional-plugins and builds +independent adapter.cjs packages outside the automatic plugin discovery root. +Core has a generic validated package loader; host retains authorization, +provenance, ingestion and storage. Only app-supplied matching bytes load. +Discovery/install do not execute parsers or read transcripts; changing source +stops capture. The source row alone owns official AI lifecycle and project +recording. Screenshot tuning/external setup steps use existing disclosures. +No marketplace, arbitrary executable-plugin API, path migration or new main +navigation layer introduced. Source-level provider names/roots/order come from +package metadata; a fourth synthetic package works without a core switch. + +RED: two root-policy failures and one generic-Plugins exposure failure were +observed before repairs. Modified package bytes, literal custom directories, +provider unload/cache, cwd isolation and receipt boundaries have meaningful +regressions. Existing tests which required removed entry paths/control text +were updated with the new contract, not skipped. User changes through +52b80c80, including Transcript anchoring and fractional viewport/rounding +assertions, remain intact. + +Ordered implementation gates passed: typecheck → specs → architecture → i18n +→ unit. **4,188 passed / 28 existing skips; 418 files passed / 5 skipped**, +117.84s (`/private/tmp/redlog-placement-unit-final.log`). An initial sandbox +worker stall was interrupted and is not a pass. Architecture sees 329 source +files, including optional packages, with 8 existing exemptions. Ten distinct +affected source journeys passed across initial/rerun logs; one initial case +still expected the old generic toggle and passed after correction. + +Final closeout build includes the observed anchor-status wording repair in +both locales: verification checks the anchor's covered prefix, not necessarily +the current head. Existing integrity UI tests now assert that case with an +older anchor. Typecheck/specs/architecture/i18n and 23 focused UI/copy/search/ +i18n cases pass after the copy change. Crypto/query/export semantics unchanged. + +Final packaged candidate: +`/private/tmp/redlog-ui-placement-20261024/packaged-closeout/mac-arm64/RedLog.app`. +Resource contract/checksum and strict/deep ad-hoc signature passed. **3/3 +installed AI journeys passed (26.6s)**, both Claude/Codex through new full tool +I/O, real-file watchers, search/actual JSON, no historical/private content, +stop/removal/reinstall and source-root/project switching. **5/5 capture-settings +source journeys passed (15.1s)** including latest user geometry at 800px and +1/1.5/2 zoom in both locales, generic plugin width, port validation, failure +states and source-owned AI recovery. Logs: +`/private/tmp/redlog-placement-installed-closeout.log` and +`/private/tmp/redlog-placement-geometry-closeout.log`. + +Native CUA review manually visited every current work/settings route, including +representative 1400×900, 875×1095 and 875×548 logical sizes. The route/state +matrix is in the linked review; it does not mean every page at every size/error +state. Native Claude selected real system-picker paths, explicitly activated, +received only later synthetic prompt/reply/tool I/O, reset source (stopped), and +removed integration while retained conversation remained readable. Native +terminal output and AI evidence survived actual 27-event JSON export read-back; +preactivation history was absent. Final package reopened the same isolated +project and fully verified 32 events; native wording correctly says the old +4-event anchor matches the evidence it covers. Normal quit stopped only the +isolated candidate. Node SQLite restored and in-memory open checked afterward. + +No model API/free-model external review, private transcript, real shell profile, +CA trust modification or production project used. Mac candidate is not +notarized/released, and no new commit/push performed. Windows/Linux installer, +real vendor-format/version and image coverage, OS-global HUD and update/defer/ +restart remain unverified here. Unrelated lockfile/traffic DB left untouched. + +## AI trust follow-up / T068–T071(2026-10-24) + +Read-only remote check confirmed origin/main a4e1b456 and candidate 52b80c80; +PR #437 remains Draft/Open with the reported CI/install smoke green. PR #439 +is an open documentation contract; it was read, not merged or represented as +implemented. PowerShell #438/#440 work is already present and not redone. + +Specify/Clarify/Checklist/Analyze are recorded in +ai-durability-and-session-boundaries.md. The user explicitly authorized the +pause-as-preservation boundary and session exclusions; no classifier/model or +new page required. Canonical lifecycle/domain/operator documents were updated. + +RED cases reproduced JSONL event failure with advanced progress, per-message +index-before-event, raw retention during pause, cross-boundary partial lines, +new conversations discovered only after resume, partial message acknowledgement, +wrong restart seed session ID, metadata-parent blocking and unavailable managed +package updates. Tests use isolated real SQLite/files; write/rename faults are +injected at the actual module boundary and observe committed raw bytes/rows. +The initial fs mock used the wrong module namespace; corrected the test boundary +and explicitly verified that the injection runs. No protection weakened. + +Host now commits independent source offsets and raw byte counts, retries +failed complete units, dedups by AI session/UUID, and replays retained pending +children after restart. App-level recording boundary metadata also covers +sessions not yet discovered during pause. Pause/resume omit raw originals, +including lines/mutable message units crossing the boundary. Only offsets, +IDs/dispositions and metadata-only capture_gap are stored for omissions. Source +errors surface on the provider row and recover after a successful commit. + +Per-session exclusions persist under the existing source/project settings; +reallow uses the later session/source boundary. Source rows expose an expandable +conversation list; another conversation continues when one is stopped. A short +pre-authorization warning explains that tools can read outside the selected cwd. +There is no content classifier and cwd is not a sandbox for tool reads. + +Machine installation records distinguish intact older packages from modified +files. Stale code cannot load. Explicit update installs current app-supplied +bytes, preserves receipt/project authorization, and rolls back if the receipt +commit fails. Unknown files stay intact. This does not restore compatibility +with prior receipt-only development builds or provide a marketplace/updater. + +Real installed Claude Code 2.1.310 was exercised against a loopback fake +Anthropic protocol endpoint with a dummy key, isolated HOME/config/cwd and no +inherited account credentials. No external model response or paid inference +used. Initial failure had two independent causes: missing transcript root was +not watched, and actual replies referenced ignored attachment metadata UUIDs. +Authorized subtree watching now follows existing canonical ancestors; ignored +parent metadata resolves to recorded ancestors with original parent UUID +retained. Both new/existing-directory serializer journeys passed (11.4s) before +final package acceptance. Tool printf actually ran locally and its input/output +were captured. This is a real producer/serializer test, not a vendor-service or +IDE-client certification. The CLI prints a synthetic cost estimate even for +the fake endpoint; that is not a paid API transaction. + +Final ordered gates passed: typecheck → specs → architecture → i18n → unit. +**4,207 passed / 28 existing skips; 419 files passed / 5 skipped** (109.57s). +Log: `/private/tmp/redlog-ai-trust-full-final.log`. An earlier complete run +found one stale lifecycle mock missing the newly used error-report methods; +its fixture was updated and the whole suite passed afterward. Interrupted +intermediate runs were not counted. Fifteen dedicated durability/privacy cases +plus managed-upgrade/rollback and source-control behavior are included. + +Final packaged/native acceptance follows below. T008–T013 and Spec 062's Draft +status are not closed by this bounded follow-up; no commit/push/merge/release. diff --git a/src/core/agent-capture-policy.ts b/src/core/agent-capture-policy.ts index 75ae862f..479ca3af 100644 --- a/src/core/agent-capture-policy.ts +++ b/src/core/agent-capture-policy.ts @@ -1,8 +1,12 @@ import path from 'node:path' import type { OfficialAgentPlugin } from './plugins/official-agents' import type { LoadedPlugin } from './plugins/types' -export type AgentKind = OfficialAgentPlugin['agentKind'] +export type AgentKind = string export interface AgentCaptureSourceConfig { + /** Transcript directory, independent of authorized working directories. */ + sourceRoot?: string + excludedSessions?: string[] + sessionStarts?: Record enabled?: boolean installationId?: string workPaths?: string[] @@ -26,12 +30,22 @@ export function resolveAgentCapture(config: AgentCaptureConfig, catalog: readonl if (entry.state !== 'installed' || !plugins.some(p => p.manifest.id === entry.pluginId && p.status === 'active')) return [] if (!entry.installationId || source?.installationId !== entry.installationId) return [] if (source?.enabled !== true || !Number.isSafeInteger(source.startedAt) || source.startedAt! <= 0) return [] + if (source.sourceRoot !== undefined && (typeof source.sourceRoot !== 'string' || !source.sourceRoot.trim() || !path.isAbsolute(source.sourceRoot))) return [] + if (source.excludedSessions !== undefined && (!Array.isArray(source.excludedSessions) || source.excludedSessions.some(id => typeof id !== 'string' || !id || id.length > 300))) return [] + if (source.sessionStarts !== undefined && (!source.sessionStarts || typeof source.sessionStarts !== 'object' || Array.isArray(source.sessionStarts) || Object.values(source.sessionStarts).some(at => !Number.isSafeInteger(at) || at <= 0))) return [] const paths = source.workPaths if (!Array.isArray(paths) || !paths.length || paths.some(p => typeof p !== 'string' || !p.trim() || !path.isAbsolute(p))) return [] return [{ agentKind: entry.agentKind, workPaths: [...new Set(paths)], startedAt: source.startedAt! }] }) } -export function agentTranscriptRoot(kind: AgentKind, home: string): string { - return kind === 'claude-code' ? path.join(home, '.claude', 'projects') : kind === 'codex' ? path.join(home, '.codex', 'sessions') : path.join(home, '.local', 'share', 'opencode', 'storage', 'message') +export function agentTranscriptRoot(entry: Pick, home: string, source?: AgentCaptureSourceConfig, env: Record = process.env): string { + if (source?.sourceRoot !== undefined) { + if (!source.sourceRoot.trim() || !path.isAbsolute(source.sourceRoot)) throw new Error('AI transcript directory must be absolute') + return source.sourceRoot + } + const location = entry.source + const vendorRoot = location.configEnv ? env[location.configEnv] : undefined + if (vendorRoot && path.isAbsolute(vendorRoot)) return path.join(vendorRoot, location.envSuffix ?? '') + return path.join(home, location.defaultPath) } diff --git a/src/core/bundle-export.ts b/src/core/bundle-export.ts index 6dc6c6ff..1d5cca6f 100644 --- a/src/core/bundle-export.ts +++ b/src/core/bundle-export.ts @@ -90,8 +90,7 @@ export interface ExportBundleOpts { /** v0.7.2 F: include the raw Claude Code (and future OpenCode / Codex) * transcripts from `/agent-transcripts/` in the bundle. * DEFAULT FALSE. The sidecar `.jsonl` files are verbatim copies of the - * agent's own transcript, except for stretches read while recording was - * paused, which are newline filler. They may include operator-pasted secrets + * agent's own transcript, which may include operator-pasted secrets * (API keys the operator typed into a Claude Code chat to test auth, * passwords used in an example, etc.). The `agent.transcript_snapshot` * event's sha256 lets an auditor verify the sidecar hasn't been @@ -521,8 +520,6 @@ export function exportBundle(engagementId: string, opts: ExportBundleOpts): Evid if (fs.existsSync(srcAgent)) { fs.mkdirSync(dstAgent, { recursive: true }) for (const name of fs.readdirSync(srcAgent)) { - // The tailer's pause-tail marker is its own bookkeeping, not evidence. - if (name.endsWith('.pause-tail')) continue const s = path.join(srcAgent, name) const d = path.join(dstAgent, name) if (fs.statSync(s).isFile()) { diff --git a/src/core/capture-health.ts b/src/core/capture-health.ts index b231df9d..a0bc1c9a 100644 --- a/src/core/capture-health.ts +++ b/src/core/capture-health.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { resolveAgentCapture, agentTranscriptRoot } from './agent-capture-policy' +import { resolveAgentCapture, agentTranscriptRoot, type AgentCaptureConfig } from './agent-capture-policy' import { createOfficialAgentPluginManager } from './plugins/official-agents' import os from 'node:os' import path from 'node:path' @@ -7,7 +7,7 @@ import { summarizeUnattributedSpool, clearUnattributedSpool, type UnattributedSpoolSummary, type ClearUnattributedResult } from './spool-replay' -import { isMemberSelected, isPackAvailable, AGENT_MEMBER_KIND, type CapturePackId, type PackMemberId } from './capture-packs' +import { isMemberSelected, isPackAvailable, type CapturePackId, type PackMemberId } from './capture-packs' import { listPlugins } from './plugins' import { getDB } from './db/index' import { detectHooks, invalidateCommandCache } from './hooks-manager' @@ -507,10 +507,6 @@ function computeCaptureHealth(now: number): CaptureHealth { const clipboardLast = lastEventFor(`agent_type = 'clipboard'`) // One per agent: a Claude Code tailer that has gone quiet must not be hidden // by a busy Codex one. `data.agent` is the adapter's agentKind. - const agentLast = Object.fromEntries( - (Object.entries(AGENT_MEMBER_KIND) as Array<[keyof typeof AGENT_MEMBER_KIND, string]>) - .map(([member, kind]) => [member, lastEventFor(`agent_type = 'agent' AND json_extract(data,'$.agent') = '${kind}'`)]) - ) as Record const screenshotLast = lastEventFor(`agent_type = 'screenshot'`) // Any shell hook being installed makes the row installed — `??` could not @@ -611,15 +607,18 @@ function computeCaptureHealth(now: number): CaptureHealth { const agentCatalog = createOfficialAgentPluginManager(path.join(os.homedir(), '.redlog', 'plugins')).list() const activeAgents = resolveAgentCapture(cfgSnapshot, agentCatalog, listPlugins()) - const agentRows = Object.entries(AGENT_MEMBER_KIND).map(([member,kind]) => { - const entry = agentCatalog.find(a => a.agentKind === kind)! + const agentRows = agentCatalog.map(entry => { + const kind = entry.agentKind const enabled = activeAgents.some(a => a.agentKind === kind) - const row = mk(`agent-${kind}`, agentLast[member as keyof typeof agentLast], { installed: entry.state === 'installed', enabled }) - if (entry.state === 'error' || entry.state === 'conflict' || (enabled && !existsSync(agentTranscriptRoot(entry.agentKind, os.homedir())))) { - row.state = 'error'; row.lastError = { at: now, message: entry.error ?? 'AI transcript directory not found' } + const last = lastEventFor(`agent_type = 'agent' AND json_extract(data,'$.agent') = ?`, [kind]) + const row = mk(`agent-${kind}`, last, { installed: entry.state === 'installed' || entry.state === 'update-available', enabled }) + row.label = entry.name + if (entry.state === 'error' || entry.state === 'conflict' || entry.state === 'update-available' || (enabled && !existsSync(agentTranscriptRoot(entry, os.homedir(), (cfgSnapshot as AgentCaptureConfig).agentTailer?.sources?.[kind])))) { + row.state = 'error'; row.lastError = { at: now, message: entry.error ?? (entry.state === 'update-available' ? 'AI capture integration update required' : 'AI transcript directory not found') } } return row }) + const sources: CaptureSource[] = [ // One row for both terminals. `installed` is the operator's own shell hook // — the only half there is anything to install — and `alwaysPresent` keeps diff --git a/src/core/capture-packs.ts b/src/core/capture-packs.ts index e6221a66..81e6b5ce 100644 --- a/src/core/capture-packs.ts +++ b/src/core/capture-packs.ts @@ -30,14 +30,6 @@ export const CAPTURE_PACKS = { export type CapturePackId = keyof typeof CAPTURE_PACKS -/** Labels used for per-tool health history. Activation is decided by - * agent-capture-policy, independently of capture packs. */ -export const AGENT_MEMBER_KIND = { - claudeCode: 'claude-code', - codex: 'codex', - opencode: 'opencode' -} as const - /** Whether a pack's plugin is installed and active. */ export function isPackAvailable(id: CapturePackId, plugins: readonly LoadedPlugin[]): boolean { const pluginId = CAPTURE_PACKS[id].pluginId diff --git a/src/core/plugins/official-agents.ts b/src/core/plugins/official-agents.ts index 153254fa..bdaf5b55 100644 --- a/src/core/plugins/official-agents.ts +++ b/src/core/plugins/official-agents.ts @@ -1,120 +1,204 @@ -import fs from 'fs' -import { randomUUID } from 'node:crypto' -import * as path from 'path' -import { PLUGIN_API_VERSION, type PluginManifest } from './types' +import fs from 'node:fs' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import { PLUGIN_API_VERSION } from './types' +export interface AgentSourceLocation { + defaultPath: string + configEnv?: string + envSuffix?: string +} export interface OfficialAgentPlugin { - agentKind: 'claude-code' | 'codex' | 'opencode' + agentKind: string pluginId: string name: string + source: AgentSourceLocation installationId?: string - state: 'not-installed' | 'installed' | 'conflict' | 'error' + state: 'not-installed' | 'installed' | 'update-available' | 'conflict' | 'error' error?: string } export type OfficialAgentMutation = | { ok: true; changed: boolean; pluginId: string } | { ok: false; error: string; changed?: boolean; pluginId?: string } +interface Package { + manifest: Record + agentKind: string + pluginId: string + name: string + source: AgentSourceLocation + order: number + code: Buffer +} +const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') +const missing = (e: unknown): boolean => (e as NodeJS.ErrnoException)?.code === 'ENOENT' +const errorText = (e: unknown): string => String((e as Error)?.message ?? e) +const safeRelative = (p: unknown): p is string => typeof p === 'string' && !!p && !path.isAbsolute(p) && !p.split(/[\\/]/).includes('..') -type Entry = Pick -const CATALOG: readonly Entry[] = [ - { agentKind: 'claude-code', pluginId: 'official-ai-claude-code', name: 'Claude Code capture' }, - { agentKind: 'codex', pluginId: 'official-ai-codex', name: 'Codex capture' }, - { agentKind: 'opencode', pluginId: 'official-ai-opencode', name: 'OpenCode capture' } -] - -// Templates are not plugin directories and are never auto-discovered. This -// installer supplies an installation receipt. The shared capture policy requires -// that receipt, an active plugin and explicit project paths before recording. -function manifestBytes(entry: Entry, installationId: string): string { - const manifest: PluginManifest & { redlogInstallationId: string } = { - redlogInstallationId: installationId, - id: entry.pluginId, name: entry.name, version: '1.0.0', - redlogApi: PLUGIN_API_VERSION, kind: 'pack', author: 'RedLog', - description: 'Optional RedLog capture integration, not the AI tool itself. Installation alone does not enable recording.', - contributes: {} - } - return JSON.stringify(manifest, null, 2) + '\n' +/** Inert packages are outside the discoverable bundled plugin root. */ +export function optionalAgentPackagesRoot(): string { + const packaged = path.join(process.resourcesPath ?? '', 'optional-ai') + if (process.resourcesPath && fs.existsSync(packaged)) return packaged + const built = path.resolve(__dirname, '../optional-ai') + return fs.existsSync(built) ? built : path.resolve(__dirname, '../../../out/optional-ai') } -function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === 'ENOENT' } -function errorText(error: unknown): string { return String((error as Error)?.message ?? error) } +function packages(root: string): Package[] { + const items = fs.readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => { + const dir = path.join(root, e.name) + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8')) + const a = manifest.redlogAgent + if (manifest.redlogApi !== PLUGIN_API_VERSION || !/^[a-z0-9][a-z0-9-]{1,63}$/.test(manifest.id) + || !a || !/^[a-z0-9][a-z0-9-]{1,63}$/.test(a.agentKind) || a.entry !== 'adapter.cjs' + || !a.source || !safeRelative(a.source.defaultPath) + || (a.source.configEnv !== undefined && !/^[A-Z][A-Z0-9_]*$/.test(a.source.configEnv)) + || (a.source.envSuffix !== undefined && !safeRelative(a.source.envSuffix))) throw new Error(`Invalid optional AI package: ${e.name}`) + const codePath = path.join(dir, 'adapter.cjs') + const stat = fs.lstatSync(codePath) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) throw new Error(`Invalid AI adapter: ${e.name}`) + const code = fs.readFileSync(codePath) + if (sha256(code) !== a.sha256) throw new Error(`AI package checksum mismatch: ${e.name}`) + return { manifest, code, agentKind: a.agentKind, pluginId: manifest.id, name: manifest.name, source: a.source, order: Number(a.order) || 0 } + }) + if (new Set(items.map(p => p.agentKind)).size !== items.length || new Set(items.map(p => p.pluginId)).size !== items.length) throw new Error('Duplicate optional AI package identity') + return items.sort((a, b) => a.order - b.order || a.pluginId.localeCompare(b.pluginId)) +} +function manifestBytes(p: Package, installationId: string): string { + return JSON.stringify({ ...p.manifest, redlogInstallationId: installationId }, null, 2) + '\n' +} -/** Local, allowlisted integration installation. The main process supplies the - * user plugin root; renderer input is only one of the three agent names. No - * transcript paths, project files, network or package manager are involved. */ -export function createOfficialAgentPluginManager(root: string): { +/** Only app-supplied, checksum-matching packages may execute in this host. + * Third-party executable tailers remain refused by the generic plugin system. */ +export function createOfficialAgentPluginManager(root: string, packageRoot = optionalAgentPackagesRoot()): { list(): OfficialAgentPlugin[] install(agentKind: unknown): OfficialAgentMutation uninstall(agentKind: unknown): OfficialAgentMutation + modulePath(agentKind: string): string | undefined } { - const find = (id: unknown): Entry | undefined => CATALOG.find(entry => entry.agentKind === id) - function inspect(entry: Entry): OfficialAgentPlugin { + const available = (): Package[] => packages(packageRoot) + type Receipt = { installationId: string; manifestHash: string; codeHash: string } + const ledgerPath = path.join(root, '.official-ai-installations.json') + function receipts(): Record { + if (!fs.existsSync(ledgerPath)) return {} + const st = fs.lstatSync(ledgerPath) + if (!st.isFile() || st.isSymbolicLink() || st.size > 1024 * 1024) throw new Error('Invalid AI installation records') + const value = JSON.parse(fs.readFileSync(ledgerPath, 'utf8')) + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid AI installation records') + return value + } + function saveReceipt(p: Package, installationId?: string): void { + const all = receipts() + if (installationId) all[p.pluginId] = { installationId, manifestHash: sha256(Buffer.from(manifestBytes(p, installationId))), codeHash: sha256(p.code) } + else delete all[p.pluginId] + const tmp = ledgerPath + '.' + randomUUID() + '.tmp' + try { + fs.writeFileSync(tmp, JSON.stringify(all), { flag: 'wx', mode: 0o600 }) + fs.renameSync(tmp, ledgerPath) + } finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) } + } + function inspect(p: Package): OfficialAgentPlugin { + const entry = { agentKind: p.agentKind, pluginId: p.pluginId, name: p.name, source: p.source } try { let parent: fs.Stats try { parent = fs.lstatSync(root) } catch (e) { if (missing(e)) return { ...entry, state: 'not-installed' }; throw e } if (!parent.isDirectory() || parent.isSymbolicLink()) return { ...entry, state: 'error', error: 'Plugin root is not a regular directory' } - const dir = path.join(root, entry.pluginId) + const dir = path.join(root, p.pluginId) let stat: fs.Stats try { stat = fs.lstatSync(dir) } catch (e) { if (missing(e)) return { ...entry, state: 'not-installed' }; throw e } if (!stat.isDirectory() || stat.isSymbolicLink()) return { ...entry, state: 'conflict', error: 'Integration path is not a regular directory' } - const files = fs.readdirSync(dir) - if (files.length !== 1 || files[0] !== 'plugin.json') return { ...entry, state: 'conflict', error: 'Integration directory contains unrecognized files' } - const file = path.join(dir, 'plugin.json') - const manifest = fs.lstatSync(file) - if (!manifest.isFile() || manifest.isSymbolicLink() || manifest.size > 4096) return { ...entry, state: 'conflict', error: 'Integration manifest is not a managed file' } - const bytes = fs.readFileSync(file, 'utf8') + const files = fs.readdirSync(dir).sort() + if (files.join(',') !== 'adapter.cjs,plugin.json') return { ...entry, state: 'conflict', error: 'Integration directory contains unrecognized or missing files' } + for (const file of files) { + const st = fs.lstatSync(path.join(dir, file)) + if (!st.isFile() || st.isSymbolicLink() || st.size > 4 * 1024 * 1024) return { ...entry, state: 'conflict', error: 'Integration is not a managed file' } + } + const bytes = fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8') let installationId: string try { installationId = JSON.parse(bytes).redlogInstallationId } catch { return { ...entry, state: 'conflict', error: 'Integration manifest is not valid JSON' } } - if (typeof installationId !== 'string' || !/^[0-9a-f-]{36}$/.test(installationId) || bytes !== manifestBytes(entry, installationId)) { - return { ...entry, state: 'conflict', error: 'Integration manifest is not the managed original' } + const code = fs.readFileSync(path.join(dir, 'adapter.cjs')) + const receipt = receipts()[p.pluginId] + if (typeof installationId !== 'string' || !/^[0-9a-f-]{36}$/.test(installationId) + || !receipt || receipt.installationId !== installationId + || receipt.manifestHash !== sha256(Buffer.from(bytes)) || receipt.codeHash !== sha256(code)) { + return { ...entry, state: 'conflict', error: 'Integration differs from its installation record' } } + if (bytes !== manifestBytes(p, installationId) || !code.equals(p.code)) return { ...entry, state: 'update-available', installationId } return { ...entry, state: 'installed', installationId } } catch (e) { return { ...entry, state: 'error', error: errorText(e) } } } + const find = (id: unknown): Package | undefined => available().find(p => p.agentKind === id) return { - list: () => CATALOG.map(inspect), - install(agentKind) { - const entry = find(agentKind) - if (!entry) return { ok: false, error: 'Unknown official AI capture integration' } - const current = inspect(entry) - if (current.state === 'installed') return { ok: true, changed: false, pluginId: entry.pluginId } + list: () => available().map(inspect), + modulePath(kind) { + const p = find(kind) + return p && inspect(p).state === 'installed' ? path.join(root, p.pluginId, 'adapter.cjs') : undefined + }, + install(kind) { + const p = find(kind) + if (!p) return { ok: false, error: 'Unknown official AI capture integration' } + const current = inspect(p) + if (current.state === 'installed') return { ok: true, changed: false, pluginId: p.pluginId } + if (current.state === 'update-available') { + const dir = path.join(root, p.pluginId) + const staging = path.join(root, '.' + p.pluginId + '.' + randomUUID()) + const backup = staging + '.previous' + let swapped = false + try { + fs.mkdirSync(staging) + fs.writeFileSync(path.join(staging, 'adapter.cjs'), p.code, { flag: 'wx', mode: 0o600 }) + fs.writeFileSync(path.join(staging, 'plugin.json'), manifestBytes(p, current.installationId!), { flag: 'wx', mode: 0o600 }) + fs.renameSync(dir, backup) + try { fs.renameSync(staging, dir) } catch (e) { fs.renameSync(backup, dir); throw e } + swapped = true + try { saveReceipt(p, current.installationId!) } catch (e) { + fs.renameSync(dir, staging); fs.renameSync(backup, dir); swapped = false; throw e + } + for (const file of ['plugin.json', 'adapter.cjs']) fs.unlinkSync(path.join(backup, file)) + fs.rmdirSync(backup) + return { ok: true, changed: true, pluginId: p.pluginId } + } catch (e) { return { ok: false, error: errorText(e), changed: swapped || undefined, pluginId: p.pluginId } } + finally { + if (fs.existsSync(staging)) { + for (const file of ['plugin.json', 'adapter.cjs']) if (fs.existsSync(path.join(staging, file))) fs.unlinkSync(path.join(staging, file)) + fs.rmdirSync(staging) + } + } + } if (current.state !== 'not-installed') return { ok: false, error: current.error! } - const dir = path.join(root, entry.pluginId) + const dir = path.join(root, p.pluginId) let created = false - const staging = path.join(dir, '.plugin.json.installing') + const owned: string[] = [] try { - fs.mkdirSync(root, { recursive: true }) - // No recursive create at the destination: even an empty foreign - // directory is not ours to adopt or overwrite. - fs.mkdirSync(dir) - created = true - fs.writeFileSync(staging, manifestBytes(entry, randomUUID()), { flag: 'wx', mode: 0o600 }) - fs.renameSync(staging, path.join(dir, 'plugin.json')) - return { ok: true, changed: true, pluginId: entry.pluginId } + fs.mkdirSync(root, { recursive: true }); fs.mkdirSync(dir); created = true + const code = path.join(dir, 'adapter.cjs') + fs.writeFileSync(code, p.code, { flag: 'wx', mode: 0o600 }); owned.push(code) + const staging = path.join(dir, '.plugin.json.installing') + fs.writeFileSync(staging, manifestBytes(p, randomUUID()), { flag: 'wx', mode: 0o600 }); owned.push(staging) + const manifestPath = path.join(dir, 'plugin.json') + fs.renameSync(staging, manifestPath); owned.push(manifestPath) + const installationId = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).redlogInstallationId as string + saveReceipt(p, installationId) + return { ok: true, changed: true, pluginId: p.pluginId } } catch (e) { if (created) { - try { fs.unlinkSync(staging) } catch { /* only our staging file */ } - try { fs.rmdirSync(dir) } catch { /* never recursively remove unknown files */ } + for (const file of owned) try { fs.unlinkSync(file) } catch { /* only files created here */ } + try { fs.rmdirSync(dir) } catch { /* preserve foreign files */ } } return { ok: false, error: errorText(e) } } }, - uninstall(agentKind) { - const entry = find(agentKind) - if (!entry) return { ok: false, error: 'Unknown official AI capture integration' } - const current = inspect(entry) - if (current.state === 'not-installed') return { ok: true, changed: false, pluginId: entry.pluginId } - if (current.state !== 'installed') return { ok: false, error: current.error! } + uninstall(kind) { + const p = find(kind) + if (!p) return { ok: false, error: 'Unknown official AI capture integration' } + const current = inspect(p) + if (current.state === 'not-installed') return { ok: true, changed: false, pluginId: p.pluginId } + if (current.state !== 'installed' && current.state !== 'update-available') return { ok: false, error: current.error! } let removed = false try { - const dir = path.join(root, entry.pluginId) - fs.unlinkSync(path.join(dir, 'plugin.json')) - removed = true - fs.rmdirSync(dir) - return { ok: true, changed: true, pluginId: entry.pluginId } - } catch (e) { - return { ok: false, error: errorText(e), ...(removed ? { changed: true, pluginId: entry.pluginId } : {}) } - } + const dir = path.join(root, p.pluginId) + fs.unlinkSync(path.join(dir, 'plugin.json')); removed = true + fs.unlinkSync(path.join(dir, 'adapter.cjs')); fs.rmdirSync(dir); saveReceipt(p) + return { ok: true, changed: true, pluginId: p.pluginId } + } catch (e) { return { ok: false, error: errorText(e), changed: removed || undefined, pluginId: removed ? p.pluginId : undefined } } } } } diff --git a/src/main/index.ts b/src/main/index.ts index 21663a4b..953ee362 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { resolveAgentCapture } from '../core/agent-capture-policy' +import { resolveAgentCapture, agentTranscriptRoot } from '../core/agent-capture-policy' import { createOfficialAgentPluginManager } from '../core/plugins/official-agents' import { app, BrowserWindow, ipcMain, Menu, Tray, globalShortcut, dialog, screen, session, shell, protocol, powerMonitor } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' @@ -69,7 +69,7 @@ import { initPlugins, listPlugins } from '../core/plugins' import { configureIngest, ingest, ingestEvent } from '../core/ingest' import { resetCausesResolver } from '../core/causes-resolver' import { setTailerContributionSink, type TailerLike } from '../core/plugins/tailer-registry' -import { registerAdapter as registerTailerAdapter, unregisterAdapter as unregisterTailerAdapter, registerSessionId, getRegisteredSessions, type TailerAdapter } from './services/tailer-host' +import { registerAdapter as registerTailerAdapter, unregisterAdapter as unregisterTailerAdapter, registerSessionId, getRegisteredSessions, listAgentSessions, type TailerAdapter } from './services/tailer-host' import { applyLoginPath } from './login-path' import { getCaptureHealth, invalidateHooksCache, noteSampleBroken, clearSampleBroken, configureCaptureHealth, configureManagedProxyHealth, noteDbError, pendingSpoolDirectory, clearUnattributedCommands } from '../core/capture-health' import { launchBrowser, stopBrowser, isBrowserRunning, detectBrowser, onBrowserExit } from './services/browser-launcher' @@ -436,12 +436,23 @@ function applyCapturePacks(cfg: RedLogConfig): void { configureConnectionMonitor({ enabled: on('connectionMonitor') }) configureProcessMonitor({ enabled: on('processMonitor') }) void configurePowershellTranscript({ enabled: on('powershellTranscript') }) - const agents = resolveAgentCapture(cfg, createOfficialAgentPluginManager(path.join(homedir(), '.redlog', 'plugins')).list(), plugins) - configureAgentTailer({ enabled: agents.length > 0, enabledAgents: agents.map(a => a.agentKind), + const manager = createOfficialAgentPluginManager(path.join(homedir(), '.redlog', 'plugins')) + const catalog = manager.list() + const agents = resolveAgentCapture(cfg, catalog, plugins) + configureAgentTailer({ + integrations: agents.flatMap(a => { + const entry = catalog.find(p => p.agentKind === a.agentKind)! + const modulePath = manager.modulePath(a.agentKind) + return modulePath ? [{ agentKind: a.agentKind, modulePath, root: agentTranscriptRoot(entry, homedir(), cfg.agentTailer?.sources?.[a.agentKind]) }] : [] + }), + enabled: agents.length > 0, enabledAgents: agents.map(a => a.agentKind), engagementId: cfg.engagement.id, operatorId: cfg.operator.id, emitThinking: cfg.agentTailer?.emitThinking ?? false, watchPathsByAgent: Object.fromEntries(agents.map(a => [a.agentKind, a.workPaths])), activationStarts: Object.fromEntries(agents.map(a => [a.agentKind, a.startedAt])), + excludedSessionsByAgent: Object.fromEntries(agents.map(a => [a.agentKind, cfg.agentTailer?.sources?.[a.agentKind]?.excludedSessions ?? []])), + sessionStartsByAgent: Object.fromEntries(agents.map(a => [a.agentKind, cfg.agentTailer?.sources?.[a.agentKind]?.sessionStarts ?? {}])), excludedPaths: readHookConfig().excludedPaths }) + } // Recent distinct pivot nodes for the overlay — dedup by intermediate node, @@ -1364,7 +1375,14 @@ app.whenReady().then(() => { // AI source metadata and shared hook exclusions. The folder picker itself // does not grant capture permission. - ipcMain.handle('agents:sources', () => describeAgentSources()) + ipcMain.handle('agents:sessions', () => listAgentSessions()) + ipcMain.handle('agents:sources', (_event, roots?: Record) => { + const config = activeProject ? loadConfig(getProjectPath(activeProject)) : {} + if (roots === undefined) return describeAgentSources(config) + // Metadata-only preview of the unsaved form: directory existence is not permission. + if (!roots || typeof roots !== 'object' || Array.isArray(roots) || Object.entries(roots).some(([kind, root]) => !createOfficialAgentPluginManager(path.join(homedir(), '.redlog', 'plugins')).list().some(p => p.agentKind === kind) || typeof root !== 'string' || !path.isAbsolute(root))) throw new Error('Invalid AI source directory') + return describeAgentSources({ agentTailer: { sources: Object.fromEntries(Object.entries(roots).map(([kind, root]) => [kind, { sourceRoot: root }])) } }) + }) ipcMain.handle('hookConfig:get', () => readHookConfig()) ipcMain.handle('hookConfig:save', (_e, cfg: { excludedPaths?: string[] }) => { try { diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 248bcb03..57b35fa2 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -18,7 +18,9 @@ import { eventBus } from '../../core/event-bus' // Serialise LoadedPlugin to a UI-friendly shape (drop absolute dirs/hashes we // don't need in the renderer; keep what the panel renders + acts on). function pluginView() { + const official = createOfficialAgentPluginManager(path.join(homedir(), '.redlog', 'plugins')).list() return listPlugins().map((p) => ({ + officialAgent: official.some(entry => entry.pluginId === p.manifest.id), id: p.manifest.id, name: p.manifest.name, version: p.manifest.version, description: p.manifest.description ?? '', author: p.manifest.author ?? '', source: p.source, tier: p.tier, status: p.status, @@ -50,6 +52,14 @@ export function registerPluginsIpc(ipcMain: IpcMain, ctx: IpcContext): void { const mutateOfficialAgent = (operation: 'install' | 'uninstall', agentKind: unknown) => { const before = officialAgents.list().find(entry => entry.agentKind === agentKind) const result = officialAgents[operation](agentKind) + // Recovery is explicit, source-owned and does not authorize any project. + const disabled = result.ok && operation === 'install' && result.pluginId + ? listPlugins().find(p => p.manifest.id === result.pluginId)?.status === 'disabled' : false + if (disabled && result.pluginId) { + setPluginEnabled(result.pluginId, true) + invalidateHooksCache(); invalidateHooksDetectCache(); ctx.onPluginsChanged() + auditPluginChange(ctx, { [`plugin.${result.pluginId}.enabled`]: { from: false, to: true } }, `AI capture integration restored: ${result.pluginId}`) + } // A final-directory cleanup failure can follow a successful manifest // removal. Refresh in that case too, while keeping the error visible. if (result.changed && result.pluginId) { @@ -86,6 +96,7 @@ export function registerPluginsIpc(ipcMain: IpcMain, ctx: IpcContext): void { // a gap in it that nothing in the record explains. The flag is app-wide, so // the gap is not even confined to the project it was thrown in. ipcMain.handle('plugins:setEnabled', (_e, id: string, enabled: boolean) => { + if (officialAgents.list().some(entry => entry.pluginId === id)) throw new Error('Manage official AI integrations in capture sources') const before = listPlugins().find((p) => p.manifest.id === id) setPluginEnabled(id, enabled) invalidateHooksCache(); invalidateHooksDetectCache(); ctx.onPluginsChanged() diff --git a/src/main/services/agent-tailer.ts b/src/main/services/agent-tailer.ts index feb972bf..5c5be278 100644 --- a/src/main/services/agent-tailer.ts +++ b/src/main/services/agent-tailer.ts @@ -1,67 +1,52 @@ -// The agent tailer: registers the three bundled transcript adapters with the -// tailer host and exposes the configure/stop pair main/index.ts drives from -// `config.agentTailer`. The host owns the plumbing (tailer-host.ts); each -// adapter owns its agent's format (adapters/). - -import fs from 'fs' -import os from 'os' -import path from 'path' -import { configureHost, registerAdapter, type TailerHostConfig } from './tailer-host' -import { claudeCodeAdapter, overrideClaudeProjectsDir } from './adapters/claude-code' -import { codexAdapter, overrideCodexTranscriptRoot } from './adapters/codex' -import { opencodeAdapter, overrideOpencodeStorageRoot } from './adapters/opencode' - -export interface AgentTailerConfig extends TailerHostConfig { - /** Root of Claude Code's per-session transcripts. Overridable for tests - * and for cross-platform paths. */ - claudeProjectsDir?: string - /** Root of Codex CLI's session rollouts (default `~/.codex/sessions`). - * Test-only override — no user-facing config. */ - codexSessionsDir?: string - /** Root of OpenCode storage dir (default - * `~/.local/share/opencode/storage`). Test-only override. */ - opencodeStorageDir?: string +// Shared host only. Optional packages provide parsers; no provider module is +// imported or registered until its installation and project authorization pass. +import fs from 'node:fs' +import os from 'node:os' +import chokidar from 'chokidar' +import { configureHost, registerAdapter, unregisterAdapter, type TailerHostConfig, type TailerAdapter } from './tailer-host' +import { agentTranscriptRoot, type AgentCaptureConfig } from '../../core/agent-capture-policy' +import { createOfficialAgentPluginManager } from '../../core/plugins/official-agents' +import path from 'node:path' +import { noteCaptureError } from '../../core/capture-health' + +interface AgentModule { agentKind: string; modulePath: string; root: string } +export interface AgentTailerConfig extends TailerHostConfig { integrations?: AgentModule[] } +const loaded = new Map() +function unload(): void { + // Stop before discarding module instances, including auxiliary watchers. + for (const [kind, file] of loaded) { unregisterAdapter(kind); delete require.cache[file] } + loaded.clear() } - -let adaptersRegistered = false - export function configureAgentTailer(next: Partial): void { - const { claudeProjectsDir, codexSessionsDir, opencodeStorageDir, ...hostCfg } = next - if (claudeProjectsDir) overrideClaudeProjectsDir(claudeProjectsDir) - if (codexSessionsDir) overrideCodexTranscriptRoot(codexSessionsDir) - if (opencodeStorageDir) overrideOpencodeStorageRoot(opencodeStorageDir) - if (!adaptersRegistered) { - registerAdapter(claudeCodeAdapter) - registerAdapter(codexAdapter) - registerAdapter(opencodeAdapter) - adaptersRegistered = true + const { integrations, ...hostCfg } = next + if (integrations !== undefined) { + configureHost({ enabled: false, enabledAgents: [] }) + unload() + for (const entry of integrations) { + let cacheKey = entry.modulePath + try { + cacheKey = require.resolve(entry.modulePath) + const plugin = require(cacheKey) as { createAdapter?: (root: string, services: { watch: typeof chokidar.watch }) => TailerAdapter } + const adapter = plugin.createAdapter?.(entry.root, { watch: chokidar.watch }) + if (!adapter || adapter.agentKind !== entry.agentKind || typeof adapter.parseUnit !== 'function' || typeof adapter.subtypeFor !== 'function' || !(adapter.knownIngestTypes instanceof Set)) throw new Error('AI plugin has no compatible adapter') + registerAdapter(adapter); loaded.set(entry.agentKind, cacheKey) + } catch (e) { + delete require.cache[cacheKey] + noteCaptureError(`agent-${entry.agentKind}`, e) + } + } } configureHost(hostCfg) } - export function stopAgentTailer(): void { configureHost({ enabled: false, enabledAgents: [] }) + unload() } - -export interface AgentSourceInfo { - id: string - label: string - /** The directory the adapter watches, with `~` expanded for display. */ - root: string - /** Whether that directory exists on this machine. This says the tool has - * written sessions there, not that anything is being read. */ - detected: boolean -} - -const AGENT_LABELS: Record = { 'claude-code': 'Claude Code', codex: 'Codex CLI', opencode: 'OpenCode' } - -/** What the tailer can read and whether each source is on this machine. Nothing - * is opened or read: it only checks that the watched directory exists. */ -export function describeAgentSources(): AgentSourceInfo[] { - return [claudeCodeAdapter, codexAdapter, opencodeAdapter].map((a) => { - const glob = a.transcriptGlob.startsWith('~/') ? path.join(os.homedir(), a.transcriptGlob.slice(2)) : a.transcriptGlob - const star = glob.search(/[*?[{]/) - const root = (star === -1 ? glob : glob.slice(0, star)).replace(/[\\/]+$/, '') - return { id: a.agentKind, label: AGENT_LABELS[a.agentKind] ?? a.agentKind, root, detected: fs.existsSync(root) } +export interface AgentSourceInfo { id: string; label: string; root: string; detected: boolean } +/** Metadata and existence only; never require a module or open a transcript. */ +export function describeAgentSources(config: AgentCaptureConfig = {}): AgentSourceInfo[] { + return createOfficialAgentPluginManager(path.join(os.homedir(), '.redlog', 'plugins')).list().map(entry => { + const root = agentTranscriptRoot(entry, os.homedir(), config.agentTailer?.sources?.[entry.agentKind]) + return { id: entry.agentKind, label: entry.name, root, detected: fs.existsSync(root) } }) } diff --git a/src/main/services/tailer-host.ts b/src/main/services/tailer-host.ts index 2636cf8e..da6fe294 100644 --- a/src/main/services/tailer-host.ts +++ b/src/main/services/tailer-host.ts @@ -14,7 +14,7 @@ // // **What lives here:** // - chokidar dir-watch on each adapter's `transcriptGlob` -// - Sidecar file management under `/agent-transcripts/` +// - Sidecar, durable source checkpoints and privacy boundaries per project // - Redaction on the way IN to the events table (via secret-redaction.ts) // - `_causes` resolution — per-session `Map` // seeded at session-register time from the DB (v0.7.4 F2) @@ -51,7 +51,7 @@ import { ingestEvent } from '../../core/ingest' import { extractTargetWithProvenance } from '../../core/target-extractor' import { eventBus } from '../../core/event-bus' import { getProjectDir, getDB } from '../../core/db/index' -import { noteDbError } from '../../core/capture-health' +import { noteDbError, noteCaptureError, clearCaptureError } from '../../core/capture-health' import { redactSecrets, outputIfPathHiddenByCommand } from '../../core/secret-redaction' import { isInsideDir } from '../../core/paths' @@ -143,7 +143,7 @@ export interface ParsedTurn { * react to `add` events on files the host already watches, and use this * surface only for supplemental streams. */ export interface HostControlSurface { - acceptsMessage(agentKind: string, messageId: string): boolean + acceptsMessage(agentKind: string, messageId: string, changedAt?: number): boolean emitTurns(agentKind: string, sessionId: string, turns: ParsedTurn[]): void } @@ -157,6 +157,8 @@ export interface TailerAdapter { /** Chokidar-watched pattern rooted at the adapter's home dir. May start * with `~/` (host expands). Recursive globs (`**`) are honored. */ transcriptGlob: string + /** Literal built-in source root; never interpret directory names as globs. */ + transcriptRoot?: string /** True = each matching FILE is one unit (OpenCode's * `~/.local/share/opencode/storage/message/*.json` layout). * False (default) = each matching file is a JSONL stream, one unit per @@ -242,6 +244,8 @@ export interface TailerHostConfig { watchPaths?: string[] watchPathsByAgent?: Record activationStarts?: Record + excludedSessionsByAgent?: Record + sessionStartsByAgent?: Record> /** v0.12.0: called after every `agent.tool_call` event insert with the * extracted target host (if any). Feeds the alert subsystem's Scope * policy so agent-driven scope violations register the same way shell @@ -263,12 +267,22 @@ interface SessionState { adapter: TailerAdapter sourcePath: string sourceBaseOffset: number + sourceOffset: number + discardPartial: boolean + checkpointPaused: boolean + pauseSkippedBytes: number + resumedAt: number + unitSidecarOffset: number + replayFrom?: number + completedFiles: Map + skipMissingParents: boolean + ignoredParents: Map sidecarPath: string transcriptCwd: string pendingLineBuffer: string redlogIdByUuid: Map toolCommandByUseId: Map - pendingByParentUuid: Map> + pendingByParentUuid: Map> linesSeen: number turnsEmitted: number postCompact: boolean @@ -286,26 +300,6 @@ interface SessionState { pauseSkippedLines: number /** Timestamp when the first pause-skipped line was encountered. */ pauseStartedAt: number | null - /** Turns whose event write threw. Read around a unit to tell whether every - * turn in it landed, since emitTurn reports failure only to capture health. */ - writeFailures: number - /** The partial line held in pendingLineBuffer began while paused. Its rest - * arrives after resume and must be dropped with it, not recorded. */ - pauseTail: boolean - /** Bounded retry for units that did not land: the watcher may never fire - * for that file again. `attempt` counts timer-driven passes only, so a - * burst of file events cannot use up the backoff in milliseconds. */ - retry: { timer: ReturnType; attempt: number } | null - /** Set while the retry timer's own pass runs. */ - retryFiring: boolean - /** This pass left something unwritten (bytes put back, a file unindexed). */ - heldBack: boolean - /** Per-message files given up on unparsed in this pass, for the gap record. */ - unparsedGivenUp: number - /** Turns whose write threw, by uuid, retried at the start of every pass - * that is not paused. A child flushed when its parent lands, or a turn read - * before a pause, has no bytes left to re-read; this is what still holds it. */ - failedTurns: Map } const registeredAdapters = new Map() @@ -323,11 +317,26 @@ function sessionKey(agentKind: string, sessionId: string): string { return `${agentKind}:${sessionId}` } +let resumeBoundary = 0 +function boundaryPath(): string { return path.join(getProjectDir(), 'agent-transcripts', '.recording-boundary.json') } +function saveBoundary(paused: boolean): void { + const file = boundaryPath(), at = Date.now(), tmp = file + '.tmp' + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }) + try { fs.writeFileSync(tmp, JSON.stringify({ paused, at }), { mode: 0o600 }); fs.renameSync(tmp, file) } + catch (e) { try { fs.unlinkSync(tmp) } catch { /* staging metadata only */ }; throw e } + resumeBoundary = at +} +function restoreBoundary(): void { + resumeBoundary = 0 + const file = boundaryPath() + if (!fs.existsSync(file)) return + const value = JSON.parse(fs.readFileSync(file, 'utf8')) + if (!Number.isSafeInteger(value.at) || value.at <= 0 || typeof value.paused !== 'boolean') throw new Error('Invalid AI recording boundary') + resumeBoundary = value.at + if (value.paused && !eventBus.paused) saveBoundary(false) +} + const COALESCE_MS = 50 -/** Retries for a unit whose events did not land (see scheduleUnitRetry). */ -const UNIT_RETRY_BASE_MS = 1000 -const UNIT_RETRY_LIMIT = 5 -const PAUSE_TAIL_SUFFIX = '.pause-tail' function coalescedCatchUp(agentKind: string, sid: string): void { const key = sessionKey(agentKind, sid) @@ -360,19 +369,34 @@ export function getRegisteredSessions(): string[] { return [...sessionRegistry] } +export interface AgentSessionInfo { agentKind: string; sessionId: string; cwd?: string; recording: boolean } +export function listAgentSessions(): AgentSessionInfo[] { + const result = [...sessions.values()].map(s => ({ agentKind: s.agentKind, sessionId: s.sessionId, cwd: s.transcriptCwd, recording: cfg.enabled && !eventBus.paused })) as AgentSessionInfo[] + for (const [agentKind, ids] of Object.entries(cfg.excludedSessionsByAgent ?? {})) for (const sessionId of ids) { + if (!result.some(s => s.agentKind === agentKind && s.sessionId === sessionId)) result.push({ agentKind, sessionId, recording: false }) + } + return result +} +function sessionAllowed(kind: string, id: string): boolean { + return isSessionRegistered(id) && !cfg.excludedSessionsByAgent?.[kind]?.includes(id) +} +function activationFor(kind: string, id: string): number | undefined { + return Math.max(cfg.sessionStartsByAgent?.[kind]?.[id] ?? 0, cfg.activationStarts?.[kind] ?? 0) || undefined +} + // ─── Adapter registry ─────────────────────────────────────────────────────── const initializedAdapters = new Set() const hostControlSurface: HostControlSurface = { - acceptsMessage(agentKind, messageId) { - if (!cfg.enabled || !agentEnabled(agentKind) || !/^[a-zA-Z0-9_-]+$/.test(messageId)) return false - return [...sessions.values()].some(s => s.agentKind === agentKind && s.adapter.perMessageDir && fs.existsSync(path.join(s.sourcePath, messageId + '.json'))) + acceptsMessage(agentKind, messageId, changedAt) { + if (eventBus.paused || !cfg.enabled || !agentEnabled(agentKind) || !/^[a-zA-Z0-9_-]+$/.test(messageId)) return false + return [...sessions.values()].some(s => s.agentKind === agentKind && sessionAllowed(agentKind, s.sessionId) && s.adapter.perMessageDir && s.redlogIdByUuid.get(messageId + '.json') !== '__paused__' && (changedAt === undefined || changedAt >= s.resumedAt + 1) && fs.existsSync(path.join(s.sourcePath, messageId + '.json'))) }, emitTurns(agentKind: string, sessionId: string, turns: ParsedTurn[]): void { if (eventBus.paused) return const s = sessions.get(sessionKey(agentKind, sessionId)) - if (!s) { + if (!s || !sessionAllowed(agentKind, sessionId)) { // No live session — the msg stub for this sessionId hasn't been // observed yet. Drop silently; the adapter is expected to retry (or // the primary watcher will pick up the session soon). @@ -380,10 +404,8 @@ const hostControlSurface: HostControlSurface = { } for (const turn of turns) { if (!s.adapter.knownIngestTypes.has(turn.type)) continue - emitTurn(turn, { session: s }) + try { emitTurn(turn, { session: s }) } catch (e) { noteDbError('tailer-host', e) } } - // A failed turn waits in failedTurns; nothing else would come back for it. - if (s.failedTurns.size > 0) scheduleUnitRetry(s) } } @@ -574,13 +596,19 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { const s = ctx.session if (s.redlogIdByUuid.has(t.uuid)) return + let parentUuid = t.parentUuid + const parentPath = new Set() + while (parentUuid && s.ignoredParents.has(parentUuid)) { + if (parentPath.has(parentUuid)) { parentUuid = null; break } + parentPath.add(parentUuid); parentUuid = s.ignoredParents.get(parentUuid) ?? null + } // v0.7.5 G2: pending-parent buffer. Adapter guarantees ordering only // within a JSONL file; Claude Code has been observed writing children // before parents in ~0.09% of turns. Buffer them; flush recursively // when the parent lands. let causesArr: string[] | undefined - if (t.parentUuid && !ctx._skipParentBuffer) { - const parentRid = s.redlogIdByUuid.get(t.parentUuid) + if (parentUuid && !ctx._skipParentBuffer && (!s.skipMissingParents || s.redlogIdByUuid.has(parentUuid))) { + const parentRid = s.redlogIdByUuid.get(parentUuid) if (parentRid) { causesArr = [parentRid] } else { @@ -591,12 +619,10 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { if (!ot.uuid || s.redlogIdByUuid.has(ot.uuid)) continue emitTurn(ot, { ...ctx, _skipParentBuffer: true }) } - const queue = s.pendingByParentUuid.get(t.parentUuid) ?? [] - // A re-read after a failed pass meets the same child again. - if (queue.some((q) => q.turn.uuid === t.uuid)) return + const queue = s.pendingByParentUuid.get(parentUuid) ?? [] if (queue.length + pendingSize(s) < MAX_PENDING_CHILDREN) { - queue.push({ turn: t, queuedAt: Date.now() }) - s.pendingByParentUuid.set(t.parentUuid, queue) + queue.push({ turn: t, queuedAt: Date.now(), sidecarOffset: s.unitSidecarOffset }) + s.pendingByParentUuid.set(parentUuid, queue) return } if (!s.parentMissingAdvisoryFired) { @@ -606,7 +632,7 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { subtype: 'transcript_parent_missing', session_id: s.sessionId, agent: s.agentKind, - parent_uuid: t.parentUuid, + parent_uuid: parentUuid, child_uuid: t.uuid, child_type: t.type, pending_full: true, @@ -629,6 +655,7 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { agent: s.agentKind, session_id: s.sessionId, transcript_uuid: t.uuid, + ...(t.parentUuid ? { transcript_parent_uuid: t.parentUuid } : {}), ...(t.model ? { model: t.model } : {}), ...(t.version ? { agent_version: t.version } : {}), ...(t.gitBranch ? { git_branch: t.gitBranch } : {}), @@ -736,9 +763,9 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) + if (!ev) throw new Error('AI event was not saved') if (ev) { s.redlogIdByUuid.set(t.uuid, ev.id) - s.failedTurns.delete(t.uuid) s.turnsEmitted++ if (subtype === 'tool_call') s.toolCallsEmitted++ // v0.12.0: hand tool_call events to the alert subsystem so agent- @@ -766,7 +793,7 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { for (const q of waiting) emitTurn(q.turn, ctx) } } - } catch (e) { s.writeFailures++; s.failedTurns.set(t.uuid, t); noteDbError('tailer-host', e) } + } catch (e) { noteDbError('tailer-host', e); throw e } } function sweepStalePending(s: SessionState): ParsedTurn[] { @@ -791,338 +818,181 @@ function pendingSize(s: SessionState): number { // ─── Ingest loop ──────────────────────────────────────────────────────────── -/** Catch a session up from `sidecarSize` to the current source size. +/** Catch a session up from its committed source offset to the current size. * Handles both per-line (JSONL) and per-message (file-per-unit) adapters * based on `adapter.perMessageDir`. Public for testability. */ export function catchUpSession(agentKind: string, sessionId: string): void { const s = sessions.get(sessionKey(agentKind, sessionId)) - if (!s) return - // Turns that failed earlier go first. Paused, they wait: ingest refuses - // writes during a pause, and these were read while recording was on. - if (!eventBus.paused) for (const t of [...s.failedTurns.values()]) { - if (t.uuid && s.redlogIdByUuid.has(t.uuid)) { s.failedTurns.delete(t.uuid); continue } - emitTurn(t, { session: s }) - } - s.heldBack = false - s.unparsedGivenUp = 0 + if (!s || !sessionAllowed(agentKind, sessionId)) return if (s.adapter.perMessageDir) { catchUpPerMessageDir(s) } else { catchUpJsonl(s) } - if (eventBus.paused) return - if (retriesSpent(s) && (s.failedTurns.size > 0 || s.unparsedGivenUp > 0)) recordGivenUp(s) - if (s.heldBack || s.failedTurns.size > 0) scheduleUnitRetry(s) - else if (s.retry) { clearTimeout(s.retry.timer); s.retry = null } } -/** The retry timer's last pass is running. */ -function retriesSpent(s: SessionState): boolean { - return s.retryFiring && (s.retry?.attempt ?? 0) >= UNIT_RETRY_LIMIT -} - -/** Say what the bounded retries could not write, where gaps are read, rather - * than leave it implied by a quiet source. */ -function recordGivenUp(s: SessionState): void { - const failed = s.failedTurns.size +function checkpoint(s: SessionState, offset = s.sourceOffset, savedBytes?: number): void { + const bytes = savedBytes ?? (fs.existsSync(s.sidecarPath) ? fs.statSync(s.sidecarPath).size : 0) + const file = s.sidecarPath + '.offset.json' + const tmp = file + '.tmp' try { - ingestEvent('system', { - subtype: 'capture_gap', - reason: 'write_failed', - agent: s.agentKind, - session_id: s.sessionId, - failed_turns: failed, - unparsed_units: s.unparsedGivenUp, - description: `${failed} transcript turn(s) could not be written and ${s.unparsedGivenUp} unit(s) could not be read after ${UNIT_RETRY_LIMIT} retries; they are not in the record.` - }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) - } catch (e) { noteDbError('tailer-host', e) } - s.failedTurns.clear() + fs.writeFileSync(tmp, JSON.stringify({ source: s.sourcePath, startedAt: activationFor(s.agentKind, s.sessionId), base: s.sourceBaseOffset, + offset, savedBytes: bytes, discardPartial: s.discardPartial, paused: s.checkpointPaused, + skippedBytes: s.pauseSkippedBytes, gapStartedAt: s.pauseStartedAt, resumedAt: s.resumedAt, skipMissingParents: s.skipMissingParents, + ignoredParents: Object.fromEntries(s.ignoredParents), + replayFrom: s.adapter.perMessageDir || !s.pendingByParentUuid.size ? undefined : Math.min(...[...s.pendingByParentUuid.values()].flat().map(q => q.sidecarOffset)) }), { mode: 0o600 }) + fs.renameSync(tmp, file) + } catch (e) { try { fs.unlinkSync(tmp) } catch { /* only our staging file */ }; throw e } } -function catchUpJsonl(s: SessionState): void { - if (!cfg.enabled || sessions.get(sessionKey(s.agentKind, s.sessionId)) !== s) return - let sourceSize: number - try { sourceSize = fs.statSync(s.sourcePath).size } catch { return } - let sidecarSize = 0 - try { sidecarSize = fs.statSync(s.sidecarPath).size } catch { /* new */ } - - const shouldReset = s.adapter.detectReset - ? s.adapter.detectReset(s.sourceBaseOffset + sidecarSize, sourceSize) - : (sourceSize < s.sourceBaseOffset + sidecarSize) - - if (shouldReset) { - const oldHash = fileSha256(s.sidecarPath) - // Audit 2026-09-18 P1: archive the sidecar before truncating so the raw - // pre-compact transcript is preserved for forensic review. - if (sidecarSize > 0) { - const archivePath = s.sidecarPath + `.${Date.now()}.bak` - try { - fs.copyFileSync(s.sidecarPath, archivePath) - fs.chmodSync(archivePath, 0o600) - } catch { /* best-effort */ } - } - try { fs.truncateSync(s.sidecarPath, 0) } catch { /* ignore */ } - s.pendingLineBuffer = '' - // The held partial line is gone with the old content; a pause mark on it - // would drop the first line of the new one. - setPauseTail(s, false) - s.redlogIdByUuid.clear() - s.linesSeen = 0 - s.postCompact = true - s.lastSnapshotBytes = 0 - s.bytesAppendedSinceSnapshot = 0 - s.runningHash = crypto.createHash('sha256') - try { - const ev = ingestEvent('agent', { - subtype: 'transcript_compacted', - session_id: s.sessionId, - agent: s.agentKind, - old_sidecar_bytes: sidecarSize, - old_sidecar_sha256: oldHash, - new_source_bytes: sourceSize, - description: 'Source transcript shrunk (likely /compact, /clear, or adapter-specific reset). Sidecar reset; subsequent per-turn events are tagged post_compact=true.' - }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) - } catch (e) { noteDbError('tailer-host', e) } - sidecarSize = 0 - s.sourceBaseOffset = 0 - if (cfg.activationStarts?.[s.agentKind]) { - try { fs.writeFileSync(s.sidecarPath + '.offset.json', JSON.stringify({ source: s.sourcePath, startedAt: cfg.activationStarts[s.agentKind], base: 0 }), { mode: 0o600 }) } catch (e) { noteDbError('tailer-host', e); return } +/** Boundaries are metadata only: never append paused source bytes to evidence. */ +function discardJsonl(s: SessionState): void { + const size = fs.statSync(s.sourcePath).size + if (size !== s.sourceOffset) { + s.pauseSkippedBytes += Math.max(0, size - s.sourceOffset) + s.pauseStartedAt ??= Date.now() + s.discardPartial = false + if (size > 0) { + const fd = fs.openSync(s.sourcePath, 'r') + try { const tail = Buffer.alloc(1); fs.readSync(fd, tail, 0, 1, size - 1); s.discardPartial = tail[0] !== 10 } + finally { fs.closeSync(fd) } } } + s.pendingLineBuffer = '' + s.pendingByParentUuid.clear() + s.skipMissingParents = true + s.toolCommandByUseId.clear() + s.ignoredParents.clear() + checkpoint(s, size) + s.sourceOffset = size +} - const sourceOffset = s.sourceBaseOffset + sidecarSize - if (sourceSize <= sourceOffset) return +function reportGap(s: SessionState): void { + if (!s.pauseSkippedBytes && !s.pauseSkippedLines) return + const ev = ingestEvent('system', { subtype: 'capture_gap', agent: s.agentKind, session_id: s.sessionId, + skipped_bytes: s.pauseSkippedBytes, skipped_units: s.pauseSkippedLines, + gap_started_at: s.pauseStartedAt, gap_ended_at: Date.now(), + description: 'Recording paused: source content omitted from events and sidecar.' + }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) + if (!ev) throw new Error('AI capture gap was not saved') + s.pauseSkippedBytes = 0; s.pauseSkippedLines = 0; s.pauseStartedAt = null +} - // P0 fix: read delta in bounded chunks to avoid OOM on large transcripts. - const MAX_CHUNK = 10 * 1024 * 1024 // 10 MB per tick - const toRead = Math.min(sourceSize - sourceOffset, MAX_CHUNK) - let bytes: Buffer +function appendAndCommit(s: SessionState, bytes: Buffer, offset: number): void { + const before = fs.existsSync(s.sidecarPath) ? fs.statSync(s.sidecarPath).size : 0 try { - const handle = fs.openSync(s.sourcePath, 'r') - try { - bytes = Buffer.alloc(toRead) - let readTotal = 0 - while (readTotal < toRead) { - const n = fs.readSync(handle, bytes, readTotal, toRead - readTotal, sourceOffset + readTotal) - if (n === 0) break - readTotal += n - } - bytes = bytes.slice(0, readTotal) - } finally { try { fs.closeSync(handle) } catch { /* ignore */ } } - } catch (e) { noteDbError('tailer-host', e); return } - - // P0 fix (sidecar-cursor decoupling): parse lines and insert DB events - // BEFORE advancing the sidecar. Previously sidecar was written first, - // which meant a crash between sidecar-append and DB-insert permanently - // lost those turns (sidecar had advanced, restart would skip them). - // Now: parse → insert → sidecar-append, so a crash before sidecar-append - // simply re-reads the same bytes on restart. - const paused = eventBus.paused - // To put back if a write fails: these bytes are then read again. - const before = { - pending: s.pendingLineBuffer, pauseTail: s.pauseTail, pauseSkipped: s.pauseSkippedLines, - // Counted again on the re-read; toolCallsSeen left doubled would raise a - // false tool-gap advisory against toolCallsEmitted. - linesSeen: s.linesSeen, toolCallsSeen: s.toolCallsSeen - } - const text = s.pendingLineBuffer + bytes.toString('utf-8') - const lastNl = text.lastIndexOf('\n') - let complete = lastNl === -1 ? '' : text.slice(0, lastNl) - s.pendingLineBuffer = lastNl === -1 ? text : text.slice(lastNl + 1) - - // What RedLog keeps of these bytes. While paused it keeps none of them: - // newlines of the same length stand in, so the sidecar's size still marks - // how far the source has been read and resume does not re-read the pause, - // and a restart's line alignment finds a boundary at its end. - // A paused line still unfinished after resume is filler too, all of it. - let kept = paused || (s.pauseTail && lastNl === -1) ? Buffer.alloc(bytes.length, 0x0a) : bytes - if (!paused && s.pauseTail && lastNl !== -1) { - // The first complete line finishes one that began during the pause. - const cut = bytes.indexOf(0x0a) + 1 - kept = Buffer.concat([Buffer.alloc(cut, 0x0a), bytes.subarray(cut)]) - const firstNl = complete.indexOf('\n') - complete = firstNl === -1 ? '' : complete.slice(firstNl + 1) - s.pauseSkippedLines++ - setPauseTail(s, false) + fs.appendFileSync(s.sidecarPath, bytes, { mode: 0o600 }) + checkpoint(s, offset, before + bytes.length) + } catch (e) { + // A short write or a failed checkpoint cannot leave an advanced raw cursor. + if (fs.existsSync(s.sidecarPath)) fs.truncateSync(s.sidecarPath, before) + throw e } + s.sourceOffset = offset + s.runningHash.update(bytes) + s.bytesAppendedSinceSnapshot += bytes.length + clearCaptureError(`agent-${s.agentKind}`) +} - if (!paused) { - const failuresBefore = s.writeFailures - for (const raw of complete.split('\n')) { - const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw - if (!line.trim()) continue - s.linesSeen++ - // A malformed complete line stays malformed: skipped, as before. - if (processUnit(s, line, s.sourcePath) === 'unparsed') noteDbError('tailer-host', new Error('transcript line did not parse')) +function catchUpJsonl(s: SessionState): void { + if (!cfg.enabled || sessions.get(sessionKey(s.agentKind, s.sessionId)) !== s) return + try { + if (eventBus.paused || s.checkpointPaused) { + // Also recover a shutdown/crash during a persisted pause without replay. + discardJsonl(s) + if (eventBus.paused) return + s.checkpointPaused = false; checkpoint(s) } - // A turn that did not land keeps the sidecar where it was, so the same - // bytes are read again; turns that did land are skipped then by uuid. - // Advancing anyway lost the turn for good while the source looked fine. - // After the bounded retries the bytes are consumed, and the loss is - // recorded where the operator reads gaps rather than left implied. - if (s.writeFailures > failuresBefore && !retriesSpent(s)) { - s.pendingLineBuffer = before.pending - setPauseTail(s, before.pauseTail) - s.pauseSkippedLines = before.pauseSkipped - s.linesSeen = before.linesSeen - s.toolCallsSeen = before.toolCallsSeen - s.heldBack = true + const sourceSize = fs.statSync(s.sourcePath).size + if (s.adapter.detectReset?.(s.sourceOffset, sourceSize) ?? (sourceSize < s.sourceOffset)) { + const size = fs.existsSync(s.sidecarPath) ? fs.statSync(s.sidecarPath).size : 0 + // Keep prior evidence append-only; a reset never truncates recorded bytes. + const ev = ingestEvent('agent', { subtype: 'transcript_compacted', agent: s.agentKind, session_id: s.sessionId, + old_sidecar_bytes: size, old_sidecar_sha256: fileSha256(s.sidecarPath), new_source_bytes: sourceSize + }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) + if (!ev) throw new Error('AI reset was not saved') + s.discardPartial = false; checkpoint(s, 0); s.sourceOffset = 0 + s.pendingByParentUuid.clear(); s.pendingLineBuffer = ''; s.postCompact = true + s.ignoredParents.clear(); s.toolCommandByUseId.clear(); s.skipMissingParents = true + } + if (sourceSize <= s.sourceOffset) return + const limit = Math.min(sourceSize - s.sourceOffset, 10 * 1024 * 1024) + const fd = fs.openSync(s.sourcePath, 'r') + let bytes: Buffer + try { bytes = Buffer.alloc(limit); const read = fs.readSync(fd, bytes, 0, limit, s.sourceOffset); bytes = bytes.subarray(0, read) } + finally { fs.closeSync(fd) } + if (s.discardPartial) { + const end = bytes.indexOf(10) + const skip = end < 0 ? bytes.length : end + 1 + const previous = s.discardPartial + s.discardPartial = end < 0 + try { checkpoint(s, s.sourceOffset + skip) } catch (e) { s.discardPartial = previous; throw e } + s.sourceOffset += skip; bytes = bytes.subarray(skip) + } + const lastNl = bytes.lastIndexOf(10) + if (lastNl < 0) { + if (bytes.length === 10 * 1024 * 1024) throw new Error('AI transcript unit exceeds the 10 MB read limit') return } - if (s.pauseSkippedLines > 0) { - try { - const ev = ingestEvent('system', { - subtype: 'capture_gap', - agent: s.agentKind, - session_id: s.sessionId, - skipped_lines: s.pauseSkippedLines, - gap_started_at: s.pauseStartedAt, - gap_ended_at: Date.now(), - description: `Recording was paused; ${s.pauseSkippedLines} transcript line(s) consumed but not ingested or kept. The sidecar holds newline filler for those bytes.` - }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) - } catch (e) { noteDbError('tailer-host', e) } - s.pauseSkippedLines = 0 - s.pauseStartedAt = null + const complete = bytes.subarray(0, lastNl + 1) + reportGap(s) + // Preserve partial UTF-8 bytes at the source; decode only complete lines. + s.unitSidecarOffset = fs.existsSync(s.sidecarPath) ? fs.statSync(s.sidecarPath).size : 0 + for (const line of complete.toString('utf8').split('\n')) { + if (line.trim()) processUnit(s, line.replace(/\r$/, ''), s.sourcePath) + s.unitSidecarOffset += Buffer.byteLength(line + '\n') } + appendAndCommit(s, complete, s.sourceOffset + complete.length) + s.linesSeen += complete.toString('utf8').split('\n').filter(line => line.trim()).length scheduleIdleSnapshot(s) - } else { - const now = Date.now() - for (const raw of complete.split('\n')) { - if (raw.trim() || (raw.endsWith('\r') && raw.slice(0, -1).trim())) { - s.linesSeen++ - s.pauseSkippedLines++ - if (s.pauseStartedAt === null) s.pauseStartedAt = now - } - } - if (s.pendingLineBuffer) setPauseTail(s, true) - } - - // Sidecar append AFTER successful DB processing — the forensic raw copy - // is still maintained, but the cursor (sidecar size) now reflects only - // bytes whose events have been committed. - try { fs.appendFileSync(s.sidecarPath, kept, { mode: 0o600 }) } catch (e) { - noteDbError('tailer-host', e); return - } - s.runningHash.update(kept) - s.bytesAppendedSinceSnapshot += kept.length - - // If there is more data beyond the chunk cap, schedule another tick. - if (sourceSize - sourceOffset > toRead) { - setImmediate(() => catchUpJsonl(s)) - } + if (sourceSize - s.sourceOffset > complete.length && limit === 10 * 1024 * 1024) setImmediate(() => catchUpJsonl(s)) + } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } } -/** Per-message directory scan: each new file in the watched dir is one - * unit. Sidecar stores an index of processed filenames (line-per-name) - * so restart-safe by construction. */ function catchUpPerMessageDir(s: SessionState): void { if (!cfg.enabled || sessions.get(sessionKey(s.agentKind, s.sessionId)) !== s) return - // The "source path" for a per-message adapter is a DIRECTORY. List its - // contents, filter to files we haven't seen, sort by mtime, and process. - let entries: string[] = [] - try { entries = fs.readdirSync(s.sourcePath) } catch { return } - // Sidecar for a per-message adapter is an append-only newline-delimited - // index of processed filenames. Reload it into memory at register-time - // (see registerSession); check membership by looking at - // `s.redlogIdByUuid.has(filename)` where the "uuid" IS the filename. - const toProcess: Array<{ name: string; full: string; mtime: number }> = [] - for (const name of entries) { - if (s.redlogIdByUuid.has(name)) continue - const full = path.join(s.sourcePath, name) - let stat: fs.Stats - try { stat = fs.statSync(full) } catch { continue } - if (!stat.isFile() || stat.mtimeMs < (cfg.activationStarts?.[s.agentKind] ?? 0)) continue - toProcess.push({ name, full, mtime: stat.mtimeMs }) - } - toProcess.sort((a, b) => a.mtime - b.mtime) - - // A file goes into the index only once its events are written. Indexed - // first, a failed or interrupted write left the file marked done and it was - // never read again. A paused file is indexed unread, so resume does not - // record the pause. - // Once the retries are spent the file is indexed anyway, so one file the - // store or the parser keeps refusing does not keep the session retrying; - // catchUpSession records what was given up. - const exhausted = retriesSpent(s) - for (const p of toProcess) { - let raw: string - try { raw = fs.readFileSync(p.full, 'utf-8') } catch { continue } - s.linesSeen++ - if (eventBus.paused) { - markIndexed(s, p.name, '__paused__') - continue - } - const outcome = processUnit(s, raw, p.full) - if (outcome === 'ok') markIndexed(s, p.name, '__seen__') - else if (!exhausted) s.heldBack = true - else if (outcome === 'unparsed') { markIndexed(s, p.name, '__unparsed__'); s.unparsedGivenUp++ } - else markIndexed(s, p.name, '__failed__') - } - if (!eventBus.paused) scheduleIdleSnapshot(s) -} - -/** pauseTail outlives a restart in a marker beside the sidecar. Without it the - * rest of a paused line, arriving after a restart, reads as an ordinary - * fragment and its bytes are kept. */ -function setPauseTail(s: SessionState, on: boolean): void { - if (s.pauseTail === on) return - s.pauseTail = on try { - if (on) fs.writeFileSync(s.sidecarPath + PAUSE_TAIL_SUFFIX, '', { mode: 0o600 }) - else fs.rmSync(s.sidecarPath + PAUSE_TAIL_SUFFIX, { force: true }) - } catch (e) { noteDbError('tailer-host', e) } -} - -/** A marker only means something beside the sidecar it was written with. If - * retention pruned that sidecar the source is read from the start, and the - * marker would drop its first line. */ -function readPauseTail(adapter: TailerAdapter, sidecarPath: string): boolean { - const marker = sidecarPath + PAUSE_TAIL_SUFFIX - if (adapter.perMessageDir || !fs.existsSync(marker)) return false - let size = 0 - try { size = fs.statSync(sidecarPath).size } catch { /* pruned */ } - if (size > 0) return true - try { fs.rmSync(marker, { force: true }) } catch { /* harmless: re-checked next time */ } - return false -} - -function markIndexed(s: SessionState, name: string, sentinel: string): void { - try { fs.appendFileSync(s.sidecarPath, name + '\n') } catch { /* the in-memory mark still holds for this run */ } - s.bytesAppendedSinceSnapshot += name.length + 1 - // Not over a real event id: an adapter whose turn uuid is the file name has - // already recorded it, and `_causes` lookups need the id, not a sentinel. - if (!s.redlogIdByUuid.has(name)) s.redlogIdByUuid.set(name, sentinel) -} - -function scheduleUnitRetry(s: SessionState): void { - // A pass the watcher started leaves a pending retry's schedule alone. - if (s.retry && !s.retryFiring) return - const attempt = (s.retry?.attempt ?? 0) + 1 - // Bounded: a store that stays broken is already in capture health, and the - // next change tries again from here. - if (attempt > UNIT_RETRY_LIMIT) { s.retry = null; return } - armUnitRetry(s, attempt) + const units = fs.readdirSync(s.sourcePath).filter(name => name.endsWith('.json')).map(name => ({ name, full: path.join(s.sourcePath, name) })) + .map(p => ({ ...p, stat: fs.statSync(p.full) })).filter(p => p.stat.isFile() && p.stat.mtimeMs >= (activationFor(s.agentKind, s.sessionId) ?? 0)) + .sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.name.localeCompare(b.name)) + for (const p of units) { + if (s.redlogIdByUuid.get(p.name) === '__paused__') continue + if (eventBus.paused || s.checkpointPaused || (resumeBoundary > 0 && p.stat.mtimeMs < resumeBoundary + 1)) { + // Names only, no message/part content. Persist exclusions across restart. + appendAndCommit(s, Buffer.from('!pause:' + p.name + '\n'), s.sourceOffset) + s.redlogIdByUuid.set(p.name, '__paused__'); s.pauseSkippedLines++; s.pauseStartedAt ??= Date.now() + continue + } + const fingerprint = `${p.stat.mtimeMs}:${p.stat.size}` + if (s.completedFiles.get(p.name) === fingerprint) continue + reportGap(s) + processUnit(s, fs.readFileSync(p.full, 'utf8'), p.full) + if (!s.redlogIdByUuid.has(p.name)) appendAndCommit(s, Buffer.from(p.name + '\n'), s.sourceOffset) + s.redlogIdByUuid.set(p.name, '__seen__'); s.completedFiles.set(p.name, fingerprint); s.linesSeen++ + } + if (!eventBus.paused && s.checkpointPaused) { s.checkpointPaused = false; checkpoint(s) } + if (!eventBus.paused) scheduleIdleSnapshot(s) + } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } } -function armUnitRetry(s: SessionState, attempt: number): void { - if (s.retry) clearTimeout(s.retry.timer) - const delay = UNIT_RETRY_BASE_MS * 2 ** (attempt - 1) - const timer = setTimeout(() => { - if (sessions.get(sessionKey(s.agentKind, s.sessionId)) !== s) return - // Paused, a pass would only consume: wait out the pause, attempt unspent. - if (eventBus.paused) { armUnitRetry(s, attempt); return } - s.retryFiring = true - try { catchUpSession(s.agentKind, s.sessionId) } finally { s.retryFiring = false } - }, delay) - s.retry = { timer, attempt } +// Synchronous resume boundary precedes any delayed watcher callback. Empty +// sessions/new files are registered by normal discovery and use the same gate. +const onRecording = (recording: boolean): void => { + if (!cfg.enabled) return + try { saveBoundary(!recording) } catch (e) { for (const kind of cfg.enabledAgents ?? registeredAdapters.keys()) noteCaptureError(`agent-${kind}`, e); cfg.enabled = false; return } + for (const s of sessions.values()) { + try { + s.checkpointPaused = true + if (s.adapter.perMessageDir) catchUpPerMessageDir(s) + else discardJsonl(s) + s.resumedAt = Date.now(); s.skipMissingParents = true + if (recording) { s.checkpointPaused = false; checkpoint(s) } + } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } + } } +eventBus.on('recording', onRecording) -/** Whether the unit fully landed. `unparsed`: the adapter could not read it (a - * per-message file can be caught mid-write; a JSONL line can be malformed). - * `failed`: a turn's write threw. */ -type UnitOutcome = 'ok' | 'unparsed' | 'failed' -function processUnit(s: SessionState, rawContent: string, sourcePath: string): UnitOutcome { - const failuresBefore = s.writeFailures +function processUnit(s: SessionState, rawContent: string, sourcePath: string): void { let raw: ParsedTurn | ParsedTurn[] | null try { raw = s.adapter.parseUnit(rawContent, sourcePath) @@ -1141,12 +1011,17 @@ function processUnit(s: SessionState, rawContent: string, sourcePath: string): U }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) } catch (err) { noteDbError('tailer-host', err) } } - return 'unparsed' + noteCaptureError(`agent-${s.agentKind}`, e) + throw e } - if (!raw) return 'ok' + if (!raw) return const turns = Array.isArray(raw) ? raw : [raw] for (const turn of turns) { + if (s.adapter.knownIgnoredTypes.has(turn.type)) { + if (turn.uuid) s.ignoredParents.set(turn.uuid, turn.parentUuid) + continue + } // Schema-drift check: parseUnit returned a turn whose type isn't in the // adapter's whitelist. Fire the advisory once per session and skip. if (turn.type === 'tool_use') s.toolCallsSeen++ @@ -1168,7 +1043,6 @@ function processUnit(s: SessionState, rawContent: string, sourcePath: string): U } emitTurn(turn, { session: s }) } - return s.writeFailures === failuresBefore ? 'ok' : 'failed' } function scheduleIdleSnapshot(s: SessionState): void { @@ -1246,7 +1120,7 @@ function buildSeedIndex(): Map> { // of json_extract full-table scan. Falls back to json_extract for DBs // that haven't migrated yet. const rows = getDB().prepare( - `SELECT id, session_id AS sid, + `SELECT id, json_extract(data, '$.session_id') AS sid, json_extract(data, '$.agent') AS agent, transcript_uuid AS uuid FROM events @@ -1286,22 +1160,26 @@ export function registerSession(agentKind: string, sourcePath: string): void { : path.basename(sourcePath, path.extname(sourcePath)) // strip .jsonl const key = sessionKey(agentKind, sessionId) if (sessions.has(key)) return + if (!sessionAllowed(agentKind, sessionId)) return const cwd = adapter.resolveCwd(sourcePath) if (!cwd) return if (isSelfExcludedCwd(cwd, cfg.selfExclusionMarker ?? '.redlog-app-root')) return if (!cwdPassesGate(cwd, cfg.excludedPaths, allowedPaths)) return - if (!isSessionRegistered(sessionId)) return + if (!sessionAllowed(agentKind, sessionId)) return let projectDir: string try { projectDir = getProjectDir() } catch { return } const sidecarDir = path.join(projectDir, 'agent-transcripts') try { fs.mkdirSync(sidecarDir, { recursive: true, mode: 0o700 }) } catch { /* ignore */ } - const sidecarPath = path.join(sidecarDir, `${agentKind}-${sessionId}${cfg.activationStarts?.[agentKind] ? `-${cfg.activationStarts[agentKind]}` : ''}.jsonl`) + const sidecarPath = path.join(sidecarDir, `${agentKind}-${sessionId}${activationFor(agentKind, sessionId) ? `-${activationFor(agentKind, sessionId)}` : ''}.jsonl`) if (!isInsideDir(sidecarDir, sidecarPath)) return let sourceBaseOffset = 0 - const startedAt = cfg.activationStarts?.[agentKind] - if (startedAt && !adapter.perMessageDir) { + const startedAt = activationFor(agentKind, sessionId) + // File timestamps have fractional milliseconds; ambiguous same-ms bytes + // belong to the omitted side of a privacy boundary. + const captureFrom = Math.max(startedAt ?? 0, resumeBoundary) + if (captureFrom && !adapter.perMessageDir) { const cursorPath = sidecarPath + '.offset.json' try { if (fs.existsSync(cursorPath)) { @@ -1310,7 +1188,7 @@ export function registerSession(agentKind: string, sourcePath: string): void { sourceBaseOffset = cursor.base } else { const source = fs.statSync(sourcePath) - sourceBaseOffset = source.birthtimeMs < startedAt ? source.size : 0 + sourceBaseOffset = source.birthtimeMs < captureFrom + 1 ? source.size : 0 fs.writeFileSync(cursorPath, JSON.stringify({ source: sourcePath, startedAt, base: sourceBaseOffset }), { flag: 'wx', mode: 0o600 }) ingestEvent('agent', { subtype: 'transcript_capture_started', agent: agentKind, session_id: sessionId, skipped_source_bytes: sourceBaseOffset, activation_requested_at: startedAt, sidecar_file: path.basename(sidecarPath) }, { engagementId: cfg.engagementId, operatorId: cfg.operatorId }) } @@ -1322,6 +1200,15 @@ export function registerSession(agentKind: string, sourcePath: string): void { adapter, sourcePath, sourceBaseOffset, + sourceOffset: sourceBaseOffset, + discardPartial: false, + checkpointPaused: eventBus.paused, + pauseSkippedBytes: 0, + resumedAt: 0, + unitSidecarOffset: 0, + completedFiles: new Map(), + skipMissingParents: false, + ignoredParents: new Map(), sidecarPath, transcriptCwd: cwd, pendingLineBuffer: '', @@ -1341,44 +1228,30 @@ export function registerSession(agentKind: string, sourcePath: string): void { toolGapAdvisoryFired: false, runningHash: crypto.createHash('sha256'), pauseSkippedLines: 0, - pauseStartedAt: null, - writeFailures: 0, - retry: null, - retryFiring: false, - heldBack: false, - unparsedGivenUp: 0, - failedTurns: new Map(), - pauseTail: readPauseTail(adapter, sidecarPath) - } - // Align sidecar to the last complete-line boundary. Within a run, - // pendingLineBuffer carries incomplete trailing text across catchUp calls. - // On restart pendingLineBuffer is lost; any bytes past the last '\n' in - // the sidecar would never be re-read from the source. Truncate so those - // bytes are re-read and combined with new data on the next catchUp. - if (!adapter.perMessageDir) { - const TAIL_SCAN = 64 * 1024 - try { - const fd = fs.openSync(sidecarPath, 'r') - try { - const st = fs.fstatSync(fd) - if (st.size > 0) { - const readSize = Math.min(st.size, TAIL_SCAN) - const buf = Buffer.alloc(readSize) - fs.readSync(fd, buf, 0, readSize, st.size - readSize) - let lastNl = -1 - for (let i = buf.length - 1; i >= 0; i--) { - if (buf[i] === 0x0A) { lastNl = i; break } - } - if (lastNl >= 0) { - const alignedSize = st.size - readSize + lastNl + 1 - if (alignedSize < st.size) { - fs.ftruncateSync(fd, alignedSize) - } - } - } - } finally { fs.closeSync(fd) } - } catch { /* new sidecar or read error */ } + pauseStartedAt: null } + try { + const cursorPath = sidecarPath + '.offset.json' + if (fs.existsSync(cursorPath)) { + const cursor = JSON.parse(fs.readFileSync(cursorPath, 'utf8')) + if (cursor.source !== sourcePath || !Number.isSafeInteger(cursor.base) || !Number.isSafeInteger(cursor.offset ?? cursor.base) + || (cursor.offset ?? cursor.base) < 0) throw new Error('Invalid AI source checkpoint') + s.sourceOffset = cursor.offset ?? cursor.base + s.discardPartial = cursor.discardPartial === true + s.checkpointPaused = cursor.paused === true || eventBus.paused + s.pauseSkippedBytes = cursor.skippedBytes ?? 0; s.pauseStartedAt = cursor.gapStartedAt ?? null + if (cursor.ignoredParents) { + if (typeof cursor.ignoredParents !== 'object' || Array.isArray(cursor.ignoredParents) || Object.values(cursor.ignoredParents).some(id => id !== null && typeof id !== 'string')) throw new Error('Invalid AI parent metadata') + s.ignoredParents = new Map(Object.entries(cursor.ignoredParents)) + } + s.resumedAt = cursor.resumedAt ?? 0; s.skipMissingParents = cursor.skipMissingParents === true; s.replayFrom = cursor.replayFrom + if (cursor.savedBytes !== undefined) { + const actual = fs.existsSync(sidecarPath) ? fs.statSync(sidecarPath).size : 0 + if (!Number.isSafeInteger(cursor.savedBytes) || cursor.savedBytes < 0 || actual < cursor.savedBytes) throw new Error('AI sidecar is shorter than its checkpoint') + if (actual > cursor.savedBytes) fs.truncateSync(sidecarPath, cursor.savedBytes) + } + } else checkpoint(s) + } catch (e) { noteDbError('tailer-host', e); return } // Seed running hash from existing sidecar so incremental updates produce // the same digest as a full-file read would. try { @@ -1420,6 +1293,7 @@ export function registerSession(agentKind: string, sourcePath: string): void { const idx = fs.readFileSync(sidecarPath, 'utf-8') for (const raw of idx.split('\n')) { const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw + if (name.startsWith('!pause:')) { s.redlogIdByUuid.set(name.slice(7), '__paused__'); continue } if (name && !s.redlogIdByUuid.has(name)) { s.redlogIdByUuid.set(name, '__seen__') // sentinel, not a real id } @@ -1427,6 +1301,17 @@ export function registerSession(agentKind: string, sourcePath: string): void { } catch { /* new sidecar, no index yet */ } } + if (!eventBus.paused && !s.checkpointPaused && s.replayFrom !== undefined && !adapter.perMessageDir) { + try { + const bytes = fs.readFileSync(sidecarPath) + if (!Number.isSafeInteger(s.replayFrom) || s.replayFrom < 0 || s.replayFrom > bytes.length) throw new Error('Invalid AI pending replay boundary') + s.unitSidecarOffset = s.replayFrom + for (const line of bytes.subarray(s.replayFrom).toString('utf8').split('\n')) { + if (line.trim()) processUnit(s, line, sourcePath) + s.unitSidecarOffset += Buffer.byteLength(line + '\n') + } + } catch (e) { noteDbError('tailer-host', e); sessions.delete(key); return } + } catchUpSession(agentKind, sessionId) } @@ -1434,7 +1319,6 @@ function unregisterSession(key: string): void { const s = sessions.get(key) if (!s) return if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null } - if (s.retry) { clearTimeout(s.retry.timer); s.retry = null } const ct = coalesceTimers.get(key) if (ct) { clearTimeout(ct); coalesceTimers.delete(key) } // v0.7.4 F4: skip snapshot + session_end emits when paused. Still remove @@ -1460,6 +1344,9 @@ function unregisterSession(key: string): void { export function configureHost(next: Partial): void { stopHost() cfg = { ...cfg, ...next } + if (cfg.enabled) { + try { restoreBoundary() } catch (e) { for (const kind of cfg.enabledAgents ?? registeredAdapters.keys()) noteCaptureError(`agent-${kind}`, e); cfg.enabled = false; return } + } restartAll() } @@ -1494,29 +1381,39 @@ function restartAdapter(adapter: TailerAdapter): void { if (!cfg.enabled || !agentEnabled(adapter.agentKind) || (cfg.watchPathsByAgent && !cfg.watchPathsByAgent[adapter.agentKind]?.length)) { watchersByAgent.delete(adapter.agentKind); return } const generation = hostGeneration const current = (): boolean => generation === hostGeneration && cfg.enabled && agentEnabled(adapter.agentKind) - const root = expandTilde(adapter.transcriptGlob.split('*')[0] || adapter.transcriptGlob) + const root = adapter.transcriptRoot ?? expandTilde(adapter.transcriptGlob.split('*')[0] || adapter.transcriptGlob) const watchDir = adapter.perMessageDir ? root // per-message: watch parent dir, each subdir/file is a session : root // per-line: same — chokidar filters within - if (!fs.existsSync(watchDir)) return if (adapter.init && !initializedAdapters.has(adapter.agentKind)) { initializedAdapters.add(adapter.agentKind) - adapter.init({ acceptsMessage: (kind, msg) => current() && hostControlSurface.acceptsMessage(kind, msg), emitTurns: (kind, sid, turns) => { if (current()) hostControlSurface.emitTurns(kind, sid, turns) } }) + adapter.init({ acceptsMessage: (kind, msg, changedAt) => current() && hostControlSurface.acceptsMessage(kind, msg, changedAt), emitTurns: (kind, sid, turns) => { if (current()) hostControlSurface.emitTurns(kind, sid, turns) } }) } const isJsonl = !adapter.perMessageDir - const watchDirCanon = path.resolve(watchDir) - const watcher = chokidar.watch(watchDir, { + let existing = path.resolve(watchDir) + while (!fs.existsSync(existing) && path.dirname(existing) !== existing) existing = path.dirname(existing) + const missing = path.relative(existing, path.resolve(watchDir)) + const watchTarget = fs.realpathSync(existing) + const watchDirCanon = path.join(watchTarget, missing) + const watcher = chokidar.watch(missing ? watchTarget : watchDirCanon, { + disableGlobbing: true, // Ignore nested hidden paths, not the explicitly authorized transcript // root (.codex/.claude). Directories must remain traversable: filtering // every non-jsonl path here also filtered all intermediate directories. ignored: (p: string, stat?: fs.Stats): boolean => { - const relative = path.relative(watchDirCanon, path.resolve(p)) + const candidate = path.resolve(p) + const relative = path.relative(watchDirCanon, candidate) + // Chokidar checks missing roots through their existing ancestors. Keep + // those directories traversable without admitting sibling files/trees. + if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) { + return !(watchDirCanon.startsWith(candidate + path.sep) && (!stat || stat.isDirectory())) + } if (relative.split(path.sep).some(part => part.startsWith('.') || part === 'node_modules')) return true return !!(isJsonl && stat?.isFile() && !p.endsWith('.jsonl')) }, persistent: true, ignoreInitial: false, - depth: 6, + depth: 6 + (missing ? missing.split(path.sep).length : 0), awaitWriteFinish: false }) watcher.on('add', (p) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f02f077..8d1287dc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,7 +92,8 @@ const api: RedLogAPI = { reveal: (kind: 'artifact' | 'screenshot', file: string) => ipcRenderer.invoke('artifacts:reveal', kind, file) as Promise }, agents: { - sources: () => ipcRenderer.invoke('agents:sources') as Promise> + sessions: () => ipcRenderer.invoke('agents:sessions') as Promise>, + sources: (roots?: Record) => ipcRenderer.invoke('agents:sources', roots) as Promise> }, hookConfig: { get: () => ipcRenderer.invoke('hookConfig:get') as Promise<{ excludedPaths: string[] }>, diff --git a/src/renderer/src/components/settings/AgentsPanel.tsx b/src/renderer/src/components/settings/AgentsPanel.tsx index 1793208f..3b0e5971 100644 --- a/src/renderer/src/components/settings/AgentsPanel.tsx +++ b/src/renderer/src/components/settings/AgentsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { FieldGroup, type ConfigState } from './SettingsShared' import { Button } from '../Button' +import { FieldGroup, type ConfigState } from './SettingsShared' import { confirmGraded } from '../ConfirmDialog' import { SourceLastSeen } from './SourceLastSeen' import { CheckRow } from './CheckRow' @@ -20,10 +20,10 @@ type Source = { id: string; label: string; root: string; detected: boolean } // different screens (this panel, Plugins, the capture-health card) are now // told here, in the health card's own vocabulary, with the recovery action // next to the sentence that says something is wrong. -function AgentRow({ item, source, active, config, setConfig, t, mutate, busy, onOpenPlugins, health }: { +function AgentRow({ item, source, active, config, setConfig, t, mutate, busy, health, sessions }: { item: OfficialAgentPlugin; source?: Source; active: boolean; config: ConfigState; setConfig: (c: ConfigState) => void; t: T mutate: (kind: AgentKind, operation: 'install' | 'uninstall') => Promise; busy: boolean - onOpenPlugins?: () => void + sessions: Array<{ sessionId: string; cwd?: string; recording: boolean }> health?: CaptureSourceInfo }): JSX.Element { const latest = useRef(config); latest.current = config @@ -87,75 +87,73 @@ function AgentRow({ item, source, active, config, setConfig, t, mutate, busy, on if (!ok) return await mutate(item.agentKind, 'uninstall') } - return
-
+ const pickSource = async (): Promise => { + const project = latest.current.engagement.id + setPicking(true); setError(null) + try { + const root = await window.redlog.hookConfig.pickPath(t('settings.agents.changeSource')) + if (!root || latest.current.engagement.id !== project) return + const current = latest.current.agentTailer?.sources?.[item.agentKind] ?? {} + if (root !== (current.sourceRoot ?? source?.root)) update({ ...current, sourceRoot: root, enabled: false, startedAt: undefined, scopeChangedAt: current.enabled ? Date.now() : undefined }) + } catch (e) { setError(String((e as Error).message ?? e)) } finally { setPicking(false) } + } + const root = value.sourceRoot ?? source?.root + return
+
{source?.label ?? item.name} - {item.state === 'not-installed' ? - : item.state === 'installed' ? : null} + {item.state === 'update-available' ? : item.state === 'not-installed' ? + : item.state === 'installed' && !active ? + : item.state === 'installed' ? update({ ...value, enabled: next, installationId: item.installationId, startedAt: next ? Date.now() : value.startedAt, scopeChangedAt: undefined })} + label={t(scopeChanged ? 'settings.agents.confirmScope' : 'settings.agents.enableProject')} + /> : null}
- {/* Two axes, named. "Installed" is about this computer; "recording" is - about this project. They were one sentence, so an operator who had - installed the integration everywhere could not tell which one the row - was answering. */} -

- {t(item.state === 'installed' ? 'settings.agents.installedHere' : 'settings.agents.notInstalled')} - {item.state === 'installed' && <> · {t(projectRecording ? 'settings.agents.recordingForProject' : 'settings.agents.notRecordingForProject')}} -

- {/* Transcript folder. This line used to say "start the AI tool, then - reopen this project" whenever the folder was missing — including when - the integration was not installed at all, where following that advice - records nothing and there is no way to tell why. The folder is a fact - about the AI tool; whether it can be read is a fact about the - integration. Only the installed row has anything to do with it, and - only there is the advice actionable. */} - {source && ( -

- {t(source.detected ? 'settings.agents.detected' : 'settings.agents.notDetected')} · {source.root} - {!source.detected && item.state === 'installed' && <> — {t('settings.agents.notDetectedHint')}} -

- )} - {/* What it last delivered. The health card says this for the whole - capture; here it is the source's own row, so the operator does not have - to leave the page to find out whether anything is arriving. */} - - {(item.state === 'conflict' || item.state === 'error') &&

{item.error}

} + {(item.state === 'conflict' || item.state === 'error') &&

{item.error}

} {health?.lastError &&

{health.lastError.message}

} {item.state === 'installed' && <> - {!active &&
-

{t('settings.agents.pluginDisabled')}

- {onOpenPlugins && } -
} -

{t('settings.agents.projectWork')}

-
    {paths.map(dir =>
  • {dir}
  • )}
- - {!paths.length &&

{t('settings.agents.noWork')}

} - {/* The scope moved under a recording source. The sentence says which - happened, and the checkbox below becomes the one action that resolves - it — one control, not two ways to do the same thing. */} - {scopeChanged - ?

{t('settings.agents.scopeChanged')}

- : active && paths.length > 0 && !projectRecording &&

{t('settings.agents.notRecording')}

} - update({ - ...value, - enabled: next, - installationId: item.installationId, - startedAt: next ? Date.now() : value.startedAt, - scopeChangedAt: undefined - })} - label={t(scopeChanged ? 'settings.agents.confirmScope' : 'settings.agents.enableProject')} - /> + {scopeChanged &&

{t('settings.agents.scopeChanged')}

} +
+ {paths.length > 0 &&
    {paths.map(dir =>
  • {dir}
  • )}
} + +
+ {sessions.length > 0 &&
{t('settings.agents.sessions')} ({sessions.length})
    {sessions.map(session => { + const excluded = value.excludedSessions?.includes(session.sessionId) === true + return
  • + {session.sessionId} + {t(excluded ? 'settings.agents.sessionExcluded' : session.recording ? 'settings.agents.sessionRecording' : 'settings.agents.sessionPaused')} + +
  • + })}
} + {!projectRecording && paths.length > 0 &&

{t('settings.agents.captureBoundary')}

} + +
+ {t('settings.agents.sourceSettings')} +
+

{t(source?.detected ? 'settings.agents.detected' : 'settings.agents.notDetected')}

+ {root} +
+ + {value.sourceRoot !== undefined && } + +
+
+
} - {error &&

{error}

} -
+ {error &&

{error}

} + } -export default function AgentsPanel({ t, config, setConfig, onOpenPlugins }: { t: T; config: ConfigState; setConfig: (c: ConfigState) => void; onOpenPlugins?: () => void }): JSX.Element { +export default function AgentsPanel({ t, config, setConfig }: { t: T; config: ConfigState; setConfig: (c: ConfigState) => void }): JSX.Element { const [items, setItems] = useState(null) const [sources, setSources] = useState([]) + const [sessions, setSessions] = useState>([]) const [active, setActive] = useState([]) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) @@ -167,12 +165,22 @@ export default function AgentsPanel({ t, config, setConfig, onOpenPlugins }: { t const load = useCallback(async () => { const token = ++generation.current; setError(null) try { - const [catalog, detected, plugins] = await Promise.all([window.redlog.plugins.officialAgents(), window.redlog.agents.sources(), window.redlog.plugins.list()]) + const [catalog, detected, plugins] = await Promise.all([window.redlog.plugins.officialAgents(), window.redlog.agents.sources(Object.fromEntries(Object.entries(config.agentTailer?.sources ?? {}).flatMap(([kind, source]) => source?.sourceRoot ? [[kind, source.sourceRoot]] : []))), window.redlog.plugins.list()]) if (token !== generation.current) return setItems(catalog); setSources(detected); setActive((plugins as Array<{ id: string; status: string }>).filter(p => p.status === 'active').map(p => p.id)) } catch (e) { if (token === generation.current) setError(String((e as Error).message ?? e)) } - }, []) - useEffect(() => { void load(); return () => { ++generation.current } }, [load]) + }, [config.engagement.id, agentSignature]) + useEffect(() => { void load(); return () => { ++generation.current } }, [load, config.engagement.id, agentSignature]) + useEffect(() => { + let current = true + const refresh = async (): Promise => { + try { const next = await window.redlog.agents.sessions(); if (current) setSessions(next) } + catch (e) { if (current) { setSessions([]); setError(String((e as Error).message ?? e)) } } + } + void refresh() + const timer = setInterval(() => void refresh(), 5_000) + return () => { current = false; clearInterval(timer) } + }, [config.engagement.id, agentSignature]) const mutate = async (kind: AgentKind, operation: 'install' | 'uninstall'): Promise => { setBusy(true); setError(null) try { @@ -183,16 +191,13 @@ export default function AgentsPanel({ t, config, setConfig, onOpenPlugins }: { t } catch (e) { setError(String((e as Error).message ?? e)) } finally { setBusy(false) } } return -

{t('settings.agents.optionalHint')}

-

{t('settings.agents.fromNow')}

- {error &&

{error}

} + {error &&

{error}

} {!items && !error &&

{t('common.loading')}

} -
{items?.map(item => s.id === item.agentKind)} active={active.includes(item.pluginId)} config={config} setConfig={setConfig} t={t} mutate={mutate} busy={busy} onOpenPlugins={onOpenPlugins} health={health?.sources.find(s => s.id === `agent-${item.agentKind}`)} />)}
- {items?.map(item => s.id === item.agentKind)} active={active.includes(item.pluginId)} config={config} setConfig={setConfig} t={t} mutate={mutate} busy={busy} sessions={sessions.filter(s => s.agentKind === item.agentKind)} health={health?.sources.find(s => s.id === `agent-${item.agentKind}`)} />)}
+ {items?.some(item => item.state === 'installed') && setConfig({ ...config, agentTailer: { ...config.agentTailer, emitThinking: next } })} label={t('settings.agents.emitThinking')} - /> -

{t('settings.agents.selfExclusionHint')}

+ />} } diff --git a/src/renderer/src/components/settings/CaptureControlPage.tsx b/src/renderer/src/components/settings/CaptureControlPage.tsx index c7026c2c..5a0d4dc1 100644 --- a/src/renderer/src/components/settings/CaptureControlPage.tsx +++ b/src/renderer/src/components/settings/CaptureControlPage.tsx @@ -38,6 +38,84 @@ export default function CaptureControlPage({ of this page. The same four sources are rows in the Dashboard's capture inventory, where they carry whether they are actually feeding the record — which is what an operator came here to find out. */} + {/* Optional AI integrations are installed per tool and authorized per project. */} + + + + +
+ {[ + { v: 0, k: 'settings.screenshot.interval.off' }, + { v: 30, k: 'settings.screenshot.interval.30s' }, + { v: 60, k: 'settings.screenshot.interval.60s' }, + { v: 300, k: 'settings.screenshot.interval.5m' } + ].map((opt) => ( + + ))} +
+ + {/* The screenshot source is a source like the others: when it last + delivered a frame is a fact about the record, not about the + switch. */} + 0 || config.screenshot?.captureOnCommand === true} + t={t} + /> + + setConfig({ ...config, screenshot: { ...config.screenshot, captureOnCommand: on } })} + label={t('settings.screenshot.onCommand')} + /> +
+ {t('settings.screenshot.options')} +
+ setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} + type="number" + /> + + +
+ {[ + { v: 0, k: 'settings.screenshot.diff.off' }, + { v: 5, k: 'settings.screenshot.diff.standard' }, + { v: 12, k: 'settings.screenshot.diff.major' } + ].map((opt) => ( + + ))} +
+

{t('settings.screenshot.diffHint')}

+ + + + +
+
+
+ - {/* Optional AI integrations are installed per tool and authorized per project. */} - {/* A Windows capture on a Mac is not setup that is missing. Off Windows it waits in a collapsed group, and stays in the open if it was ever @@ -135,70 +211,7 @@ export default function CaptureControlPage({ what ships with RedLog is not a plugin. */} - -
- {[ - { v: 0, k: 'settings.screenshot.interval.off' }, - { v: 30, k: 'settings.screenshot.interval.30s' }, - { v: 60, k: 'settings.screenshot.interval.60s' }, - { v: 300, k: 'settings.screenshot.interval.5m' } - ].map((opt) => ( - - ))} -
- - {/* The screenshot source is a source like the others: when it last - delivered a frame is a fact about the record, not about the - switch. */} - 0 || config.screenshot?.captureOnCommand === true} - t={t} - /> - setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} - type="number" - /> - - -
- {[ - { v: 0, k: 'settings.screenshot.diff.off' }, - { v: 5, k: 'settings.screenshot.diff.standard' }, - { v: 12, k: 'settings.screenshot.diff.major' } - ].map((opt) => ( - - ))} -
-

{t('settings.screenshot.diffHint')}

- - setConfig({ ...config, screenshot: { ...config.screenshot, captureOnCommand: on } })} - label={t('settings.screenshot.onCommand')} - /> -

{t('settings.screenshot.onCommandHint')}

-
) diff --git a/src/renderer/src/components/settings/ExternalCaptureGroup.tsx b/src/renderer/src/components/settings/ExternalCaptureGroup.tsx index 08dc6e0b..80311759 100644 --- a/src/renderer/src/components/settings/ExternalCaptureGroup.tsx +++ b/src/renderer/src/components/settings/ExternalCaptureGroup.tsx @@ -129,27 +129,16 @@ export default function ExternalCaptureGroup({ t }: { question an operator actually has about a producer they started by hand. */} {l && } - {/* The manifest's own prose is written for whoever maintains - the pack, in English. The translated line says what this - puts in the record, which is also what makes the source - findable: searching 封包 or tcpdump used to return nothing - at all, because a key built at runtime is invisible to the - index generator. */} - {describe(hook) && ( -

{describe(hook)}

- )} -
- {groupIndex === 0 && } -
+
+ {t('settings.externalSetup')} + {describe(hook) &&

{describe(hook)}

} + {groupIndex === 0 && } +
) }) return groupIndex === 0 ?
{rows}
- : group.length > 0 ?
+ : group.length > 0 ?
{t('settings.otherPlatforms')} ({group.length}) {rows}
: null diff --git a/src/renderer/src/components/settings/PluginsPanel.tsx b/src/renderer/src/components/settings/PluginsPanel.tsx index 560ae83d..337702a9 100644 --- a/src/renderer/src/components/settings/PluginsPanel.tsx +++ b/src/renderer/src/components/settings/PluginsPanel.tsx @@ -7,6 +7,7 @@ import { PluginSampleTester } from './PluginSampleTester' import { CAPTURE_PACKS } from '../../../../core/capture-packs' interface PluginView { + officialAgent?: boolean id: string name: string version: string @@ -134,8 +135,8 @@ const OWNED_ELSEWHERE = new Set([ // existed would sit disabled forever, with 擷取 pack reporting it unavailable // and the only control that could bring it back hidden from the operator. A // row that is off is always shown, because that is the row someone needs. -const isHidden = (p: { id: string; status: string }): boolean => - OWNED_ELSEWHERE.has(p.id) && p.status !== 'disabled' +const isHidden = (p: { id: string; status: string; officialAgent?: boolean }): boolean => + p.officialAgent === true || (OWNED_ELSEWHERE.has(p.id) && p.status !== 'disabled') // The manifests describe these packs to whoever maintains them: spec // numbers, doc paths, the identifiers the code uses, and the reason the diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index c3d965b2..ee4625a7 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -215,8 +215,9 @@ interface RedLogAPI { reveal: (kind: 'artifact' | 'screenshot', file: string) => Promise } agents: { + sessions: () => Promise>, /** The AI tools whose local session files RedLog can read, and whether each folder exists here. */ - sources: () => Promise> + sources: (roots?: Record) => Promise> } hookConfig: { get: () => Promise<{ excludedPaths: string[] }> diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index af2cf776..51b889ed 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -153,8 +153,8 @@ "settings.integrityFullWalked": "Walked {{n}} events", "settings.integrityFullBrokenAt": "Broken at event", "settings.integrityFullReason": "Reason", - "settings.integrityFullAnchorMatch": "✓ Latest anchor matches walked head", - "settings.integrityFullAnchorMismatch": "⚠ Latest anchor does NOT match walked head", + "settings.integrityFullAnchorMatch": "✓ Anchor matches the events it covers", + "settings.integrityFullAnchorMismatch": "⚠ Anchor does not match the events it covers", "settings.integrityFullNoAnchor": "No anchor yet: only the rows were checked against each other.", "settings.integrityFullClockAnomalies": "{{n}} clock anomaly / anomalies detected", "timeline.clusterMoreItems": "+{{count}} more — zoom in to see individually", @@ -358,11 +358,10 @@ "settings.displayZoneLocal": "Local", "settings.displayZoneUtc": "UTC", "settings.displayZoneHint": "Every event time in the app is printed in this zone, and a UTC time ends in Z. Exports stay ISO 8601 with the offset.", - "settings.agents": "AI Agents", + "settings.agents": "AI conversations", "settings.agents.detected": "Transcript folder found", - "settings.agents.notDetected": "No transcript folder yet", + "settings.agents.notDetected": "Transcript folder not found", "settings.agents.emitThinking": "Include thinking blocks", - "settings.agents.selfExclusionHint": "A `.redlog-app-root` file in a repo opts it out of tailing — commit one to keep RedLog out of that repo.", "settings.loading": "Loading...", "settings.engagementIdHint": "Generated when the project was created, stamped on every event, not editable. Include it in a bug report.", "settings.engagementNameHint": "Display only. Changing this renames the project — the title bar and the project picker follow.", @@ -430,7 +429,7 @@ "settings.overlayShowInDock": "Keep Dock icon (macOS)", "settings.overlayShowInDockHint": "Off hides the Dock icon while the HUD is open; run it from the menu bar.", "settings.screenshotGroup": "Screenshots", - "settings.screenshot.interval.off": "Off", + "settings.screenshot.interval.off": "No scheduled capture", "settings.screenshot.interval.30s": "Every 30s", "settings.screenshot.interval.60s": "Every 1m", "settings.screenshot.interval.5m": "Every 5m", @@ -482,7 +481,6 @@ "settings.screenshot.diff.major": "Major changes only", "settings.screenshot.diffHint": "Skips frames that look the same as the last stored one. Manual captures always land.", "settings.screenshot.onCommand": "Screenshot when a command finishes", - "settings.screenshot.onCommandHint": "Captures a frame when a command finishes and links it to that command.", "settings.cdp": "Chrome DevTools Protocol", "settings.cdpPort": "CDP Port", "settings.testConnection": "Test Connection", @@ -1369,7 +1367,7 @@ "statusBar.uptimeSince": "Time since this project was created ({{date}}), not this session", "settings.lootRuleStopped": "Stopped: this rule ran past its time limit, so it no longer runs — or masks what it matches. Reload the plugin after fixing the pattern.", "settings.externalCapture": "External capture sources", - "settings.externalCaptureHint": "These need root or change this machine, so you run the command. Applies to every project on this machine.", + "settings.externalCaptureHint": "Some steps require elevated privileges or change this computer’s network settings.", "settings.externalDisabled": "Switched off in Plugins", "settings.externalDisabledFeeding": "Switched off — and still receiving", "settings.externalFeeding": "Running", @@ -1659,23 +1657,22 @@ "export.formatHelp.har": "Web request/response exchanges for compatible HTTP tools.", "export.formatHelp.timeline": "The selected events as a readable timeline.", "export.range.all": "Entire project", - "settings.agents.optionalHint": "Installs the RedLog capture integration locally. Installation does not enable capture or install the AI tool.", - "settings.agents.fromNow": "Captures new content only. Existing sessions start at the current end; changing work directories requires enabling again.", + "settings.agents.updateCapture": "Update integration", + "settings.agents.captureBoundary": "Authorized conversations include tool content read from outside these working directories.", + "settings.agents.sessions": "Conversations", + "settings.agents.sessionExcluded": "Excluded", + "settings.agents.sessionRecording": "Recording", + "settings.agents.sessionPaused": "Paused", + "settings.agents.allowSession": "Record new content", + "settings.agents.excludeSession": "Stop this conversation", "settings.agents.installCapture": "Install integration", "settings.agents.removeCapture": "Remove integration", - "settings.agents.installedHere": "Installed on this computer", - "settings.agents.notInstalled": "Capture integration not installed", - "settings.agents.pluginDisabled": "The plugin is not active. Check Plugins for the reason and next step.", - "settings.agents.notRecording": "These working directories are not being recorded yet.", "settings.agents.projectWork": "Working directories allowed for this project", "settings.agents.pickWork": "Add working directory…", - "settings.agents.noWork": "No working directory selected; no AI conversations will be recorded.", "settings.agents.enableProject": "Record new conversations in this project", "settings.agents.removeWork": "Remove directory: {{path}}", "settings.agents.removeDirectory": "Remove", - "settings.agents.recordingForProject": "recording for this project", - "settings.agents.notRecordingForProject": "not recording for this project", - "settings.agents.scopeChanged": "Scope changed — recording stopped until the new range is confirmed.", + "settings.agents.scopeChanged": "Source range changed; confirm to resume recording.", "settings.agents.confirmScope": "Confirm this range and record", "settings.agents.removeTitle": "Remove capture integration", "settings.agents.removeWhy": "The integration is removed from this computer.", @@ -1692,7 +1689,6 @@ "settings.wsl.status.pass": "passed", "settings.wsl.status.fail": "failed", "settings.wsl.status.warn": "warning", - "settings.agents.notDetectedHint": "Start the AI tool once; it creates the folder.", "common.openAtMoment": "Open the timeline at this moment", "capture.httpsUnverified": "HTTPS not verified with a client", "firstRun.http.browserChoice": "Browser to record", @@ -1701,5 +1697,11 @@ "firstRun.http.verifySettings": "Verify HTTPS capture", "export.preview.attachments": "Attachments", "export.condition.enabled": "Enabled", - "export.condition.disabled": "Disabled" + "export.condition.disabled": "Disabled", + "settings.agents.restoreCapture": "Restore integration", + "settings.agents.sourceSettings": "Source settings", + "settings.agents.changeSource": "Choose transcript folder…", + "settings.agents.defaultSource": "Use default location", + "settings.screenshot.options": "Image options", + "settings.externalSetup": "Setup" } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 05227b94..b32f1f9b 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -153,8 +153,8 @@ "settings.integrityFullWalked": "已走過 {{n}} 筆事件", "settings.integrityFullBrokenAt": "斷點事件", "settings.integrityFullReason": "原因", - "settings.integrityFullAnchorMatch": "✓ 最新錨點對應走完後的 head", - "settings.integrityFullAnchorMismatch": "⚠ 最新錨點與走完後的 head 不符", + "settings.integrityFullAnchorMatch": "✓ 錨點與其涵蓋的證據鏈相符", + "settings.integrityFullAnchorMismatch": "⚠ 錨點與其涵蓋的證據鏈不符", "settings.integrityFullNoAnchor": "尚無錨點:只檢查了各列之間的雜湊。", "settings.integrityFullClockAnomalies": "偵測到 {{n}} 次時鐘異常", "timeline.clusterMoreItems": "還有 {{count}} 個 — 放大以個別檢視", @@ -358,11 +358,10 @@ "settings.displayZoneLocal": "本地", "settings.displayZoneUtc": "UTC", "settings.displayZoneHint": "App 裡所有事件時間都以此時區顯示,UTC 時間結尾標示 Z。匯出一律是含時差的 ISO 8601。", - "settings.agents": "AI 代理", + "settings.agents": "AI 對話", "settings.agents.detected": "已找到對話紀錄目錄", - "settings.agents.notDetected": "尚無對話紀錄目錄", + "settings.agents.notDetected": "找不到對話資料夾", "settings.agents.emitThinking": "包含 thinking blocks", - "settings.agents.selfExclusionHint": "在 repo 放 `.redlog-app-root` 可排除 tailing;commit 它就能避免 RedLog 監聽該 repo。", "settings.loading": "載入中...", "settings.engagementIdHint": "建立專案時產生,蓋在每一筆事件上,不可更改。回報問題時請附上。", "settings.engagementNameHint": "顯示用。改這裡就是改專案名稱——標題列與專案挑選器都會跟著變。", @@ -430,7 +429,7 @@ "settings.overlayShowInDock": "在 Dock 顯示圖示(macOS)", "settings.overlayShowInDockHint": "關閉後 HUD 開啟時不顯示 Dock 圖示,從選單列操作。", "settings.screenshotGroup": "截圖", - "settings.screenshot.interval.off": "關閉", + "settings.screenshot.interval.off": "關閉定時截圖", "settings.screenshot.interval.30s": "每 30 秒", "settings.screenshot.interval.60s": "每 1 分鐘", "settings.screenshot.interval.5m": "每 5 分鐘", @@ -482,7 +481,6 @@ "settings.screenshot.diff.major": "只存明顯變動", "settings.screenshot.diffHint": "跳過與前一張相同的畫面;手動擷取一律保存。", "settings.screenshot.onCommand": "指令結束時截圖", - "settings.screenshot.onCommandHint": "指令結束時截圖並鏈回該指令。", "settings.cdp": "Chrome DevTools Protocol", "settings.cdpPort": "CDP 連接埠", "settings.testConnection": "測試連線", @@ -1369,7 +1367,7 @@ "statusBar.uptimeSince": "自專案建立({{date}})起的時間,不是本次工作階段", "settings.lootRuleStopped": "已停止:這條規則超過執行時間上限,不再執行,它比對的內容也不再被遮蔽。修正 pattern 後請重新載入 plugin。", "settings.externalCapture": "外部擷取來源", - "settings.externalCaptureHint": "這些需要 root 或會改動本機設定,因此由你執行指令。套用到這台機器的所有專案。", + "settings.externalCaptureHint": "部分步驟需要系統權限,或會變更本機網路設定。", "settings.externalDisabled": "已在外掛頁停用", "settings.externalDisabledFeeding": "已停用,但資料還在進來", "settings.externalFeeding": "執行中", @@ -1659,23 +1657,22 @@ "export.formatHelp.har": "網頁請求與回應,供支援 HAR 的流量工具使用。", "export.formatHelp.timeline": "將所選事件整理為可閱讀的時間軸。", "export.range.all": "整個專案", - "settings.agents.optionalHint": "在本機安裝 RedLog 擷取整合;安裝不會啟用擷取,也不會安裝 AI 工具。", - "settings.agents.fromNow": "只保存新內容。既有 session 從接入時開始,不匯入歷史;更換工作目錄後需重新啟用。", + "settings.agents.updateCapture": "更新整合", + "settings.agents.captureBoundary": "授權對話會包含工具從其他目錄讀取的內容。", + "settings.agents.sessions": "對話", + "settings.agents.sessionExcluded": "已排除", + "settings.agents.sessionRecording": "擷取中", + "settings.agents.sessionPaused": "已暫停", + "settings.agents.allowSession": "記錄新內容", + "settings.agents.excludeSession": "停止此對話", "settings.agents.installCapture": "安裝擷取整合", "settings.agents.removeCapture": "移除整合", - "settings.agents.installedHere": "已安裝於本機", - "settings.agents.notInstalled": "尚未安裝擷取整合", - "settings.agents.pluginDisabled": "外掛目前未啟用;請到「外掛」設定查看原因並處理。", - "settings.agents.notRecording": "這組工作目錄尚未開始記錄。", "settings.agents.projectWork": "本專案允許的工作目錄", "settings.agents.pickWork": "加入工作目錄…", - "settings.agents.noWork": "尚未選擇工作目錄;不會記錄任何 AI 對話。", "settings.agents.enableProject": "在此專案記錄新對話", "settings.agents.removeWork": "移除目錄:{{path}}", "settings.agents.removeDirectory": "移除", - "settings.agents.recordingForProject": "為本專案記錄中", - "settings.agents.notRecordingForProject": "未為本專案記錄", - "settings.agents.scopeChanged": "範圍已變更,記錄已停止,確認新範圍後才會繼續。", + "settings.agents.scopeChanged": "來源範圍已變更,確認後才會繼續記錄。", "settings.agents.confirmScope": "確認範圍並記錄", "settings.agents.removeTitle": "移除擷取整合", "settings.agents.removeWhy": "這台電腦上的擷取整合會被移除。", @@ -1692,7 +1689,6 @@ "settings.wsl.status.pass": "通過", "settings.wsl.status.fail": "失敗", "settings.wsl.status.warn": "警告", - "settings.agents.notDetectedHint": "先啟動一次 AI 工具,它會建立這個目錄。", "common.openAtMoment": "在時間軸開啟這個時刻", "capture.httpsUnverified": "HTTPS 尚未以工具驗證", "firstRun.http.browserChoice": "要記錄的瀏覽器", @@ -1701,5 +1697,11 @@ "firstRun.http.verifySettings": "驗證 HTTPS 擷取", "export.preview.attachments": "附件", "export.condition.enabled": "開啟", - "export.condition.disabled": "關閉" + "export.condition.disabled": "關閉", + "settings.agents.restoreCapture": "恢復擷取整合", + "settings.agents.sourceSettings": "來源設定", + "settings.agents.changeSource": "選擇對話資料夾…", + "settings.agents.defaultSource": "使用預設位置", + "settings.screenshot.options": "影像選項", + "settings.externalSetup": "設定步驟" } diff --git a/src/renderer/src/lib/settingsSearchIndex.ts b/src/renderer/src/lib/settingsSearchIndex.ts index 10ed6052..d679c2b7 100644 --- a/src/renderer/src/lib/settingsSearchIndex.ts +++ b/src/renderer/src/lib/settingsSearchIndex.ts @@ -59,6 +59,12 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.wsl.diagTitle' ], captureControl: [ + 'settings.screenshotGroup', + 'settings.screenshot.onCommand', + 'settings.screenshot.options', + 'settings.jpegQuality', + 'settings.screenshot.diffLabel', + 'settings.screenshot.diffHint', 'settings.packHostMonitors', 'settings.packHostMonitorsHint', 'settings.clipboardGroup', @@ -84,34 +90,28 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.packWindowsOutput', 'settings.powershellTranscriptEnableHint', 'settings.otherPlatforms', - 'settings.screenshotGroup', - 'settings.jpegQuality', - 'settings.screenshot.diffLabel', - 'settings.screenshot.diffHint', - 'settings.screenshot.onCommand', - 'settings.screenshot.onCommandHint', 'settings.agents.pickWork', 'settings.agents.removeTitle', 'settings.agents.removeWhy', 'settings.agents.removeConfirm', 'settings.agents.removeStops', 'settings.agents.removeKeeps', + 'settings.agents.changeSource', + 'settings.agents.updateCapture', 'settings.agents.installCapture', - 'settings.agents.removeCapture', - 'settings.agents.notDetectedHint', - 'settings.agents.pluginDisabled', - 'settings.openPlugins', + 'settings.agents.restoreCapture', + 'plugins.openFolder', + 'settings.agents.scopeChanged', 'settings.agents.projectWork', 'settings.agents.removeWork', 'settings.agents.removeDirectory', - 'settings.agents.noWork', - 'settings.agents.scopeChanged', - 'settings.agents.notRecording', + 'settings.agents.sessions', + 'settings.agents.captureBoundary', + 'settings.agents.sourceSettings', + 'settings.agents.defaultSource', + 'settings.agents.removeCapture', 'settings.agents', - 'settings.agents.optionalHint', - 'settings.agents.fromNow', 'settings.agents.emitThinking', - 'settings.agents.selfExclusionHint', 'settings.externalInvalidResponse', 'settings.externalCapture', 'settings.externalCaptureHint', @@ -124,6 +124,7 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.externalIdle', 'settings.externalNotRunning', 'settings.externalLastKnown', + 'settings.externalSetup', 'settings.source.lastReceived', 'settings.source.neverReceived', 'settings.externalDesc.c2-tailers', diff --git a/test/agent-capture-policy.test.ts b/test/agent-capture-policy.test.ts index d3387359..406ba1a5 100644 --- a/test/agent-capture-policy.test.ts +++ b/test/agent-capture-policy.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import path from 'node:path' -import { resolveAgentCapture } from '../src/core/agent-capture-policy' +import { resolveAgentCapture, agentTranscriptRoot } from '../src/core/agent-capture-policy' const catalog = [{ agentKind: 'codex', pluginId: 'official-ai-codex', state: 'installed', installationId: 'install-1' }] const plugins = [{ manifest: { id: 'official-ai-codex' }, status: 'active' }] const config = { agentTailer: { sources: { codex: { enabled: true, installationId: 'install-1', workPaths: [path.resolve('work/client')], startedAt: 1000 } } } } @@ -25,3 +25,24 @@ describe('official AI capture policy', () => { } }) }) + +describe('AI transcript source location', () => { + const home = path.resolve('fixture-home') + const claude = { source: { defaultPath: '.claude/projects', configEnv: 'CLAUDE_CONFIG_DIR', envSuffix: 'projects' } } + const codex = { source: { defaultPath: '.codex/sessions', configEnv: 'CODEX_HOME', envSuffix: 'sessions' } } + it('uses a project override before an absolute vendor environment root, then the default', () => { + const vendor = path.resolve('vendor-home') + const custom = path.resolve('來源 with spaces') + expect(agentTranscriptRoot(claude, home, undefined, { CLAUDE_CONFIG_DIR: vendor })).toBe(path.join(vendor, 'projects')) + expect(agentTranscriptRoot(codex, home, undefined, { CODEX_HOME: vendor })).toBe(path.join(vendor, 'sessions')) + expect(agentTranscriptRoot(codex, home, { sourceRoot: custom }, { CODEX_HOME: vendor })).toBe(custom) + expect(agentTranscriptRoot(claude, home, undefined, { CLAUDE_CONFIG_DIR: 'relative' })).toBe(path.join(home, '.claude', 'projects')) + expect(agentTranscriptRoot(codex, home, undefined, {})).toBe(path.join(home, '.codex', 'sessions')) + }) + it('refuses activation with a relative or blank explicit source', () => { + for (const sourceRoot of ['relative', '']) { + const source = { ...config.agentTailer.sources.codex, sourceRoot } + expect(resolveAgentCapture({ agentTailer: { sources: { codex: source } } }, catalog as never, plugins as never)).toEqual([]) + } + }) +}) diff --git a/test/agent-source-lifecycle.test.ts b/test/agent-source-lifecycle.test.ts index 63045347..52e8febf 100644 --- a/test/agent-source-lifecycle.test.ts +++ b/test/agent-source-lifecycle.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' const m = vi.hoisted(() => ({ project: '', ingest: vi.fn(() => ({ id: 'turn' })), watchers: [] as Array void>> })) vi.mock('../src/core/db/index', () => ({ getProjectDir: () => m.project, getDB: () => ({ prepare: () => ({ all: () => [] }) }) })) vi.mock('../src/core/ingest', () => ({ ingestEvent: m.ingest })) -vi.mock('../src/core/capture-health', () => ({ noteDbError: vi.fn() })) +vi.mock('../src/core/capture-health', () => ({ noteDbError: vi.fn(), noteCaptureError: vi.fn(), clearCaptureError: vi.fn() })) vi.mock('chokidar', () => ({ default: { watch: () => { const callbacks: Record void> = {}; m.watchers.push(callbacks); return { on(name: string, fn: (...args: string[]) => void) { callbacks[name] = fn; return this }, close: async () => { } } } } })) import { configureHost, registerAdapter, unregisterAdapter, registerSession, catchUpSession, stopHost, type TailerAdapter } from '../src/main/services/tailer-host' let root: string diff --git a/test/agent-sources-panel.test.tsx b/test/agent-sources-panel.test.tsx index ead0fadb..268046ab 100644 --- a/test/agent-sources-panel.test.tsx +++ b/test/agent-sources-panel.test.tsx @@ -26,9 +26,9 @@ let health: ReturnType let saved: unknown const uninstalled = { agentKind: 'codex', pluginId: 'official-ai-codex', name: 'Codex', state: 'not-installed' } const installed = { ...uninstalled, state: 'installed', installationId: 'receipt-1' } -function Harness({ onOpenPlugins }: { onOpenPlugins?: () => void } = {}) { +function Harness() { const [config, setConfig] = useState({ engagement: { id: 'project-a' }, agentTailer: { sources: {} } }) - return { saved = c; setConfig(c as never) }} onOpenPlugins={onOpenPlugins} /> + return { saved = c; setConfig(c as never) }} /> } const healthSnapshot = (sources: unknown[]): unknown => ({ verdict: 'healthy', hasRecorded: false, sources, lastEventAt: null, checkedAt: Date.now(), dbErrorTotal: 0, dbErrorFirstAt: null @@ -37,7 +37,7 @@ beforeEach(() => { saved = undefined; shown.length = 0; answer = true catalog = vi.fn(async () => [uninstalled]); install = vi.fn(async () => ({ ok: true, changed: true })); remove = vi.fn(async () => ({ ok: true, changed: true })); pick = vi.fn(async () => '/work/client') health = vi.fn(async () => healthSnapshot([])) - ; (window as unknown as { redlog: unknown }).redlog = { plugins: { officialAgents: catalog, installOfficialAgent: install, uninstallOfficialAgent: remove, list: async () => [{ id: 'official-ai-codex', status: 'active' }] }, agents: { sources: async () => [{ id: 'codex', label: 'Codex', root: '/h/.codex/sessions', detected: true }] }, hookConfig: { pickPath: pick }, capture: { health } } + ; (window as unknown as { redlog: unknown }).redlog = { plugins: { officialAgents: catalog, installOfficialAgent: install, uninstallOfficialAgent: remove, list: async () => [{ id: 'official-ai-codex', status: 'active' }] }, agents: { sessions: async () => [], sources: async () => [{ id: 'codex', label: 'Codex', root: '/h/.codex/sessions', detected: true }] }, hookConfig: { pickPath: pick }, capture: { health } } }) afterEach(() => { cleanup(); vi.restoreAllMocks() }) describe('AI optional installation settings', () => { @@ -58,24 +58,32 @@ describe('AI optional installation settings', () => { fireEvent.click(screen.getByTestId('agent-enabled-codex')) expect(saved).toMatchObject({ agentTailer: { sources: { codex: { enabled: true, workPaths: ['/work/client'], installationId: 'receipt-1', startedAt: expect.any(Number) } } } }) }) - it('offers a direct route to plugin settings without enabling or installing anything', async () => { + it('recovers a disabled integration here without authorizing the project', async () => { catalog.mockResolvedValue([installed]) window.redlog.plugins.list = vi.fn().mockResolvedValue([{ id: 'official-ai-codex', status: 'disabled' }]) - const onOpenPlugins = vi.fn() - render() - fireEvent.click(await screen.findByRole('button', { name: 'settings.openPlugins' })) - expect(onOpenPlugins).toHaveBeenCalledTimes(1) + render() + fireEvent.click(await screen.findByTestId('agent-restore-codex')) + await waitFor(() => expect(install).toHaveBeenCalledWith('codex')) expect(saved).toBeUndefined() - expect(install).not.toHaveBeenCalled() - expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(true) + expect(screen.queryByTestId('agent-enabled-codex')).toBeNull() + }) + it('stops recording and clears the activation boundary when the transcript source changes', async () => { + catalog.mockResolvedValue([installed]); render() + fireEvent.click(await screen.findByTestId('agent-add-path-codex')) + await waitFor(() => expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(false)) + fireEvent.click(screen.getByTestId('agent-enabled-codex')) + pick.mockResolvedValueOnce('/custom/codex/sessions') + fireEvent.click(screen.getByTestId('agent-source-path-codex')) + await screen.findByTestId('agent-scope-changed-codex') + expect(saved).toMatchObject({ agentTailer: { sources: { codex: { sourceRoot: '/custom/codex/sessions', enabled: false, startedAt: undefined, workPaths: ['/work/client'] } } } }) + expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).checked).toBe(false) }) it('makes a directory change visibly stop recording until the existing checkbox is explicitly enabled again', async () => { catalog.mockResolvedValue([installed]) render() fireEvent.click(await screen.findByTestId('agent-add-path-codex')) - await screen.findByText('settings.agents.notRecording') + await waitFor(() => expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(false)) fireEvent.click(screen.getByTestId('agent-enabled-codex')) - expect(screen.queryByText('settings.agents.notRecording')).toBeNull() pick.mockResolvedValueOnce('/work/second-client') fireEvent.click(screen.getByTestId('agent-add-path-codex')) // The source WAS recording, so the row names what happened — the scope @@ -108,7 +116,7 @@ describe('AI optional installation settings', () => { catalog.mockResolvedValue([installed]) render() fireEvent.click(await screen.findByTestId('agent-add-path-codex')) - await screen.findByText('settings.agents.notRecording') + await waitFor(() => expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(false)) fireEvent.click(screen.getByTestId('agent-enabled-codex')) expect(saved).toMatchObject({ agentTailer: { sources: { codex: { enabled: true } } } }) pick.mockResolvedValueOnce('/work/second-client') @@ -128,7 +136,7 @@ describe('AI optional installation settings', () => { catalog.mockResolvedValue([installed]) render() fireEvent.click(await screen.findByTestId('agent-add-path-codex')) - await screen.findByText('settings.agents.notRecording') + await waitFor(() => expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(false)) expect(screen.queryByText('settings.agents.scopeChanged')).toBeNull() expect((saved as { agentTailer: { sources: { codex: { scopeChangedAt?: number } } } }).agentTailer.sources.codex.scopeChangedAt).toBeUndefined() }) @@ -183,17 +191,39 @@ describe('AI optional installation settings', () => { ] render() const card = await screen.findByTestId('agent-source-codex') - expect(card.textContent).toContain('settings.agents.notDetected') - expect(card.textContent).not.toContain('settings.agents.notDetectedHint') + expect(screen.queryByTestId('agent-details-codex')).toBeNull() }) - it('offers the start-the-tool step once the integration is installed', async () => { + it('keeps a missing transcript directory visible in source settings', async () => { catalog.mockResolvedValue([installed]) window.redlog.agents.sources = async () => [ { id: 'codex', label: 'Codex', root: '/h/.codex/sessions', detected: false } ] render() const card = await screen.findByTestId('agent-source-codex') - expect(card.textContent).toContain('settings.agents.notDetectedHint') + expect(card.textContent).toContain('settings.agents.notDetected') }) }) + + +it('stops only the selected conversation and reauthorizes it from now', async () => { + catalog.mockResolvedValue([installed]) + window.redlog.agents.sessions = vi.fn(async () => [{ agentKind: 'codex', sessionId: 'session-a', cwd: '/work/client', recording: true }]) + render() + fireEvent.click(await screen.findByTestId('agent-add-path-codex')) + await waitFor(() => expect((screen.getByTestId('agent-enabled-codex') as HTMLInputElement).disabled).toBe(false)) + fireEvent.click(screen.getByTestId('agent-enabled-codex')) + fireEvent.click(await screen.findByTestId('agent-session-session-a')) + expect(saved).toMatchObject({ agentTailer: { sources: { codex: { enabled: true, excludedSessions: ['session-a'] } } } }) + fireEvent.click(await screen.findByTestId('agent-session-session-a')) + expect(saved).toMatchObject({ agentTailer: { sources: { codex: { enabled: true, excludedSessions: [], sessionStarts: { 'session-a': expect.any(Number) } } } } }) +}) + +it('offers an official package update here without granting project permission', async () => { + catalog.mockResolvedValue([{ ...installed, state: 'update-available' }]) + render() + fireEvent.click(await screen.findByTestId('agent-update-codex')) + await waitFor(() => expect(install).toHaveBeenCalledWith('codex')) + expect(saved).toBeUndefined() + expect(screen.queryByTestId('agent-enabled-codex')).toBeNull() +}) diff --git a/test/agent-sources.test.ts b/test/agent-sources.test.ts index 63d87992..7b5f2ad0 100644 --- a/test/agent-sources.test.ts +++ b/test/agent-sources.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { describeAgentSources, configureAgentTailer } from '../src/main/services/agent-tailer' +import { describeAgentSources } from '../src/main/services/agent-tailer' // The settings page says which AI tools RedLog can read and whether each one is // on this machine. It must only look at directories — never open a transcript. @@ -24,12 +24,8 @@ describe('describeAgentSources', () => { scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-sources-')) const claude = path.join(scratch, 'claude-projects') fs.mkdirSync(claude) - configureAgentTailer({ - claudeProjectsDir: claude, - codexSessionsDir: path.join(scratch, 'no-codex'), - opencodeStorageDir: path.join(scratch, 'no-opencode') - }) - const byId = Object.fromEntries(describeAgentSources().map((s) => [s.id, s])) + const config = { agentTailer: { sources: { 'claude-code': { sourceRoot: claude }, codex: { sourceRoot: path.join(scratch, 'no-codex') }, opencode: { sourceRoot: path.join(scratch, 'no-opencode') } } } } + const byId = Object.fromEntries(describeAgentSources(config).map((s) => [s.id, s])) expect(byId['claude-code'].detected).toBe(true) expect(byId['claude-code'].root).toBe(claude) expect(byId.codex.detected).toBe(false) diff --git a/test/agent-tailer.test.ts b/test/agent-tailer.test.ts index 506da0b1..6b32bd45 100644 --- a/test/agent-tailer.test.ts +++ b/test/agent-tailer.test.ts @@ -1,9 +1,11 @@ +import chokidar from 'chokidar' +import { createAdapter as createOpenCodeAdapter } from '../optional-plugins/opencode/adapter' import { describe, it, expect, beforeEach, afterEach } from 'vitest' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { stopAgentTailer, configureAgentTailer, type AgentTailerConfig } from '../src/main/services/agent-tailer' -import { parseTranscriptLine, readTranscriptCwd, claudeCodeAdapter } from '../src/main/services/adapters/claude-code' +import { stopAgentTailer, type AgentTailerConfig } from '../src/main/services/agent-tailer' +import { parseTranscriptLine, readTranscriptCwd, claudeCodeAdapter } from '../optional-plugins/claude-code/adapter' import { cwdPassesGate, isSelfExcludedCwd, @@ -22,7 +24,7 @@ let scratch: string const OP = 'operator-1' let hostConfigured = false -function registerSession(sourcePath: string, cfg: AgentTailerConfig): void { +function registerSession(sourcePath: string, cfg: AgentTailerConfig & { claudeProjectsDir?: string }): void { if (cfg.claudeProjectsDir) claudeCodeAdapter.transcriptGlob = path.join(cfg.claudeProjectsDir, '**', '*.jsonl') registerAdapter(claudeCodeAdapter) if (!hostConfigured) { @@ -427,10 +429,8 @@ describe('catchUpSession — end-to-end', () => { })) // Configure host with the storage override, then hand-register the // ses_e2e session (skips chokidar — deterministic for the test). - configureAgentTailer({ - enabled: true, engagementId: 'e1', operatorId: OP, - opencodeStorageDir: storage - }) + registerAdapter(createOpenCodeAdapter(path.join(storage, 'message'), { watch: chokidar.watch })) + configureHost({ enabled: true, engagementId: 'e1', operatorId: OP }) hostRegisterSession('opencode', path.join(storage, 'message', 'ses_e2e')) const rows = queryEvents({ agentType: 'agent', limit: 100 }) @@ -474,10 +474,8 @@ describe('catchUpSession — end-to-end', () => { state: { status: 'pending', input: { command: 'sleep 5' } } })) // Configure + register (uses the wrapped adapter's init to capture host). - configureAgentTailer({ - enabled: true, engagementId: 'e1', operatorId: OP, - opencodeStorageDir: storage - }) + registerAdapter(createOpenCodeAdapter(path.join(storage, 'message'), { watch: chokidar.watch })) + configureHost({ enabled: true, engagementId: 'e1', operatorId: OP }) hostRegisterSession('opencode', path.join(storage, 'message', 'ses_delta')) let rows = queryEvents({ agentType: 'agent', limit: 100 }) @@ -516,7 +514,7 @@ describe('catchUpSession — end-to-end', () => { rows = rows // silence unused-var lint fs.rmSync(projCwd, { recursive: true, force: true }) - const { _stopOpencodeSecondaryWatcherForTest } = await import('../src/main/services/adapters/opencode') + const { _stopOpencodeSecondaryWatcherForTest } = await import('../optional-plugins/opencode/adapter') _stopOpencodeSecondaryWatcherForTest() }) diff --git a/test/capture-settings-interaction.test.tsx b/test/capture-settings-interaction.test.tsx index d75f044e..4b3a078d 100644 --- a/test/capture-settings-interaction.test.tsx +++ b/test/capture-settings-interaction.test.tsx @@ -164,7 +164,9 @@ describe('external source facts and failed reads', () => { health.mockResolvedValue({ sources: [{ id: foreign.id, state: 'ready', running: true }] }) fireEvent.focus(window) await screen.findByText('settings.externalFeeding') - expect(container.querySelector('details')).toBeNull() + expect(screen.queryByTestId('external-other-platforms')).toBeNull() + expect(container.textContent).toContain('powershell windows.ps1') + expect(container.querySelector('details')?.open).toBe(false) }) it.each([false, true])('shows a live producer even when disabled=%s', async disabled => { diff --git a/test/codex-adapter.test.ts b/test/codex-adapter.test.ts index 76912b96..ec8d33dc 100644 --- a/test/codex-adapter.test.ts +++ b/test/codex-adapter.test.ts @@ -5,7 +5,7 @@ import * as path from 'path' import { parseCodexLine, readCodexCwd, codexAdapter, CODEX_INGEST_TYPES, CODEX_IGNORED_TYPES -} from '../src/main/services/adapters/codex' +} from '../optional-plugins/codex/adapter' let scratch: string beforeEach(() => { scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-')) }) diff --git a/test/integrity-verify.test.tsx b/test/integrity-verify.test.tsx index 1c6355a5..10bb489d 100644 --- a/test/integrity-verify.test.tsx +++ b/test/integrity-verify.test.tsx @@ -38,6 +38,8 @@ describe('Integrity verify', () => { expect(screen.queryByText('Verify latest')).toBeNull() fireEvent.click(screen.getByText('Verify full chain')) await screen.findByText('Chain intact') + // The anchor covers two of the three walked events, not the current head. + expect(screen.getByText('✓ Anchor matches the events it covers')).toBeTruthy() expect(verify).toHaveBeenCalledTimes(1) }) @@ -55,6 +57,7 @@ describe('Integrity verify', () => { render() fireEvent.click(screen.getByText('Verify full chain')) await waitFor(() => expect(chainIssue()).toBe(true)) + expect(screen.getByText('⚠ Anchor does not match the events it covers')).toBeTruthy() }) it('clears the chain issue when the walk and the anchor agree', async () => { diff --git a/test/official-agent-plugins-ipc.test.ts b/test/official-agent-plugins-ipc.test.ts index 7852fb6a..32b3f66d 100644 --- a/test/official-agent-plugins-ipc.test.ts +++ b/test/official-agent-plugins-ipc.test.ts @@ -4,11 +4,11 @@ import { homedir } from 'node:os' const m = vi.hoisted(() => ({ list: vi.fn(), install: vi.fn(), uninstall: vi.fn(), factory: vi.fn(), - reload: vi.fn(), plugins: vi.fn(() => []), health: vi.fn(), hooks: vi.fn(), insert: vi.fn(() => ({ id: 'audit' })), publish: vi.fn() + reload: vi.fn(), enable: vi.fn(), plugins: vi.fn(() => []), health: vi.fn(), hooks: vi.fn(), insert: vi.fn(() => ({ id: 'audit' })), publish: vi.fn() })) vi.mock('electron', () => ({ shell: {} })) vi.mock('../src/core/plugins/official-agents', () => ({ createOfficialAgentPluginManager: m.factory })) -vi.mock('../src/core/plugins', () => ({ listPlugins: m.plugins, listEventTypes: vi.fn(), setPluginEnabled: vi.fn(), grantPluginTrust: vi.fn(), revokePluginTrust: vi.fn(), reloadPlugins: m.reload })) +vi.mock('../src/core/plugins', () => ({ listPlugins: m.plugins, listEventTypes: vi.fn(), setPluginEnabled: m.enable, grantPluginTrust: vi.fn(), revokePluginTrust: vi.fn(), reloadPlugins: m.reload })) vi.mock('../src/core/config', () => ({ loadConfig: vi.fn() })) vi.mock('../src/core/project-manager', () => ({ getProjectDir: vi.fn() })) vi.mock('../src/core/plugins/test-sample', () => ({ testSample: vi.fn() })) @@ -33,6 +33,7 @@ function setup(engagement: string | null = 'eng') { describe('official AI installation IPC', () => { beforeEach(() => { Object.values(m).forEach(fn => fn.mockReset()) + m.plugins.mockReturnValue([]) m.factory.mockReturnValue({ list: m.list, install: m.install, uninstall: m.uninstall }) m.list.mockReturnValue([{ agentKind: 'codex', pluginId: 'official-ai-codex', state: 'not-installed' }]) m.insert.mockReturnValue({ id: 'audit' }) @@ -84,4 +85,16 @@ describe('official AI installation IPC', () => { expect(ctx.onPluginsChanged).toHaveBeenCalledTimes(1) expect(m.reload).toHaveBeenCalledTimes(1) }) + it('rejects a second global official toggle, but can explicitly recover via source installation', () => { + m.plugins.mockReturnValue([{ manifest: { id: 'official-ai-codex' }, status: 'disabled' }] as never) + m.install.mockReturnValue({ ok: true, changed: false, pluginId: 'official-ai-codex' }) + const { call, ctx } = setup() + expect(() => call('plugins:setEnabled', 'official-ai-codex', false)).toThrow('capture sources') + expect(m.enable).not.toHaveBeenCalled() + call('plugins:installOfficialAgent', 'codex') + expect(m.enable).toHaveBeenCalledWith('official-ai-codex', true) + expect(ctx.onPluginsChanged).toHaveBeenCalledTimes(1) + expect(m.insert).toHaveBeenCalledWith('system', expect.objectContaining({ changed: { 'plugin.official-ai-codex.enabled': { from: false, to: true } } }), expect.anything()) + }) + }) diff --git a/test/official-agent-plugins.test.ts b/test/official-agent-plugins.test.ts index 9c0f99a1..3991c73d 100644 --- a/test/official-agent-plugins.test.ts +++ b/test/official-agent-plugins.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { createOfficialAgentPluginManager } from '../src/core/plugins/official-agents' +import { createOfficialAgentPluginManager, optionalAgentPackagesRoot } from '../src/core/plugins/official-agents' import { validateManifest, tierOf } from '../src/core/plugins/manifest' let base: string @@ -19,10 +20,10 @@ describe('official AI capture plugin installation', () => { expect(catalog.every(p => p.state === 'not-installed')).toBe(true) expect(fs.existsSync(root)).toBe(false) }) - it('installs exactly one declarative manifest, never the AI tool or other adapters', () => { + it('installs only the selected package manifest and parser, never the AI tool', () => { expect(manager().install('codex')).toMatchObject({ ok: true, changed: true, pluginId: 'official-ai-codex' }) - expect(fs.readdirSync(root)).toEqual(['official-ai-codex']) - expect(fs.readdirSync(pluginDir())).toEqual(['plugin.json']) + expect(fs.readdirSync(root).filter(n => !n.startsWith('.'))).toEqual(['official-ai-codex']) + expect(fs.readdirSync(pluginDir())).toEqual(['adapter.cjs', 'plugin.json']) const raw = JSON.parse(fs.readFileSync(path.join(pluginDir(), 'plugin.json'), 'utf8')) const parsed = validateManifest(raw, pluginDir()) expect(parsed.ok).toBe(true) @@ -43,7 +44,7 @@ describe('official AI capture plugin installation', () => { expect(manager().install('codex')).toMatchObject({ ok: true, changed: false }) expect(fs.statSync(manifest).mtimeMs).toBe(before) }) - it('uninstalls only its manifest and leaves existing evidence/configuration untouched', () => { + it('uninstalls only its managed package and leaves existing evidence/configuration untouched', () => { const project = path.join(base, 'project') fs.mkdirSync(project) fs.writeFileSync(path.join(project, 'config.yaml'), 'agentTailer: {}\n') @@ -119,3 +120,76 @@ describe('official AI capture plugin installation', () => { }) }) + +it('refuses a modified executable package before returning a loadable module', () => { + manager().install('codex') + const entry = manager().modulePath('codex')! + expect(entry).toBe(path.join(pluginDir(), 'adapter.cjs')) + fs.appendFileSync(entry, '\nthrow new Error("untrusted replacement")') + expect(manager().modulePath('codex')).toBeUndefined() + expect(manager().list().find(p => p.agentKind === 'codex')?.state).toBe('conflict') + expect(manager().uninstall('codex').ok).toBe(false) +}) + +it('discovers and installs an additional package without a core provider list', () => { + const catalogRoot = path.join(base, 'catalog') + const dir = path.join(catalogRoot, 'fixture-agent') + fs.mkdirSync(dir, { recursive: true }) + const code = 'module.exports = { fixture: true }\n' + fs.writeFileSync(path.join(dir, 'adapter.cjs'), code) + const manifest = { id: 'official-fixture', name: 'Fixture AI', version: '1.0.0', redlogApi: 1, kind: 'pack', contributes: {}, redlogAgent: { agentKind: 'fixture-agent', entry: 'adapter.cjs', source: { defaultPath: '.fixture/sessions' }, sha256: createHash('sha256').update(code).digest('hex') } } + fs.writeFileSync(path.join(dir, 'plugin.json'), JSON.stringify(manifest)) + const extra = createOfficialAgentPluginManager(root, catalogRoot) + expect(extra.list()).toEqual([expect.objectContaining({ agentKind: 'fixture-agent', state: 'not-installed' })]) + expect(extra.modulePath('fixture-agent')).toBeUndefined() + expect(extra.install('fixture-agent')).toMatchObject({ ok: true }) + expect(fs.readFileSync(extra.modulePath('fixture-agent')!, 'utf8')).toBe(code) +}) + + +it('offers an in-app update for an intact managed older package without loading it', () => { + const catalogRoot = path.join(base, 'versioned-catalog') + const dir = path.join(catalogRoot, 'fixture'); fs.mkdirSync(dir, { recursive: true }) + const write = (version: string, code: string) => { + fs.writeFileSync(path.join(dir, 'adapter.cjs'), code) + fs.writeFileSync(path.join(dir, 'plugin.json'), JSON.stringify({ id: 'official-versioned', name: 'Fixture', version, redlogApi: 1, kind: 'pack', contributes: {}, redlogAgent: { agentKind: 'fixture', entry: 'adapter.cjs', source: { defaultPath: '.fixture' }, sha256: createHash('sha256').update(code).digest('hex') } })) + } + write('1.0.0', 'module.exports = { old: true }') + const first = createOfficialAgentPluginManager(root, catalogRoot) + expect(first.install('fixture').ok).toBe(true) + const receipt = first.list()[0].installationId + write('2.0.0', 'module.exports = { next: true }') + const next = createOfficialAgentPluginManager(root, catalogRoot) + expect(next.list()[0]).toMatchObject({ state: 'update-available', installationId: receipt }) + expect(next.modulePath('fixture')).toBeUndefined() + expect(next.install('fixture')).toMatchObject({ ok: true, changed: true }) + expect(next.list()[0]).toMatchObject({ state: 'installed', installationId: receipt }) + expect(fs.readFileSync(next.modulePath('fixture')!, 'utf8')).toContain('next') + expect(next.uninstall('fixture').ok).toBe(true) +}) + + +it('rolls a managed package update back when its installation-record commit fails', () => { + const catalog = path.join(base, 'catalog') + fs.cpSync(optionalAgentPackagesRoot(), catalog, { recursive: true }) + const managed = createOfficialAgentPluginManager(root, catalog) + expect(managed.install('codex').ok).toBe(true) + const installedCode = fs.readFileSync(path.join(pluginDir(), 'adapter.cjs')) + const generated = path.join(catalog, 'codex') + const newCode = Buffer.concat([fs.readFileSync(path.join(generated, 'adapter.cjs')), Buffer.from('\n// fixture version update\n')]) + fs.writeFileSync(path.join(generated, 'adapter.cjs'), newCode) + const manifest = JSON.parse(fs.readFileSync(path.join(generated, 'plugin.json'), 'utf8')) + manifest.version = '2.0.0'; manifest.redlogAgent.sha256 = createHash('sha256').update(newCode).digest('hex') + fs.writeFileSync(path.join(generated, 'plugin.json'), JSON.stringify(manifest)) + const real = fs.renameSync + const fail = vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).endsWith('.official-ai-installations.json')) throw new Error('injected ledger refusal') + return real(from, to) + }) + expect(managed.install('codex')).toMatchObject({ ok: false }) + expect(fs.readFileSync(path.join(pluginDir(), 'adapter.cjs'))).toEqual(installedCode) + expect(managed.modulePath('codex')).toBeUndefined() + fail.mockRestore() + expect(managed.install('codex')).toMatchObject({ ok: true }) + expect(fs.readFileSync(managed.modulePath('codex')!)).toEqual(newCode) +}) diff --git a/test/opencode-adapter.test.ts b/test/opencode-adapter.test.ts index ed686cd6..80968b8a 100644 --- a/test/opencode-adapter.test.ts +++ b/test/opencode-adapter.test.ts @@ -5,7 +5,7 @@ import * as path from 'path' import { parseOpencodeMessage, readOpenCodeCwd, opencodeAdapter, partToTurns -} from '../src/main/services/adapters/opencode' +} from '../optional-plugins/opencode/adapter' let scratch: string let storage: string diff --git a/test/plugins-panel-truth.test.tsx b/test/plugins-panel-truth.test.tsx index 5671bfe7..9ef0d4e0 100644 --- a/test/plugins-panel-truth.test.tsx +++ b/test/plugins-panel-truth.test.tsx @@ -21,7 +21,7 @@ const t = (key: string, vars?: Record): string => type Status = 'active' | 'disabled' const view = (id: string, status: Status = 'active') => ({ - id, name: `Plugin ${id}`, version: '1.0.0', description: '', author: 'x', source: 'user' as const, + officialAgent: id.startsWith('official-ai-'), id, name: `Plugin ${id}`, version: '1.0.0', description: '', author: 'x', source: 'user' as const, tier: 'declarative' as const, status, capabilities: [], contributes: [] }) @@ -183,3 +183,13 @@ describe('the plugins page shows what main has done', () => { }) }) }) + +// Official integrations have their installation and project controls on the source page. +it('does not expose a second official AI switch alongside third-party plugins', async () => { + api.list.mockResolvedValue([view('official-ai-claude-code'), view('official-ai-codex', 'disabled'), view('third-party')]) + draw() + await screen.findByText('Plugin third-party') + expect(screen.queryByText('Plugin official-ai-codex')).toBeNull() + expect(screen.queryByText('Plugin official-ai-claude-code')).toBeNull() + cleanup() +}) diff --git a/test/tailer-durability.test.ts b/test/tailer-durability.test.ts new file mode 100644 index 00000000..9b637c36 --- /dev/null +++ b/test/tailer-durability.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import * as fs from 'fs' +vi.mock('fs', async original => ({ ...await original() })) +import os from 'node:os' +import path from 'node:path' +import * as ingestion from '../src/core/ingest' +import { getCaptureError, clearCaptureError } from '../src/core/capture-health' +import { eventBus } from '../src/core/event-bus' +import { initDB, closeDB } from '../src/core/db/index' +import { queryEvents } from '../src/core/db/events' +import { createOperator, generateToken } from '../src/core/db/operators' +import { registerAdapter, unregisterAdapter, configureHost, stopHost, registerSession, catchUpSession, listAgentSessions } from '../src/main/services/tailer-host' +import type { TailerAdapter } from '../src/main/services/tailer-host' + +let dir: string, source: string +const adapter: TailerAdapter = { + agentKind: 'durability-fixture', transcriptGlob: '/nonexistent/redlog-durability/*.jsonl', + knownIngestTypes: new Set(['user']), knownIgnoredTypes: new Set(['metadata']), + resolveCwd: () => '/work/client', parseUnit: raw => JSON.parse(raw), subtypeFor: () => 'user_message' +} +const cfg = { enabled: true, engagementId: 'e1', operatorId: 'op', idleFlushMs: 60_000 } +const line = (uuid: string, text = uuid) => JSON.stringify({ uuid, type: 'user', textContent: text }) + '\n' +const rows = () => queryEvents({ agentType: 'agent', limit: 100 }).filter(e => e.data.transcript_uuid) +const sidecars = () => fs.existsSync(path.join(dir, 'agent-transcripts')) ? fs.readdirSync(path.join(dir, 'agent-transcripts')).filter(n => n.endsWith('.jsonl')).map(n => fs.readFileSync(path.join(dir, 'agent-transcripts', n), 'utf8')).join('') : '' +function start(perMessage = false) { + registerAdapter({ ...adapter, perMessageDir: perMessage }) + configureHost(cfg) + registerSession(adapter.agentKind, source) +} +function restart(perMessage = false) { stopHost(); start(perMessage) } +const catchUp = () => catchUpSession(adapter.agentKind, path.basename(source, path.extname(source))) +beforeEach(() => { + clearCaptureError('agent-' + adapter.agentKind) + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tailer-durable-')) + source = path.join(dir, 'fixture.jsonl') + configureHost({ enabled: false, watchPathsByAgent: undefined, activationStarts: undefined, excludedSessionsByAgent: undefined, sessionStartsByAgent: undefined }) + initDB(dir); createOperator({ id: 'op', name: 'Fixture', token: generateToken() }); eventBus.resume() +}) +afterEach(() => { vi.restoreAllMocks(); stopHost(); unregisterAdapter(adapter.agentKind); eventBus.resume(); closeDB(); fs.rmSync(dir, { recursive: true, force: true }) }) + +describe('AI capture durable progress and privacy boundaries', () => { + it('does not commit JSONL progress when one event fails, and retries without duplicates after restart', () => { + fs.writeFileSync(source, line('one') + line('two')) + const real = ingestion.ingestEvent + const fail = vi.spyOn(ingestion, 'ingestEvent').mockImplementation((kind, data, options) => { + if (data.transcript_uuid === 'two') throw new Error('injected DB write refusal') + return real(kind, data, options) + }) + start(); expect(rows().map(e => e.data.transcript_uuid)).toEqual(['one']) + expect(sidecars()).not.toContain('two') + fail.mockRestore(); restart(); catchUp() + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['one', 'two']) + }) + + it('retries a per-message unit when its event write fails, rather than seeding it as seen', () => { + source = path.join(dir, 'session'); fs.mkdirSync(source) + fs.writeFileSync(path.join(source, 'msg.json'), line('message')) + const real = ingestion.ingestEvent + const fail = vi.spyOn(ingestion, 'ingestEvent').mockImplementation((kind, data, options) => { + if (data.transcript_uuid) throw new Error('injected DB write refusal') + return real(kind, data, options) + }) + start(true); expect(rows()).toHaveLength(0); expect(sidecars()).toBe('') + fail.mockRestore(); restart(true) + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['message']) + }) + + it('does not corrupt a partial JSONL unit when sidecar append fails', () => { + fs.writeFileSync(source, line('one')); start() + const complete = line('two', '中文🙂 split payload') + fs.appendFileSync(source, complete.slice(0, 22)); catchUp() + const real = fs.appendFileSync + const fail = vi.spyOn(fs, 'appendFileSync').mockImplementation((file, data, options) => { + if (String(file).includes('agent-transcripts')) throw new Error('injected sidecar write refusal') + return real(file, data, options) + }) + fs.appendFileSync(source, complete.slice(22)); catchUp() + expect(fail).toHaveBeenCalled(); fail.mockRestore(); catchUp(); restart(); catchUp() + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['one', 'two']) + expect(sidecars()).toBe(line('one') + complete) + }) + + it('saves no paused raw text and does not replay it after resume or restart, even without a watcher tick', () => { + fs.writeFileSync(source, line('before')); start() + eventBus.pause('ui'); fs.appendFileSync(source, line('private', 'PAUSED_PRIVATE_SENTINEL')) + // Resume must establish a boundary even if the coalesced watcher did not run. + eventBus.resume('ui'); fs.appendFileSync(source, line('after')); catchUp(); restart(); catchUp() + expect(sidecars()).not.toContain('PAUSED_PRIVATE_SENTINEL') + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + expect(queryEvents({ agentType: 'system', limit: 100 }).some(e => e.data.subtype === 'capture_gap')).toBe(true) + }) + + it('does not replay the end of a line that crossed a pause boundary', () => { + fs.writeFileSync(source, line('before')); start() + const privateLine = line('private', 'CROSS_PAUSE_PRIVATE') + fs.appendFileSync(source, privateLine.slice(0, 15)); catchUp() + eventBus.pause(); fs.appendFileSync(source, privateLine.slice(15, 30)); catchUp(); eventBus.resume() + fs.appendFileSync(source, privateLine.slice(30) + line('after')); catchUp(); restart(); catchUp() + expect(sidecars()).toBe(line('before') + line('after')) + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + }) + it('does not commit source progress after a failed checkpoint rename', () => { + fs.writeFileSync(source, line('one')); start() + const real = fs.renameSync + const fail = vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).endsWith('.offset.json')) throw new Error('injected checkpoint refusal') + return real(from, to) + }) + fs.appendFileSync(source, line('two')); catchUp() + expect(sidecars()).toBe(line('one')) + expect(getCaptureError('agent-' + adapter.agentKind)?.message).toContain('checkpoint refusal') + fail.mockRestore(); restart(); catchUp() + expect(getCaptureError('agent-' + adapter.agentKind)).toBeUndefined() + expect(sidecars()).toBe(line('one') + line('two')) + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['one', 'two']) + }) + + it('does not seed a per-message filename after a failed raw-index append', () => { + source = path.join(dir, 'session'); fs.mkdirSync(source) + const real = fs.appendFileSync + const fail = vi.spyOn(fs, 'appendFileSync').mockImplementation((file, data, options) => { + if (String(file).includes('agent-transcripts')) throw new Error('injected index refusal') + return real(file, data, options) + }) + fs.writeFileSync(path.join(source, 'msg.json'), line('message')); start(true) + fail.mockRestore(); restart(true) + expect(sidecars()).toBe('msg.json\n') + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['message']) + }) + + it('respects a persisted paused boundary when reopening after a shutdown', () => { + fs.writeFileSync(source, line('before')); start() + eventBus.pause(); fs.appendFileSync(source, line('private', 'PRIVATE_DURING_SHUTDOWN')); stopHost() + eventBus.resume(); start(); fs.appendFileSync(source, line('after')); catchUp() + expect(sidecars()).toBe(line('before') + line('after')) + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + }) + + it('does not parse paused per-message files, and does not recover them on restart', () => { + source = path.join(dir, 'session'); fs.mkdirSync(source); start(true) + eventBus.pause(); fs.writeFileSync(path.join(source, 'private.json'), line('private', 'PRIVATE_PER_MESSAGE')) + eventBus.resume(); restart(true) + fs.writeFileSync(path.join(source, 'after.json'), line('after')); fs.utimesSync(path.join(source, 'after.json'), new Date(Date.now() + 10), new Date(Date.now() + 10)); catchUpSession(adapter.agentKind, 'session') + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['after']) + expect(sidecars()).not.toContain('PRIVATE_PER_MESSAGE') + }) + + it('excludes one provider session and only captures new content after reauthorization', () => { + fs.writeFileSync(source, line('before')); start() + const first = listAgentSessions().find(s => s.sessionId === 'fixture')! + expect(first).toMatchObject({ agentKind: adapter.agentKind, recording: true, cwd: '/work/client' }) + configureHost({ ...cfg, excludedSessionsByAgent: { [adapter.agentKind]: ['fixture'] } }) + fs.appendFileSync(source, line('private')) + registerSession(adapter.agentKind, source); catchUp() + expect(listAgentSessions().find(s => s.sessionId === 'fixture')).toMatchObject({ recording: false }) + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['before']) + configureHost({ ...cfg, excludedSessionsByAgent: {}, sessionStartsByAgent: { [adapter.agentKind]: { fixture: Date.now() + 1 } } }) + registerSession(adapter.agentKind, source); fs.appendFileSync(source, line('after')); catchUp() + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + expect(sidecars()).not.toContain('private') + }) + + it('restores a pending child from retained raw evidence when the parent arrives after restart', () => { + const child = JSON.stringify({ uuid: 'child', parentUuid: 'parent', type: 'user', textContent: 'child first' }) + '\n' + fs.writeFileSync(source, child); start(); expect(rows()).toHaveLength(0) + restart(); fs.appendFileSync(source, line('parent')); catchUp() + const saved = rows(); expect(saved.map(e => e.data.transcript_uuid).sort()).toEqual(['child', 'parent']) + expect(saved.find(e => e.data.transcript_uuid === 'child')!.data._causes).toEqual([saved.find(e => e.data.transcript_uuid === 'parent')!.id]) + }) + + it('keeps a new child after pause without inventing a cause for an omitted parent', () => { + fs.writeFileSync(source, line('before')); start(); eventBus.pause() + fs.appendFileSync(source, line('private')); eventBus.resume() + fs.appendFileSync(source, JSON.stringify({ uuid: 'after', parentUuid: 'private', type: 'user', textContent: 'after' }) + '\n'); catchUp() + const saved = rows(); expect(saved.map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + expect(saved.find(e => e.data.transcript_uuid === 'after')!.data._causes).toBeUndefined() + }) + + it('links through deliberately ignored metadata instead of indefinitely buffering the next reply', () => { + fs.writeFileSync(source, line('prompt') + JSON.stringify({ type: 'metadata', uuid: 'attachment', parentUuid: 'prompt' }) + '\n' + JSON.stringify({ type: 'user', uuid: 'reply', parentUuid: 'attachment', textContent: 'reply' }) + '\n') + start() + const saved = rows(); expect(saved.map(e => e.data.transcript_uuid).sort()).toEqual(['prompt', 'reply']) + expect(saved.find(e => e.data.transcript_uuid === 'reply')!.data._causes).toEqual([saved.find(e => e.data.transcript_uuid === 'prompt')!.id]) + expect(saved.find(e => e.data.transcript_uuid === 'reply')!.data.transcript_parent_uuid).toBe('attachment') + }) + + it('omits a conversation created during pause even when discovery happens after resume', () => { + fs.writeFileSync(source, line('before')); start(); eventBus.pause() + const later = path.join(dir, 'later.jsonl'); fs.writeFileSync(later, line('private', 'NEW_SESSION_WHILE_PAUSED')) + eventBus.resume(); registerSession(adapter.agentKind, later) + fs.appendFileSync(later, line('after')); catchUpSession(adapter.agentKind, 'later') + expect(sidecars()).not.toContain('NEW_SESSION_WHILE_PAUSED') + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + }) + + it('does not acknowledge a half-written per-message file and retries its later complete write', () => { + source = path.join(dir, 'session'); fs.mkdirSync(source) + const file = path.join(source, 'message.json'); fs.writeFileSync(file, '{') + start(true); expect(sidecars()).toBe('') + fs.writeFileSync(file, line('completed')); catchUpSession(adapter.agentKind, 'session') + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['completed']) + fs.writeFileSync(file, line('later-unit')); catchUpSession(adapter.agentKind, 'session') + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['completed', 'later-unit']) + }) + +}) diff --git a/test/tailer-jsonl-durability.test.ts b/test/tailer-jsonl-durability.test.ts index 6cb17552..200fcfd1 100644 --- a/test/tailer-jsonl-durability.test.ts +++ b/test/tailer-jsonl-durability.test.ts @@ -6,7 +6,7 @@ import * as host from '../src/main/services/tailer-host' import * as events from '../src/core/db/events' import * as db from '../src/core/db/index' import { createOperator, generateToken } from '../src/core/db/operators' -import { claudeCodeAdapter } from '../src/main/services/adapters/claude-code' +import { claudeCodeAdapter } from '../optional-plugins/claude-code/adapter' import { eventBus } from '../src/core/event-bus' // The JSONL tailer reads from where its sidecar ends and appends what it read @@ -97,22 +97,6 @@ describe('a transcript turn whose write fails', () => { await vi.advanceTimersByTimeAsync(1000) expect(recorded()).toEqual(['last words']) }) - - it('is given up after the bounded retries, says so, and does not hold back what follows', async () => { - vi.useFakeTimers() - fs.writeFileSync(source, turn('u1', 'never')) - h.failOn.add('never') - host.registerSession('claude-code', source) - await vi.advanceTimersByTimeAsync(10 * 60_000) - - const gap = events.queryEvents({ agentType: 'system', limit: 10 }).find((e) => e.data.subtype === 'capture_gap') - expect(gap?.data).toMatchObject({ reason: 'write_failed', failed_turns: 1 }) - expect(sidecarSize()).toBe(fs.statSync(source).size) - - fs.appendFileSync(source, turn('u2', 'next')) - host.catchUpSession('claude-code', SID) - expect(recorded()).toEqual(['next']) - }) }) describe('a turn with nothing left to re-read', () => { @@ -129,19 +113,6 @@ describe('a turn with nothing left to re-read', () => { expect(recorded()).toEqual(['child', 'parent']) expect(gaps()).toEqual([]) }) - - it('is written after a pause that began before its retry', async () => { - vi.useFakeTimers() - fs.writeFileSync(source, turn('u1', 'pre-pause work')) - h.failOn.add('pre-pause work') - host.registerSession('claude-code', source) - h.failOn.clear() - eventBus.pause() - await vi.advanceTimersByTimeAsync(5000) - eventBus.resume() - await vi.advanceTimersByTimeAsync(1000) - expect(recorded()).toEqual(['pre-pause work']) - }) }) describe('the retry budget', () => { diff --git a/test/tailer-naming.test.ts b/test/tailer-naming.test.ts index 5c4c0065..c87b554a 100644 --- a/test/tailer-naming.test.ts +++ b/test/tailer-naming.test.ts @@ -25,13 +25,15 @@ describe('tailer naming', () => { expect(exists('src/main/services/transcript-tailer.ts')).toBe(false) }) - it('keeps every agent adapter in adapters/, and the wiring apart from them', () => { + it('keeps optional AI parsers in their packages and the shared host free of provider imports', () => { for (const a of ['claude-code', 'codex', 'opencode']) { - expect(exists(`src/main/services/adapters/${a}.ts`)).toBe(true) + expect(exists(`optional-plugins/${a}/adapter.ts`)).toBe(true) + expect(exists(`src/main/services/adapters/${a}.ts`)).toBe(false) } expect(exists('src/main/services/agent-transcript-tailer.ts')).toBe(false) const wiring = fs.readFileSync(path.join(root, 'src/main/services/agent-tailer.ts'), 'utf-8') // The wiring registers adapters; it does not parse any agent's format. expect(wiring).not.toMatch(/JSON\.parse|parseTranscriptLine\s*\(/) + expect(wiring).not.toMatch(/from ['"][^'"]*(?:claude-code|codex|opencode)/) }) }) diff --git a/test/tailer-pause-sidecar.test.ts b/test/tailer-pause-sidecar.test.ts index 6b683bb7..ab8dce24 100644 --- a/test/tailer-pause-sidecar.test.ts +++ b/test/tailer-pause-sidecar.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { claudeCodeAdapter } from '../src/main/services/adapters/claude-code' +import { claudeCodeAdapter } from '../optional-plugins/claude-code/adapter' import { registerAdapter, configureHost, registerSession, catchUpSession, stopHost, _sessionsForTest } from '../src/main/services/tailer-host' import { eventBus } from '../src/core/event-bus' import { initDB, closeDB } from '../src/core/db/index' @@ -13,7 +13,7 @@ import { createOperator, generateToken } from '../src/core/db/operators' // writing events during a pause but went on appending the transcript's raw // bytes to its own sidecar, so the private conversation was kept on disk all // the same. It now keeps none of those bytes, and resume must not read them -// back in either: the sidecar's size is also where the next read starts. +// back in either. const OP = 'operator-1' const SID = 'aaaaaaaa-bbbb-cccc-dddd-00000000p0se' @@ -65,10 +65,8 @@ describe('an AI transcript read while recording is paused', () => { expect(recorded()).toEqual(['after the pause', 'before the pause']) expect(sidecar()).not.toContain('PRIVATE-CONVERSATION') - // The sidecar still marks how far the source was read. - expect(fs.statSync(_sessionsForTest().get(`claude-code:${SID}`)!.sidecarPath).size).toBe(fs.statSync(source).size) const gap = queryEvents({ agentType: 'system', limit: 10 }).find((e) => e.data.subtype === 'capture_gap') - expect(gap?.data.skipped_lines).toBe(1) + expect(gap?.data.skipped_bytes).toBe(Buffer.byteLength(turn('u2', 'PRIVATE-CONVERSATION'))) }) it('drops a line that began during the pause and finished after it', () => { diff --git a/test/tailer-per-message-durability.test.ts b/test/tailer-per-message-durability.test.ts deleted file mode 100644 index c96bcf3d..00000000 --- a/test/tailer-per-message-durability.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'fs' -import * as os from 'os' -import * as path from 'path' - -// A per-message source (OpenCode's layout) writes one file per message, and -// the host keeps an index of the file names it has handled. That index used to -// be written before the file's events: a write that failed, or a file read -// before its writer finished, was marked done and never read again. The source -// looked healthy and the message was simply not in the record. - -const h = vi.hoisted(() => ({ - written: [] as string[], - failOn: new Set(), - paused: false, - n: 0, - parsed: [] as string[], - gaps: [] as Array> -})) - -vi.mock('../src/core/ingest', () => ({ - ingestEvent: (_type: string, data: { subtype?: string; full?: string; preview?: string }) => { - if (data.subtype === 'capture_gap') h.gaps.push(data as Record) - if (data.subtype !== 'user_message') return { id: `meta-${++h.n}` } - const text = String(data.full ?? data.preview ?? '') - if (h.failOn.has(text)) throw new Error('SQLITE_BUSY') - h.written.push(text) - return { id: `ev-${++h.n}` } - } -})) -vi.mock('../src/core/event-bus', () => ({ eventBus: { get paused() { return h.paused }, on: () => {}, off: () => {} } })) -vi.mock('../src/core/capture-health', () => ({ noteDbError: () => {} })) - -let scratch: string -let sessionDir: string -let host: typeof import('../src/main/services/tailer-host') -let db: typeof import('../src/core/db/index') - -const AGENT = 'fake-per-message' - -function adapter(root: string): import('../src/main/services/tailer-host').TailerAdapter { - return { - agentKind: AGENT, - transcriptGlob: path.join(root, '*', '*.json'), - perMessageDir: true, - knownIngestTypes: new Set(['user']), - knownIgnoredTypes: new Set(), - resolveCwd: () => scratch, - parseUnit: (raw: string) => { - const m = JSON.parse(raw) as { id: string; text: string; parent?: string } - h.parsed.push(m.id) - return { uuid: m.id, parentUuid: m.parent ?? null, type: 'user', role: 'user', textContent: m.text } - }, - subtypeFor: () => 'user_message' - } -} - -const message = (name: string, text: string, parent?: string, mtime?: number): void => { - const file = path.join(sessionDir, name) - fs.writeFileSync(file, JSON.stringify({ id: name.replace('.json', ''), text, parent })) - if (mtime) fs.utimesSync(file, mtime / 1000, mtime / 1000) -} - -const index = (): string[] => { - const file = path.join(scratch, 'agent-transcripts', `${AGENT}-ses_1.jsonl`) - try { return fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean) } catch { return [] } -} - -beforeEach(async () => { - vi.resetModules() - h.written.length = 0 - h.parsed.length = 0 - h.gaps.length = 0 - h.failOn.clear() - h.paused = false - scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'tailer-pm-')) - const root = path.join(scratch, 'messages') - sessionDir = path.join(root, 'ses_1') - fs.mkdirSync(sessionDir, { recursive: true }) - db = await import('../src/core/db/index') - db.initDB(scratch) - host = await import('../src/main/services/tailer-host') - host.registerAdapter(adapter(root)) - host.configureHost({ enabled: true, engagementId: 'e1', operatorId: 'op', enabledAgents: [AGENT], idleFlushMs: 60_000 }) -}) - -afterEach(() => { - host.stopHost() - vi.useRealTimers() - try { db.closeDB() } catch { /* ignore */ } - fs.rmSync(scratch, { recursive: true, force: true }) -}) - -describe('a per-message file whose events did not land', () => { - it('is not indexed, and is written on the next pass', () => { - message('msg_a.json', 'first') - message('msg_b.json', 'second') - h.failOn.add('second') - host.registerSession(AGENT, sessionDir) - expect(h.written).toEqual(['first']) - expect(index()).toEqual(['msg_a.json']) - - h.failOn.clear() - host.catchUpSession(AGENT, 'ses_1') - expect(h.written).toEqual(['first', 'second']) - expect(index()).toEqual(['msg_a.json', 'msg_b.json']) - }) - - it('is retried on its own when the directory does not change again', async () => { - vi.useFakeTimers() - message('msg_a.json', 'last words') - h.failOn.add('last words') - host.registerSession(AGENT, sessionDir) - expect(h.written).toEqual([]) - - h.failOn.clear() - await vi.advanceTimersByTimeAsync(1000) - expect(h.written).toEqual(['last words']) - }) - - it('is read again when it was caught mid-write', () => { - fs.writeFileSync(path.join(sessionDir, 'msg_a.json'), '{"id":"msg_a","te') - host.registerSession(AGENT, sessionDir) - expect(index()).toEqual([]) - - message('msg_a.json', 'finished') - host.catchUpSession(AGENT, 'ses_1') - expect(h.written).toEqual(['finished']) - expect(index()).toEqual(['msg_a.json']) - }) - - it('gives up after the bounded retries, says so, and stops trying', async () => { - vi.useFakeTimers() - message('msg_a.json', 'never') - h.failOn.add('never') - let attempts = 0 - const real = h.failOn.has.bind(h.failOn) - h.failOn.has = (t: string) => { if (t === 'never') attempts++; return real(t) } - host.registerSession(AGENT, sessionDir) - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(h.gaps).toEqual([expect.objectContaining({ reason: 'write_failed', failed_turns: 1 })]) - expect(index()).toEqual(['msg_a.json']) - const settled = attempts - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(attempts).toBe(settled) - }) -}) - -describe('a handled per-message file', () => { - it('is not read again on later passes', () => { - message('msg_a.json', 'once') - host.registerSession(AGENT, sessionDir) - host.catchUpSession(AGENT, 'ses_1') - host.catchUpSession(AGENT, 'ses_1') - expect(h.parsed).toEqual(['msg_a']) - }) - - it('is indexed unread while paused, and not recorded on resume', () => { - host.registerSession(AGENT, sessionDir) - h.paused = true - message('msg_a.json', 'private') - host.catchUpSession(AGENT, 'ses_1') - h.paused = false - message('msg_b.json', 'work') - host.catchUpSession(AGENT, 'ses_1') - expect(h.written).toEqual(['work']) - expect(index()).toEqual(['msg_a.json', 'msg_b.json']) - }) -}) - -describe('a turn with nothing left to re-read', () => { - it('is kept when it was a queued child whose flush failed', () => { - const t = Date.now() - 10_000 - // The child's file is older, so it is read first and waits for its parent. - message('msg_b.json', 'child', 'msg_a', t) - message('msg_a.json', 'parent', undefined, t + 1000) - h.failOn.add('child') - host.registerSession(AGENT, sessionDir) - h.failOn.clear() - host.catchUpSession(AGENT, 'ses_1') - expect(h.written.sort()).toEqual(['child', 'parent']) - }) - - it('is written after a pause that began before its retry', async () => { - vi.useFakeTimers() - message('msg_a.json', 'pre-pause') - h.failOn.add('pre-pause') - host.registerSession(AGENT, sessionDir) - h.failOn.clear() - h.paused = true - await vi.advanceTimersByTimeAsync(5000) - h.paused = false - await vi.advanceTimersByTimeAsync(1000) - expect(h.written).toEqual(['pre-pause']) - }) -}) - -describe('a per-message file that never parses', () => { - it('is given up after the retries and recorded as unread', async () => { - vi.useFakeTimers() - fs.writeFileSync(path.join(sessionDir, 'msg_junk.json'), 'not json') - host.registerSession(AGENT, sessionDir) - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(index()).toEqual(['msg_junk.json']) - expect(h.gaps).toEqual([expect.objectContaining({ reason: 'write_failed', unparsed_units: 1 })]) - const parses = h.parsed.length - message('msg_ok.json', 'ok') - host.catchUpSession(AGENT, 'ses_1') - expect(h.written).toEqual(['ok']) - expect(h.parsed.length).toBe(parses + 1) - }) -}) diff --git a/tsconfig.check.json b/tsconfig.check.json index ca199ec7..444907ec 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -17,5 +17,5 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["node"] }, - "include": ["src/**/*"] + "include": ["src/**/*", "optional-plugins/**/*.ts"] } From 9932a31aab50f2535337a97666bd563506f6580a Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Sat, 24 Oct 2026 19:31:20 +0800 Subject: [PATCH 2/2] fix(ai-capture): keep failed children, retry idle failures, survive pruning Four gaps in the checkpoint design, each found by a #441 test or a probe against it: - A waiting child whose flush failed was lost for good. Its parent was recorded, so the re-read skipped it, and the child's own line was already committed. Children now leave the queue only once written, and a re-read parent flushes whatever still waits on it. - A re-read child was queued again on every failed pass, and a re-read tool call was counted again. - A failure with no later file event (the last turn of a session) waited for a restart. A few backed-off passes (1 s to 16 s) now retry it; after those it waits for the next event or restart, the error in capture health. - Retention removes whole sidecars, and the checkpoint then refused the session as tampered, stopping its capture with only a DB error. A missing sidecar now resumes at the checkpoint; one that is present but short is still refused. Each fix is caught by at least one test when reverted. Co-Authored-By: Claude Opus 5.5 --- src/main/services/tailer-host.ts | 60 ++++++++++++++++++++----- test/tailer-durability.test.ts | 77 +++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/main/services/tailer-host.ts b/src/main/services/tailer-host.ts index da6fe294..79453851 100644 --- a/src/main/services/tailer-host.ts +++ b/src/main/services/tailer-host.ts @@ -287,6 +287,8 @@ interface SessionState { turnsEmitted: number postCompact: boolean idleTimer: NodeJS.Timeout | null + retryTimer: NodeJS.Timeout | null + retryAttempt: number lastSnapshotBytes: number bytesAppendedSinceSnapshot: number driftAdvisoryFired: Set @@ -337,6 +339,17 @@ function restoreBoundary(): void { } const COALESCE_MS = 50 +const RETRY_BASE_MS = 1000 +const RETRY_LIMIT = 5 + +/** A failed pass is read again on the next file event or restart. The last + * turn of a session gets neither, so a few backed-off passes cover it; after + * those it waits for the same two things, with the error in capture health. */ +function scheduleRetry(s: SessionState): void { + if (s.retryTimer || s.retryAttempt >= RETRY_LIMIT) return + s.retryTimer = setTimeout(() => { s.retryTimer = null; catchUpSession(s.agentKind, s.sessionId) }, RETRY_BASE_MS * 2 ** s.retryAttempt++) + s.retryTimer.unref?.() +} function coalescedCatchUp(agentKind: string, sid: string): void { const key = sessionKey(agentKind, sid) @@ -594,7 +607,8 @@ interface EmitContext { function emitTurn(t: ParsedTurn, ctx: EmitContext): void { if (!t.uuid) return const s = ctx.session - if (s.redlogIdByUuid.has(t.uuid)) return + // A re-read parent still flushes: children whose write failed wait here. + if (s.redlogIdByUuid.has(t.uuid)) { flushWaiting(s, t.uuid, ctx); return } let parentUuid = t.parentUuid const parentPath = new Set() @@ -620,6 +634,8 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { emitTurn(ot, { ...ctx, _skipParentBuffer: true }) } const queue = s.pendingByParentUuid.get(parentUuid) ?? [] + // A failed pass is read again; its children are already waiting. + if (queue.some(q => q.turn.uuid === t.uuid)) return if (queue.length + pendingSize(s) < MAX_PENDING_CHILDREN) { queue.push({ turn: t, queuedAt: Date.now(), sidecarOffset: s.unitSidecarOffset }) s.pendingByParentUuid.set(parentUuid, queue) @@ -787,15 +803,22 @@ function emitTurn(t: ParsedTurn, ctx: EmitContext): void { } } } - const waiting = s.pendingByParentUuid.get(t.uuid) - if (waiting) { - s.pendingByParentUuid.delete(t.uuid) - for (const q of waiting) emitTurn(q.turn, ctx) - } + flushWaiting(s, t.uuid, ctx) } } catch (e) { noteDbError('tailer-host', e); throw e } } +/** Children leave the queue only once written. Their own lines are already + * committed, so a child dropped on a failed write would never be read again. */ +function flushWaiting(s: SessionState, parentUuid: string, ctx: EmitContext): void { + const waiting = s.pendingByParentUuid.get(parentUuid) + if (!waiting) return + s.pendingByParentUuid.delete(parentUuid) + for (let i = 0; i < waiting.length; i++) { + try { emitTurn(waiting[i].turn, ctx) } catch (e) { s.pendingByParentUuid.set(parentUuid, waiting.slice(i)); throw e } + } +} + function sweepStalePending(s: SessionState): ParsedTurn[] { const cutoff = Date.now() - PENDING_TTL_MS const orphans: ParsedTurn[] = [] @@ -945,8 +968,9 @@ function catchUpJsonl(s: SessionState): void { appendAndCommit(s, complete, s.sourceOffset + complete.length) s.linesSeen += complete.toString('utf8').split('\n').filter(line => line.trim()).length scheduleIdleSnapshot(s) + s.retryAttempt = 0 if (sourceSize - s.sourceOffset > complete.length && limit === 10 * 1024 * 1024) setImmediate(() => catchUpJsonl(s)) - } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } + } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e); scheduleRetry(s) } } function catchUpPerMessageDir(s: SessionState): void { @@ -972,7 +996,8 @@ function catchUpPerMessageDir(s: SessionState): void { } if (!eventBus.paused && s.checkpointPaused) { s.checkpointPaused = false; checkpoint(s) } if (!eventBus.paused) scheduleIdleSnapshot(s) - } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } + s.retryAttempt = 0 + } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e); scheduleRetry(s) } } // Synchronous resume boundary precedes any delayed watcher callback. Empty @@ -1016,7 +1041,12 @@ function processUnit(s: SessionState, rawContent: string, sourcePath: string): v } if (!raw) return const turns = Array.isArray(raw) ? raw : [raw] + // A failed unit is read again, so its count must not stick. + const toolCallsSeen = s.toolCallsSeen + try { processTurns(s, turns) } catch (e) { s.toolCallsSeen = toolCallsSeen; throw e } +} +function processTurns(s: SessionState, turns: ParsedTurn[]): void { for (const turn of turns) { if (s.adapter.knownIgnoredTypes.has(turn.type)) { if (turn.uuid) s.ignoredParents.set(turn.uuid, turn.parentUuid) @@ -1024,7 +1054,7 @@ function processUnit(s: SessionState, rawContent: string, sourcePath: string): v } // Schema-drift check: parseUnit returned a turn whose type isn't in the // adapter's whitelist. Fire the advisory once per session and skip. - if (turn.type === 'tool_use') s.toolCallsSeen++ + if (turn.type === 'tool_use' && !(turn.uuid && s.redlogIdByUuid.has(turn.uuid))) s.toolCallsSeen++ if (!s.adapter.knownIngestTypes.has(turn.type)) { if (!s.driftAdvisoryFired.has(turn.type)) { s.driftAdvisoryFired.add(turn.type) @@ -1219,6 +1249,8 @@ export function registerSession(agentKind: string, sourcePath: string): void { turnsEmitted: 0, postCompact: false, idleTimer: null, + retryTimer: null, + retryAttempt: 0, lastSnapshotBytes: 0, bytesAppendedSinceSnapshot: 0, driftAdvisoryFired: new Set(), @@ -1246,9 +1278,12 @@ export function registerSession(agentKind: string, sourcePath: string): void { } s.resumedAt = cursor.resumedAt ?? 0; s.skipMissingParents = cursor.skipMissingParents === true; s.replayFrom = cursor.replayFrom if (cursor.savedBytes !== undefined) { - const actual = fs.existsSync(sidecarPath) ? fs.statSync(sidecarPath).size : 0 - if (!Number.isSafeInteger(cursor.savedBytes) || cursor.savedBytes < 0 || actual < cursor.savedBytes) throw new Error('AI sidecar is shorter than its checkpoint') - if (actual > cursor.savedBytes) fs.truncateSync(sidecarPath, cursor.savedBytes) + // Retention removes whole sidecars and records it; reading resumes at + // the checkpoint. A sidecar that is present but short is refused. + const actual = fs.existsSync(sidecarPath) ? fs.statSync(sidecarPath).size : null + if (!Number.isSafeInteger(cursor.savedBytes) || cursor.savedBytes < 0 || (actual !== null && actual < cursor.savedBytes)) throw new Error('AI sidecar is shorter than its checkpoint') + if (actual === null) { cursor.savedBytes = 0; s.replayFrom = undefined } + if (actual !== null && actual > cursor.savedBytes) fs.truncateSync(sidecarPath, cursor.savedBytes) } } else checkpoint(s) } catch (e) { noteDbError('tailer-host', e); return } @@ -1319,6 +1354,7 @@ function unregisterSession(key: string): void { const s = sessions.get(key) if (!s) return if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null } + if (s.retryTimer) { clearTimeout(s.retryTimer); s.retryTimer = null } const ct = coalesceTimers.get(key) if (ct) { clearTimeout(ct); coalesceTimers.delete(key) } // v0.7.4 F4: skip snapshot + session_end emits when paused. Still remove diff --git a/test/tailer-durability.test.ts b/test/tailer-durability.test.ts index 9b637c36..742b3907 100644 --- a/test/tailer-durability.test.ts +++ b/test/tailer-durability.test.ts @@ -9,7 +9,7 @@ import { eventBus } from '../src/core/event-bus' import { initDB, closeDB } from '../src/core/db/index' import { queryEvents } from '../src/core/db/events' import { createOperator, generateToken } from '../src/core/db/operators' -import { registerAdapter, unregisterAdapter, configureHost, stopHost, registerSession, catchUpSession, listAgentSessions } from '../src/main/services/tailer-host' +import { registerAdapter, unregisterAdapter, configureHost, stopHost, registerSession, catchUpSession, listAgentSessions, _sessionsForTest } from '../src/main/services/tailer-host' import type { TailerAdapter } from '../src/main/services/tailer-host' let dir: string, source: string @@ -204,3 +204,78 @@ describe('AI capture durable progress and privacy boundaries', () => { }) }) + +describe('a failed write without a restart', () => { + const child = (uuid: string, parentUuid: string | null) => JSON.stringify({ uuid, parentUuid, type: 'user', textContent: uuid }) + '\n' + const ids = () => rows().map(e => String(e.data.transcript_uuid)).sort() + const failOn = (uuid: string) => { + const real = ingestion.ingestEvent + return vi.spyOn(ingestion, 'ingestEvent').mockImplementation((kind, data, options) => { + if (data.transcript_uuid === uuid) throw new Error('injected DB write refusal') + return real(kind, data, options) + }) + } + const session = () => [..._sessionsForTest().values()][0] + + it('keeps a waiting child whose flush failed, for the next pass and for a restart', () => { + fs.writeFileSync(source, child('c', 'p')); start() + fs.appendFileSync(source, child('p', null)) + const fail = failOn('c'); catchUp(); fail.mockRestore() + expect(ids()).toEqual(['p']) + restart(); catchUp() + expect(ids()).toEqual(['c', 'p']) + }) + + it('queues a waiting child once, however often a failed pass is read again', () => { + fs.writeFileSync(source, child('c', 'never') + child('x', null)) + const fail = failOn('x'); start(); catchUp(); catchUp(); fail.mockRestore() + expect(session().pendingByParentUuid.get('never')).toHaveLength(1) + }) + + it('counts a tool call once when its unit is read again', () => { + registerAdapter({ ...adapter, knownIngestTypes: new Set(['tool_use']), subtypeFor: () => 'tool_call' }) + configureHost(cfg) + fs.writeFileSync(source, JSON.stringify({ uuid: 't1', type: 'tool_use', toolName: 'Bash', toolInput: { command: 'id' } }) + '\n') + const fail = failOn('t1'); registerSession(adapter.agentKind, source); fail.mockRestore(); catchUp() + expect([session().toolCallsSeen, session().toolCallsEmitted]).toEqual([1, 1]) + }) + + it('retries the last unit of a session by itself, with no further file event', async () => { + vi.useFakeTimers() + try { + fs.writeFileSync(source, line('last-words')) + const fail = failOn('last-words'); start(); fail.mockRestore() + expect(ids()).toEqual([]) + await vi.advanceTimersByTimeAsync(1000) + expect(ids()).toEqual(['last-words']) + } finally { vi.useRealTimers() } + }) + + it('retries a per-message unit on the next pass, without the file changing', () => { + source = path.join(dir, 'session'); fs.mkdirSync(source) + fs.writeFileSync(path.join(source, 'a.json'), line('a')); fs.writeFileSync(path.join(source, 'b.json'), line('b')) + const fail = failOn('b'); start(true); fail.mockRestore() + expect(ids()).toEqual(['a']) + catchUpSession(adapter.agentKind, 'session') + expect(ids()).toEqual(['a', 'b']) + }) +}) + +describe('a sidecar removed by retention', () => { + it('resumes from the checkpoint, neither stopping nor replaying', () => { + fs.writeFileSync(source, line('old')); start() + const sidecar = [..._sessionsForTest().values()][0].sidecarPath + stopHost(); fs.rmSync(sidecar) + fs.appendFileSync(source, line('new')); start(); catchUp() + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['new', 'old']) + expect(fs.readFileSync(sidecar, 'utf8')).toBe(line('new')) + }) + + it('still refuses a sidecar that is present but shorter than its checkpoint', () => { + fs.writeFileSync(source, line('old')); start() + const sidecar = [..._sessionsForTest().values()][0].sidecarPath + stopHost(); fs.writeFileSync(sidecar, '') + fs.appendFileSync(source, line('new')); start(); catchUp() + expect(rows().map(e => e.data.transcript_uuid)).toEqual(['old']) + }) +})