From 63af0bdd24fc0b97a0eadf359ecc9d71ebc989de Mon Sep 17 00:00:00 2001 From: Evgenii Date: Mon, 10 Aug 2026 04:50:02 +0700 Subject: [PATCH] feat(agents): add Prime Agent as a supported TUI agent with session history (#12935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): add Prime Agent as a supported TUI agent with session history Wire Prime Intellect's prime-agent CLI (a Pi fork) into the desktop and mobile agent catalogs following the Trae registration pattern, and into the Agent Session History browser following the OMP pattern: - types.ts, tui-agent-config.ts: register 'prime-agent' with argv prompt injection behind a `--` separator (its own help documents `--` as "treat all following arguments as messages"; without it, prompts starting with `help`/`agents`/`-…` dispatch as subcommands or flags), plus csi-u Shift+Enter encoding matching the Pi TUI it embeds. - agent-kind.ts, telemetry-events.ts, agent-status-types.ts, agent-type-label.ts, tui-agent-display-names.ts, tui-agent-selection.ts, skills-cli-agent-keys.ts: standard per-agent registrations. - agent-headless-command.ts: `-p/--print` one-shot runs share the print-mode matcher with Claude/Trae so they are not mistaken for live interactive panes. - agent-process-recognition: the npm shim launches a generic bundled cli.js, so only the exact package path is an authoritative identity (same as Pi and cursor-agent). The three per-agent regex branches are now one table in agent-node-entrypoint-identities.ts — the module was at its max-lines budget and a table makes the next agent one entry. - AI Vault: sessions are Pi's message-graph JSONL under ~/.prime/agent/sessions (override PRIME_AGENT_CODING_AGENT_DIR — Prime Agent brands Pi's env contract instead of sharing PI_CODING_AGENT_DIR); parsed by the shared message-graph parser with incremental append-resume; discovered locally, in WSL homes, and over remote SSH; resumes by absolute transcript path (`prime-agent --resume `) like OMP, with session-id fallback. - skill-discovery-sources.ts: ~/.prime/agent/skills home source. - Catalog, i18n (en/es/ja/ko/zh), mobile registries, and a bundled 64x64 favicon (required by mobile's offline-icon invariant). Scanner-test fixtures for OMP and Prime Agent move into session-scanner-test-fixtures.ts and the incremental fixture into its own module, keeping every touched file inside its max-lines budget without ratchet bumps. * fix(ai-vault): map custom Prime Agent roots to their sessions child PRIME_AGENT_CODING_AGENT_DIR is consumed verbatim by the CLI as its agent config dir, with transcripts always in /sessions — unlike PI_CODING_AGENT_DIR's /agent/sessions shape the shared normalizer models. A custom root with a non-special basename (or a `.prime` leaf) was therefore scanned as-is instead of its sessions child. Dedicated normalizePrimeAgentSessionsDir appends `sessions` to every configured root, taking only an explicit `.../sessions` path as-is; the shared Pi/OMP normalizer drops the `.prime` widening it no longer needs. Raised in review on #12935. * fix(ai-vault): guard degenerate Prime Agent roots and cover the remote source normalizePrimeAgentSessionsDir stripped a filesystem-root value ('/' or '//') to '', which then joined into the relative root 'sessions' and would walk the main-process cwd. session-scanner-roots.ts already carries this guard for the OMP variant; apply the same fallback here. The remote SSH source had no test: deleting jsonlSource('prime-agent', ...) left the suite green, unlike the local path which is pinned by the AI_VAULT_AGENTS exhaustiveness assertion in session-scanner.test.ts. Add a case that fixes the .prime/agent/sessions root segments, the .jsonl extension, and parser routing. Raised in review on #12935. * fix(ai-vault): honor Prime Agent's sessions-root env and non-interactive modes Verified against upstream PrimeIntellect-ai/prime-agent source rather than inferred from the CLI's help text. config.ts getSessionsDir() reads PRIME_AGENT_SESSION_DIR (and its legacy PRIME_AGENT_CODING_AGENT_SESSION_DIR alias) ahead of the agent dir and uses it verbatim; setting either left the vault silently empty. It also appends `sessions` to the agent dir unconditionally, with no basename escape hatch, so PRIME_AGENT_CODING_AGENT_DIR=/data/sessions writes to /data/sessions/sessions while Orca scanned /data/sessions. getAgentDir() and the session-dir override both run through expandTildePath, so a `~` value set outside a shell resolves. cli/args.ts also spells the non-interactive runs `--mode json|rpc|acp|daemon`, which the shared print-mode matcher does not know, so those panes were counted as live interactive agents and the paste-submit path would write user text into a JSON-RPC/ACP stream. Match upstream exactly: only the space-separated form, since `--mode=json` is not parsed by the CLI and does start the TUI. Raised in review on #12935. * fix(ai-vault): keep Prime Agent roots absolute and remote segments posix Two holes in the previous commit. The degenerate-root guard only rejected pure-separator values, so a relative env value still resolved against the main-process cwd: PRIME_AGENT_CODING_AGENT_DIR='.' scanned '/sessions' and, worse, PRIME_AGENT_SESSION_DIR='.' scanned the cwd itself. Require an absolute path in both branches and fall back to the default otherwise. remotePrimeAgentSessionsSegments() built its segments with the local-platform join, so on a Windows client scanning a posix SSH host it produced '\.prime\agent\sessions' and split('/') collapsed it to one bogus segment — remote discovery would have found nothing. Remote roots are posix regardless of client platform, so keep them literal. Pi and OMP are unaffected: their normalizer returns a '.../sessions' input unchanged and never joins. Raised in review on #12935. * test(ai-vault): pin Windows drive roots to the Prime Agent default fallback 'C:\' and 'C:/' strip to the drive-relative 'C:', which isAbsolute rejects on every platform — assert they land in the default fallback so a looser truthiness check can't reintroduce a 'C:sessions' scan root. Raised in review on #12935. * test(ai-vault): pin the drive-relative root form and state what the posix runner can assert --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- mobile/pnpm-lock.yaml | 46 +++++++-- .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 3 + .../remote-session-scanner-sources.ts | 24 +++++ .../ai-vault/remote-session-scanner.test.ts | 29 ++++++ .../ai-vault/session-scanner-agent-parser.ts | 2 + .../ai-vault/session-scanner-agent-sources.ts | 15 ++- .../session-scanner-codex-workers.test.ts | 1 + .../ai-vault/session-scanner-graph-parsers.ts | 7 +- .../session-scanner-incremental-fixtures.ts | 2 + ...canner-opencode-sqlite-coexistence.test.ts | 3 +- .../ai-vault/session-scanner-parse-cache.ts | 3 +- .../session-scanner-prime-agent-fixtures.ts | 50 +++++++++ .../ai-vault/session-scanner-test-fixtures.ts | 67 ++++++++++++ src/main/ai-vault/session-scanner-types.ts | 1 + .../ai-vault/session-scanner-values.test.ts | 97 ++++++++++++++++++ src/main/ai-vault/session-scanner-values.ts | 37 ++++++- src/main/ai-vault/session-scanner.test.ts | 53 +++------- src/main/skills/skill-discovery-sources.ts | 8 ++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/renderer/src/lib/agent-catalog.tsx | 7 ++ src/renderer/src/lib/agent-favicon-assets.ts | 2 + src/renderer/src/lib/agent-status.test.ts | 5 + src/renderer/src/lib/agent-status.ts | 1 + .../src/lib/tui-agent-startup.test.ts | 29 ++++++ src/shared/agent-headless-command.ts | 5 +- src/shared/agent-icons/prime-agent.png | Bin 0 -> 644 bytes src/shared/agent-kind.ts | 1 + .../agent-node-entrypoint-identities.ts | 30 ++++++ src/shared/agent-process-recognition.test.ts | 55 ++++++++++ src/shared/agent-process-recognition.ts | 16 +-- src/shared/agent-status-types.ts | 1 + src/shared/agent-type-label.ts | 1 + src/shared/ai-vault-resume-command.test.ts | 27 +++++ src/shared/ai-vault-resume-command.ts | 20 ++-- src/shared/ai-vault-types.ts | 2 + src/shared/prime-agent-headless-command.ts | 22 ++++ src/shared/skills-cli-agent-keys.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/tui-agent-config.ts | 12 +++ src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-selection.ts | 1 + src/shared/types.ts | 1 + 47 files changed, 624 insertions(+), 71 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-prime-agent-fixtures.ts create mode 100644 src/shared/agent-icons/prime-agent.png create mode 100644 src/shared/agent-node-entrypoint-identities.ts create mode 100644 src/shared/prime-agent-headless-command.ts diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 3fadaff66..2d616e566 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -10279,7 +10279,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -10299,7 +10299,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -10316,6 +10316,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10325,6 +10334,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10354,6 +10367,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -11633,11 +11661,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -11661,7 +11689,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -11684,7 +11712,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11695,7 +11723,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -14948,6 +14976,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 9aa4efde3..3f93b3dfa 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -17,6 +17,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial = { trae: 'Trae', pi: 'Pi', omp: 'OMP', + 'prime-agent': 'Prime Agent', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', @@ -88,6 +90,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> ante: 'antigma.ai', trae: 'www.trae.cn', omp: 'omp.sh', + 'prime-agent': 'primeintellect.ai', gemini: 'gemini.google.com', antigravity: 'antigravity.google', goose: 'goose-docs.ai', diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index debdc3881..8bd9fdaf6 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -101,6 +101,13 @@ export function remoteSessionSources( // partition keeps the children out of the top-level list (#9330). partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths }, + jsonlSource( + 'prime-agent', + remoteHome, + hostPlatform, + remotePrimeAgentSessionsSegments(), + primeAgentParser + ), jsonlSource( 'droid', remoteHome, @@ -264,6 +271,16 @@ function ompParser( return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) } +function primeAgentParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) +} + function openClawParser( file: FileWithMtime, content: string, @@ -285,3 +302,10 @@ function remotePiSessionsSegments(): string[] { function remoteOmpSessionsSegments(): string[] { return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) } + +// Why: remote roots are posix regardless of the client platform, so these stay literal +// rather than round-tripping through a local-platform path join that would emit +// backslashes on a Windows client and collapse into a single bogus segment. +function remotePrimeAgentSessionsSegments(): string[] { + return ['.prime', 'agent', 'sessions'] +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 462ced9aa..7884fa946 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' +import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { @@ -160,6 +161,34 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('discovers Prime Agent transcripts under the remote home sessions root', async () => { + const provider = new MemoryRemoteProvider() + const fixture = primeAgentFixture() + provider.addFile( + `/home/ada/.prime/agent/sessions/${fixture.fileName}`, + [...fixture.seedLines, ...fixture.appendLines].join('\n'), + 40 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'prime-agent', + title: 'prime-agent seed question', + model: 'inference/big-model', + filePath: `/home/ada/.prime/agent/sessions/${fixture.fileName}` + }) + }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { const provider = new MemoryRemoteProvider() const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1c2c9e84e..61554f663 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -68,6 +68,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('pi', candidate.file, platform) case 'omp': return parseMessageGraphSessionFile('omp', candidate.file, platform) + case 'prime-agent': + return parseMessageGraphSessionFile('prime-agent', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 33320498b..803bf6a3f 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -9,7 +9,7 @@ import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' import type { AiVaultScanOptions } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir, primeAgentSessionsDirFromEnv } from './session-scanner-values' export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') const CODEX_SESSIONS_DIR = join( @@ -29,6 +29,10 @@ const PI_SESSIONS_DIR = normalizeAgentSessionsDir( process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), '.pi' ) +// Why: Prime Agent brands Pi's env contract with its own prefix and adds a +// dedicated sessions-root override, so resolution differs from Pi/OMP in shape +// as well as in variable name. +const PRIME_AGENT_SESSIONS_DIR = primeAgentSessionsDirFromEnv() // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), @@ -180,6 +184,15 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // Depth 0 is the workspace dir, which is never an artifact dir. directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) }, + 'prime-agent': { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.primeAgentSessionsDir ?? PRIME_AGENT_SESSIONS_DIR, wslHomeDirs, [ + '.prime', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'] + }, openclaw: { // Sessions live under /agents; a stateDir already ending in // `agents` is used as-is. The current and legacy state dirs are the same diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index d8a8e2850..a1a35a6c4 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -119,6 +119,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), ompSessionsDir: join(root, 'omp-sessions'), + primeAgentSessionsDir: join(root, 'prime-agent-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 8aeb89089..250ebaf9a 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -166,8 +166,9 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str } // Agents whose transcripts are append-only message-graph JSONL (session + -// model_change + message records). OMP is a Pi fork and shares the format. -export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' +// model_change + message records). OMP and Prime Agent are Pi forks and +// share the format. +export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent' export async function parseMessageGraphSessionFile( agent: MessageGraphAgent, @@ -246,7 +247,7 @@ export function createMessageGraphSessionResumeState( ) // Why: only OMP materializes task-subagent transcripts beside its sessions // (in the same-named artifact dir); the row UI shows the count without - // expanding details. Pi/OpenClaw have no such layout — skip the readdir. + // expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir. return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 54f0171e6..809096dba 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -2,6 +2,7 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' +import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' // Line builders for the incremental-parse differential tests: each agent gets // a seed transcript, an appended continuation, and a truncated rewrite, all in @@ -287,6 +288,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), + primeAgentFixture(), geminiJsonlFixture(), antigravityFixture() ] diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 84c9c7d7c..a5981a016 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -51,7 +51,8 @@ function isolatedScanRoots(root: string) { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), - ompSessionsDir: join(root, 'omp-sessions') + ompSessionsDir: join(root, 'omp-sessions'), + primeAgentSessionsDir: join(root, 'prime-agent-sessions') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 8c11557e2..727d70ac3 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -60,7 +60,8 @@ function resumableStateFactoryFor( return () => createDroidSessionResumeState(candidate.file) case 'openclaw': case 'pi': - case 'omp': { + case 'omp': + case 'prime-agent': { const agent = candidate.agent return () => createMessageGraphSessionResumeState(agent, candidate.file) } diff --git a/src/main/ai-vault/session-scanner-prime-agent-fixtures.ts b/src/main/ai-vault/session-scanner-prime-agent-fixtures.ts new file mode 100644 index 000000000..b796404ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-prime-agent-fixtures.ts @@ -0,0 +1,50 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +// Prime Agent is a Pi fork sharing the message-graph format and Pi's +// `modelId` key; its own fixture exercises the registry's 'prime-agent' +// branch with the v3 tree-session header its releases write. +export function primeAgentFixture(): IncrementalAgentFixture { + return { + agent: 'prime-agent', + fileName: 'dddddddd-eeee-4fff-8aaa-111111111111.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + version: 3, + id: 'dddddddd-eeee-4fff-8aaa-111111111111', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'model_change', + provider: 'prime-intellect', + modelId: 'inference/big-model', + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'prime-agent seed question' }] }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'prime-agent incremental answer' }], + model: 'inference/big-model', + usage: { input: 30, output: 10, totalTokens: 40 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'dddddddd-eeee-4fff-8aaa-111111111111', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index f69bf2939..6da538c6a 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -21,6 +21,7 @@ export function isolatedScanRoots(root: string) { openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), ompSessionsDir: join(root, 'omp-sessions'), + primeAgentSessionsDir: join(root, 'prime-agent-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') @@ -50,6 +51,72 @@ export function writeAntigravityHistory(brainDir: string, records: unknown[]): P return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) } +// Message-graph fixtures for the Pi forks: each writes one session transcript +// and returns its path, since both agents resume by absolute transcript path. +export async function writeOmpScannerFixture(sessionsDir: string): Promise { + const sessionFile = join(sessionsDir, 'omp-session.jsonl') + await writeJsonlFile(sessionFile, [ + { + type: 'session', + version: 3, + id: 'omp-session', + title: 'OMP session title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:08:30.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:31.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'OMP answer' }], + model: 'gpt-5.4-mini', + // totalTokens deliberately != input+output so the assertion proves + // the explicit-total field is read, not an input/output sum. + usage: { input: 10, output: 5, totalTokens: 160 } + } + } + ]) + return sessionFile +} + +// Prime Agent shares Pi's message-graph format (and its `modelId` key) but +// reads its own ~/.prime/agent/sessions root. +export async function writePrimeAgentScannerFixture(sessionsDir: string): Promise { + const sessionFile = join(sessionsDir, 'prime-agent-session.jsonl') + await writeJsonlFile(sessionFile, [ + { + type: 'session', + version: 3, + id: 'prime-agent-session', + timestamp: '2026-05-01T10:08:40.000Z', + cwd: '/tmp/prime-agent' + }, + { + type: 'model_change', + provider: 'prime-intellect', + modelId: 'inference/big-model', + timestamp: '2026-05-01T10:08:40.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:41.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'Prime Agent title' }] } + } + ]) + return sessionFile +} + export function writeAntigravityScannerFixture( brainDir: string, sessionId: string diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 13b40698c..9021b1f7c 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -30,6 +30,7 @@ export type AiVaultScanOptions = { openclawLegacyStateDir?: string piSessionsDir?: string ompSessionsDir?: string + primeAgentSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 3ca343272..10f6195b0 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,9 +1,13 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { extractFullFirstUserPromptText, extractPreviewContentText, normalizeAgentSessionsDir, normalizeFullFirstUserPromptText, + normalizePrimeAgentSessionsDir, + primeAgentSessionsDirFromEnv, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -87,4 +91,97 @@ describe('AI Vault session scanner text values', () => { '/agents/.omp/agent/sessions' ) }) + + // Prime Agent's env var is its agent dir verbatim and the CLI writes to + // `/sessions`, so every configured dir maps to that child. + it('maps any Prime Agent agent dir to its sessions child', () => { + expect(normalizePrimeAgentSessionsDir('/tmp/prime-agent')).toBe('/tmp/prime-agent/sessions') + expect(normalizePrimeAgentSessionsDir('/tmp/prime-agent///')).toBe('/tmp/prime-agent/sessions') + expect(normalizePrimeAgentSessionsDir('/agents/.prime')).toBe('/agents/.prime/sessions') + expect(normalizePrimeAgentSessionsDir('/agents/.prime/agent')).toBe( + '/agents/.prime/agent/sessions' + ) + }) + + // Why: the CLI appends `sessions` unconditionally, so an agent dir that is itself + // named `sessions` nests one deeper rather than being taken as the transcripts root. + it('still appends sessions when the agent dir is itself named sessions', () => { + expect(normalizePrimeAgentSessionsDir('/data/sessions')).toBe('/data/sessions/sessions') + }) + + it('expands a leading tilde in the agent dir', () => { + expect(normalizePrimeAgentSessionsDir('~/work/prime')).toBe( + join(homedir(), 'work', 'prime', 'sessions') + ) + }) + + // Why: any non-absolute root would resolve against the main-process cwd. The + // drive-shaped values ('C:foo' is the form that resolves against a per-drive + // cwd on Windows) are non-absolute on posix too, so they assert the same + // fallback here; real win32 semantics cannot be pinned on a posix runner. + it('falls back to the default root for every non-absolute agent dir', () => { + const fallback = join(homedir(), '.prime', 'agent', 'sessions') + for (const value of [ + '/', + '//', + ' ', + '', + '.', + '..', + 'sessions', + 'rel/path', + 'C:\\', + 'C:/', + 'C:foo' + ]) { + expect(normalizePrimeAgentSessionsDir(value)).toBe(fallback) + } + }) + + describe('primeAgentSessionsDirFromEnv', () => { + const defaultDir = join(homedir(), '.prime', 'agent', 'sessions') + + it('defaults to the home agent dir when nothing is configured', () => { + expect(primeAgentSessionsDirFromEnv({})).toBe(defaultDir) + }) + + it('appends sessions to a configured agent dir', () => { + expect(primeAgentSessionsDirFromEnv({ PRIME_AGENT_CODING_AGENT_DIR: '/opt/prime' })).toBe( + '/opt/prime/sessions' + ) + }) + + // Why: upstream reads the sessions-root overrides before the agent dir and uses + // them verbatim; ignoring them left the vault silently empty. + it('prefers the sessions-root overrides verbatim over the agent dir', () => { + expect( + primeAgentSessionsDirFromEnv({ + PRIME_AGENT_CODING_AGENT_DIR: '/opt/prime', + PRIME_AGENT_SESSION_DIR: '/mnt/transcripts' + }) + ).toBe('/mnt/transcripts') + expect( + primeAgentSessionsDirFromEnv({ + PRIME_AGENT_CODING_AGENT_DIR: '/opt/prime', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/mnt/legacy' + }) + ).toBe('/mnt/legacy') + expect( + primeAgentSessionsDirFromEnv({ + PRIME_AGENT_SESSION_DIR: '/mnt/wins', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/mnt/legacy' + }) + ).toBe('/mnt/wins') + }) + + it('expands a tilde and rejects a non-absolute sessions-root override', () => { + expect(primeAgentSessionsDirFromEnv({ PRIME_AGENT_SESSION_DIR: '~/t' })).toBe( + join(homedir(), 't') + ) + // Why: '.' would otherwise scan the main-process cwd outright. + for (const value of ['/', '.', '..', 'rel/path', ' ', 'C:foo']) { + expect(primeAgentSessionsDirFromEnv({ PRIME_AGENT_SESSION_DIR: value })).toBe(defaultDir) + } + }) + }) }) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 10ec34cd1..b058876fd 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, isAbsolute, join } from 'node:path' import { readFile } from 'node:fs/promises' import { asRecord } from './session-scanner-record-value' @@ -153,6 +153,41 @@ export function normalizeAgentSessionsDir( return normalized } +function defaultPrimeAgentSessionsDir(): string { + return join(homedir(), '.prime', 'agent', 'sessions') +} + +// Why: the CLI expands a leading `~` itself, so a value set outside a shell +// (config file, plist, quoted assignment) still resolves against the home dir. +// Returns null for anything that is not an absolute root, since a relative value +// ('', '.', '..', 'sessions') would resolve against the main-process cwd. +function absoluteConfiguredDir(rawValue: string): string | null { + const expanded = rawValue === '~' ? homedir() : rawValue.replace(/^~(?=[\\/])/, homedir()) + const normalized = expanded.replace(/[\\/]+$/, '') + return normalized && isAbsolute(normalized) ? normalized : null +} + +// Prime Agent takes PRIME_AGENT_CODING_AGENT_DIR verbatim as its agent config dir +// (no `/agent` suffixing) and always writes transcripts to `/sessions` — +// unconditionally, so a root that is itself named `sessions` still nests one deeper. +export function normalizePrimeAgentSessionsDir(rawAgentDir: string): string { + const agentDir = absoluteConfiguredDir(rawAgentDir.trim()) + return agentDir ? join(agentDir, 'sessions') : defaultPrimeAgentSessionsDir() +} + +// PRIME_AGENT_SESSION_DIR (and its legacy PRIME_AGENT_CODING_AGENT_SESSION_DIR alias) +// point straight at the transcripts root and outrank the agent dir upstream, so they +// are used verbatim with no `sessions` child. +export function primeAgentSessionsDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { + const sessionDir = + env.PRIME_AGENT_SESSION_DIR?.trim() || env.PRIME_AGENT_CODING_AGENT_SESSION_DIR?.trim() + if (sessionDir) { + return absoluteConfiguredDir(sessionDir) ?? defaultPrimeAgentSessionsDir() + } + const agentDir = env.PRIME_AGENT_CODING_AGENT_DIR?.trim() + return agentDir ? normalizePrimeAgentSessionsDir(agentDir) : defaultPrimeAgentSessionsDir() +} + export function clampPositiveInteger(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback } diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 470fbdaca..feff1b19f 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -7,7 +7,9 @@ import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines, - writeAntigravityScannerFixture + writeAntigravityScannerFixture, + writeOmpScannerFixture, + writePrimeAgentScannerFixture } from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -595,43 +597,8 @@ describe('scanAiVaultSessions', () => { ]) ) - const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl') - await mkdir(roots.ompSessionsDir, { recursive: true }) - await writeFile( - ompSessionFile, - jsonLines([ - { - type: 'session', - version: 3, - id: 'omp-session', - title: 'OMP session title', - timestamp: '2026-05-01T10:08:30.000Z', - cwd: '/tmp/omp' - }, - { - type: 'model_change', - model: 'gpt-5.4-mini', - timestamp: '2026-05-01T10:08:30.500Z' - }, - { - type: 'message', - timestamp: '2026-05-01T10:08:31.000Z', - message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } - }, - { - type: 'message', - timestamp: '2026-05-01T10:08:32.000Z', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'OMP answer' }], - model: 'gpt-5.4-mini', - // totalTokens deliberately != input+output so the assertion proves - // the explicit-total field is read, not an input/output sum. - usage: { input: 10, output: 5, totalTokens: 160 } - } - } - ]) - ) + const ompSessionFile = await writeOmpScannerFixture(roots.ompSessionsDir) + const primeAgentSessionFile = await writePrimeAgentScannerFixture(roots.primeAgentSessionsDir) await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( @@ -767,6 +734,10 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") // OMP resumes by absolute transcript path, not by internal session id. expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`) + // Prime Agent's `--resume ` takes the same absolute-path form as OMP. + expect(commandByAgent.get('prime-agent')).toBe( + `cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'` + ) expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( @@ -776,6 +747,12 @@ describe('scanAiVaultSessions', () => { const ompSession = result.sessions.find((session) => session.agent === 'omp') expect(ompSession?.model).toBe('gpt-5.4-mini') expect(ompSession?.totalTokens).toBe(160) + + // Prime Agent keeps Pi's `model_change.modelId` key, so the pre-reply model + // must come through even though no assistant message was written yet. + const primeAgentSession = result.sessions.find((session) => session.agent === 'prime-agent') + expect(primeAgentSession?.model).toBe('inference/big-model') + expect(primeAgentSession?.title).toBe('Prime Agent title') }) it('captures an in-progress OMP model from model_change before any assistant reply', async () => { diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index ba0cb4f47..ad457d00e 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -139,6 +139,14 @@ export function buildSkillDiscoverySources( ['agent-skills'], 'omp' ), + source( + 'home-prime-agent', + 'Prime Agent home', + pathApi.join(home, '.prime', 'agent', 'skills'), + 'home', + ['agent-skills'], + 'prime-agent' + ), source( 'home-gemini', 'Gemini home', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 263b33c25..24f24cddb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -419,6 +419,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code" }, "skill": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 21bb7d914..d3cd1a3f0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -344,6 +344,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c2d28dcc7..78d20fe82 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -344,6 +344,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index edd4ce24e..90fdafbc5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -344,6 +344,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code" }, "skill": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0605c9659..66272b3ab 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -344,6 +344,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code" }, "skill": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 2106c0eaa..ffac60fe4 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -127,6 +127,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' }, + { + id: 'prime-agent', + label: translate('auto.lib.agent.catalog.d443a47995', 'Prime Agent'), + cmd: 'prime-agent', + faviconDomain: 'primeintellect.ai', + homepageUrl: 'https://github.com/PrimeIntellect-ai/prime-agent' + }, { id: 'gemini', label: translate('auto.lib.agent.catalog.12e6baa4f7', 'Gemini'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index fa30062e3..dd1489e14 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -3,6 +3,7 @@ import grokUrl from '../../../shared/agent-icons/grok.png?url' import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url' import anteUrl from '../../../shared/agent-icons/ante.png?url' import traeUrl from '../../../shared/agent-icons/trae.png?url' +import primeAgentUrl from '../../../shared/agent-icons/prime-agent.png?url' import geminiUrl from '../../../shared/agent-icons/gemini.png?url' import antigravityUrl from '../../../shared/agent-icons/antigravity.png?url' import gooseUrl from '../../../shared/agent-icons/goose.png?url' @@ -36,6 +37,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { 'mimo-code': mimoCodeUrl, ante: anteUrl, trae: traeUrl, + 'prime-agent': primeAgentUrl, gemini: geminiUrl, antigravity: antigravityUrl, goose: gooseUrl, diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index ce546f541..9b614ca64 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -864,6 +864,10 @@ describe('formatAgentTypeLabel', () => { expect(formatAgentTypeLabel('trae')).toBe('Trae') }) + it("maps 'prime-agent' to 'Prime Agent'", () => { + expect(formatAgentTypeLabel('prime-agent')).toBe('Prime Agent') + }) + it('passes through arbitrary custom agent names as-is', () => { expect(formatAgentTypeLabel('weirdo')).toBe('weirdo') }) @@ -889,6 +893,7 @@ describe('agentTypeToIconAgent', () => { expect(agentTypeToIconAgent('command-code')).toBe('command-code') expect(agentTypeToIconAgent('ante')).toBe('ante') expect(agentTypeToIconAgent('trae')).toBe('trae') + expect(agentTypeToIconAgent('prime-agent')).toBe('prime-agent') }) it('returns null for arbitrary non-iconable strings', () => { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 1bfa9fc49..e49e3c002 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -105,6 +105,7 @@ const ICONABLE_AGENT_TYPES: Record = { 'mimo-code': true, pi: true, omp: true, + 'prime-agent': true, gemini: true, antigravity: true, aider: true, diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index d40bab3f4..74dba64f0 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -144,6 +144,35 @@ describe('buildAgentStartupPlan', () => { ).toBe("traecli -- 'help me name this config'") }) + it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => { + expect( + buildAgentStartupPlan({ + agent: 'prime-agent', + prompt: 'Summarize the failing tests', + cmdOverrides: {}, + platform: 'linux' + }) + ).toEqual({ + agent: 'prime-agent', + launchCommand: "prime-agent -- 'Summarize the failing tests'", + expectedProcess: 'prime-agent', + followupPrompt: null, + launchConfig: emptyLaunchConfig('prime-agent') + }) + }) + + // Why: without the separator these dispatch to Prime Agent's `help`/`agents` subcommands instead. + it('keeps subcommand-shaped Prime Agent prompts as the positional prompt', () => { + expect( + buildAgentStartupPlan({ + agent: 'prime-agent', + prompt: 'help me name this config', + cmdOverrides: {}, + platform: 'linux' + })?.launchCommand + ).toBe("prime-agent -- 'help me name this config'") + }) + it('uses cursor-agent as the actual launch binary', () => { expect( buildAgentStartupPlan({ diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 3dafdd62d..1ab62f2f5 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,14 +1,17 @@ import { isAnteHeadlessOneShotCommand } from './ante-headless-command' +import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command' import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' import type { TuiAgent } from './types' // Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share -// the same `--print` one-shot contract, Ante's `--prompt` form needs its own matcher. +// the same `--print` one-shot contract, Ante's `--prompt` form and Prime Agent's +// `--mode` forms need their own matchers. const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { claude: isPrintModeHeadlessOneShotCommand, trae: isPrintModeHeadlessOneShotCommand, + 'prime-agent': isPrimeAgentHeadlessOneShotCommand, ante: isAnteHeadlessOneShotCommand } diff --git a/src/shared/agent-icons/prime-agent.png b/src/shared/agent-icons/prime-agent.png new file mode 100644 index 0000000000000000000000000000000000000000..962b749b7fcb29d93659da272e4ccc444801896a GIT binary patch literal 644 zcmV-~0(l<`?0R~7($doR_xHfS!2A3AySuy3&(HGm@?>OW5)u-utgNA- zp(`sZo12^1*w}G#aa&tkIyyR1Qc{bHiwq15wY9ZIMn)qeBQrBI92^|JK-66T00G=d zL_t(oh3!^Jc7reswU~zh2@s}`()*vX*cKiLR{PRLyWnqlNd7{zVeG_-6Mv2<{0%#c zuK>4q*O1YvOH2;2d zK!-QeYJlYM3m$bK9l5+ET{H~9UYBuUlg~m~9P?Rg7}#a9;ir5yyg6-lP{|jvBXYetjfY4AH)EP(0yI* zOnm;K%Nk`#>84|%^e16q*xMlbe(r-V2nYi{V1k}oC6~<<469g95#fT08(y%+mCHGp z-24oMinNnP3LN1f5Wk85LqW=^!Y_iPa3#VG6 zFPz{Nd+rSkJnNy{Bd!rt{;Tf=Fv<2=c>6~k8lz*tW!K>=pI)Ccl1deV%N8NmwQroow01xJi%C1E>}BJ%eognz ei4!OO7rp><=MVA=%Wy;h0000 { ).toEqual({ agent: 'pi', processName: 'pi' }) }) + it('recognizes Prime Agent by its binary and npm entrypoint', () => { + expect(recognizeAgentProcess('prime-agent')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + expect(recognizeAgentProcess('/opt/homebrew/bin/prime-agent')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + expect( + recognizeAgentProcessFromCommandLine( + 'node /opt/homebrew/lib/node_modules/prime-agent/dist/bundle/cli.js' + ) + ).toEqual({ agent: 'prime-agent', processName: 'prime-agent' }) + expect( + recognizeAgentProcessFromCommandLine( + String.raw`node.exe C:\Users\dev\AppData\Roaming\npm\node_modules\prime-agent\dist\bundle\cli.js` + ) + ).toEqual({ agent: 'prime-agent', processName: 'prime-agent' }) + }) + + it('does not recognize Prime Agent headless one-shot commands as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('prime-agent -p "summarize this diff"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('prime-agent --print "review this"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('prime-agent --resume abc123')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + // Why: past `--` nothing is a flag, so this is the interactive pane Orca itself launches. + expect( + recognizeAgentProcessFromCommandLine('prime-agent -- "--print the release notes"') + ).toEqual({ agent: 'prime-agent', processName: 'prime-agent' }) + }) + + it('does not recognize Prime Agent non-interactive --mode runs as interactive agents', () => { + for (const mode of ['json', 'rpc', 'acp', 'daemon']) { + expect(recognizeAgentProcessFromCommandLine(`prime-agent --mode ${mode}`)).toBeNull() + } + // Why: `text` is the interactive TUI mode Orca hosts. + expect(recognizeAgentProcessFromCommandLine('prime-agent --mode text')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + // Why: the CLI only parses `--mode ` as separate tokens, so `--mode=json` + // is ignored by it and still starts the interactive mode. + expect(recognizeAgentProcessFromCommandLine('prime-agent --mode=json')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + expect(recognizeAgentProcessFromCommandLine('prime-agent -- --mode rpc')).toEqual({ + agent: 'prime-agent', + processName: 'prime-agent' + }) + }) + it('recognizes only the agent subcommand of the generic Orca CLI', () => { expect(recognizeAgentProcessFromCommandLine('orca claude-teams')).toEqual({ agent: 'claude-agent-teams', diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index 5ea409381..957bfa5cb 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -1,4 +1,5 @@ import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config' +import { EXACT_NODE_ENTRYPOINT_IDENTITIES } from './agent-node-entrypoint-identities' import type { AgentType } from './agent-status-types' import type { TuiAgent } from './types' import { filterHeadlessOneShotAgentCommand } from './agent-headless-command' @@ -52,9 +53,6 @@ const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record = { codex: ['node_modules/@openai/codex/'], gemini: ['node_modules/@google/gemini-cli/'] } -const CURSOR_AGENT_NODE_ENTRYPOINT_RE = /(?:^|\/)cursor-agent\/versions\/[^/]+\/index\.js$/ -const PI_AGENT_NODE_ENTRYPOINT_RE = - /(?:^|\/)node_modules\/@(?:earendil-works|mariozechner)\/pi-coding-agent\/dist\/cli\.js$/ const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/'] const PROCESS_TO_AGENT = new Map() @@ -206,14 +204,10 @@ function comparablePath(token: string): string { function recognizeNodeScriptEntrypoint(token: string): RecognizedAgentProcess | null { const path = comparablePath(token) - // Why: Cursor's native Windows launcher runs a generic versioned index.js, - // so its install path is the only stable identity that avoids ordinary Node apps. - if (CURSOR_AGENT_NODE_ENTRYPOINT_RE.test(path)) { - return { agent: 'cursor', processName: 'cursor-agent' } - } - // Why: Pi's npm shim launches a generic cli.js; only the exact package path is authoritative. - if (PI_AGENT_NODE_ENTRYPOINT_RE.test(path)) { - return { agent: 'pi', processName: 'pi' } + for (const identity of EXACT_NODE_ENTRYPOINT_IDENTITIES) { + if (identity.pattern.test(path)) { + return { agent: identity.agent, processName: identity.processName } + } } const normalized = normalizeProcessName(token, { stripInterpreterScriptExtension: true }) const markers = NODE_PACKAGE_SCRIPT_ENTRYPOINTS[normalized] diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 123a1bf5e..9f08791ee 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -31,6 +31,7 @@ export type WellKnownAgentType = | 'aider' | 'pi' | 'omp' + | 'prime-agent' | 'droid' | 'command-code' | 'grok' diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 05d70d238..f13dc36b7 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -16,6 +16,7 @@ const WELL_KNOWN_LABELS: Record = { aider: 'Aider', pi: 'Pi', omp: 'OMP', + 'prime-agent': 'Prime Agent', droid: 'Droid', 'command-code': 'Command Code', grok: 'Grok', diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index f1f06dabe..a111b9eed 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -122,4 +122,31 @@ describe('buildAiVaultResumeCommand', () => { }) ).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'") }) + + it('resumes Prime Agent by absolute transcript path like OMP', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'prime-agent', + sessionId: 'dddddddd-eeee-4fff-8aaa-111111111111', + resumeFilePath: + '/Users/ada/.prime/agent/sessions/dddddddd-eeee-4fff-8aaa-111111111111.jsonl', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe( + "cd '/Users/ada/repo' && prime-agent --resume '/Users/ada/.prime/agent/sessions/dddddddd-eeee-4fff-8aaa-111111111111.jsonl'" + ) + }) + + it('falls back to the session id when no Prime Agent transcript path is known', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'prime-agent', + sessionId: 'dddddddd-eeee-4fff-8aaa-111111111111', + resumeFilePath: null, + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && prime-agent --resume 'dddddddd-eeee-4fff-8aaa-111111111111'") + }) }) diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index d8406fb4f..17dd6ba7c 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -22,11 +22,15 @@ export function buildAiVaultResumeCommand(args: { const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath, shell } = args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) - // Why: OMP's `--resume` accepts an absolute transcript path, which resolves - // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL - // home) the file was discovered under, where an id-prefix lookup scoped to - // the default store would miss it. Falls back to the id if no path is known. - const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId + // Why: OMP's and Prime Agent's `--resume` accept an absolute transcript path, + // which resolves regardless of which session-dir root (custom + // OMP_CODING_AGENT_DIR / PRIME_AGENT_CODING_AGENT_DIR / WSL home) the file was + // discovered under, where an id-prefix lookup scoped to the default store + // would miss it. Falls back to the id if no path is known. + const resumeTarget = + (agent === 'omp' || agent === 'prime-agent') && resumeFilePath?.trim() + ? resumeFilePath.trim() + : sessionId const sessionArg = shell === 'cmd' ? quoteWindowsCmdArg(resumeTarget) @@ -167,10 +171,12 @@ function buildAgentResumeInvocation( case 'devin': case 'openclaw': case 'droid': - // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), - // but the `--resume ` invocation form is identical to the others here. + // Why: OMP and Prime Agent resume by absolute transcript path (see + // buildAiVaultResumeCommand), but the `--resume ` invocation form is + // identical to the others here. // falls through case 'omp': + case 'prime-agent': return `${baseCommand} --resume ${sessionArg}` case 'antigravity': return `${baseCommand} --conversation ${sessionArg}` diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6e132fce9..71387faf9 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -7,6 +7,7 @@ export const AI_VAULT_AGENTS = [ 'hermes', 'pi', 'omp', + 'prime-agent', 'cursor', 'gemini', 'antigravity', @@ -47,6 +48,7 @@ export const AI_VAULT_AGENT_LABELS = { hermes: 'Hermes', pi: 'Pi', omp: 'OMP', + 'prime-agent': 'Prime Agent', cursor: 'Cursor', gemini: 'Gemini', antigravity: 'Antigravity', diff --git a/src/shared/prime-agent-headless-command.ts b/src/shared/prime-agent-headless-command.ts new file mode 100644 index 000000000..df8abc254 --- /dev/null +++ b/src/shared/prime-agent-headless-command.ts @@ -0,0 +1,22 @@ +import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' + +// Why: Prime Agent's non-interactive runs are `--mode ` as well as +// `--print`; only `text` hosts the TUI. Matching upstream, the value must be a separate +// token — `--mode=json` is not parsed by the CLI, so it still starts the interactive mode. +const NON_INTERACTIVE_MODES = new Set(['json', 'rpc', 'acp', 'daemon']) + +export function isPrimeAgentHeadlessOneShotCommand(tokens: readonly string[]): boolean { + if (isPrintModeHeadlessOneShotCommand(tokens)) { + return true + } + for (let index = 1; index < tokens.length; index += 1) { + // Why: `--` ends option parsing, so a prompt that reads like `--mode` is still a prompt. + if (tokens[index] === '--') { + return false + } + if (tokens[index] === '--mode' && NON_INTERACTIVE_MODES.has(tokens[index + 1] ?? '')) { + return true + } + } + return false +} diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index ecd4a7d96..c74641f09 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -22,6 +22,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { 'mimo-code': null, pi: 'pi', omp: null, + 'prime-agent': null, gemini: 'gemini-cli', antigravity: 'antigravity', aider: null, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 490bb162a..6115584fd 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -77,6 +77,7 @@ export const AGENT_KIND_VALUES = [ 'mimo-code', 'pi', 'omp', + 'prime-agent', 'gemini', 'antigravity', 'aider', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 839ab251d..06d490342 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -144,6 +144,18 @@ export const TUI_AGENT_CONFIG: Record = { promptInjectionMode: 'argv', draftPromptEnvVar: 'ORCA_OMP_PREFILL' }, + 'prime-agent': { + detectCmd: 'prime-agent', + launchCmd: 'prime-agent', + expectedProcess: 'prime-agent', + // Why: `prime-agent [options] [@files...] [message...]` takes the task as positional argv. + promptInjectionMode: 'argv', + // Why: separator so prompts starting with `help`/`agents`/`-…` aren't parsed as a + // subcommand or flag — its help documents `--` as "treat all following arguments as messages". + argvPromptSeparator: '--', + // Why: Prime Agent embeds Pi's TUI and decodes CSI-u the same way (see pi above). + windowsShiftEnterEncoding: 'csi-u' + }, gemini: { detectCmd: 'gemini', launchCmd: 'gemini', diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index f680e16f9..618ef034a 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -18,6 +18,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { 'mimo-code': 'MiMo Code', pi: 'Pi', omp: 'OMP', + 'prime-agent': 'Prime Agent', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 0256c0526..e0c93daf4 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -16,6 +16,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'trae', 'pi', 'omp', + 'prime-agent', 'gemini', 'antigravity', 'aider', diff --git a/src/shared/types.ts b/src/shared/types.ts index 7755effc6..c4f8b6ca4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2626,6 +2626,7 @@ export type TuiAgent = | 'devin' // Devin CLI | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI + | 'prime-agent' // Prime Agent (Prime Intellect) export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'