diff --git a/.grok/hooks/failproofai.json b/.grok/hooks/failproofai.json new file mode 100644 index 000000000..113c1dcd9 --- /dev/null +++ b/.grok/hooks/failproofai.json @@ -0,0 +1,172 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUseFailure --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionDenied --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook StopFailure --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook Notification --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SubagentStart --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook SubagentStop --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreCompact --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostCompact --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ] + } +} diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 000000000..566a16638 --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,251 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli qwen", + "name": "failproofai-SessionStart", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli qwen", + "name": "failproofai-UserPromptSubmit", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli qwen", + "name": "failproofai-PreToolUse", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli qwen", + "name": "failproofai-PostToolUse", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUseFailure --cli qwen", + "name": "failproofai-PostToolUseFailure", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionRequest --cli qwen", + "name": "failproofai-PermissionRequest", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionDenied --cli qwen", + "name": "failproofai-PermissionDenied", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli qwen", + "name": "failproofai-Stop", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook StopFailure --cli qwen", + "name": "failproofai-StopFailure", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SubagentStart --cli qwen", + "name": "failproofai-SubagentStart", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook SubagentStop --cli qwen", + "name": "failproofai-SubagentStop", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreCompact --cli qwen", + "name": "failproofai-PreCompact", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostCompact --cli qwen", + "name": "failproofai-PostCompact", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook Notification --cli qwen", + "name": "failproofai-Notification", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "InstructionsLoaded": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook InstructionsLoaded --cli qwen", + "name": "failproofai-InstructionsLoaded", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptExpansion": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptExpansion --cli qwen", + "name": "failproofai-UserPromptExpansion", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "TodoCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook TodoCreated --cli qwen", + "name": "failproofai-TodoCreated", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "TodoCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook TodoCompleted --cli qwen", + "name": "failproofai-TodoCompleted", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli qwen", + "name": "failproofai-SessionEnd", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8446c95b4..8b31ac762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ - Stop writing credentials into the checkout, which the images now bake. `ci-entrypoint.sh` decoded the vendor OAuth tarballs into `$REPO/tokens/` and assembled the gateway env-file at `$REPO/canary.env`; both move to a run-scoped directory. That was harmless while no image had a repo-root build context and stopped being harmless the moment one did — a cleanup trap that does not run is the normal case (a killed run, a `docker kill`), and `install.sh --build-local` builds from the operator's own working tree, which on the box is exactly where those files land. Three layers now hold the line: the paths move, a repo-root `.dockerignore` names them anyway (including the beta-leg variants `tokens-beta/` and `canary-beta.env`, which `.gitignore` never covered), and each Dockerfile **refuses to build** if one arrives — so a rename on the writing side fails a build instead of publishing a credential to a public registry. (#705) +- Add **grok CLI** (xAI) as the 13th integration — live-hook enforcement plus an audit adapter, user and project scope. Verified live against grok 1.0.3: `{decision:"deny"}` blocks a tool call and overrides `--yolo`, and `{decision:"block"}` on `Stop` forces another turn. grok pipes a camelCase envelope, so `normalizeCliPayload` gains a `grok` branch; `GROK_TOOL_INPUT_MAP` maps `read_file`'s `target_file` and `list_dir`'s `target_directory` onto the keys builtins read, without which a live `.env` read walks straight past `block-env-files`. Two undocumented project-scope conditions are recorded in CLAUDE.md: grok discovers project hooks only inside a **git repo**, and only in a **trusted** folder. Audit reads `~/.grok/sessions///chat_history.jsonl` (#PR) + +- Add **Qwen Code** as the 14th integration — live-hook enforcement plus an audit adapter, user and project scope. Verified live against qwen-code 0.21.12: it honours Claude's own `permissionDecision` deny on `PreToolUse` (beating `-y`) and a top-level `{decision:"block"}` on `Stop`. Its payload is pure Claude snake_case and all six of its tools already deliver canonical input keys, so it needs no event map, no payload normalization and no tool-input map — only a name map. Audit reads `~/.qwen/projects//chats/.jsonl`, whose message bodies are Gemini-shaped `parts[]` rather than Claude content blocks (#PR) + +- **Subscribe grok and qwen to their full useful event surface** — grok 8 → **14 events** (its entire surface), qwen 12 → **19**. Verified before implementing rather than after: a live grok accepted all 14 config keys (`hook_count=14`, and grok silently skips names it does not recognize, so acceptance was the thing to check), and every qwen event has a real `executeHooks()` dispatch site in the shipped bundle. Reading that bundle also turned up three events qwen dispatches but does not document — `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch`. Most of the additions are observation, and on grok they can only ever be observation: its ACP handshake advertises `blockingEvents: ["pre_tool_use","stop","subagent_stop"]` and that is the complete list. The exception is qwen's `TodoCreated`/`TodoCompleted`, which a new `QWEN_EVENT_MAP` canonicalizes onto `TaskCreated`/`TaskCompleted` — these run in a `validation` phase where a `{decision:"block"}` genuinely prevents the write, verified live by blocking a todo that planned to skip the tests. Three events were deliberately left out: `MessageDisplay` fires per streaming chunk, `PostToolBatch` measured +76% hook invocations for a task while duplicating `PostToolUse`, and `SessionDelete` has no canonical equivalent (#PR) + +- Add grok and Qwen Code to the README's supported-CLI grid, with their marks taken from the same MIT-licensed icon set the existing twelve came from. grok's is monochrome and `currentColor` does not inherit inside an ``, so it ships as a light/dark pair rather than rendering black-on-black for half of readers; Qwen's colour mark reads on either theme and ships as one file. The grid moved from 6 columns to 7, since 14 CLIs is exactly two full rows there where 6 would leave a two-cell orphan row (#PR) + +- Fix every grok project 404ing from the projects list. grok percent-encodes its cwd folder (`%2Ftmp%2Ffp-prod`), and using that as the project name made it the URL slug — which re-encodes to `%252F…` in the link, and `/project/[name]` cannot resolve it. The slug is now the dash-encoded cwd, which is both URL-safe and byte-identical to the one Claude, Factory and Qwen derive for the same directory, so a project driven by grok and qwen now merges into one row instead of appearing twice (once unreachable). Found by clicking the link rather than by reading the list (#PR) + +- **Surface grok and qwen everywhere the other CLIs already appear.** Four gaps, all reported from a live dashboard: the two badges were indistinguishable, neither showed in the CLI filter, neither contributed to the projects list, and the daemon collected nothing from either. `lib/projects.ts` and the project detail page now aggregate both (their sessions were on disk and invisible); the badges moved off the status palette per the dashboard design system — status hues carry health meaning and must never be spent on identity — to neutral zinc for grok and magenta for qwen, which are also maximally far apart. The filter dropdown and badge colours were never missing from the code: they derive from `KNOWN_CLI_IDS`/`CLI_ENTRIES`, and the shipped `.next` bundle simply predated them, because the tarball had been packed with `--ignore-scripts` (#PR) + +- **Collect grok and qwen transcripts in the daemon, so their sessions reach the cloud like every other CLI's.** Two new `fpai-collect` sources plus their `main.rs` tasks and `HARNESS_KEYS` entries on both sides. qwen follows the Factory model (one JSONL per session, real timestamps) but its bodies are Gemini-shaped `parts[]` with `functionCall`/`functionResponse`, so the transform is its own. grok follows the **cursor** model instead, because its transcript carries **no timestamps at all**: events are stamped from the file mtime plus byte offset, keeping time approximately right while staying a pure function of the inputs, as the content-hash dedup requires. grok also needed its own path rules — the session id is the parent directory (every transcript is named `chat_history.jsonl`), the cwd folder is percent-encoded rather than dash-encoded, and `tool_calls[].arguments` arrives as a JSON string that is parsed so tool inputs stay queryable. Only `user` lines carrying `prompt_index` count as operator prompts; grok writes its own preamble and reminder injections as `user` lines too (#PR) + ### Fixes - Let the box pick the translation model per tier, and stop a re-install double-scheduling the box. `getModelForTier` now reads `TRANSLATE_MODEL_TIER1` / `TRANSLATE_MODEL_TIER23`, so the seven languages most readers actually arrive in can keep a strong model while the long tail runs on something cheap — the CLI's `--model` flag flattens every tier to one model, which is the opposite of what the tier split exists for. Any id the gateway serves over the Anthropic `/v1/messages` shape works, since that is the API the translator speaks (verified: `deepseek-v4-pro` and `deepseek-v4-flash` both answer there). Separately, `install.sh` now strips the pre-marker cron form as well as its own marker: a box set up before the marker existed carries a long-form inline `docker run … -e CANARY_JOB=` line, and matching only the marker left it in place — six entries, every job scheduled twice, one on the old image and one on the new. The per-job flock keeps that from doing damage and turns it into something worse to diagnose: which image runs becomes a coin toss. Found on the real box, whose crontab is exactly that shape. (#705) @@ -26,6 +40,8 @@ - Give the nightly translation a voice when it fails, and a pulse when it does not run. It posted nothing by design — the reasoning being that its output is the pull request — which held for both success shapes and failed for the third: a run that dies also leaves no PR, so failing and idling produced the identical signal, none. Between 2026-08-11 and 2026-08-17 it opened nothing while 28 pages sat missing from 14 locales, and what noticed was a finding in the weekly docs audit rather than the job itself. Failure now posts to the same Slack webhook the other two jobs use, naming the step and carrying the log tail; success stays quiet, because a nightly "all good" is noise. Every exit also writes `last-run.json` into the work dir, and the weekly docs audit reports its AGE — the one failure no error handler can catch is the job never starting, and only a file's age can see that from outside. (#705) +- **Stop failproofai's Claude hooks from running inert inside grok.** grok's hook discovery scans `~/.claude/settings.json`, `~/.claude/settings.local.json` and `/.claude/settings.json` by default — the last being exactly the file `policies --install --cli claude --scope project` writes — so on any machine with both tools, grok was already executing our hooks: passing `--cli claude` while piping its own camelCase payload. `tool_name` and `tool_input` arrived `undefined`, so every builtin that matches a tool name or inspects a command or path (`block-sudo`, `block-env-files`, `block-secrets-write`, `block-force-push`) saw nothing and allowed. A deny would not have landed anyway, because grok ignores Claude's `hookSpecificOutput` shape — verified by A/B on one live hook: Claude's shape let the command run, grok's blocked it. The hooks were installed, running, costing latency, and enforcing nothing, which is worse than no coverage because the install reports success. The handler now detects grok's envelope from the payload shape alone (never from an env var, so a real Claude event cannot be misread) and routes the event onto grok's contract — tool maps and response shape both. Real Claude payloads are untouched (#PR) + ### Fixes - **Stop the dashboard server's telemetry from stranding its own events, and stop it printing `Error while flushing PostHog` while doing it.** Four options on the `posthog-node` client each disabled a different part of the library's delivery machinery, and together they turned a slow network into lost events plus a stack trace in the user's terminal — the one `failproofai audit` starts, where `launch()`'s log filter only strips the Server Action skew block. The injected `resilientFetch` was the root of it: it retried five times over ~40s and then returned a synthetic `200` so the library would never log a network error, but posthog-node does not merely hand its abort signal to an injected fetch, it **races that fetch against its own `requestTimeout`** (`Promise.race([fetchPromise, deadline])`) precisely because an injected fetch may ignore the signal — which ours did, by stripping it. A ~40s budget racing a 5s deadline can never return in time, so the synthetic `200` was unreachable code, the `console.error` it existed to prevent fired anyway at 5s, and the retries ran on detached from a client that had already given up. Worse, that `200` was the wrong answer even when it did land: posthog-node deliberately does NOT dequeue a batch that failed with a network error, so reporting success is what would have made it discard events that never arrived. The wrapper is gone; plain global fetch is what the library expects. `fetchRetryCount` was `0`, leaving that wrapper as the only thing retrying, at the wrong layer — the library retries inside a single flush, knows which errors are retryable, and keeps its queue coherent while doing it. `requestTimeout` was `5000`, half the library's own default, so every attempt had half the room. And `flushInterval` was `0`, which is falsy and therefore disables the flush timer outright — that is the one that actually stranded events, because the batch posthog-node retains after a network error then had nothing scheduled to resend it and sat in an in-memory queue (`PostHogMemoryStorage`, so nothing survives the process) until some unrelated later event happened to trigger a flush. `flushAt: 1` is unchanged and deliberate: volume is a handful of events per process, batching buys nothing, and sending immediately is the best defense a memory-only queue has against the process dying. Measured against a server that answers correctly but takes 6s — a slow network, not an outage — the old options delivered the event **four times** and logged two flush errors, because the wrapper re-POSTed the same batch on each of its own retries while the library still held its retained copy; the new ones deliver it **once**, with nothing logged. The exit drain is now idempotent, since `beforeExit` re-fires every time a handler schedules async work and an unguarded one started a fresh 30s `shutdown()` on each pass. **No event, trigger or property changed** — all 73 call sites across the three dispatchers fire exactly as before. (#701) @@ -43,7 +59,6 @@ ### Docs - Ignore `/blog/`, so long-form design write-ups can be drafted in the checkout without landing in a diff. These are contributor-local working files — a draft that is one `git add -A` away from being committed is a draft written more cautiously than it should be. Nothing shipped in the package changes. (#717) - ## 1.0.1-beta.0 — 2026-08-14 ### Docs diff --git a/CLAUDE.md b/CLAUDE.md index fee009122..c94f32307 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -819,10 +819,220 @@ For production users the recommended Goose install is: failproofai policies --install --cli goose --scope project ``` -### Dogfood configs for Factory / Devin / Antigravity / Goose +### grok hooks (`~/.grok/hooks/failproofai.json` / `.grok/hooks/failproofai.json`) + +xAI's **grok** CLI is the 13th integration — **dual-pillar** (live hooks + audit), +**user + project** scope, Claude/Codex-style external shell hooks. The whole contract +below was **verified live against grok 1.0.3 (1a29d5bc12)** with a recorder hook on all +14 events plus deny and stop-gate probes. Binary probe: `grok`. + +**Schema is Claude's nested form**, in its own directory — grok loads every `*.json` +under `~/.grok/hooks/` (user, always trusted) or `/.grok/hooks/` (project). We own +one file, `failproofai.json`, so install never merges with the user's other hook files +(same arrangement as Copilot's `.github/hooks/failproofai.json`). `timeout` is in +**seconds** (grok's default 5; 600 for Stop/SubagentStop). The matcher is **omitted** — +grok treats it as a regex, and an omitted matcher matches every tool. + +**`grok` READS OTHER CLIS' HOOK CONFIGS — this is the most important fact here.** +Discovery scans, by default (`[compat.claude] hooks = true`): + +``` +global : ~/.grok/hooks/ ~/.claude/settings.json ~/.claude/settings.local.json ~/.cursor/hooks.json +project: /.claude/settings.json /.claude/settings.local.json /.grok/hooks/ /.cursor/hooks.json +``` + +`/.claude/settings.json` is exactly what `policies --install --cli claude --scope +project` writes, so **grok executes our Claude hooks**, passing `--cli claude` while +piping its own camelCase payload. That made every such hook **inert**: `tool_name` and +`tool_input` arrived `undefined`, so every content/path builtin allowed — and a deny +would not have landed anyway, since grok ignores Claude's `hookSpecificOutput` shape +(A/B verified on one live hook). `resolveEffectiveCli()` in `normalize-cli-payload.ts` +detects grok's envelope **from the payload shape only** (`hookEventName` + +`workspaceRoot`, no `hook_event_name` — a shape Claude never sends; never an env var, +which could misread a real Claude event) and re-routes onto grok's contract. Note the +mirror case is **not** true: a Cursor-schema `~/.cursor/hooks.json` loads `count=0` — +grok accepts Cursor's event *names* but expects the nested Claude *structure*. + +Consequence worth knowing: in a repo carrying **both** `.claude/settings.json` and +`.grok/hooks/failproofai.json` (this repo does), a grok session fires **both**, so every +tool call is evaluated twice. Harmless — identical verdicts, deny wins — but it doubles +hook latency. + +**Two undocumented project-scope conditions, each a silent no-op:** + +| Condition | Symptom | +|---|---| +| The directory must be a **git repo** | A *trusted* non-git dir with a valid `.grok/hooks/*.json` logs `project_sources=0`; the hook never fires | +| The folder must be **trusted** | `grok --trust` or `/hooks-trust`; until then project hooks are silently skipped | + +Neither is in grok's hooks doc. Verified by A/B: the only change between +`project_sources=0` and `project_sources=4` was `git init`. User scope +(`~/.grok/hooks/`) needs neither. + +**Wire format — camelCase envelope, snake_case event *value*.** `hookEventName` +(`"pre_tool_use"`), `sessionId`, `cwd`, `workspaceRoot`, `transcriptPath`, +`permissionMode`, `toolName`, `toolInput`, `toolUseId`. `normalizeCliPayload`'s `grok` +branch maps these to snake_case, including `toolResult` → `tool_response` (grok does +**not** use Claude's key) and `workspaceRoot` → `cwd`. `hookEventName` is deliberately +**not** mapped — its value is snake_case while the canonical set is PascalCase, and the +`--hook` arg already carries the right name, so there is **no `GROK_EVENT_MAP`**. + +**Tool surface** (`GROK_TOOL_MAP`, every entry observed on the wire): +`run_terminal_command→Bash`, `write→Write`, `read_file→Read`, `search_replace→Edit`, +`grep→Grep`, `list_dir→LS`. grok's own docs disagree on the shell tool — +the hooks doc says `run_terminal_command` (what the wire sends), the headless doc says +`run_terminal_cmd`; both are mapped. **`GROK_TOOL_INPUT_MAP` has exactly two entries**: +`read_file` delivers the path as **`target_file`** and `list_dir` as +**`target_directory`**. The first is load-bearing — without it a live `.env` read passes +`block-env-files`, the identical bug `COPILOT_TOOL_INPUT_MAP` was added to fix. + +**Event surface: all 14, which is grok's entire surface.** `GROK_HOOK_EVENT_TYPES` +covers `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PostToolUseFailure`, `PermissionDenied`, `Stop`, `StopFailure`, `Notification`, +`SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact`, `SessionEnd`. A live grok +accepted every key (`hooks: loaded from global source … count=14`, `loaded hooks +hook_count=14`, no unknown-key warning) — worth checking rather than assuming, because +**grok silently SKIPS event keys it does not recognize**, so a typo costs coverage with no +error anywhere. `Notification` (type `agent_error` — a type grok's own docs omit) and +`StopFailure` (`{error:"rate_limit", errorDetails, lastAssistantMessage}`) were both +observed firing. Everything beyond `PreToolUse`/`Stop`/`SubagentStop` is **observation by +construction, not by caution** — the ACP handshake above is the complete blocking list. + +**Response shapes** (`policy-evaluator.ts`, `cli === "grok"`): + +| Case | Shape (exit 0) | +|------|----------------| +| Deny on tool/prompt events | `{decision:"deny", reason}` — VERIFIED, beat `--yolo` | +| Deny on `Stop`/`SubagentStop` | `{decision:"block", reason: }` — VERIFIED force-retry | +| Instruct on `Stop` | the same block shape (grok also documents `hookSpecificOutput.additionalContext` there; unverified, so we use the proven one) | +| Instruct on other events | stderr note only (degrade like Hermes/Goose) | + +**`Stop` fires TWICE per session** and only the first is actionable: once per real turn +end (`reason: "end_turn"`) and once at shutdown (`reason: "shutdown"`), whose decision +grok parses and then **discards**. The Stop branch gates on `reason === "end_turn"`; +blocking on the shutdown fire would log and count a deny that can never be acted on. An +unlabelled Stop is treated as real, so the failure mode leans toward enforcement. +Captured sequence for one turn: `end_turn`/`stopHookActive=false` (we blocked) → the +agent ran the required command → `end_turn`/`stopHookActive=true` (allowed) → +`shutdown`. grok caps continuations at **8 per turn**. + +Env injected on every hook: `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, +`GROK_WORKSPACE_ROOT`, and **`CLAUDE_PROJECT_DIR`** (a Claude-compatible alias). + +**Audit pillar.** `~/.grok/sessions///` — **percent** +encoding (`%2Fhome%2Fyou%2Frepo`), NOT the dash style Claude/Factory/Qwen use, so +`lib/grok-sessions.ts` decodes with `decodeURIComponent` and grok project folders do not +merge with Claude's by slug (cwd filtering still works). Each session dir holds +`chat_history.jsonl` (the turns), `events.jsonl`, and `summary.json` (`info.cwd`, +`session_summary`, `created_at`, `num_messages`). Two parser consequences: +`chat_history.jsonl` carries **no timestamps**, so `grokLinesToLogEntries` takes an +explicit `startMs` (summary.json's `created_at`) and lays turns 1ms apart — ordering +exact, per-turn wall-clock honestly synthesized; and `tool_calls[].arguments` is a JSON +**string**, parsed on the way in. Only `user` lines carrying `prompt_index` are real +operator turns (grok also writes its environment preamble and `synthetic_reason` +reminders as `user` lines). `GROK_HOME` overrides the home dir for tests. + +For production users the recommended grok install is: +```bash +failproofai policies --install --cli grok --scope project +``` + +### Qwen Code hooks (`~/.qwen/settings.json` / `.qwen/settings.json`) + +**Qwen Code** (`qwen`, Alibaba) is the 14th integration — **dual-pillar**, **user + +project** scope, and the **cheapest integration in the codebase**: it is a near-pure +Claude clone on the wire, so it needs **no event map, no payload normalization, and no +tool-input map**. Verified live against **@qwen-code/qwen-code 0.21.12**. + +Hooks live under a Claude-style `"hooks"` key inside qwen's normal settings file, which +also holds `model`, `modelProviders` and auth — so reads/writes go through the +merge-preserving `readJsonFile`/`writeJsonFile` helpers, never a whole-file replace. + +| Scope | Path | +|---------|-----------------------------| +| user | `~/.qwen/settings.json` | +| project | `/.qwen/settings.json` | + +**`timeout` is in MILLISECONDS** (qwen's default 60000) — the only integration that is +not seconds-based. Do not unify it with the others: `30` would mean 30ms and every hook +would time out. `disableAllHooks: true` and `--safe-mode` each disable every hook. + +**Wire format is pure Claude snake_case**: `hook_event_name` (PascalCase *value*, unlike +grok), `session_id`, `transcript_path`, `cwd`, `permission_mode`, `tool_name`, +`tool_input`, `tool_response`, `stop_hook_active`. + +**Tool surface** (`QWEN_TOOL_MAP`): `run_shell_command→Bash`, `read_file→Read`, +`write_file→Write`, `edit→Edit`, `grep_search→Grep`, `list_directory→LS` (plus qwen's +legacy `ReadFile`/`WriteFile` matcher aliases). There is deliberately **no +`QWEN_TOOL_INPUT_MAP`** — all six deliver canonical keys already. + +**Response shapes.** PreToolUse honours Claude's own +`hookSpecificOutput.permissionDecision` (`allow`/`deny`/`ask`), VERIFIED live beating +`-y`, so it falls through to the generic Claude branch rather than being duplicated; +`"ask"` degrades to deny in headless runs and background subagents. Only **`Stop`** +diverges — it reads the top-level `{decision:"block", reason}`, VERIFIED forcing another +turn — which is all the `cli === "qwen"` branch in `policy-evaluator.ts` handles. +**Instruct is a real channel** here (`hookSpecificOutput.additionalContext` on +PreToolUse/PostToolUse/UserPromptSubmit, wrapped in a +`` provenance tag), putting qwen ahead of +Hermes/Goose/Factory, which all degrade instruct to a stderr note. + +**Event surface: 19 subscribed.** Every event failproofai installs has a real +`executeHooks("")` dispatch site in the shipped bundle — checked by reading it, +which is also how `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch` turned +up: all three are dispatched and **absent from qwen's documented event table**. +`QWEN_EVENT_MAP` exists for exactly two of the nineteen: qwen calls its task list "todos", +so `TodoCreated`/`TodoCompleted` canonicalize onto `TaskCreated`/`TaskCompleted`. + +**qwen's todo hooks are a real veto point, not observation.** They run in two phases and +the payload says which: during `phase: "validation"` a top-level `{decision:"block", +reason}` prevents the write and the reason goes back to the model; during `postWrite` the +todo is already persisted and the block is ignored. `policy-evaluator.ts` emits that shape +unconditionally for `TaskCreated`/`TaskCompleted` — it enforces where it can and is inert +where it cannot, and the phase is upstream's to decide. Verified live: a policy denying a +todo that planned to skip the tests blocked the `todo_write` outright. Note the block +prevents the **whole write**, not the single offending item. + +**Three events are deliberately NOT subscribed:** `MessageDisplay` fires per streaming +chunk (a hook process per chunk), `PostToolBatch` fired 6× in a task where `PostToolUse` +fired 5 while carrying the same tool calls in batch form (+76% hook invocations, no +builtin reads it), and `SessionDelete` has no canonical equivalent. Each is one line to +add if a custom policy ever needs it. + +**Two behaviours that will bite a policy author:** + +- **`stop_hook_active` is `true` on the FIRST Stop fire**, before anything has blocked + (verified live). It is NOT a usable "already retrying" signal on qwen, and no + failproofai loop guard may depend on it. Unlike grok there is no session-end Stop fire. +- **`UserPromptSubmit` fires per MODEL INVOCATION, not per user prompt** — one observed + turn produced **four**, one per tool-result continuation. qwen's docs confirm it covers + UserQuery/ToolResult/Hook sends and warn that `prompt` is not necessarily user input. + `submitted_prompt` is present only for interactive-TUI submissions (absent in headless, + ACP, `serve`, SDK), so it identifies real submissions but cannot be a general filter. + +**Audit pillar.** `~/.qwen/projects//chats/.jsonl` — +Claude-style encoded-cwd folders, but note the extra `chats/` level. Every line carries +`{uuid, parentUuid, sessionId, cwd, timestamp, type}`, and a real per-line `cwd` means +audit groups by project like Claude/Devin/Goose. The message body is **Gemini-shaped, +not Claude-shaped**: `message.parts[]` of `{text}` / `{functionCall:{id,name,args}}` / +`{functionResponse:{id,name,response}}`, with the assistant role spelled `"model"` — so +`lib/qwen-sessions.ts` is NOT a clone of `factory-sessions.ts` despite the similar +layout. A `toolCallResult` sidecar carries the rendered `resultDisplay`, preferred over +the raw response blob. `QWEN_HOME` overrides the home dir for tests. + +**Not failproofai's bug, but worth knowing:** qwen exports the provider API key from its +settings `env` block into **every hook process's environment**, so any hook a user +installs can read it. + +For production users the recommended Qwen install is: +```bash +failproofai policies --install --cli qwen --scope project +``` + +### Dogfood configs for Factory / Devin / Antigravity / Goose / grok / Qwen Like the Codex / Cursor / OpenCode / Pi setups above, this repo ships -**project-scope dogfood configs** for the four newest CLIs so failproofai +**project-scope dogfood configs** for the six newest CLIs so failproofai enforces on itself when you drive this repo with them. Each uses the dev `node scripts/dev-hook.mjs --hook --cli ` command (never the `npx` production form — same self-reference caveat as the others): @@ -833,6 +1043,8 @@ production form — same self-reference caveat as the others): | Devin | `.devin/config.json` | Claude `"hooks"` wrapper | | Antigravity (`agy`) | `.agents/hooks.json` | named-hook schema under the `failproofai` key | | Goose | `.agents/plugins/failproofai/hooks/hooks.json` | Open Plugins (auto-discovered; matcher omitted — a bare `*` matches nothing) | +| grok | `.grok/hooks/failproofai.json` | Claude nested schema, seconds timeout, all 14 events (matcher omitted; needs `grok --trust` once, and only works because this repo is a git repo) | +| Qwen (`qwen`) | `.qwen/settings.json` | Claude `"hooks"` wrapper, **milliseconds** timeout, 19 events | These were generated from each integration's own `writeHookEntries`, so they track the live schema. See each CLI's architecture section above for the full diff --git a/README.md b/README.md index b047320f6..422a52048 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ before they become incidents. Zero latency. Runs locally. ## Supported agent CLIs - + + +
@@ -75,6 +76,14 @@ before they become incidents. Zero latency. Runs locally. + + + + grok CLI + + +
@@ -116,6 +125,11 @@ before they become incidents. Zero latency. Runs locally. + + Qwen Code + +
diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 3d662c3e2..f3af8e9aa 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -263,6 +263,8 @@ describe("ProjectList", () => { "Devin CLI", "Antigravity CLI", "Goose", + "grok CLI", + "Qwen Code", ]); }); diff --git a/__tests__/hooks/dogfood-configs.test.ts b/__tests__/hooks/dogfood-configs.test.ts index f0b07c15d..4178e0eec 100644 --- a/__tests__/hooks/dogfood-configs.test.ts +++ b/__tests__/hooks/dogfood-configs.test.ts @@ -64,6 +64,8 @@ const CONFIGS = [ { file: ".devin/config.json", cli: "devin", count: 7 }, { file: ".agents/hooks.json", cli: "antigravity", count: 4 }, { file: ".agents/plugins/failproofai/hooks/hooks.json", cli: "goose", count: 5 }, + { file: ".grok/hooks/failproofai.json", cli: "grok", count: 14 }, + { file: ".qwen/settings.json", cli: "qwen", count: 19 }, ]; describe.each(CONFIGS)("$file", ({ file, cli, count }) => { diff --git a/__tests__/hooks/fail-closed-force-decision.test.ts b/__tests__/hooks/fail-closed-force-decision.test.ts index 82224c1f4..a5cb62d00 100644 --- a/__tests__/hooks/fail-closed-force-decision.test.ts +++ b/__tests__/hooks/fail-closed-force-decision.test.ts @@ -157,12 +157,16 @@ describe("the fail-closed verdict is enforcing on every supported CLI", () => { const CLIS = [ "claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", + "grok", "qwen", ] as const; // The CLIs that read their verdict from stdout JSON and IGNORE the exit code. // For these, an empty stdout is not a weak deny — it is an allow. const STDOUT_DRIVEN = [ "cursor", "pi", "hermes", "openclaw", "devin", "antigravity", "goose", + // grok reads {decision:"deny"} off stdout and ignores the exit code; qwen + // reads Claude's hookSpecificOutput shape there. Both verified live. + "grok", "qwen", ] as const; it.each(CLIS)("%s receives a verdict that actually enforces", async (cli) => { diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts index 5e82044ec..69e3d2f8e 100644 --- a/__tests__/hooks/fp-reset.test.ts +++ b/__tests__/hooks/fp-reset.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as daemonService from "@/src/hooks/daemon-service"; import { mkdtempSync, rmSync, @@ -540,12 +541,37 @@ describe("checkLayoutForCli", () => { // its next reboot, and a daemon-configured machine that cannot reach its // daemon denies every tool call. describe("the daemon warning on the branch that migrates", () => { + /** Collapse the warning's terminal hard-wrapping so a phrase that straddles + * a newline still matches. */ + function unwrapped(text: string): string { + return text.replace(/\s+/g, " "); + } + /** A managed install of `ver`, which is what `daemonVersionSkew()` reads. */ function installedDaemon(ver: string) { mkdirSync(binDir(), { recursive: true }); writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF"); } + // `daemonServiceStatus()` reads the HOST's systemd unit, which no + // FAILPROOFAI_HOME can sandbox — so on a developer machine that has run + // `failproofai config`, `healDaemonFlag()` sees a real running service, + // fails its probe against this temp home's absent socket, and prints its + // own "cannot evaluate policies" paragraph instead. That both clears + // `daemon.configured` and drowns out the skew warning these tests are + // about, so they passed or failed depending on whether the developer + // happened to have a daemon installed. + // + // Pinned to "stopped": the one status `healDaemonFlag` deliberately ignores + // (a stopped service is usually a restart in progress), which leaves + // `staleDaemonHint()` as the only thing writing lines here. + beforeEach(() => { + vi.spyOn(daemonService, "daemonServiceStatus").mockReturnValue("stopped"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("warns hard when the machine REQUIRES a daemon that will not start", async () => { seedLayoutOne(); installedDaemon("0.0.1-old"); @@ -557,7 +583,9 @@ describe("checkLayoutForCli", () => { expect(text).toContain("0.0.1-old"); // Must name the consequence, not just the mismatch: the reason to act now // rather than at the next reboot is that the next reboot is the failure. - expect(text).toMatch(/denies every tool call/i); + // Matched on whitespace-normalised text because the warning is + // hard-wrapped for the terminal, so the phrase straddles a newline. + expect(unwrapped(text)).toMatch(/denies every tool call/i); // And the command that actually fixes it. `failproofai config` was the // old advice and rebuilds the service rather than updating the binary. expect(text).toContain("failproofai update"); @@ -574,7 +602,9 @@ describe("checkLayoutForCli", () => { const text = (await checkLayoutForCli()).lines.join("\n"); expect(text).toContain("0.0.1-old"); - expect(text).not.toMatch(/denies every tool call/i); + // Normalised for the same reason as above — a raw-text negative would + // pass merely because the phrase happened to wrap. + expect(unwrapped(text)).not.toMatch(/denies every tool call/i); }); it("says nothing about the daemon when there is no skew", async () => { diff --git a/__tests__/hooks/grok-qwen-canonicalize.test.ts b/__tests__/hooks/grok-qwen-canonicalize.test.ts new file mode 100644 index 000000000..ed762dfbd --- /dev/null +++ b/__tests__/hooks/grok-qwen-canonicalize.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node +// +// Locks in the grok + qwen wire contracts, all captured from live recorder +// hooks (grok 1.0.3 / qwen-code 0.21.12) rather than read off either CLI's +// docs — which matters, because in grok's case the docs were wrong twice. +// +// The cases that are NOT cosmetic, and why each is here: +// • grok's `read_file` delivers `target_file`, so without the input map a +// live `.env` read walks past block-env-files (the Copilot bug, again). +// • grok pipes camelCase, so without normalization `tool_name`/`tool_input` +// reach every builtin as undefined. +// • grok EXECUTES `.claude/settings.json` — the file our own claude install +// writes — so a grok payload can arrive on a hook flagged `--cli claude`. +import { describe, it, expect } from "vitest"; +import { canonicalizeToolName, canonicalizeToolInput } from "@/src/hooks/tool-name-canonicalize"; +import { + normalizeCliPayload, + isGrokEnvelope, + resolveEffectiveCli, +} from "@/src/hooks/normalize-cli-payload"; +import { + GROK_HOOK_EVENT_TYPES, + GROK_TOOL_INPUT_MAP, + QWEN_HOOK_EVENT_TYPES, + QWEN_EVENT_MAP, + HOOK_EVENT_TYPES, +} from "@/src/hooks/types"; +import { canonicalizeEventType } from "@/src/hooks/handler"; + +/** A PreToolUse payload exactly as grok 1.0.3 pipes it (captured verbatim). */ +function grokPreToolUsePayload(): Record { + return { + hookEventName: "pre_tool_use", + sessionId: "01a00bc4-57ad-7231-98ef-9f037a781572", + cwd: "/tmp/fp-probe/ws", + workspaceRoot: "/tmp/fp-probe/ws", + transcriptPath: "/home/u/.grok/sessions/%2Ftmp/01a00bc4/chat_history.jsonl", + permissionMode: "bypassPermissions", + toolName: "run_terminal_command", + toolInput: { command: "echo FPPROBE", description: "Echo FPPROBE to stdout" }, + toolUseId: "call-ed78750f", + toolInputTruncated: false, + timestamp: "2026-08-16T18:10:12Z", + }; +} + +describe("grok + qwen event types", () => { + it("are all already-canonical PascalCase HookEventTypes (no event map needed)", () => { + const canonical = new Set(HOOK_EVENT_TYPES); + for (const ev of GROK_HOOK_EVENT_TYPES) { + expect(canonical.has(ev), `${ev} must be a HookEventType`).toBe(true); + } + // qwen is canonical too, EXCEPT its two Todo names, which QWEN_EVENT_MAP + // translates to TaskCreated/TaskCompleted. + for (const ev of QWEN_HOOK_EVENT_TYPES) { + const mapped = QWEN_EVENT_MAP[ev]; + expect(canonical.has(mapped), `${ev} must map to a HookEventType`).toBe(true); + } + }); + + it("both subscribe to Stop — each has a verified force-retry gate", () => { + // Unlike goose/hermes, both of these CAN keep the agent working, so the 5 + // require-*-before-stop builtins are applicable and Stop must be installed. + expect(GROK_HOOK_EVENT_TYPES).toContain("Stop"); + expect(QWEN_HOOK_EVENT_TYPES).toContain("Stop"); + }); + + it("grok subscribes to its ENTIRE event surface", () => { + // A live grok 1.0.3 accepted all 14 (`hook_count=14`, no unknown-key + // warning). grok silently skips names it doesn't recognize, so this list + // drifting out of sync would cost coverage with no error anywhere. + expect(GROK_HOOK_EVENT_TYPES).toHaveLength(14); + for (const e of ["PermissionDenied", "StopFailure", "Notification", "SubagentStart", "PreCompact", "PostCompact"]) { + expect(GROK_HOOK_EVENT_TYPES, `${e} must be installed`).toContain(e); + } + }); + + it("qwen omits the two events that would cost more than they return", () => { + // MessageDisplay fires per streaming chunk — a hook process per chunk. + // PostToolBatch fired 6× where PostToolUse fired 5, carrying the same tool + // calls in batch form, and no builtin reads it. Both are deliberate. + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("MessageDisplay"); + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("PostToolBatch"); + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("SessionDelete"); + }); + + it("qwen's Todo events canonicalize onto the Task events", () => { + // qwen calls its task list "todos"; mapping them means a policy written + // against TaskCreated fires on qwen and Claude alike. + expect(canonicalizeEventType("TodoCreated", "qwen")).toBe("TaskCreated"); + expect(canonicalizeEventType("TodoCompleted", "qwen")).toBe("TaskCompleted"); + }); + + it("qwen's other 17 events canonicalize to themselves", () => { + for (const e of QWEN_HOOK_EVENT_TYPES) { + if (e === "TodoCreated" || e === "TodoCompleted") continue; + expect(canonicalizeEventType(e, "qwen"), e).toBe(e); + } + }); + + it("the qwen event map covers every installed event", () => { + // Exhaustive at the type level; assert it at runtime too, since a missing + // entry would write an `undefined` event key into a user's settings.json. + for (const e of QWEN_HOOK_EVENT_TYPES) { + expect(QWEN_EVENT_MAP[e], `${e} needs a canonical mapping`).toBeTruthy(); + } + }); + + it("grok needs no event map — its names are canonical already", () => { + for (const e of GROK_HOOK_EVENT_TYPES) { + expect(canonicalizeEventType(e, "grok"), e).toBe(e); + } + }); +}); + +describe("grok tool canonicalization", () => { + it("maps grok's tool ids to Claude builtins", () => { + expect(canonicalizeToolName("run_terminal_command", "grok")).toBe("Bash"); + expect(canonicalizeToolName("write", "grok")).toBe("Write"); + expect(canonicalizeToolName("read_file", "grok")).toBe("Read"); + expect(canonicalizeToolName("search_replace", "grok")).toBe("Edit"); + expect(canonicalizeToolName("grep", "grok")).toBe("Grep"); + expect(canonicalizeToolName("list_dir", "grok")).toBe("LS"); + }); + + it("also accepts the shell-tool name grok's headless doc uses", () => { + // The hooks doc says `run_terminal_command` (which is what the wire sends); + // the headless doc says `run_terminal_cmd`. Both canonicalize. + expect(canonicalizeToolName("run_terminal_cmd", "grok")).toBe("Bash"); + }); + + it("passes unknown tools through unchanged", () => { + expect(canonicalizeToolName("some_mcp__tool", "grok")).toBe("some_mcp__tool"); + }); + + it("maps read_file's target_file to file_path so path builtins fire", () => { + // THE load-bearing case: block-env-files / block-read-outside-cwd read + // `file_path`. Without this a live `.env` read is invisible to them. + expect(canonicalizeToolInput("Read", { target_file: ".env" }, "grok")).toEqual({ + file_path: ".env", + }); + }); + + it("maps list_dir's target_directory to path", () => { + expect(canonicalizeToolInput("LS", { target_directory: "/etc" }, "grok")).toEqual({ + path: "/etc", + }); + }); + + it("leaves already-canonical inputs untouched", () => { + // Bash/Write/Edit/Grep deliver canonical keys, so they have no map entry. + expect(GROK_TOOL_INPUT_MAP.Bash).toBeUndefined(); + const bash = { command: "rm -rf /" }; + expect(canonicalizeToolInput("Bash", bash, "grok")).toEqual(bash); + const edit = { file_path: "a.ts", old_string: "a", new_string: "b" }; + expect(canonicalizeToolInput("Edit", edit, "grok")).toEqual(edit); + }); +}); + +describe("qwen tool canonicalization", () => { + it("maps qwen's runtime tool ids to Claude builtins", () => { + expect(canonicalizeToolName("run_shell_command", "qwen")).toBe("Bash"); + expect(canonicalizeToolName("read_file", "qwen")).toBe("Read"); + expect(canonicalizeToolName("write_file", "qwen")).toBe("Write"); + expect(canonicalizeToolName("edit", "qwen")).toBe("Edit"); + expect(canonicalizeToolName("grep_search", "qwen")).toBe("Grep"); + expect(canonicalizeToolName("list_directory", "qwen")).toBe("LS"); + }); + + it("also accepts qwen's legacy display-name matcher aliases", () => { + expect(canonicalizeToolName("ReadFile", "qwen")).toBe("Read"); + expect(canonicalizeToolName("WriteFile", "qwen")).toBe("Write"); + }); + + it("needs NO input mapping — every qwen tool key is already canonical", () => { + const cases: Array<[string, Record]> = [ + ["Bash", { command: "echo hi" }], + ["Read", { file_path: "/tmp/a.txt" }], + ["Write", { file_path: "/tmp/a.txt", content: "x" }], + ["Edit", { file_path: "a.ts", old_string: "a", new_string: "b" }], + ["Grep", { pattern: "x", path: "." }], + ["LS", { path: "." }], + ]; + for (const [tool, input] of cases) { + expect(canonicalizeToolInput(tool, input, "qwen"), tool).toEqual(input); + } + }); +}); + +describe("grok payload normalization", () => { + it("maps the camelCase envelope onto the snake_case fields builtins read", () => { + const p = grokPreToolUsePayload(); + normalizeCliPayload("grok", p); + expect(p.tool_name).toBe("run_terminal_command"); + expect(p.tool_input).toEqual({ command: "echo FPPROBE", description: "Echo FPPROBE to stdout" }); + expect(p.session_id).toBe("01a00bc4-57ad-7231-98ef-9f037a781572"); + expect(p.permission_mode).toBe("bypassPermissions"); + expect(p.transcript_path).toBe( + "/home/u/.grok/sessions/%2Ftmp/01a00bc4/chat_history.jsonl", + ); + }); + + it("maps PostToolUse's toolResult onto tool_response (grok does not send Claude's key)", () => { + const p: Record = { + hookEventName: "post_tool_use", + workspaceRoot: "/w", + toolName: "run_terminal_command", + toolResult: { exit_code: 0, output_for_prompt: "hi\n" }, + }; + normalizeCliPayload("grok", p); + expect(p.tool_response).toEqual({ exit_code: 0, output_for_prompt: "hi\n" }); + }); + + it("falls back to workspaceRoot for cwd, without clobbering a real cwd", () => { + const withCwd = grokPreToolUsePayload(); + withCwd.cwd = "/real/cwd"; + normalizeCliPayload("grok", withCwd); + expect(withCwd.cwd).toBe("/real/cwd"); + + const noCwd = grokPreToolUsePayload(); + delete noCwd.cwd; + normalizeCliPayload("grok", noCwd); + expect(noCwd.cwd).toBe("/tmp/fp-probe/ws"); + }); + + it("does NOT map hookEventName — its value is snake_case, the --hook arg is canonical", () => { + const p = grokPreToolUsePayload(); + normalizeCliPayload("grok", p); + expect(p.hook_event_name).toBeUndefined(); + }); +}); + +describe("grok executing another CLI's hook config", () => { + it("recognizes grok's envelope", () => { + expect(isGrokEnvelope(grokPreToolUsePayload())).toBe(true); + }); + + it("does NOT mistake a real Claude payload for grok's", () => { + // The guard that keeps Claude's own enforcement intact. + expect( + isGrokEnvelope({ + hook_event_name: "PreToolUse", + session_id: "s", + cwd: "/w", + tool_name: "Bash", + tool_input: { command: "ls" }, + }), + ).toBe(false); + // A payload with neither marker (e.g. a bare Stop) is not grok's either. + expect(isGrokEnvelope({ stop_hook_active: false })).toBe(false); + // camelCase alone is not enough — Copilot's permissionRequest sends + // `toolName`/`sessionId` too, and must keep its own contract. + expect(isGrokEnvelope({ toolName: "bash", sessionId: "s", cwd: "/w" })).toBe(false); + }); + + it("re-routes a grok payload flagged --cli claude onto grok's contract", () => { + // grok scans ~/.claude/settings.json and /.claude/settings.json by + // default, so it runs OUR claude hooks and passes `--cli claude` while + // piping its own payload. Verified live: without this the hook fires, + // every builtin sees undefined, and nothing is enforced. + expect(resolveEffectiveCli("claude", grokPreToolUsePayload())).toBe("grok"); + }); + + it("leaves every other (cli, payload) pair alone", () => { + const claudePayload = { hook_event_name: "PreToolUse", tool_name: "Bash" }; + expect(resolveEffectiveCli("claude", claudePayload)).toBe("claude"); + expect(resolveEffectiveCli("goose", { event: "PreToolUse" })).toBe("goose"); + expect(resolveEffectiveCli("qwen", { hook_event_name: "PreToolUse" })).toBe("qwen"); + // Already-declared grok stays grok. + expect(resolveEffectiveCli("grok", grokPreToolUsePayload())).toBe("grok"); + }); + + it("end-to-end: a grok-shaped .claude hook still resolves a canonical Bash command", () => { + const parsed = grokPreToolUsePayload(); + const cli = resolveEffectiveCli("claude", parsed); + normalizeCliPayload(cli, parsed); + const tool = canonicalizeToolName(parsed.tool_name as string, cli); + const input = canonicalizeToolInput(tool, parsed.tool_input, cli); + expect(tool).toBe("Bash"); + expect((input as Record).command).toBe("echo FPPROBE"); + }); + + it("end-to-end: a grok-shaped .claude hook exposes a read of .env as file_path", () => { + const parsed: Record = { + hookEventName: "pre_tool_use", + workspaceRoot: "/repo", + toolName: "read_file", + toolInput: { target_file: ".env" }, + }; + const cli = resolveEffectiveCli("claude", parsed); + normalizeCliPayload(cli, parsed); + const tool = canonicalizeToolName(parsed.tool_name as string, cli); + const input = canonicalizeToolInput(tool, parsed.tool_input, cli); + expect(tool).toBe("Read"); + // Both halves of the leak fix: without either, block-env-files sees nothing. + expect((input as Record).file_path).toBe(".env"); + }); +}); diff --git a/__tests__/hooks/inert-deny-shapes.test.ts b/__tests__/hooks/inert-deny-shapes.test.ts index 6b3cf8aba..6af2c1720 100644 --- a/__tests__/hooks/inert-deny-shapes.test.ts +++ b/__tests__/hooks/inert-deny-shapes.test.ts @@ -86,4 +86,72 @@ describe("deny shapes the CLI actually reads", () => { expect(stdout.decision).toBe("block"); expect(stdout.reason).not.toContain("MANDATORY ACTION REQUIRED"); }); + + // ── grok ────────────────────────────────────────────────────────────────── + // Verified live against grok 1.0.3 by A/B on one hook: emitting Claude's + // hookSpecificOutput shape let `echo` run; emitting {decision:"deny"} blocked + // it, overriding --yolo. This is the shape that made the difference. + it("grok PreToolUse uses {decision:'deny'}, NOT Claude's hookSpecificOutput", async () => { + const { result, stdout } = await denyOn("grok", "PreToolUse", { tool_name: "Bash" }); + expect(result.exitCode).toBe(0); + expect(stdout.decision).toBe("deny"); + expect(typeof stdout.reason).toBe("string"); + // The shape grok ignores must NOT be what we send. + expect(stdout.hookSpecificOutput).toBeUndefined(); + }); + + it("grok Stop forces a retry on a real turn end", async () => { + const { stdout } = await denyOn("grok", "Stop", { reason: "end_turn" }); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).toContain("MANDATORY ACTION REQUIRED"); + }); + + it("grok Stop does NOT block the session-shutdown fire", async () => { + // grok fires Stop a second time at shutdown and discards the decision. + // Emitting a block there would record enforcement that cannot happen, so + // the branch must fall through to allow instead. + const { result } = await denyOn("grok", "Stop", { reason: "shutdown" }); + expect(result.decision).toBe("allow"); + expect(result.stdout).toBe(""); + }); + + it("grok Stop still blocks when no reason is present", async () => { + // Fail toward enforcement: an unlabelled Stop is treated as a real one. + const { stdout } = await denyOn("grok", "Stop"); + expect(stdout.decision).toBe("block"); + }); + + // ── qwen ────────────────────────────────────────────────────────────────── + it("qwen PreToolUse keeps Claude's permissionDecision shape", async () => { + // qwen honors Claude's own PreToolUse contract (verified live — it beat + // -y), so it must fall through to the generic branch, not grow a copy. + const { stdout } = await denyOn("qwen", "PreToolUse", { tool_name: "Bash" }); + expect(stdout.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(typeof stdout.hookSpecificOutput.permissionDecisionReason).toBe("string"); + }); + + it("qwen TaskCreated/TaskCompleted deny with the top-level block shape", async () => { + // qwen's todo hooks are the only events in the widened set that can veto: + // in their `validation` phase a {decision:"block"} prevents the write. + // They do NOT read Claude's permissionDecision, so the generic PreToolUse + // branch would have been silently inert here. + for (const ev of ["TaskCreated", "TaskCompleted"]) { + const { result, stdout } = await denyOn("qwen", ev, { phase: "validation" }); + expect(result.exitCode, ev).toBe(0); + expect(stdout.decision, ev).toBe("block"); + expect(typeof stdout.reason, ev).toBe("string"); + expect(stdout.hookSpecificOutput, ev).toBeUndefined(); + // Plain blocked message here — the MANDATORY-ACTION wording is Stop's. + expect(stdout.reason, ev).not.toContain("MANDATORY ACTION REQUIRED"); + } + }); + + it("qwen Stop uses the top-level block shape, not permissionDecision", async () => { + const { stdout } = await denyOn("qwen", "Stop", { stop_hook_active: true }); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).toContain("MANDATORY ACTION REQUIRED"); + // Note stop_hook_active is true on qwen's FIRST fire, so it must not be + // used as an "already retrying" guard here. + expect(stdout.hookSpecificOutput).toBeUndefined(); + }); }); diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 5f8483772..dc3325fd9 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -142,9 +142,9 @@ describe("hooks/install-prompt", () => { "install", ); - // 1 aggregate "all" + 2 detected + 10 undetected - expect(options).toHaveLength(13); - expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + // 1 aggregate "all" + 2 detected + 12 undetected + expect(options).toHaveLength(15); + expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); expect(options[0].label).toBe("Install for all 2 detected"); @@ -169,6 +169,8 @@ describe("hooks/install-prompt", () => { "Devin CLI", "Antigravity CLI", "Goose", + "grok CLI", + "Qwen Code", ]); }); @@ -185,16 +187,16 @@ describe("hooks/install-prompt", () => { expect(options.every((o) => o.detected)).toBe(true); }); - it("install with all 12 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { + it("install with all 14 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( - ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"], + ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"], "install", ); expect(undetected).toEqual([]); - expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options).toHaveLength(15); // aggregate + 14 detected + expect(options[0].label).toBe("Install for all 14 detected"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index d1aac9728..d3a6e5063 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -46,6 +46,7 @@ import { FACTORY_HOOK_EVENT_TYPES, DEVIN_HOOK_EVENT_TYPES, ANTIGRAVITY_HOOK_EVENT_TYPES, + INTEGRATION_TYPES, GOOSE_HOOK_EVENT_TYPES, HOOK_EVENT_TYPES, CLAUDE_INSTALL_EVENT_TYPES, @@ -82,7 +83,7 @@ afterEach(() => { describe("integrations registry", () => { it("listIntegrations returns claude, codex, copilot, cursor, opencode, pi, hermes, and openclaw in declared order", () => { const ids = listIntegrations().map((i) => i.id); - expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"]); }); it("getIntegration('claude') returns claudeCode", () => { @@ -1799,3 +1800,18 @@ describe("claudeCode — WorktreeCreate is never registered", () => { }); }); + +describe("bin/failproofai.mjs --cli validation", () => { + it("accepts exactly the CLIs in INTEGRATION_TYPES", () => { + // This list lived as THREE hardcoded copies inside bin/failproofai.mjs and + // drifted: grok and qwen reached INTEGRATION_TYPES and the `--hook --cli` + // validation, but not the install parser, so `policies --install --cli grok` + // failed outright while the whole unit suite stayed green. It is one list + // now; this asserts it cannot drift again. + const src = readFileSync(resolve(process.cwd(), "bin/failproofai.mjs"), "utf8"); + const m = /const INSTALLABLE_CLIS = \[([^\]]+)\]/.exec(src); + expect(m, "INSTALLABLE_CLIS not found in bin/failproofai.mjs").toBeTruthy(); + const declared = m![1].split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean); + expect(declared.sort()).toEqual([...INTEGRATION_TYPES].sort()); + }); +}); diff --git a/__tests__/lib/cli-registry.test.ts b/__tests__/lib/cli-registry.test.ts index 238043dde..b2c544441 100644 --- a/__tests__/lib/cli-registry.test.ts +++ b/__tests__/lib/cli-registry.test.ts @@ -12,7 +12,7 @@ import { describe("lib/cli-registry", () => { it("KNOWN_CLI_IDS lists all supported CLIs in stable order", () => { - expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"]); }); it("getCliEntry returns the entry for known ids and undefined for unknown", () => { @@ -73,7 +73,7 @@ describe("lib/cli-registry", () => { it("listExternalCliEntries excludes claude", () => { const ids = listExternalCliEntries().map((c) => c.id); - expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"]); }); it("each CLI has a unique badgeClasses string", () => { diff --git a/__tests__/lib/grok-qwen-sessions.test.ts b/__tests__/lib/grok-qwen-sessions.test.ts new file mode 100644 index 000000000..6fcc7b2c1 --- /dev/null +++ b/__tests__/lib/grok-qwen-sessions.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment node +// +// Transcript parsers for the two newest CLIs. Every fixture line below is a +// trimmed copy of a REAL capture — grok 1.0.3 `chat_history.jsonl` and +// qwen-code 0.21.12 `chats/.jsonl` — so these lock the shapes that were +// actually observed, not the shapes the vendors document. +import { describe, it, expect } from "vitest"; +import { grokLinesToLogEntries, decodeGrokProjectDir } from "@/lib/grok-sessions"; +import { qwenLinesToLogEntries } from "@/lib/qwen-sessions"; +import type { AssistantEntry, ToolUseBlock } from "@/lib/log-entries"; + +function toolUses(entries: ReturnType): ToolUseBlock[] { + return entries + .filter((e): e is AssistantEntry => e.type === "assistant") + .flatMap((e) => e.message.content) + .filter((b): b is ToolUseBlock => typeof b === "object" && b.type === "tool_use"); +} + +describe("grok transcript parsing", () => { + // Verbatim shapes from a real session directory. + const LINES: Record[] = [ + { type: "system", content: "You are Grok 4.5 released by xAI…" }, + // Environment preamble: a `user` line with NO prompt_index. + { type: "user", content: [{ type: "text", text: "OS: linux" }] }, + // An injected reminder — also a `user` line, but synthetic. + { + type: "user", + synthetic_reason: "skills", + content: [{ type: "text", text: "" }], + }, + // The only line the operator actually typed. + { + type: "user", + prompt_index: 0, + content: [{ type: "text", text: "run echo FPPROBE" }], + }, + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "thinking…" }] }, + { + type: "assistant", + content: "I'll run `echo FPPROBE`.", + model_id: "grok-4.5", + tool_calls: [ + { + id: "call-1", + name: "run_terminal_command", + // grok serializes arguments as a JSON STRING, not an object. + arguments: '{"command":"echo FPPROBE","description":"Echo"}', + }, + ], + }, + { type: "tool_result", tool_call_id: "call-1", content: "exit: 0\nFPPROBE\n" }, + ]; + + it("keeps only the operator's real prompt as a user turn", () => { + const entries = grokLinesToLogEntries(LINES, 1_700_000_000_000); + const users = entries.filter((e) => e.type === "user"); + expect(users).toHaveLength(1); + expect((users[0] as { message: { content: string } }).message.content).toContain("echo FPPROBE"); + }); + + it("skips the system prompt and the model's private reasoning", () => { + const entries = grokLinesToLogEntries(LINES, 1_700_000_000_000); + // system-prompt and reasoning lines must not surface as turns. + expect(entries.filter((e) => e.type === "system")).toHaveLength(0); + }); + + it("parses tool_calls[].arguments from its JSON string into a real object", () => { + const calls = toolUses(grokLinesToLogEntries(LINES, 1_700_000_000_000)); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("run_terminal_command"); + expect(calls[0].input).toEqual({ command: "echo FPPROBE", description: "Echo" }); + }); + + it("pairs tool_result back onto its call by tool_call_id", () => { + const calls = toolUses(grokLinesToLogEntries(LINES, 1_700_000_000_000)); + expect(calls[0].result?.content).toBe("exit: 0\nFPPROBE\n"); + }); + + it("anchors the synthesized timeline on the supplied start time", () => { + // chat_history.jsonl carries NO timestamps, so ordering must come from file + // order and the absolute position from summary.json's created_at. + const startMs = 1_700_000_000_000; + const entries = grokLinesToLogEntries(LINES, startMs); + expect(entries[0].timestampMs).toBeGreaterThanOrEqual(startMs); + const times = entries.map((e) => e.timestampMs); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); + + it("keeps malformed tool arguments visible instead of dropping them", () => { + const calls = toolUses( + grokLinesToLogEntries( + [{ type: "assistant", content: "", tool_calls: [{ id: "c", name: "x", arguments: "{not json" }] }], + 1, + ), + ); + expect(calls[0].input).toEqual({ arguments: "{not json" }); + }); + + it("percent-decodes grok's project folder names", () => { + // grok percent-encodes the cwd where every other JSONL store dash-encodes it. + expect(decodeGrokProjectDir("%2Ftmp%2Ffp-probe%2Fws")).toBe("/tmp/fp-probe/ws"); + // A folder that is not valid percent-encoding degrades to the raw name. + expect(decodeGrokProjectDir("%%%")).toBe("%%%"); + }); +}); + +describe("qwen transcript parsing", () => { + // Verbatim shapes: Gemini-style `message.parts`, role "model" for assistant. + const LINES: Record[] = [ + { + type: "user", + uuid: "u1", + sessionId: "s1", + cwd: "/tmp/qws", + timestamp: "2026-08-16T18:30:00.000Z", + message: { role: "user", parts: [{ text: "create report.txt" }] }, + }, + { type: "system", uuid: "sys1", subtype: "info", systemPayload: {}, timestamp: "2026-08-16T18:30:01.000Z" }, + { + type: "assistant", + uuid: "a1", + model: "gpt-5.6-luna", + timestamp: "2026-08-16T18:30:02.000Z", + message: { + role: "model", + parts: [ + { text: "Creating it now." }, + { functionCall: { id: "call_1", name: "write_file", args: { file_path: "/tmp/qws/report.txt", content: "alpha" } } }, + ], + }, + }, + { + type: "tool_result", + uuid: "t1", + timestamp: "2026-08-16T18:30:03.000Z", + message: { + role: "user", + parts: [{ functionResponse: { id: "call_1", name: "write_file", response: { output: "written" } } }], + }, + toolCallResult: { callId: "call_1", status: "success", resultDisplay: "Wrote 5 bytes" }, + }, + ]; + + it("reads Gemini-shaped parts, treating role 'model' as the assistant", () => { + const entries = qwenLinesToLogEntries(LINES); + const assistants = entries.filter((e) => e.type === "assistant"); + expect(assistants).toHaveLength(1); + expect((assistants[0] as AssistantEntry).message.model).toBe("gpt-5.6-luna"); + }); + + it("turns functionCall parts into tool_use blocks with canonical-key args", () => { + const calls = toolUses(qwenLinesToLogEntries(LINES)); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("write_file"); + // qwen's tool args are already canonical — no input map needed anywhere. + expect(calls[0].input).toEqual({ file_path: "/tmp/qws/report.txt", content: "alpha" }); + }); + + it("prefers the toolCallResult sidecar's rendered text for the result", () => { + const calls = toolUses(qwenLinesToLogEntries(LINES)); + // resultDisplay is what the TUI showed, so it beats the raw response blob. + expect(calls[0].result?.content).toBe("Wrote 5 bytes"); + }); + + it("falls back to the functionResponse payload when no sidecar is present", () => { + const noSidecar = LINES.map((l) => + l.type === "tool_result" ? { ...l, toolCallResult: undefined } : l, + ); + const calls = toolUses(qwenLinesToLogEntries(noSidecar)); + expect(calls[0].result?.content).toBe("written"); + }); + + it("skips system bookkeeping lines", () => { + const entries = qwenLinesToLogEntries(LINES); + expect(entries.filter((e) => e.type === "system")).toHaveLength(0); + }); + + it("keeps the user turn and preserves chronological order", () => { + const entries = qwenLinesToLogEntries(LINES); + const users = entries.filter((e) => e.type === "user"); + expect(users).toHaveLength(1); + const times = entries.map((e) => e.timestampMs); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); +}); + +describe("grok project slugs", () => { + it("derives a URL-safe slug from the cwd, not grok's percent-encoded folder", async () => { + // Regression: the on-disk folder is `%2Ftmp%2Ffp-prod`, which becomes + // `%252F…` once it is a link href, and /project/[name] 404s on it. Every + // grok project was unreachable from the projects list. + const { encodeFolderName } = await import("@/lib/paths"); + const slug = encodeFolderName("/tmp/fp-prod"); + expect(slug).not.toContain("%"); + // Byte-identical to what Claude/Factory/Qwen derive for the same cwd, which + // is what makes those rows MERGE instead of showing up twice. + expect(slug).toBe(encodeFolderName("/tmp/fp-prod")); + expect(slug).toBe("-tmp-fp-prod"); + }); + + it("does not rely on decoding the slug back to a cwd", async () => { + // `decodeFolderName` is lossy whenever the path itself contains a dash — + // `-tmp-fp-prod` decodes to `/tmp/fp/prod`, not `/tmp/fp-prod`. That is the + // whole reason grok's project page takes its cwd from summary.json's + // `info.cwd` and treats the decode as a last resort, exactly as the Claude + // and Factory adapters do with their own headers. + const { decodeFolderName, encodeFolderName } = await import("@/lib/paths"); + expect(decodeFolderName(encodeFolderName("/tmp/fp-prod"))).toBe("/tmp/fp/prod"); + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index 427e6bd68..6f374ea9f 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -49,6 +49,15 @@ vi.mock("@/lib/hermes-projects", () => ({ // Antigravity reads the real ~/.gemini/antigravity-cli/brain dir; mock it to [] // so a developer's local Antigravity sessions don't leak into these assertions. +// grok and qwen read real session trees with `readdirSync` (which the +// `fs/promises` mock above does not cover), so a developer machine that has +// used either CLI would leak its own projects into these assertions. +vi.mock("@/lib/grok-projects", () => ({ + getGrokProjects: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/qwen-projects", () => ({ + getQwenProjects: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/antigravity-projects", () => ({ getAntigravityProjects: vi.fn(async () => []), })); diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx index 3478d8084..7be01ac59 100644 --- a/app/project/[name]/page.tsx +++ b/app/project/[name]/page.tsx @@ -12,6 +12,8 @@ import { getCachedFactorySessionsByEncodedName } from "@/lib/factory-projects"; import { getCachedDevinSessionsByEncodedName } from "@/lib/devin-projects"; import { getCachedAntigravitySessionsByEncodedName } from "@/lib/antigravity-projects"; import { getCachedGooseSessionsByEncodedName } from "@/lib/goose-projects"; +import { getCachedGrokSessionsByEncodedName } from "@/lib/grok-projects"; +import { getCachedQwenSessionsByEncodedName } from "@/lib/qwen-projects"; import { logWarn } from "@/lib/logger"; import { decodeFolderName, projectDisplayName, isSyntheticProjectPath } from "@/lib/paths"; import { notFound } from "next/navigation"; @@ -50,7 +52,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { } // Note: decodeFolderName is lossy when cwds contain `-` (every `-` becomes `/`), // so each external CLI looks up sessions by re-encoding cwd and matching the slug. - const [codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose] = await Promise.all([ + const [codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen] = await Promise.all([ getCachedCodexSessionsByEncodedName(name), getCachedCopilotSessionsByEncodedName(name), getCachedCursorSessionsByEncodedName(name), @@ -62,6 +64,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { getCachedDevinSessionsByEncodedName(name), getCachedAntigravitySessionsByEncodedName(name), getCachedGooseSessionsByEncodedName(name), + getCachedGrokSessionsByEncodedName(name), + getCachedQwenSessionsByEncodedName(name), ]); const codexSessions = codex.sessions; const copilotSessions = copilot.sessions; @@ -74,6 +78,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const devinSessions = devin.sessions; const antigravitySessions = antigravity.sessions; const gooseSessions = goose.sessions; + const grokSessions = grok.sessions; + const qwenSessions = qwen.sessions; if ( !claudeExists && @@ -87,7 +93,9 @@ export default async function ProjectPage({ params }: ProjectPageProps) { factorySessions.length === 0 && devinSessions.length === 0 && antigravitySessions.length === 0 && - gooseSessions.length === 0 + gooseSessions.length === 0 && + grokSessions.length === 0 && + qwenSessions.length === 0 ) { notFound(); } @@ -96,7 +104,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { // `decodeFolderName(name)` is ambiguous for cwds containing `-` (every `-` // becomes `/`). Each external transcript records the literal cwd, so they // round-trip correctly. First non-null wins (Codex → Copilot → Cursor → OpenCode → Pi). - const canonicalRoot = codex.cwd ?? copilot.cwd ?? cursor.cwd ?? opencode.cwd ?? pi.cwd ?? hermes.cwd ?? openclaw.cwd ?? factory.cwd ?? devin.cwd ?? antigravity.cwd ?? goose.cwd ?? decodedName; + const canonicalRoot = codex.cwd ?? copilot.cwd ?? cursor.cwd ?? opencode.cwd ?? pi.cwd ?? hermes.cwd ?? openclaw.cwd ?? factory.cwd ?? devin.cwd ?? antigravity.cwd ?? goose.cwd ?? grok.cwd ?? qwen.cwd ?? decodedName; // Project header metadata let lastModified: Date | null = null; @@ -110,7 +118,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { logWarn(`Failed to get stats for project ${decodedName}:`, error); } } - const newestExternal = [codexSessions[0], copilotSessions[0], cursorSessions[0], opencodeSessions[0], piSessions[0], hermesSessions[0], openclawSessions[0], factorySessions[0], devinSessions[0], antigravitySessions[0], gooseSessions[0]] + const newestExternal = [codexSessions[0], copilotSessions[0], cursorSessions[0], opencodeSessions[0], piSessions[0], hermesSessions[0], openclawSessions[0], factorySessions[0], devinSessions[0], antigravitySessions[0], gooseSessions[0], grokSessions[0], qwenSessions[0]] .filter((s): s is SessionFile => !!s) .map((s) => s.lastModified) .reduce((acc, d) => (!acc || d.getTime() > acc.getTime() ? d : acc), null); @@ -132,6 +140,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { ...devinSessions, ...antigravitySessions, ...gooseSessions, + ...grokSessions, + ...qwenSessions, ].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); // Path line: prefer the Claude storage dir if present (matches existing UX); diff --git a/assets/logos/grok-dark.svg b/assets/logos/grok-dark.svg new file mode 100644 index 000000000..cb1537fd2 --- /dev/null +++ b/assets/logos/grok-dark.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/assets/logos/grok-light.svg b/assets/logos/grok-light.svg new file mode 100644 index 000000000..a813f6c5c --- /dev/null +++ b/assets/logos/grok-light.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/assets/logos/qwen.svg b/assets/logos/qwen.svg new file mode 100644 index 000000000..f2d0ada16 --- /dev/null +++ b/assets/logos/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 419a0a53f..736cfc333 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -49,6 +49,19 @@ let lastSubcommand = null; // with the right event name. Mirrors the cli_install_failure / cli_uninstall_failure // pattern below for parity. Cleared back to null after the success track. let lastPolicyAction = null; +/** + * Every CLI `policies --install/--uninstall/list --cli` accepts. + * + * ONE list, referenced from all three flag parsers below. It used to be three + * hardcoded copies, and they drifted: grok and qwen were added to the `--hook + * --cli` validation and to INTEGRATION_TYPES, but not here, so a real + * `policies --install --cli grok` was rejected outright while every unit test + * passed. __tests__/hooks/integrations.test.ts asserts this equals + * INTEGRATION_TYPES so the next CLI cannot repeat it. + */ +const INSTALLABLE_CLIS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"]; +const VALID_CLIS_USAGE = `Missing value(s) for --cli. Usage: --cli ${INSTALLABLE_CLIS.join(" ")} (or any subset)`; + async function track(name, props) { try { if (!_telemetry) { @@ -94,7 +107,7 @@ const hookIdx = args.indexOf("--hook"); if (hookIdx >= 0) { if (!args[hookIdx + 1]) { console.error("Error: Missing event type after --hook"); - console.error("Usage: failproofai --hook [--cli ]"); + console.error("Usage: failproofai --hook [--cli ]"); process.exit(1); } const eventType = args[hookIdx + 1]; @@ -116,6 +129,8 @@ if (hookIdx >= 0) { || cliArg === "devin" || cliArg === "antigravity" || cliArg === "goose" + || cliArg === "grok" + || cliArg === "qwen" ) ? cliArg : "claude"; @@ -300,9 +315,9 @@ COMMANDS policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|grok|qwen Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose) or repeated. + (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose grok qwen) or repeated. Default: detect installed CLIs and prompt. --scope user|project|local Config scope to write to (default: user) (Codex / Copilot / Cursor / OpenCode / Pi support user|project only) @@ -311,7 +326,7 @@ COMMANDS policies --uninstall, -u Disable policies or remove hooks [names...] Specific policy names to disable - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|grok|qwen Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -390,7 +405,7 @@ EXAMPLES failproofai policies --install --cli pi --scope project failproofai policies --install --cli factory --scope project failproofai policies --install --cli devin --scope project - failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose + failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose grok qwen failproofai policies --install --custom ./my-policies.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo @@ -1134,9 +1149,9 @@ USAGE OPTIONS (install) [names...] Specific policy names to enable (omit for interactive) - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|grok|qwen Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose) or repeated. + (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose grok qwen) or repeated. Omit to detect installed CLIs and prompt (or auto-pick if only one is found). --scope user|project|local Config scope to write to (default: user) @@ -1147,7 +1162,7 @@ OPTIONS (install) OPTIONS (uninstall) [names...] Specific policy names to disable (omit to remove hooks) - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|grok|qwen Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -1164,7 +1179,7 @@ EXAMPLES failproofai policies --install --cli pi --scope project failproofai policies --install --cli factory --scope project failproofai policies --install --cli devin --scope project - failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose + failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose grok qwen failproofai policies --install --custom ./my-policies.js failproofai policies --install --custom ./security.js --custom ./workflow.js failproofai policies -i -c ./my-policies.js @@ -1206,7 +1221,7 @@ EXAMPLES // --cli claude codex copilot // --cli claude --cli codex // Values are consumed greedily until the next flag or end of argv. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1223,7 +1238,7 @@ EXAMPLES consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi hermes openclaw (or any subset)"); + throw new CliError(VALID_CLIS_USAGE); } } @@ -1295,7 +1310,7 @@ EXAMPLES } // --cli accepts one or more space-separated values; same parser as install. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1312,7 +1327,7 @@ EXAMPLES consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi hermes openclaw (or any subset)"); + throw new CliError(VALID_CLIS_USAGE); } } @@ -1408,7 +1423,7 @@ USAGE failproofai policy remove Disable one policy OPTIONS - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|grok|qwen Agent CLI(s) to apply to; space-separated or repeated. Omit to detect installed CLIs and prompt. --scope user|project|local Config scope (default: user) @@ -1446,7 +1461,7 @@ EXAMPLES } // --cli accepts one or more space-separated values, optionally repeated. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1461,7 +1476,7 @@ EXAMPLES consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi hermes openclaw (or any subset)"); + throw new CliError(VALID_CLIS_USAGE); } } diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 51a3a456b..71131173d 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -686,6 +686,8 @@ const HARNESS_KEYS: &[&str] = &[ "opencode", "devin", "hermes", + "grok", + "qwen", ]; fn collector_tasks() -> Vec { @@ -874,7 +876,7 @@ fn collector_tasks() -> Vec { }; use fpai_collect::sources::{ - antigravity, claude, codex, copilot, cursor, factory, openclaw, pi, + antigravity, claude, codex, copilot, cursor, factory, grok, openclaw, pi, qwen, }; // Claude's two formats share one root and therefore one harness key: @@ -982,6 +984,38 @@ fn collector_tasks() -> Vec { redact, ); + let grok_roots = vec![grok_sessions_root()]; + file_source( + &mut tasks, + "grok", + grok::FORMAT, + grok_roots.clone(), + &extras("grok", &grok_roots), + grok::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + + let qwen_roots = vec![qwen_projects_root()]; + file_source( + &mut tasks, + "qwen", + qwen::FORMAT, + qwen_roots.clone(), + &extras("qwen", &qwen_roots), + qwen::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + let factory_roots = vec![factory_sessions_root()]; file_source( &mut tasks, @@ -1524,6 +1558,28 @@ fn codex_sessions_root() -> std::path::PathBuf { home.join(".codex").join("sessions") } +/// `~/.grok/sessions`, honouring the `GROK_HOME` override the audit side uses. +fn grok_sessions_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("GROK_HOME") { + return std::path::PathBuf::from(p).join("sessions"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".grok").join("sessions") +} + +/// `~/.qwen/projects`, honouring the `QWEN_HOME` override the audit side uses. +fn qwen_projects_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("QWEN_HOME") { + return std::path::PathBuf::from(p).join("projects"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".qwen").join("projects") +} + /// `~/.factory/sessions`, honouring the `FACTORY_HOME` override the audit /// adapter uses so tests can point at a fixture tree. fn factory_sessions_root() -> std::path::PathBuf { diff --git a/crates/fpai-collect/src/sources/grok/mod.rs b/crates/fpai-collect/src/sources/grok/mod.rs new file mode 100644 index 000000000..e6775bca8 --- /dev/null +++ b/crates/fpai-collect/src/sources/grok/mod.rs @@ -0,0 +1,86 @@ +//! grok CLI session capture — a [`filetail`](crate::filetail) adapter. +//! +//! grok stores a session as a DIRECTORY, not a file: +//! `~/.grok/sessions///chat_history.jsonl`, +//! beside `events.jsonl`, `summary.json` and lock files we never touch. We open +//! the transcript read-only; grok's own files are never written or moved. +//! +//! Three things make this the odd one out, and each shapes the code below: +//! +//! 1. **The session id is the PARENT DIRECTORY**, not the filename — every +//! session's transcript is called `chat_history.jsonl`. +//! 2. **The cwd folder is PERCENT-encoded** (`%2Fhome%2Fyou%2Frepo`), where +//! Claude/Factory/Qwen dash-encode. It decodes losslessly, so unlike those +//! the agent id can come straight from the path. +//! 3. **The transcript carries NO timestamps at all.** Per-event times live in +//! the sibling `events.jsonl`, which is not 1:1 with the turns. Rather than +//! mis-pair them this takes the cursor source's approach: stamp from the +//! file's mtime (`Ctx::file_epoch_ms`, captured once at discovery) plus the +//! byte offset, which keeps time approximately real AND a pure function of +//! the inputs, as the content-hash dedup requires. + +pub mod transform; + +use std::path::Path; + +use crate::filetail::{Format, RereadPolicy, no_seed_state}; + +/// Transcripts: a `chat_history.jsonl` whose parent directory is a uuid. +pub const FORMAT: Format = Format { + kind: "grok", + is_source_file: is_transcript, + session_id_from_path, + agent_id_from_path, + agent_start: transform::agent_start, + seed_state: no_seed_state, + agent_end: transform::agent_end, + transform_line: transform::transform_line, + // grok appends to chat_history.jsonl. A wholesale rewrite changes the size + // and the engine re-reads from zero; offset-keyed events then hash + // identically, so the server collapses the re-ship. + reread: RereadPolicy::ByteCursor, +}; + +/// The agent id used when the folder yields no project. +pub const DEFAULT_AGENT_ID: &str = "grok"; + +/// The one filename grok gives every session transcript. +const TRANSCRIPT: &str = "chat_history.jsonl"; + +const UUID_LEN: usize = 36; + +/// `8-4-4-4-12` hex, checked positionally. grok mints UUIDv7s, which are +/// positionally identical. +fn is_uuid36(s: &str) -> bool { + s.len() == UUID_LEN + && s.bytes().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// A transcript is exactly `/chat_history.jsonl`. The recursive walk means +/// this predicate is the whole filter; requiring both the filename and a uuid +/// parent excludes `events.jsonl`, `rewind_points.jsonl` and every lock file. +fn is_transcript(path: &Path) -> bool { + path.file_name().and_then(|n| n.to_str()) == Some(TRANSCRIPT) + && session_id_from_path(path).is_some() +} + +/// The session id is the PARENT directory name, since every transcript shares +/// one filename. +fn session_id_from_path(path: &Path) -> Option { + let dir = path.parent()?.file_name()?.to_str()?; + is_uuid36(dir).then(|| dir.to_string()) +} + +/// `grok-` from the percent-encoded cwd folder that CONTAINS the +/// session dir (`…/<%2Fpath%2Fto%2Frepo>//chat_history.jsonl`). +/// +/// Percent-encoding is reversible, so unlike Factory/Qwen this needs no lookup +/// inside the file — but the header is still consulted as a fallback for a +/// folder that fails to decode. +fn agent_id_from_path(path: &Path, _header: &[String]) -> Option { + let folder = path.parent()?.parent()?.file_name()?.to_str()?; + transform::agent_id_from_folder(folder) +} diff --git a/crates/fpai-collect/src/sources/grok/transform.rs b/crates/fpai-collect/src/sources/grok/transform.rs new file mode 100644 index 000000000..51fe3a3b0 --- /dev/null +++ b/crates/fpai-collect/src/sources/grok/transform.rs @@ -0,0 +1,274 @@ +//! grok transcript → AgentEye events. +//! +//! grok's lines are OpenAI-shaped, not Claude-shaped: an assistant turn is a +//! flat `content` string plus `tool_calls[]` whose `arguments` is a JSON +//! **string**, and a result is its own `{type:"tool_result", tool_call_id, +//! content}` line. `system` and `reasoning` lines are not turns. +//! +//! TIME: a grok transcript carries no timestamps on any line, so events are +//! stamped from the file's mtime (`ctx.file_epoch_ms`, captured once at +//! discovery and immutable) plus the byte offset in microseconds — the same +//! scheme the cursor source uses, and for the same reason: it keeps time about +//! right while staying a pure function of the inputs, so a re-read hashes +//! identically and the server collapses it. + +use serde_json::{Map, Value, json}; + +use crate::cursor::TailState; +use crate::filetail::Ctx; +// Reused, not re-cloned: `sanitize_id_part` MUST agree with the hook source's +// agent-id scheme so a hook event and this transcript's events share an id, and +// `to_rfc3339_micros` is a pure epoch→RFC3339 formatter. +use crate::sources::claude::transform::sanitize_id_part; +use crate::sources::goose::transform::to_rfc3339_micros; + +/// Longest session goal kept — the operator's first real prompt. +const MAX_GOAL_CHARS: usize = 500; + +/// A synthetic event timestamp: the file's mtime plus `offset` microseconds, +/// nudged by the block `index` so several events from one line keep their order. +fn synth_ts(ctx: &Ctx, offset: u64, index: usize) -> Option { + let base = ctx.file_epoch_ms.unwrap_or(0); + let micros = offset.saturating_add(index as u64); + to_rfc3339_micros( + base.saturating_add((micros / 1000) as i64), + (micros % 1000) as u32, + ) +} + +/// The envelope every emitted event carries. `grok_line_offset` is the dedup +/// discriminator: two identical events from different lines must hash +/// differently. +fn base(ctx: &Ctx, kind: &str, offset: u64, index: usize) -> Option> { + let mut m = Map::new(); + m.insert("timestamp".into(), json!(synth_ts(ctx, offset, index)?)); + m.insert("session_id".into(), json!(ctx.session_id)); + m.insert("agent_id".into(), json!(ctx.agent_id)); + m.insert("type".into(), json!(kind)); + m.insert("environment".into(), json!(ctx.environment)); + m.insert("grok_line_offset".into(), json!(offset)); + if index > 0 { + m.insert("grok_block_index".into(), json!(index)); + } + Some(m) +} + +/// Percent-decode grok's cwd folder, then derive `grok-`. +/// +/// Only `%XX` escapes appear in these names; anything malformed falls through +/// as a literal so a strange folder degrades to a visible id rather than none. +pub fn agent_id_from_folder(folder: &str) -> Option { + let decoded = percent_decode(folder); + let project = sanitize_id_part( + decoded + .trim_end_matches('/') + .rsplit('/') + .find(|p| !p.is_empty())?, + ); + (!project.is_empty()).then(|| format!("grok-{project}")) +} + +/// Minimal `%XX` decoder — no dependency, and the input alphabet is grok's own. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// `(type, value)` for a line, or `None` when it is not JSON. +fn parsed(line: &str) -> Option<(String, Value)> { + let v: Value = serde_json::from_str(line).ok()?; + let t = v.get("type")?.as_str()?.to_string(); + Some((t, v)) +} + +/// Text from a `content` that is either a string or an array of `{type:"text"}`. +fn content_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Array(items) => items + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// A real operator prompt: a `user` line carrying `prompt_index` and no +/// `synthetic_reason`. grok writes its environment preamble and its own +/// reminder injections as `user` lines too, and surfacing those as prompts +/// would make a session read as if the human pasted grok's boilerplate. +fn is_operator_prompt(v: &Value) -> bool { + v.get("prompt_index").is_some() && v.get("synthetic_reason").is_none() +} + +/// Build the session's `agent_start`; the goal is the first operator prompt. +pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option)> { + let mut m = base(ctx, "agent_start", offset, 0)?; + let goal = header.iter().find_map(|line| { + let (t, v) = parsed(line)?; + if t != "user" || !is_operator_prompt(&v) { + return None; + } + Some(content_text(v.get("content")?)) + }); + if let Some(goal) = goal.filter(|g| !g.trim().is_empty()) { + m.insert( + "goal".into(), + json!(goal.chars().take(MAX_GOAL_CHARS).collect::()), + ); + } + Some((Value::Object(m), synth_ts(ctx, offset, 0))) +} + +/// The single `agent_end`, at index 999 so it sorts after every content event, +/// stamped from the file size so it is the latest synthetic time in the session. +pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value { + match base(ctx, "agent_end", size, 999) { + Some(m) => Value::Object(m), + None => json!({ + "timestamp": last_ts, + "session_id": ctx.session_id, + "agent_id": ctx.agent_id, + "type": "agent_end", + "environment": ctx.environment, + }), + } +} + +/// One line to its timestamp and the events it yields. +/// +/// Every line gets a synthetic timestamp so `agent_end` tracks the file, even +/// for the `system` / `reasoning` lines that produce no events. +pub fn transform_line( + line: &str, + ctx: &Ctx, + offset: u64, + state: &mut TailState, +) -> (Option, Vec) { + let ts = synth_ts(ctx, offset, 0); + let Some((t, v)) = parsed(line) else { + return (ts, Vec::new()); + }; + let events = match t.as_str() { + // The system prompt and the model's private reasoning are not turns. + "system" | "reasoning" => Vec::new(), + "user" => user_events(&v, ctx, offset, state), + "assistant" => assistant_events(&v, ctx, offset, state), + "tool_result" => tool_result_events(&v, ctx, offset, state), + _ => Vec::new(), + }; + (ts, events) +} + +/// An operator prompt becomes one `model_request`; anything else is context. +fn user_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + if !is_operator_prompt(v) { + return Vec::new(); + } + let Some(content) = v.get("content") else { + return Vec::new(); + }; + let text = content_text(content); + if text.trim().is_empty() { + return Vec::new(); + } + let Some(mut m) = base(ctx, "model_request", offset, 0) else { + return Vec::new(); + }; + if let Some(model) = &state.last_model { + m.insert("model".into(), json!(model)); + } + m.insert( + "messages".into(), + json!([{ "role": "user", "content": text }]), + ); + vec![Value::Object(m)] +} + +/// An assistant line: its text, then each of its tool calls. +fn assistant_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + let model = v.get("model_id").and_then(|m| m.as_str()); + if let Some(model) = model { + state.last_model = Some(model.to_string()); + } + let mut out = Vec::new(); + + let text = v.get("content").map(content_text).unwrap_or_default(); + if !text.trim().is_empty() + && let Some(mut m) = base(ctx, "model_response", offset, 0) + { + m.insert("role".into(), json!("assistant")); + m.insert("content".into(), json!(text)); + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + + let calls = v.get("tool_calls").and_then(|c| c.as_array()); + for (i, call) in calls.into_iter().flatten().enumerate() { + // Index from 1: index 0 belongs to the text event above, and two events + // from one line must not share a synthetic timestamp. + let Some(mut m) = base(ctx, "tool_use", offset, i + 1) else { + continue; + }; + let name = call.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); + let id = call + .get("id") + .and_then(|x| x.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("grok-{offset}-{i}")); + state.remember_tool(id.clone(), name.to_string()); + m.insert("tool_name".into(), json!(name)); + m.insert("tool_call_id".into(), json!(id)); + // grok serializes arguments as a JSON STRING; parse it so tool inputs + // are queryable like every other source's, and keep the raw text when + // it will not parse rather than dropping the call's arguments. + if let Some(args) = call.get("arguments") { + let parsed_args = args + .as_str() + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or_else(|| args.clone()); + m.insert("input".into(), parsed_args); + } + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + out +} + +/// A `tool_result` line, paired back to its call by `tool_call_id`. +fn tool_result_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + let Some(mut m) = base(ctx, "tool_result", offset, 0) else { + return Vec::new(); + }; + if let Some(id) = v.get("tool_call_id").and_then(|x| x.as_str()) { + m.insert("tool_call_id".into(), json!(id)); + // The tool's name is on NO result line — carry it from the call, or + // every result is a blank row in the product. + if let Some(name) = state.tool_name(id) { + m.insert("tool_name".into(), json!(name)); + } + } + if let Some(content) = v.get("content") { + m.insert("output".into(), json!(content_text(content))); + } + vec![Value::Object(m)] +} diff --git a/crates/fpai-collect/src/sources/mod.rs b/crates/fpai-collect/src/sources/mod.rs index d70c0c543..ef627a16c 100644 --- a/crates/fpai-collect/src/sources/mod.rs +++ b/crates/fpai-collect/src/sources/mod.rs @@ -21,8 +21,10 @@ pub mod cursor; pub mod devin; pub mod factory; pub mod goose; +pub mod grok; pub mod hermes; pub mod hooks; pub mod openclaw; pub mod opencode; pub mod pi; +pub mod qwen; diff --git a/crates/fpai-collect/src/sources/qwen/mod.rs b/crates/fpai-collect/src/sources/qwen/mod.rs new file mode 100644 index 000000000..016445c2c --- /dev/null +++ b/crates/fpai-collect/src/sources/qwen/mod.rs @@ -0,0 +1,85 @@ +//! Qwen Code session capture — a [`filetail`](crate::filetail) adapter. +//! +//! qwen writes live-appended JSONL at +//! `~/.qwen/projects//chats/.jsonl`, one file per +//! session. We open them read-only; qwen's own files are never written, moved +//! or deleted. +//! +//! # Closest to Factory on disk, furthest from it inside +//! +//! The layout is Factory's (Claude-style encoded-cwd folder, `.jsonl` +//! stem) with one extra `chats/` level. The BODIES are not: qwen descends from +//! Gemini CLI, so a message is `message.parts[]` of `{text}` / +//! `{functionCall}` / `{functionResponse}` and the assistant role is spelled +//! `"model"`. See [`transform`]. +//! +//! # The encoded-cwd folder is lossy, so the agent id comes from inside +//! +//! Every line carries a real `cwd`, which is what [`agent_id_from_path`] reads +//! — matching the scheme the hook source derives from the same cwd, so a hook +//! event and this transcript's events share one agent id. + +pub mod transform; + +use std::path::Path; + +use crate::filetail::{Format, RereadPolicy, no_seed_state}; + +/// Transcripts: `.jsonl` directly inside a `chats/` directory. +pub const FORMAT: Format = Format { + kind: "qwen", + is_source_file: is_transcript, + session_id_from_path, + agent_id_from_path, + agent_start: transform::agent_start, + // Nothing needs priming: the model is on each assistant line's `model`. + seed_state: no_seed_state, + agent_end: transform::agent_end, + transform_line: transform::transform_line, + // qwen appends. A wholesale rewrite changes the size, and the engine's + // shrink-detection re-reads from zero — offset-keyed events then hash + // identically, so the server collapses the re-ship rather than doubling it. + reread: RereadPolicy::ByteCursor, +}; + +/// The agent id used when no line carries a cwd. +pub const DEFAULT_AGENT_ID: &str = "qwen"; + +const UUID_LEN: usize = 36; + +/// `8-4-4-4-12` hex, checked positionally. +fn is_uuid36(s: &str) -> bool { + s.len() == UUID_LEN + && s.bytes().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// A transcript is `.jsonl` whose parent directory is `chats`. The +/// recursive walk means this predicate is the whole filter; requiring the +/// `chats/` parent excludes any other `.jsonl` a widened root might contain. +fn is_transcript(path: &Path) -> bool { + if session_id_from_path(path).is_none() { + return false; + } + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + == Some("chats") +} + +/// The uuid embedded in the filename IS the session id. +fn session_id_from_path(path: &Path) -> Option { + let stem = path.file_name()?.to_str()?.strip_suffix(".jsonl")?; + is_uuid36(stem).then(|| stem.to_string()) +} + +/// `qwen-`, from the real `cwd` present on every line. +fn agent_id_from_path(_path: &Path, header: &[String]) -> Option { + let cwd = header.iter().find_map(|line| { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + v.get("cwd")?.as_str().map(str::to_string) + })?; + transform::agent_id_from_cwd(&cwd) +} diff --git a/crates/fpai-collect/src/sources/qwen/transform.rs b/crates/fpai-collect/src/sources/qwen/transform.rs new file mode 100644 index 000000000..959eb0cfa --- /dev/null +++ b/crates/fpai-collect/src/sources/qwen/transform.rs @@ -0,0 +1,297 @@ +//! Qwen Code transcript → AgentEye events. +//! +//! qwen's bodies are **Gemini-shaped, not Claude-shaped**: `message.parts[]` +//! holds `{text}`, `{functionCall:{id,name,args}}` and +//! `{functionResponse:{id,name,response}}`, and the assistant role is spelled +//! `"model"`. That is the whole reason this is not a clone of the Factory +//! transform despite the near-identical on-disk layout. +//! +//! Timestamps are real (top-level ISO `timestamp` on every line), so unlike the +//! cursor/grok sources nothing here is synthesised. + +use serde_json::{Map, Value, json}; + +use crate::cursor::TailState; +use crate::filetail::Ctx; +// Reused, not re-cloned: `sanitize_id_part` MUST agree with the hook source's +// so a hook event and this transcript's events land under one agent id, and +// `with_index` is a pure timestamp normaliser. +use crate::sources::claude::transform::{sanitize_id_part, with_index}; + +/// Longest session goal kept — the human's first prompt. +const MAX_GOAL_CHARS: usize = 500; + +/// The envelope every emitted event carries. +/// +/// `qwen_line_offset` is the dedup discriminator: two identical events from +/// different lines must hash differently. One transcript is one session, so a +/// byte offset is unique within it and stable across a re-read. +fn base(ctx: &Ctx, kind: &str, ts: &str, index: usize, offset: u64) -> Option> { + let mut m = Map::new(); + m.insert("timestamp".into(), json!(with_index(ts, index)?)); + m.insert("session_id".into(), json!(ctx.session_id)); + m.insert("agent_id".into(), json!(ctx.agent_id)); + m.insert("type".into(), json!(kind)); + m.insert("environment".into(), json!(ctx.environment)); + m.insert("qwen_line_offset".into(), json!(offset)); + if index > 0 { + m.insert("qwen_block_index".into(), json!(index)); + } + Some(m) +} + +/// Parse a line, keeping its `type` discriminator. +fn parsed(line: &str) -> Option<(String, Value)> { + let v: Value = serde_json::from_str(line).ok()?; + let t = v.get("type")?.as_str()?.to_string(); + Some((t, v)) +} + +/// Top-level ISO timestamp, only when the server can parse it. +fn ts_of(v: &Value) -> Option { + let ts = v.get("timestamp")?.as_str()?.to_string(); + with_index(&ts, 0).is_some().then_some(ts) +} + +/// Derive `qwen-` from an absolute cwd. +pub fn agent_id_from_cwd(cwd: &str) -> Option { + let project = sanitize_id_part( + cwd.trim_end_matches('/') + .rsplit('/') + .find(|p| !p.is_empty())?, + ); + (!project.is_empty()).then(|| format!("qwen-{project}")) +} + +/// Concatenate the `text` parts of a `message.parts[]` array. +fn parts_text(message: &Value) -> String { + message + .get("parts") + .and_then(|p| p.as_array()) + .map(|parts| { + parts + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + +/// Build the session's `agent_start` from its header: the first user prompt is +/// the goal, and the first parseable timestamp anchors the event. +pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option)> { + let mut goal: Option = None; + let mut first_ts: Option = None; + + for line in header { + let Some((t, v)) = parsed(line) else { continue }; + if first_ts.is_none() + && let Some(ts) = ts_of(&v) + { + first_ts = Some(ts); + } + if goal.is_none() && t == "user" { + let text = v.get("message").map(parts_text).unwrap_or_default(); + if !text.trim().is_empty() { + goal = Some(text.chars().take(MAX_GOAL_CHARS).collect()); + } + } + } + + let ts = first_ts?; + let mut m = base(ctx, "agent_start", &ts, 0, offset)?; + if let Some(g) = goal { + m.insert("goal".into(), json!(g)); + } + Some((Value::Object(m), Some(ts))) +} + +/// The single `agent_end`, at index 999 so it sorts after every content event +/// sharing its timestamp. +pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value { + match base(ctx, "agent_end", last_ts, 999, size) { + Some(m) => Value::Object(m), + None => json!({ + "timestamp": last_ts, + "session_id": ctx.session_id, + "agent_id": ctx.agent_id, + "type": "agent_end", + "environment": ctx.environment, + }), + } +} + +/// One content line to its timestamp and the events it yields. +/// +/// `system` lines are qwen's own bookkeeping and carry no turn, but still +/// return their timestamp so `agent_end` reflects when the file last moved. +pub fn transform_line( + line: &str, + ctx: &Ctx, + offset: u64, + state: &mut TailState, +) -> (Option, Vec) { + let Some((t, v)) = parsed(line) else { + return (None, Vec::new()); + }; + let ts = ts_of(&v); + if t == "system" { + return (ts, Vec::new()); + } + let (Some(ts), Some(message)) = (ts, v.get("message")) else { + return (ts_of(&v), Vec::new()); + }; + let model = v.get("model").and_then(|m| m.as_str()); + let role = message + .get("role") + .and_then(|r| r.as_str()) + .unwrap_or_default(); + + // Gemini spells the assistant "model"; accept both, and treat everything + // else (user, tool_result) as the inbound side. + let events = if role == "model" || role == "assistant" { + assistant_events(message, ctx, &ts, offset, state, model) + } else { + user_events(message, ctx, &ts, offset, state) + }; + (Some(ts), events) +} + +/// A user / tool_result message: prompt text and/or function responses. +fn user_events( + message: &Value, + ctx: &Ctx, + ts: &str, + offset: u64, + state: &mut TailState, +) -> Vec { + let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if let Some(fr) = part.get("functionResponse") { + let Some(mut m) = base(ctx, "tool_result", ts, i, offset) else { + continue; + }; + if let Some(id) = fr.get("id").and_then(|x| x.as_str()) { + m.insert("tool_call_id".into(), json!(id)); + // The name is on the response too, but carry the remembered one + // when it is not, so no result renders as a blank row. + if let Some(name) = state.tool_name(id) { + m.insert("tool_name".into(), json!(name)); + } + } + if let Some(name) = fr.get("name").and_then(|n| n.as_str()) { + m.insert("tool_name".into(), json!(name)); + } + if let Some(resp) = fr.get("response") { + m.insert("output".into(), json!(stringify(resp))); + // qwen reports a failed call as `response.error`. + if let Some(err) = resp.get("error") { + m.insert("error".into(), json!(stringify(err))); + m.insert("error_type".into(), json!("qwen_tool_error")); + } + } + out.push(Value::Object(m)); + continue; + } + let Some(text) = part.get("text").and_then(|t| t.as_str()) else { + continue; + }; + if text.trim().is_empty() { + continue; + } + let Some(mut m) = base(ctx, "model_request", ts, i, offset) else { + continue; + }; + if let Some(model) = &state.last_model { + m.insert("model".into(), json!(model)); + } + m.insert( + "messages".into(), + json!([{ "role": "user", "content": text }]), + ); + out.push(Value::Object(m)); + } + out +} + +/// An assistant message: text and/or function calls. +fn assistant_events( + message: &Value, + ctx: &Ctx, + ts: &str, + offset: u64, + state: &mut TailState, + model: Option<&str>, +) -> Vec { + if let Some(model) = model { + state.last_model = Some(model.to_string()); + } + let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if let Some(fc) = part.get("functionCall") { + let Some(mut m) = base(ctx, "tool_use", ts, i, offset) else { + continue; + }; + let name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); + // Fall back to an offset-derived id so parallel identical calls + // cannot hash-collapse into one row. + let id = fc + .get("id") + .and_then(|x| x.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("qwen-{offset}-{i}")); + state.remember_tool(id.clone(), name.to_string()); + m.insert("tool_name".into(), json!(name)); + m.insert("tool_call_id".into(), json!(id)); + if let Some(args) = fc.get("args") { + m.insert("input".into(), args.clone()); + } + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + continue; + } + let Some(text) = part.get("text").and_then(|t| t.as_str()) else { + continue; + }; + if text.trim().is_empty() { + continue; + } + let Some(mut m) = base(ctx, "model_response", ts, i, offset) else { + continue; + }; + m.insert("role".into(), json!("assistant")); + m.insert("content".into(), json!(text)); + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + out +} + +/// Flatten a `functionResponse.response` payload to display text. +fn stringify(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(map) => { + for key in ["output", "result", "content", "error"] { + if let Some(Value::String(s)) = map.get(key) + && !s.is_empty() + { + return s.clone(); + } + } + v.to_string() + } + other => other.to_string(), + } +} diff --git a/crates/fpai-collect/tests/grok_qwen_sources.rs b/crates/fpai-collect/tests/grok_qwen_sources.rs new file mode 100644 index 000000000..9a56c2a44 --- /dev/null +++ b/crates/fpai-collect/tests/grok_qwen_sources.rs @@ -0,0 +1,298 @@ +//! grok + qwen sources: path predicates and transform correctness. +//! +//! Record shapes are taken verbatim from real transcripts captured on +//! grok 1.0.3 (`~/.grok/sessions///chat_history.jsonl`) and +//! qwen-code 0.21.12 (`~/.qwen/projects//chats/.jsonl`). +//! +//! These two are near-mirror-images and the tests below lean on that: grok has +//! no timestamps and an OpenAI-shaped body, qwen has real timestamps and a +//! Gemini-shaped one. + +use std::path::PathBuf; + +use fpai_collect::cursor::TailState; +use fpai_collect::filetail::Ctx; +use fpai_collect::sources::{grok, qwen}; +use serde_json::{Value, json}; + +const UUID: &str = "01a01432-6b6c-7593-9de8-54cd7ddd6fe6"; + +fn grok_ctx() -> Ctx { + Ctx { + session_id: UUID.into(), + agent_id: "grok-VTU".into(), + environment: "local".into(), + // grok transcripts carry no timestamps; the engine hands the file mtime. + file_epoch_ms: Some(1_760_000_000_000), + } +} + +fn qwen_ctx() -> Ctx { + Ctx { + session_id: UUID.into(), + agent_id: "qwen-VTU".into(), + environment: "local".into(), + ..Default::default() + } +} + +fn kinds(events: &[Value]) -> Vec { + events + .iter() + .filter_map(|e| e.get("type")?.as_str().map(str::to_string)) + .collect() +} + +// ── grok ───────────────────────────────────────────────────────────────────── + +#[test] +fn grok_identifies_only_chat_history_under_a_uuid_dir() { + let base = PathBuf::from("/home/u/.grok/sessions/%2Fhome%2Fu%2Frepo"); + let ok = base.join(UUID).join("chat_history.jsonl"); + assert!((grok::FORMAT.is_source_file)(&ok)); + // The session id is the PARENT dir, since every transcript shares a name. + assert_eq!( + (grok::FORMAT.session_id_from_path)(&ok).as_deref(), + Some(UUID) + ); + // Siblings in the very same directory must never be tailed. + for sibling in ["events.jsonl", "rewind_points.jsonl", "summary.json"] { + let p = base.join(UUID).join(sibling); + assert!( + !(grok::FORMAT.is_source_file)(&p), + "{sibling} must not be treated as a transcript" + ); + } + // A chat_history.jsonl not under a uuid directory is not a session. + assert!(!(grok::FORMAT.is_source_file)( + &base.join("chat_history.jsonl") + )); +} + +#[test] +fn grok_agent_id_percent_decodes_the_cwd_folder() { + let p = PathBuf::from("/home/u/.grok/sessions/%2Fhome%2Fchetan%2FDesktop%2FVTU") + .join(UUID) + .join("chat_history.jsonl"); + // Percent-encoding is reversible, so unlike Factory/Qwen the id needs no + // lookup inside the file. + assert_eq!( + (grok::FORMAT.agent_id_from_path)(&p, &[]).as_deref(), + Some("grok-VTU") + ); +} + +#[test] +fn grok_skips_system_and_reasoning_but_keeps_a_timestamp() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + for line in [ + json!({"type": "system", "content": "You are Grok…"}).to_string(), + json!({"type": "reasoning", "id": "rs_1", "summary": []}).to_string(), + ] { + let (ts, events) = (grok::FORMAT.transform_line)(&line, &ctx, 10, &mut state); + assert!(events.is_empty(), "system/reasoning are not turns"); + // Still timestamped, so agent_end tracks the file. + assert!(ts.is_some()); + } +} + +#[test] +fn grok_counts_only_operator_prompts_as_user_turns() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + + // The environment preamble: a `user` line with no prompt_index. + let preamble = + json!({"type": "user", "content": [{"type": "text", "text": "…"}]}).to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&preamble, &ctx, 1, &mut state); + assert!( + events.is_empty(), + "preamble must not read as a human prompt" + ); + + // An injected reminder is also a `user` line. + let synthetic = json!({"type": "user", "synthetic_reason": "skills", + "content": [{"type": "text", "text": "…"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&synthetic, &ctx, 2, &mut state); + assert!(events.is_empty(), "synthetic injections are not prompts"); + + // The real thing carries prompt_index. + let real = json!({"type": "user", "prompt_index": 0, + "content": [{"type": "text", "text": "run echo hi"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&real, &ctx, 3, &mut state); + assert_eq!(kinds(&events), vec!["model_request"]); +} + +#[test] +fn grok_parses_tool_arguments_from_their_json_string() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({ + "type": "assistant", "content": "I'll run it.", "model_id": "grok-4.6", + "tool_calls": [{ + "id": "call-1", "name": "run_terminal_command", + // grok serializes arguments as a JSON STRING, not an object. + "arguments": "{\"command\":\"echo hi\",\"description\":\"Echo\"}" + }] + }) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&line, &ctx, 100, &mut state); + assert_eq!(kinds(&events), vec!["model_response", "tool_use"]); + + let call = &events[1]; + assert_eq!(call["tool_name"], json!("run_terminal_command")); + assert_eq!(call["input"]["command"], json!("echo hi")); + assert_eq!(call["model"], json!("grok-4.6")); + + // Two events off one line must not share a synthetic timestamp, or the + // content-hash dedup collapses them into one row. + assert_ne!(events[0]["timestamp"], events[1]["timestamp"]); + + // The result line carries no tool name; it must be carried from the call. + let result = + json!({"type": "tool_result", "tool_call_id": "call-1", "content": "exit: 0\nhi\n"}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&result, &ctx, 200, &mut state); + assert_eq!(kinds(&events), vec!["tool_result"]); + assert_eq!(events[0]["tool_name"], json!("run_terminal_command")); + assert_eq!(events[0]["output"], json!("exit: 0\nhi\n")); +} + +#[test] +fn grok_keeps_unparseable_tool_arguments_rather_than_dropping_them() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "assistant", "content": "", + "tool_calls": [{"id": "c", "name": "x", "arguments": "{not json"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&line, &ctx, 5, &mut state); + assert_eq!(events[0]["input"], json!("{not json")); +} + +#[test] +fn grok_stamps_events_from_the_file_mtime() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "user", "prompt_index": 0, + "content": [{"type": "text", "text": "hi"}]}) + .to_string(); + let (_, a) = (grok::FORMAT.transform_line)(&line, &ctx, 0, &mut state); + let (_, b) = (grok::FORMAT.transform_line)(&line, &ctx, 5_000, &mut state); + // Offset advances synthetic time, so ordering within a session is right and + // a re-read at the same offset reproduces the same value. + assert!(a[0]["timestamp"].as_str().unwrap() < b[0]["timestamp"].as_str().unwrap()); +} + +// ── qwen ───────────────────────────────────────────────────────────────────── + +#[test] +fn qwen_identifies_only_uuid_jsonl_inside_a_chats_dir() { + let base = PathBuf::from("/home/u/.qwen/projects/-home-u-repo"); + let ok = base.join("chats").join(format!("{UUID}.jsonl")); + assert!((qwen::FORMAT.is_source_file)(&ok)); + assert_eq!( + (qwen::FORMAT.session_id_from_path)(&ok).as_deref(), + Some(UUID) + ); + // Same filename one level up is not a transcript — the `chats/` parent is + // what separates qwen's layout from Factory's. + assert!(!(qwen::FORMAT.is_source_file)( + &base.join(format!("{UUID}.jsonl")) + )); +} + +#[test] +fn qwen_agent_id_comes_from_the_cwd_on_any_line() { + let header = vec![json!({"type": "user", "cwd": "/home/chetan/Desktop/VTU"}).to_string()]; + let p = PathBuf::from("/home/u/.qwen/projects/-x/chats").join(format!("{UUID}.jsonl")); + assert_eq!( + (qwen::FORMAT.agent_id_from_path)(&p, &header).as_deref(), + Some("qwen-VTU") + ); +} + +#[test] +fn qwen_reads_gemini_shaped_parts() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + + // The assistant role is spelled "model" (Gemini lineage), and a tool call is + // a `functionCall` part rather than a Claude `tool_use` block. + let line = json!({ + "type": "assistant", "uuid": "a1", "model": "gpt-5.6-luna", + "timestamp": "2026-08-16T18:30:02.000Z", + "message": {"role": "model", "parts": [ + {"text": "Creating it now."}, + {"functionCall": {"id": "call_1", "name": "write_file", + "args": {"file_path": "/tmp/a.txt", "content": "alpha"}}} + ]} + }) + .to_string(); + let (ts, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 100, &mut state); + // Real timestamps, unlike grok — nothing synthesised. + assert_eq!(ts.as_deref(), Some("2026-08-16T18:30:02.000Z")); + assert_eq!(kinds(&events), vec!["model_response", "tool_use"]); + assert_eq!(events[1]["tool_name"], json!("write_file")); + // qwen's args are already canonical — no input map anywhere in the stack. + assert_eq!(events[1]["input"]["file_path"], json!("/tmp/a.txt")); + + // A functionResponse pairs back by id and inherits the remembered name. + let result = json!({ + "type": "tool_result", "uuid": "t1", "timestamp": "2026-08-16T18:30:03.000Z", + "message": {"role": "user", "parts": [ + {"functionResponse": {"id": "call_1", "name": "write_file", + "response": {"output": "written"}}} + ]} + }) + .to_string(); + let (_, events) = (qwen::FORMAT.transform_line)(&result, &ctx, 200, &mut state); + assert_eq!(kinds(&events), vec!["tool_result"]); + assert_eq!(events[0]["tool_name"], json!("write_file")); + assert_eq!(events[0]["output"], json!("written")); +} + +#[test] +fn qwen_marks_a_failed_call_as_an_error() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + let line = json!({ + "type": "tool_result", "timestamp": "2026-08-16T18:30:03.000Z", + "message": {"role": "user", "parts": [ + {"functionResponse": {"id": "c1", "name": "read_file", + "response": {"error": "File not found: /tmp/x"}}} + ]} + }) + .to_string(); + let (_, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 1, &mut state); + assert_eq!(events[0]["error_type"], json!("qwen_tool_error")); + assert_eq!(events[0]["error"], json!("File not found: /tmp/x")); +} + +#[test] +fn qwen_skips_system_bookkeeping_lines() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "system", "subtype": "info", "systemPayload": {}, + "timestamp": "2026-08-16T18:30:01.000Z"}) + .to_string(); + let (ts, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 1, &mut state); + assert!(events.is_empty()); + assert_eq!(ts.as_deref(), Some("2026-08-16T18:30:01.000Z")); +} + +#[test] +fn qwen_agent_start_takes_the_first_prompt_as_the_goal() { + let ctx = qwen_ctx(); + let header = vec![ + json!({"type": "user", "cwd": "/x", "timestamp": "2026-08-16T18:30:00.000Z", + "message": {"role": "user", "parts": [{"text": "create report.txt"}]}}) + .to_string(), + ]; + let (start, seed) = (qwen::FORMAT.agent_start)(&header, &ctx, 0).expect("agent_start"); + assert_eq!(start["type"], json!("agent_start")); + assert_eq!(start["goal"], json!("create report.txt")); + assert_eq!(seed.as_deref(), Some("2026-08-16T18:30:00.000Z")); +} diff --git a/lib/cli-registry.ts b/lib/cli-registry.ts index 031aea4c9..bf7132346 100644 --- a/lib/cli-registry.ts +++ b/lib/cli-registry.ts @@ -27,7 +27,7 @@ import type { IntegrationType } from "@/src/hooks/types"; /** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */ -export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const satisfies readonly IntegrationType[]; +export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"] as const satisfies readonly IntegrationType[]; export type CliId = (typeof KNOWN_CLI_IDS)[number]; /** Per-CLI metadata consumed by the dashboard. */ @@ -99,6 +99,23 @@ const CLI_ENTRIES: Record = { label: "Goose", badgeClasses: "bg-lime-500/10 text-lime-400 border-lime-500/20", }, + grok: { + id: "grok", + label: "grok CLI", + // Neutral zinc, matching the design system's `--color-default`. Deliberately + // NOT a status hue: green/amber/red carry health meaning in this dashboard + // and must never be spent on identity. Neutral also happens to suit xAI's + // monochrome brand, and it is maximally far from qwen's magenta — the two + // were previously indistinguishable. + badgeClasses: "bg-zinc-500/10 text-zinc-200 border-zinc-500/20", + }, + qwen: { + id: "qwen", + label: "Qwen Code", + // Magenta — the opposite pole from grok's neutral, and outside the + // status set (success/amber/orange/error) so it reads as identity. + badgeClasses: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20", + }, }; export function getCliEntry(id: string): CliEntry | undefined { diff --git a/lib/download-session.ts b/lib/download-session.ts index 1eea27f53..3613aafd2 100644 --- a/lib/download-session.ts +++ b/lib/download-session.ts @@ -125,6 +125,21 @@ export async function resolveDownloadSource( return path ? { kind: "file", path } : null; } + if (cli === "grok") { + // grok writes a real chat_history.jsonl per session directory — stream it + // verbatim (no synthesis, like Factory). + const { findGrokTranscript } = await import("./grok-sessions"); + const path = findGrokTranscript(sessionId); + return path ? { kind: "file", path } : null; + } + + if (cli === "qwen") { + // qwen writes a real JSONL transcript per session — stream it verbatim. + const { findQwenTranscript } = await import("./qwen-sessions"); + const path = findQwenTranscript(sessionId); + return path ? { kind: "file", path } : null; + } + if (cli === "devin") { // Devin keeps sessions in SQLite (~/.local/share/devin/cli/sessions.db). // Synthesize a JSONL export of the session's raw chat_message rows. diff --git a/lib/grok-projects.ts b/lib/grok-projects.ts new file mode 100644 index 000000000..67b078415 --- /dev/null +++ b/lib/grok-projects.ts @@ -0,0 +1,123 @@ +/** + * grok CLI project discovery. + * + * grok stores transcripts at + * `~/.grok/sessions///chat_history.jsonl`. + * + * NOTE the folder encoding differs from every other JSONL store we read: grok + * percent-encodes the cwd (`%2Fhome%2Fuser%2Frepo`) where Claude, Factory and + * Qwen dash-encode it (`-home-user-repo`). + * + * So the on-disk folder name is deliberately NOT used as the project `name`. + * Two reasons, and the first is a hard bug: `name` becomes the URL slug for + * `/project/[name]`, and a percent-encoded name re-encodes to `%252F…` in the + * link, which the route cannot resolve — every grok project 404'd. Second, a + * name no other CLI can produce merges with nothing, so a cwd driven by both + * grok and Claude showed up as two unrelated rows. + * + * Re-encoding the decoded cwd with `encodeFolderName` fixes both at once: the + * slug is URL-safe, and it is byte-identical to the one Claude/Factory/Qwen + * derive for the same cwd, so those rows merge as they should. + */ +import { encodeFolderName, decodeFolderName } from "./paths"; +import { listGrokTranscripts, getGrokSessionLog } from "./grok-sessions"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +/** Returns one ProjectFolder per percent-encoded cwd folder discovered under + * ~/.grok/sessions/. */ +export async function getGrokProjects(): Promise { + let transcripts; + try { + transcripts = listGrokTranscripts(); + } catch (error) { + logWarn("Failed to scan grok sessions:", error); + return []; + } + + const byName = new Map(); + for (const t of transcripts) { + // The URL slug is derived from the real cwd, never from grok's + // percent-encoded folder — see the module header. + const name = encodeFolderName(t.cwd); + const existing = byName.get(name); + if (!existing || t.mtimeMs > existing.latest) { + byName.set(name, { latest: t.mtimeMs, cwd: t.cwd, name }); + } + } + + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["grok"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface GrokProjectByName { + /** Canonical cwd, taken from a session's summary.json `info.cwd`. Null when + * no session could be read. */ + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Look up grok sessions for a project URL slug (the percent-encoded cwd folder + * name). The canonical cwd comes from summary.json; the percent-decode is the + * fallback. + */ +export async function getGrokSessionsByEncodedName(name: string): Promise { + let transcripts; + try { + // Match on the derived slug, since that is what the link carried. + transcripts = listGrokTranscripts().filter((t) => encodeFolderName(t.cwd) === name); + } catch (error) { + logWarn("Failed to scan grok sessions:", error); + return { cwd: null, sessions: [] }; + } + if (transcripts.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...transcripts].sort((a, b) => b.mtimeMs - a.mtimeMs); + + let cwd: string | null = sorted[0].cwd || null; + if (!cwd) { + try { + const log = await getGrokSessionLog(sorted[0].sessionId); + cwd = log?.cwd ?? null; + } catch { + // best-effort — fall back to the decode below + } + } + // `name` is a dash-encoded cwd here, so the shared decoder is the right one. + if (!cwd) cwd = decodeFolderName(name); + + const sessions: SessionFile[] = sorted.map((t) => { + const lastModified = new Date(t.mtimeMs); + return { + name: t.sessionId, + path: t.transcriptPath, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: t.sessionId, + cli: "grok" as const, + }; + }); + return { cwd, sessions }; +} + +export const getCachedGrokProjects = runtimeCache(getGrokProjects, 30); +export const getCachedGrokSessionsByEncodedName = runtimeCache( + (name: string) => getGrokSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/grok-sessions.ts b/lib/grok-sessions.ts new file mode 100644 index 000000000..e241beb9a --- /dev/null +++ b/lib/grok-sessions.ts @@ -0,0 +1,355 @@ +/** + * grok CLI session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). grok stores one DIRECTORY per session at + * `~/.grok/sessions///`, holding + * `chat_history.jsonl` (the turns), `events.jsonl` (a telemetry stream), + * `summary.json` (title + cwd + counts), and assorted lock/state files we + * ignore. Verified live against grok 1.0.3 (1a29d5bc12). + * + * TWO things differ from every other JSONL adapter, and both are load-bearing: + * + * 1. **The cwd folder is PERCENT-encoded** (`%2Fhome%2Fyou%2Frepo`), not + * dash-encoded like Claude/Factory/Qwen — so this module decodes with + * `decodeURIComponent`, NOT `decodeFolderName`. Using the shared helper + * here would silently yield a cwd of literal "%2Fhome%2F…" and every + * project grouping would be wrong. + * + * 2. **`chat_history.jsonl` carries NO timestamps.** Per-event times live in + * `events.jsonl`, which is not 1:1 with the turns. Rather than mis-pair + * them, `grokLinesToLogEntries` takes an explicit `startMs` (the session's + * `created_at` from summary.json) and lays the turns out one millisecond + * apart from there. That keeps ordering exact and the session's absolute + * position on the timeline right, while being honest that per-turn + * wall-clock times are synthesized, not recorded. + * + * Line shapes (OpenAI-ish, discriminated by `type`): + * {type:"system", content:string} — prompt, skipped + * {type:"user", content:[{type:"text",text}], prompt_index?} — see below + * {type:"reasoning", summary:[{type:"summary_text",text}], …} — skipped + * {type:"assistant", content:string, tool_calls:[{id,name,arguments:string}]} + * {type:"tool_result", tool_call_id, content:string} + * + * `tool_calls[].arguments` is a JSON **string**, not an object — it is parsed + * here so tool inputs are inspectable like every other CLI's. + * + * Only user lines carrying `prompt_index` are treated as real user turns. grok + * also writes the environment preamble and `synthetic_reason` reminder + * injections as `user` lines; surfacing those as if the operator typed them + * would make an audit read like the user pasted grok's own boilerplate. + * `prompt_history.jsonl` (a per-cwd sibling) records exactly the same set, + * which is the cross-check that this filter is the right one. + * + * Home override: set `GROK_HOME` (used by tests / to point at a copied + * sessions dir). + */ +import { readFile } from "node:fs/promises"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + parseRawLines, + type LogEntry, + type UserEntry, + type AssistantEntry, + type GenericEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** grok session directories are UUID-named (UUIDv7 in practice). */ +export const GROK_SESSION_ID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** Absolute path to grok's config home (override with GROK_HOME). */ +export function grokHome(): string { + return process.env.GROK_HOME || join(homedir(), ".grok"); +} + +/** Absolute path to the grok sessions root. */ +export function grokSessionsRoot(): string { + return join(grokHome(), "sessions"); +} + +/** + * Decode grok's percent-encoded cwd folder name. Deliberately NOT + * `decodeFolderName` — see the module header. Falls back to the raw name when + * the folder is not valid percent-encoding, so a malformed dir degrades to a + * visible label instead of throwing mid-listing. + */ +export function decodeGrokProjectDir(name: string): string { + try { + return decodeURIComponent(name); + } catch { + return name; + } +} + +// ── Parsing helpers ── + +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +/** Text from a `content` field that is either a string or an array of + * `{type:"text",text}` blocks. */ +function extractText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c) => (isPlainObject(c) && typeof c.text === "string" ? (c.text as string) : "")) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +/** grok serializes tool args as a JSON string; parse it, tolerating garbage. */ +function parseArguments(args: unknown): Record { + if (isPlainObject(args)) return args; + if (typeof args === "string") { + try { + const parsed = JSON.parse(args) as unknown; + if (isPlainObject(parsed)) return parsed; + } catch { + // fall through — keep the raw string so the audit still shows something + } + return { arguments: args }; + } + return {}; +} + +// ── Pure parser: transcript lines → LogEntry[] ── + +/** + * Convert grok `chat_history.jsonl` lines (parsed objects, in file order) into + * `LogEntry[]`. + * + * `startMs` anchors the synthesized timeline — pass the session's `created_at` + * (from summary.json). Turns are laid out 1ms apart in file order. Pure — + * unit-testable with plain line objects. + */ +export function grokLinesToLogEntries( + lines: Record[], + startMs: number = Date.now(), + source: LogSource = "session", +): LogEntry[] { + const entries: LogEntry[] = []; + const toolUseById = new Map(); + const toolUseStartMs = new Map(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isPlainObject(line)) continue; + const type = typeof line.type === "string" ? line.type : ""; + // The system prompt and the model's private reasoning are not turns. + if (type === "system" || type === "reasoning") continue; + + const date = new Date(startMs + i); + const timestamp = date.toISOString(); + const raw: Record = { + uuid: line.id != null ? String(line.id) : `grok-${i}`, + parentUuid: null, + }; + const base = baseEntry(raw, timestamp, date, source); + + if (type === "assistant") { + const blocks: ContentBlock[] = []; + const text = extractText(line.content); + if (text) blocks.push({ type: "text", text }); + const calls = Array.isArray(line.tool_calls) ? line.tool_calls : []; + for (const c of calls) { + if (!isPlainObject(c)) continue; + const id = typeof c.id === "string" ? c.id : `${String(c.name ?? "tool")}-${blocks.length}`; + const name = typeof c.name === "string" ? c.name : "tool"; + const block: ToolUseBlock = { + type: "tool_use", + id, + name, + input: parseArguments(c.arguments), + }; + blocks.push(block); + toolUseById.set(id, block); + toolUseStartMs.set(id, date.getTime()); + } + if (blocks.length === 0) continue; + entries.push({ + type: "assistant", + ...base, + message: { + role: "assistant", + content: blocks, + model: typeof line.model_id === "string" ? line.model_id : undefined, + }, + } satisfies AssistantEntry); + continue; + } + + if (type === "tool_result") { + const callId = typeof line.tool_call_id === "string" ? line.tool_call_id : undefined; + const block = callId ? toolUseById.get(callId) : undefined; + if (block) { + const started = (callId && toolUseStartMs.get(callId)) || date.getTime(); + const durationMs = Math.max(0, date.getTime() - started); + block.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: extractText(line.content), + durationMs, + durationFormatted: formatDuration(durationMs), + }; + continue; + } + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + continue; + } + + if (type === "user") { + // Real operator prompts only — see the module header on prompt_index. + if (line.prompt_index === undefined || line.synthetic_reason !== undefined) continue; + entries.push({ + type: "user", + ...base, + message: { role: "user", content: extractText(line.content) }, + } satisfies UserEntry); + continue; + } + + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + } + + entries.sort((a, b) => a.timestampMs - b.timestampMs); + return entries; +} + +// ── Discovery + file loader ── + +export interface GrokTranscriptFile { + /** Percent-encoded folder name on disk (e.g. "%2Fhome%2Fuser%2Frepo"). */ + projectName: string; + /** Decoded cwd of the project. */ + cwd: string; + sessionId: string; + /** Path to chat_history.jsonl inside the session directory. */ + transcriptPath: string; + mtimeMs: number; + sizeBytes: number; + /** Session title from summary.json, when present. */ + title?: string; + /** created_at from summary.json (ms), used to anchor synthesized timestamps. */ + createdAtMs?: number; +} + +/** Read the optional per-session summary.json sidecar. Never throws. */ +function readSummary(sessionDir: string): { + cwd?: string; + title?: string; + createdAtMs?: number; +} { + try { + const raw = JSON.parse(readFileSync(join(sessionDir, "summary.json"), "utf-8")) as unknown; + if (!isPlainObject(raw)) return {}; + const info = isPlainObject(raw.info) ? raw.info : undefined; + const createdAt = typeof raw.created_at === "string" ? Date.parse(raw.created_at) : NaN; + return { + cwd: info && typeof info.cwd === "string" ? info.cwd : undefined, + title: typeof raw.session_summary === "string" ? raw.session_summary : undefined, + createdAtMs: Number.isNaN(createdAt) ? undefined : createdAt, + }; + } catch { + return {}; + } +} + +/** Enumerate `sessions///chat_history.jsonl` transcripts. */ +export function listGrokTranscripts(): GrokTranscriptFile[] { + const root = grokSessionsRoot(); + const out: GrokTranscriptFile[] = []; + let projectDirs: import("node:fs").Dirent[]; + try { + projectDirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + return out; + } + for (const dir of projectDirs) { + const projectName = dir.name; + const projectPath = join(root, projectName); + let sessionDirs: import("node:fs").Dirent[]; + try { + sessionDirs = readdirSync(projectPath, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + continue; + } + for (const sd of sessionDirs) { + const sessionId = sd.name; + if (!GROK_SESSION_ID_RE.test(sessionId)) continue; + const sessionDir = join(projectPath, sessionId); + const transcriptPath = join(sessionDir, "chat_history.jsonl"); + let st: import("node:fs").Stats; + try { + st = statSync(transcriptPath); + } catch { + continue; // a session dir with no chat history yet + } + const summary = readSummary(sessionDir); + out.push({ + projectName, + // summary.json's info.cwd is canonical; the folder name is the fallback. + cwd: summary.cwd ?? decodeGrokProjectDir(projectName), + sessionId, + transcriptPath, + mtimeMs: st.mtimeMs, + sizeBytes: st.size, + title: summary.title, + createdAtMs: summary.createdAtMs, + }); + } + } + return out; +} + +/** Resolve a session UUID to its chat_history.jsonl path (host-side). Guards + * against traversal by requiring a UUID directory name. Synchronous so the + * hook hot path can call it without awaits. */ +export function findGrokTranscript(sessionId: string): string | null { + if (!GROK_SESSION_ID_RE.test(sessionId)) return null; + for (const t of listGrokTranscripts()) { + if (t.sessionId === sessionId) return t.transcriptPath; + } + return null; +} + +export interface GrokSessionLogData { + entries: LogEntry[]; + rawLines: Record[]; + cwd?: string; + filePath: string; +} + +/** Load and parse one session transcript by UUID. Returns `null` when the file + * is missing/unreadable or the id fails validation. */ +export async function getGrokSessionLog(sessionId: string): Promise { + if (!GROK_SESSION_ID_RE.test(sessionId)) return null; + const meta = listGrokTranscripts().find((t) => t.sessionId === sessionId); + if (!meta) return null; + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return null; + } + const rawLines = parseRawLines(content, "session"); + const entries = grokLinesToLogEntries(rawLines, meta.createdAtMs ?? meta.mtimeMs, "session"); + return { entries, rawLines, cwd: meta.cwd, filePath: meta.transcriptPath }; +} + +export const getCachedGrokSessionLog = runtimeCache( + (sessionId: string) => getGrokSessionLog(sessionId), + 60, + { maxSize: 50 }, +); diff --git a/lib/projects.ts b/lib/projects.ts index 3471be723..74b4fc749 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -16,7 +16,7 @@ import { formatDate } from "./format-date"; export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/; -export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose"; +export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose" | "grok" | "qwen"; export interface ProjectFolder { name: string; @@ -171,6 +171,8 @@ export async function getProjectFolders(): Promise { { getDevinProjects }, { getAntigravityProjects }, { getGooseProjects }, + { getGrokProjects }, + { getQwenProjects }, ] = await Promise.all([ import("./codex-projects"), import("./copilot-projects"), @@ -183,8 +185,10 @@ export async function getProjectFolders(): Promise { import("./devin-projects"), import("./antigravity-projects"), import("./goose-projects"), + import("./grok-projects"), + import("./qwen-projects"), ]); - const [claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose] = await Promise.all([ + const [claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen] = await Promise.all([ getClaudeProjectFolders(), getCodexProjects().catch((error) => { logError("Error reading Codex projects:", error); @@ -230,8 +234,16 @@ export async function getProjectFolders(): Promise { logError("Error reading Goose projects:", error); return [] as ProjectFolder[]; }), + getGrokProjects().catch((error) => { + logError("Error reading grok projects:", error); + return [] as ProjectFolder[]; + }), + getQwenProjects().catch((error) => { + logError("Error reading Qwen projects:", error); + return [] as ProjectFolder[]; + }), ]); - return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose); + return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen); } /** diff --git a/lib/qwen-projects.ts b/lib/qwen-projects.ts new file mode 100644 index 000000000..4642a848b --- /dev/null +++ b/lib/qwen-projects.ts @@ -0,0 +1,106 @@ +/** + * Qwen Code project discovery. + * + * qwen stores transcripts at + * `~/.qwen/projects//chats/.jsonl`, using the same + * Claude-style encoded-cwd folder names (`-home-user-project`) as Claude Code. + * The encoded folder doubles as the URL slug for `/project/[name]`, so a cwd + * present in both stores naturally produces the same `name` and merges on the + * Claude side (see `mergeProjectFolders` in lib/projects.ts). + */ +import { decodeFolderName } from "./paths"; +import { listQwenTranscripts, getQwenSessionLog } from "./qwen-sessions"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +/** Returns one ProjectFolder per encoded-cwd folder discovered under + * ~/.qwen/projects/. */ +export async function getQwenProjects(): Promise { + let transcripts; + try { + transcripts = listQwenTranscripts(); + } catch (error) { + logWarn("Failed to scan Qwen sessions:", error); + return []; + } + + const byName = new Map(); + for (const t of transcripts) { + const existing = byName.get(t.projectName); + if (!existing || t.mtimeMs > existing.latest) { + byName.set(t.projectName, { latest: t.mtimeMs, cwd: t.cwd, name: t.projectName }); + } + } + + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["qwen"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface QwenProjectByName { + /** Canonical cwd recovered from a session line (the folder decode is lossy). + * Null when no session could be read. */ + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Look up Qwen sessions for a project URL slug (the encoded-cwd folder name). + * qwen names its folders with the same encoding Claude uses, so the slug + * matches the folder directly. The canonical cwd is recovered from the newest + * session's own `cwd` field (folder decode is lossy for cwds containing `-`). + */ +export async function getQwenSessionsByEncodedName(name: string): Promise { + let transcripts; + try { + transcripts = listQwenTranscripts().filter((t) => t.projectName === name); + } catch (error) { + logWarn("Failed to scan Qwen sessions:", error); + return { cwd: null, sessions: [] }; + } + if (transcripts.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...transcripts].sort((a, b) => b.mtimeMs - a.mtimeMs); + + let cwd: string | null = null; + try { + const log = await getQwenSessionLog(sorted[0].sessionId); + cwd = log?.cwd ?? null; + } catch { + // best-effort — fall back to the lossy decode below + } + if (!cwd) cwd = decodeFolderName(name); + + const sessions: SessionFile[] = sorted.map((t) => { + const lastModified = new Date(t.mtimeMs); + return { + name: t.sessionId, + path: t.transcriptPath, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: t.sessionId, + cli: "qwen" as const, + }; + }); + return { cwd, sessions }; +} + +export const getCachedQwenProjects = runtimeCache(getQwenProjects, 30); +export const getCachedQwenSessionsByEncodedName = runtimeCache( + (name: string) => getQwenSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/qwen-sessions.ts b/lib/qwen-sessions.ts new file mode 100644 index 000000000..57a8e47b3 --- /dev/null +++ b/lib/qwen-sessions.ts @@ -0,0 +1,313 @@ +/** + * Qwen Code session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). qwen writes one JSONL transcript per session at + * `~/.qwen/projects//chats/.jsonl` — Claude-style + * encoded-cwd folders (e.g. `-home-chetan-project`), but note the extra + * `chats/` level, which Claude/Factory do not have. Verified live against + * @qwen-code/qwen-code 0.21.12. + * + * Every line carries `{uuid, parentUuid, sessionId, cwd, timestamp, version, + * type}`. `type` is one of: + * "user" — message.parts, role "user" + * "assistant" — message.parts, role "model" (Gemini's name for assistant) + * "tool_result" — message.parts + a `toolCallResult` sidecar + * "system" — bookkeeping (subtype/systemPayload); skipped + * + * The message body is **Gemini-shaped, not Claude-shaped**: `message.parts[]` + * holds `{text}`, `{functionCall:{id,name,args}}`, and + * `{functionResponse:{id,name,response}}` — so this is NOT a clone of + * lib/factory-sessions.ts despite the similar on-disk layout. `functionCall` is + * paired with its later `functionResponse` by id (mirroring the tool_use / + * tool_result pairing elsewhere). `qwenLinesToLogEntries` is PURE, so it is + * unit-testable with plain line objects. + * + * Home override: set `QWEN_HOME` (used by tests / to point at a copied + * projects dir). + */ +import { readFile } from "node:fs/promises"; +import { readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { decodeFolderName } from "./paths"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + parseRawLines, + type LogEntry, + type UserEntry, + type AssistantEntry, + type GenericEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** Qwen sessions are stored under UUID filenames. */ +export const QWEN_SESSION_ID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** Absolute path to Qwen's config home (override with QWEN_HOME). */ +export function qwenHome(): string { + return process.env.QWEN_HOME || join(homedir(), ".qwen"); +} + +/** Absolute path to the Qwen projects root. */ +export function qwenProjectsRoot(): string { + return join(qwenHome(), "projects"); +} + +// ── Parsing helpers ── + +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +/** Flatten a Gemini `response` payload (or any tool output) to display text. */ +function extractResponseText(response: unknown): string { + if (typeof response === "string") return response; + if (!isPlainObject(response)) return ""; + for (const key of ["output", "result", "content", "error"]) { + const v = response[key]; + if (typeof v === "string" && v.length > 0) return v; + } + return JSON.stringify(response); +} + +function toDate(value: unknown, fallbackMs: number): Date { + if (typeof value === "number" && Number.isFinite(value)) { + if (value > 1e12) return new Date(value); + if (value > 1e9) return new Date(value * 1000); + } + if (typeof value === "string") { + const ms = Date.parse(value); + if (!Number.isNaN(ms)) return new Date(ms); + } + return new Date(fallbackMs); +} + +// ── Pure parser: transcript lines → LogEntry[] ── + +/** + * Convert Qwen transcript JSONL lines (parsed objects, in file order) into + * `LogEntry[]`. `system` lines are skipped. Assistant `functionCall` parts are + * paired with their later `functionResponse` by call id. Pure — unit-testable + * with plain line objects. + */ +export function qwenLinesToLogEntries( + lines: Record[], + source: LogSource = "session", +): LogEntry[] { + const entries: LogEntry[] = []; + const toolUseById = new Map(); + const toolUseStartMs = new Map(); + const baseMs = Date.now(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isPlainObject(line)) continue; + const type = typeof line.type === "string" ? line.type : ""; + if (type === "system") continue; // subtype/systemPayload bookkeeping + + const m = isPlainObject(line.message) ? line.message : undefined; + if (!m) continue; + const parts = Array.isArray(m.parts) ? m.parts : []; + + const date = toDate(line.timestamp, baseMs + i); + const timestamp = date.toISOString(); + const raw: Record = { + uuid: line.uuid != null ? String(line.uuid) : `qwen-${i}`, + parentUuid: line.parentUuid != null ? String(line.parentUuid) : null, + }; + const base = baseEntry(raw, timestamp, date, source); + + // Assistant turn: text parts and/or functionCall parts. Qwen names the + // assistant role "model" (Gemini lineage); accept both spellings. + if (type === "assistant" || m.role === "model" || m.role === "assistant") { + const blocks: ContentBlock[] = []; + for (const p of parts) { + if (!isPlainObject(p)) continue; + if (typeof p.text === "string" && p.text.length > 0) { + blocks.push({ type: "text", text: p.text }); + continue; + } + const fc = isPlainObject(p.functionCall) ? p.functionCall : undefined; + if (fc) { + const id = typeof fc.id === "string" ? fc.id : `${String(fc.name ?? "tool")}-${blocks.length}`; + const name = typeof fc.name === "string" ? fc.name : "tool"; + const input = isPlainObject(fc.args) ? fc.args : {}; + const block: ToolUseBlock = { type: "tool_use", id, name, input }; + blocks.push(block); + toolUseById.set(id, block); + toolUseStartMs.set(id, date.getTime()); + } + } + if (blocks.length === 0) continue; // empty / failed assistant turn + entries.push({ + type: "assistant", + ...base, + message: { + role: "assistant", + content: blocks, + model: typeof line.model === "string" ? line.model : undefined, + }, + } satisfies AssistantEntry); + continue; + } + + // tool_result / user: functionResponse parts attach to their call; plain + // text parts become a user turn. + let attachedAny = false; + const textParts: string[] = []; + for (const p of parts) { + if (!isPlainObject(p)) continue; + const fr = isPlainObject(p.functionResponse) ? p.functionResponse : undefined; + if (fr) { + const callId = typeof fr.id === "string" ? fr.id : undefined; + const block = callId ? toolUseById.get(callId) : undefined; + if (block) { + const startMs = (callId && toolUseStartMs.get(callId)) || date.getTime(); + const durationMs = Math.max(0, date.getTime() - startMs); + // Prefer the sidecar's rendered text — it is what the TUI showed — + // and fall back to flattening the raw functionResponse payload. + const sidecar = isPlainObject(line.toolCallResult) ? line.toolCallResult : undefined; + const display = + sidecar && typeof sidecar.resultDisplay === "string" && sidecar.resultDisplay.length > 0 + ? sidecar.resultDisplay + : extractResponseText(fr.response); + block.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: display, + durationMs, + durationFormatted: formatDuration(durationMs), + }; + attachedAny = true; + continue; + } + } + if (typeof p.text === "string" && p.text.length > 0) textParts.push(p.text); + } + if (textParts.length > 0) { + entries.push({ + type: "user", + ...base, + message: { role: "user", content: textParts.join("\n") }, + } satisfies UserEntry); + } else if (!attachedAny) { + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + } + } + + entries.sort((a, b) => a.timestampMs - b.timestampMs); + return entries; +} + +// ── Discovery + file loader ── + +export interface QwenTranscriptFile { + /** Encoded folder name on disk (e.g. "-home-user-project"). */ + projectName: string; + /** Decoded cwd of the project (lossy; canonical cwd lives on every line). */ + cwd: string; + sessionId: string; + transcriptPath: string; + mtimeMs: number; + sizeBytes: number; +} + +/** Enumerate `projects//chats/.jsonl` transcripts. */ +export function listQwenTranscripts(): QwenTranscriptFile[] { + const root = qwenProjectsRoot(); + const out: QwenTranscriptFile[] = []; + let projectDirs: import("node:fs").Dirent[]; + try { + projectDirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + return out; + } + for (const dir of projectDirs) { + const projectName = dir.name; + const cwd = decodeFolderName(projectName); + // Transcripts live one level deeper than Claude/Factory, under `chats/`. + const chatsPath = join(root, projectName, "chats"); + let files: string[]; + try { + files = readdirSync(chatsPath); + } catch { + continue; + } + for (const file of files) { + if (!file.endsWith(".jsonl")) continue; + const sessionId = file.slice(0, -".jsonl".length); + if (!QWEN_SESSION_ID_RE.test(sessionId)) continue; + const transcriptPath = join(chatsPath, file); + try { + const st = statSync(transcriptPath); + out.push({ + projectName, + cwd, + sessionId, + transcriptPath, + mtimeMs: st.mtimeMs, + sizeBytes: st.size, + }); + } catch { + // skip unreadable + } + } + } + return out; +} + +/** Resolve a session UUID to its on-disk transcript path (host-side). Guards + * against traversal by requiring a UUID filename. Shared by the audit adapter, + * the hook transcript resolver, and download-session. Synchronous so the hook + * hot path can call it without awaits. */ +export function findQwenTranscript(sessionId: string): string | null { + if (!QWEN_SESSION_ID_RE.test(sessionId)) return null; + for (const t of listQwenTranscripts()) { + if (t.sessionId === sessionId) return t.transcriptPath; + } + return null; +} + +export interface QwenSessionLogData { + entries: LogEntry[]; + rawLines: Record[]; + cwd?: string; + filePath: string; +} + +/** Load and parse one session transcript by UUID. Returns `null` when the file + * is missing/unreadable or the id fails validation. */ +export async function getQwenSessionLog(sessionId: string): Promise { + const filePath = findQwenTranscript(sessionId); + if (!filePath) return null; + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return null; + } + const rawLines = parseRawLines(content, "session"); + const entries = qwenLinesToLogEntries(rawLines, "session"); + // Every line carries the real cwd; take the first one rather than trusting + // the lossy decoded folder name. + let cwd: string | undefined; + for (const line of rawLines) { + if (isPlainObject(line) && typeof line.cwd === "string" && line.cwd.length > 0) { + cwd = line.cwd; + break; + } + } + return { entries, rawLines, cwd, filePath }; +} + +export const getCachedQwenSessionLog = runtimeCache( + (sessionId: string) => getQwenSessionLog(sessionId), + 60, + { maxSize: 50 }, +); diff --git a/src/audit/cli-adapters/grok.ts b/src/audit/cli-adapters/grok.ts new file mode 100644 index 000000000..ee5d3c0e0 --- /dev/null +++ b/src/audit/cli-adapters/grok.ts @@ -0,0 +1,70 @@ +/** + * grok CLI transcript adapter — AUDIT (Pillar 2). + * + * grok writes one session DIRECTORY at + * `~/.grok/sessions///`, whose + * `chat_history.jsonl` holds the turns (verified live against grok 1.0.3). + * lib/grok-sessions.ts enumerates and parses them. + * + * `chat_history.jsonl` has no per-message timestamps, so the parser anchors a + * synthesized timeline on the session's `created_at` (from summary.json) — + * `listGrokTranscripts` already resolved that, so it is passed straight + * through here rather than re-read. + */ +import { readFile } from "node:fs/promises"; +import { listGrokTranscripts, grokLinesToLogEntries } from "../../../lib/grok-sessions"; +import { parseRawLines } from "../../../lib/log-entries"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listGrokTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const out: TranscriptMetadata[] = []; + + for (const t of listGrokTranscripts()) { + if (t.mtimeMs < sinceMs) continue; + // `audit --project ` filters on the real cwd (summary.json's info.cwd + // when present, else the percent-decoded folder name). + if (projectFilter && !projectFilter.has(t.cwd)) continue; + out.push({ + cli: "grok", + projectName: t.projectName, + sessionId: t.sessionId, + transcriptPath: t.transcriptPath, + mtimeMs: t.mtimeMs, + sizeBytes: t.sizeBytes, + }); + } + return out; +} + +export async function streamGrokEvents(meta: TranscriptMetadata): Promise { + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return []; + } + + // Recover this session's cwd + created_at from the enumeration (both come + // from summary.json, which streamEvents is not otherwise given). + const known = listGrokTranscripts().find((t) => t.sessionId === meta.sessionId); + + const rawLines = parseRawLines(content, "session"); + const entries = grokLinesToLogEntries( + rawLines, + known?.createdAtMs ?? meta.mtimeMs, + "session", + ); + + return logEntriesToEvents(entries, { + cli: "grok", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd: known?.cwd ?? "", + }); +} diff --git a/src/audit/cli-adapters/index.ts b/src/audit/cli-adapters/index.ts index 52fbe950b..b445d75aa 100644 --- a/src/audit/cli-adapters/index.ts +++ b/src/audit/cli-adapters/index.ts @@ -23,6 +23,8 @@ import { listFactoryTranscriptMetadata, streamFactoryEvents } from "./factory"; import { listAntigravityTranscriptMetadata, streamAntigravityEvents } from "./antigravity"; import { listDevinTranscriptMetadata, streamDevinEvents } from "./devin"; import { listGooseTranscriptMetadata, streamGooseEvents } from "./goose"; +import { listGrokTranscriptMetadata, streamGrokEvents } from "./grok"; +import { listQwenTranscriptMetadata, streamQwenEvents } from "./qwen"; export type { ListOpts }; @@ -93,6 +95,16 @@ export const ADAPTERS: Record = { listTranscripts: listGooseTranscriptMetadata, streamEvents: streamGooseEvents, }, + grok: { + cli: "grok", + listTranscripts: listGrokTranscriptMetadata, + streamEvents: streamGrokEvents, + }, + qwen: { + cli: "qwen", + listTranscripts: listQwenTranscriptMetadata, + streamEvents: streamQwenEvents, + }, }; export function getAdapter(cli: IntegrationType): CliAdapter { diff --git a/src/audit/cli-adapters/qwen.ts b/src/audit/cli-adapters/qwen.ts new file mode 100644 index 000000000..ead15f904 --- /dev/null +++ b/src/audit/cli-adapters/qwen.ts @@ -0,0 +1,71 @@ +/** + * Qwen Code transcript adapter — AUDIT (Pillar 2). + * + * qwen writes real JSONL transcripts at + * `~/.qwen/projects//chats/.jsonl` (Claude-style + * encoded-cwd folders, one level deeper than Claude's; verified live against + * qwen-code 0.21.12). lib/qwen-sessions.ts enumerates and parses them — the + * message bodies are Gemini-shaped `parts[]`, not Claude content blocks — into + * the shared LogEntry[] shape, so `logEntriesToEvents` handles the rest. + */ +import { readFile } from "node:fs/promises"; +import { listQwenTranscripts, qwenLinesToLogEntries } from "../../../lib/qwen-sessions"; +import { parseRawLines } from "../../../lib/log-entries"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listQwenTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const out: TranscriptMetadata[] = []; + + for (const t of listQwenTranscripts()) { + if (t.mtimeMs < sinceMs) continue; + // `audit --project ` filters on the decoded working directory. + if (projectFilter && !projectFilter.has(t.cwd)) continue; + out.push({ + cli: "qwen", + projectName: t.projectName, + sessionId: t.sessionId, + transcriptPath: t.transcriptPath, + mtimeMs: t.mtimeMs, + sizeBytes: t.sizeBytes, + }); + } + return out; +} + +export async function streamQwenEvents(meta: TranscriptMetadata): Promise { + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return []; + } + + const rawLines = parseRawLines(content, "session"); + const entries = qwenLinesToLogEntries(rawLines, "session"); + + // Every qwen line carries the real cwd — take the first rather than + // re-decoding the (lossy) folder name. + let cwd = ""; + for (const line of rawLines) { + if (line && typeof line === "object") { + const c = (line as Record).cwd; + if (typeof c === "string" && c.length > 0) { + cwd = c; + break; + } + } + } + + return logEntriesToEvents(entries, { + cli: "qwen", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd, + }); +} diff --git a/src/hooks/enforcement-capability.ts b/src/hooks/enforcement-capability.ts index e73cc45a1..e371e51e2 100644 --- a/src/hooks/enforcement-capability.ts +++ b/src/hooks/enforcement-capability.ts @@ -253,6 +253,61 @@ export const ENFORCEMENT_CAPABILITY: Record< // the policy-evaluator comment both claim "Goose has NO Stop event"; that is // false at v1.43.0 (agent.rs:1956, :2840 emit_stop_hook_blocking). }, + + // grok 1.0.3 (1a29d5bc12). Every "block" row below was proven by a live + // probe, not read from grok's hooks doc — which is also where two of the + // three surprises came from (see types.ts): the doc's own tool name for the + // shell tool disagrees with the wire, and nothing documents that project + // hooks need a git repo. grok's capability advertisement corroborates the + // blocking set: its ACP initialize response carries + // x.ai/hooks: {blockingEvents:["pre_tool_use","stop","subagent_stop"], + // decisions:["deny","block"], stopSignals:[…]} + // — exactly the three rows marked "block" here. + grok: { + PreToolUse: "block", // VERIFIED live: {decision:"deny",reason} blocked `echo` and beat --yolo (permissionMode bypassPermissions); Claude's hookSpecificOutput shape did NOT block (A/B on the same hook) + Stop: "block", // VERIFIED live: {decision:"block",reason} forced another turn — the agent ran the required command, then stopped. ONLY on reason==="end_turn"; the session-shutdown fire is parsed and discarded upstream. Cap: 8 continuations/turn + SubagentStop: "block", // advertised in x.ai/hooks blockingEvents; not exercised by a probe + UserPromptSubmit: "observe", // fired live; not in blockingEvents + PostToolUse: "observe", // fired live; post-hoc, and not in blockingEvents + PostToolUseFailure: "observe", + SessionStart: "observe", // fired live + SessionEnd: "observe", // fired live + // The six below are the widened set. All OBSERVE by construction, not by + // caution: blockingEvents lists exactly three events, so grok cannot honour + // a deny on any of these no matter what we send. + StopFailure: "observe", // VERIFIED firing (a 429 turn): {error:"rate_limit", errorDetails, lastAssistantMessage} + Notification: "observe", // VERIFIED firing: notificationType "agent_error" — a type grok's own docs do not list + PermissionDenied: "observe", // config key accepted (count=14); firing not exercised — needs a real permission denial + SubagentStart: "observe", // config key accepted; firing not exercised — needs a subagent + PreCompact: "observe", // config key accepted; firing not exercised — needs compaction + PostCompact: "observe", // config key accepted; firing not exercised — needs compaction + }, + + // qwen-code 0.21.12. A near-pure Claude clone on the wire, so most rows + // inherit Claude's semantics — but only the two proven ones are "block". + qwen: { + PreToolUse: "block", // VERIFIED live: hookSpecificOutput.permissionDecision:"deny" blocked run_shell_command and beat -y (yolo); reason reached the model verbatim. ("ask" degrades to deny in headless and background subagents) + Stop: "block", // VERIFIED live: top-level {decision:"block",reason} forced another turn. NOTE stop_hook_active is true on the FIRST fire, so it is not a usable loop guard here + UserPromptSubmit: "observe", // docs accept a decision, unverified — and it fires per MODEL INVOCATION (4× in one observed turn), so treat as observation until probed + PostToolUse: "observe", // docs accept `decision`, unverified; post-hoc regardless + PostToolUseFailure: "observe", + PermissionRequest: "observe", + PermissionDenied: "observe", + SubagentStart: "observe", + SubagentStop: "observe", // sibling of Stop upstream, but not probed — left honest rather than assumed + PreCompact: "observe", + SessionStart: "observe", // fired live + SessionEnd: "observe", + // Widened set. Every one has a real executeHooks() dispatch site in the + // shipped bundle; the three marked VERIFIED were also observed firing. + TaskCreated: "block", // qwen's TodoCreated. VERIFIED firing ×4 with phase:"validation", where upstream docs state a {decision:"block"} PREVENTS the write and returns the reason to the model + TaskCompleted: "block", // qwen's TodoCompleted. VERIFIED firing ×4, same validation-phase contract + InstructionsLoaded: "observe", // VERIFIED firing: {file_path, memory_type, load_reason} + Notification: "observe", // VERIFIED firing: notification_type "auth_success" + StopFailure: "observe", // upstream documents it fire-and-forget — output and exit code ignored + PostCompact: "observe", + UserPromptExpansion: "observe", + }, }; /** diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 81606f16a..d466adcbe 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -15,6 +15,7 @@ import type { HermesHookEventType, OpenClawHookEventType, AntigravityHookEventType, + QwenHookEventType, } from "./types"; import { CODEX_EVENT_MAP, @@ -23,9 +24,10 @@ import { HERMES_EVENT_MAP, OPENCLAW_EVENT_MAP, ANTIGRAVITY_EVENT_MAP, + QWEN_EVENT_MAP, } from "./types"; import { canonicalizeToolName, canonicalizeToolInput } from "./tool-name-canonicalize"; -import { normalizeCliPayload } from "./normalize-cli-payload"; +import { normalizeCliPayload, resolveEffectiveCli } from "./normalize-cli-payload"; import type { PolicyFunction, PolicyResult, HooksConfig } from "./policy-types"; import { readMergedHooksConfig } from "./hooks-config"; import { registerBuiltinPolicies } from "./builtin-policies"; @@ -91,6 +93,13 @@ export function canonicalizeEventType(raw: string, cli: IntegrationType): HookEv const mapped = ANTIGRAVITY_EVENT_MAP[raw as AntigravityHookEventType]; if (mapped) return mapped; } + if (cli === "qwen") { + // Seventeen of qwen's nineteen events are already canonical; this maps the + // two that are not — TodoCreated/TodoCompleted, which are qwen's spelling + // of TaskCreated/TaskCompleted. Verified live against qwen-code 0.21.12. + const mapped = QWEN_EVENT_MAP[raw as QwenHookEventType]; + if (mapped) return mapped; + } // claude / copilot / unknown — already PascalCase, pass through. // HOOK_EVENT_TYPES type-checks downstream. return raw as HookEventType; @@ -179,7 +188,7 @@ async function runObserved( */ export async function evaluateHookEvent( eventType: string, - cli: IntegrationType = "claude", + declaredCli: IntegrationType = "claude", stdinPayload: string, opts?: EvaluateHookEventOptions, ): Promise { @@ -200,12 +209,28 @@ export async function evaluateHookEvent( hookLogWarn(`payload parse failed for ${eventType} (${stdinPayload.length} bytes)`); void trackHookEvent(getInstanceId(), "hook_payload_parse_error", { event_type: eventType, - cli, + cli: declaredCli, payload_size: stdinPayload.length, }); } } + // grok executes other CLIs' hook configs — including the + // `/.claude/settings.json` our own claude install writes — passing + // `--cli claude` while piping ITS camelCase payload. Resolve the CLI whose + // contract actually governs this event BEFORE anything reads `cli`, so both + // halves land on grok's path: the tool maps (without which every builtin + // reads undefined) and the response shape (grok ignores Claude's + // hookSpecificOutput deny — verified by A/B against a live session). + // No-op for every other CLI. See normalize-cli-payload.ts:isGrokEnvelope. + const cli = resolveEffectiveCli(declaredCli, parsed); + if (cli !== declaredCli) { + hookLogWarn( + `payload for ${eventType} is a ${cli} envelope but --cli says ${declaredCli}; ` + + `evaluating with the ${cli} contract`, + ); + } + normalizeCliPayload(cli, parsed); // Canonicalize event name (Codex sends snake_case; internals expect PascalCase) diff --git a/src/hooks/harness-cli.ts b/src/hooks/harness-cli.ts index 3b385e3e1..574f94983 100644 --- a/src/hooks/harness-cli.ts +++ b/src/hooks/harness-cli.ts @@ -50,7 +50,7 @@ import { configFile } from "./fp-home"; * the two lists are identical by reading the Rust source, because nothing else * connects them. * - * Twelve keys, thirteen sources: `claude` covers both the main and the subagent + * Fourteen keys, fifteen sources: `claude` covers both the main and the subagent * transcript formats, which share a root — an extra path holding Claude * transcripts holds their subagents too. */ @@ -67,6 +67,8 @@ export const HARNESS_KEYS = [ "opencode", "devin", "hermes", + "grok", + "qwen", ] as const; export type HarnessKey = (typeof HARNESS_KEYS)[number]; diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index f809f61f8..9e22a0a9f 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -38,6 +38,10 @@ import { ANTIGRAVITY_HOOK_EVENT_TYPES, ANTIGRAVITY_HOOK_SCOPES, GOOSE_HOOK_EVENT_TYPES, + GROK_HOOK_EVENT_TYPES, + GROK_HOOK_SCOPES, + QWEN_HOOK_EVENT_TYPES, + QWEN_HOOK_SCOPES, GOOSE_HOOK_SCOPES, FAILPROOFAI_HOOK_MARKER, INTEGRATION_TYPES, @@ -2282,6 +2286,279 @@ export const goose: Integration = { // type error. `hermes` now has BOTH an audit adapter // (src/audit/cli-adapters/hermes.ts) AND live-hook install support, so it is // registered here. +// ── grok (xAI grok CLI) integration ───────────────────────────────────────── +// +// grok reads Claude's NESTED hook schema out of its own directory: every +// `*.json` under `~/.grok/hooks/` (user, always trusted) or +// `/.grok/hooks/` (project). We own one file, `failproofai.json`, so the +// install never has to merge with a user's other hook files — the same +// arrangement as Copilot's `.github/hooks/failproofai.json`. +// +// `timeout` is in SECONDS (grok's default is 5, which is too tight for a cold +// `npx` start; 30 matches what the other second-based CLIs use here). +// +// TWO project-scope conditions that are NOT in grok's docs and were found by +// probing grok 1.0.3 — both silently produce a config that never fires: +// • the directory must be a GIT REPO (a trusted non-git dir logs +// `project_sources=0`), and +// • the folder must be TRUSTED (`grok --trust` or `/hooks-trust`). +// `projectScopeWarning()` below reports the first; the second is surfaced in +// the install output, since neither has any other detector. +// +// Deny/instruct semantics live in policy-evaluator.ts's `cli === "grok"` branch +// (grok's OWN `{decision:"deny"}` / `{decision:"block"}` shapes — it ignores +// Claude's hookSpecificOutput deny, verified by A/B on a live session). +const grok: Integration = { + id: "grok", + displayName: "grok CLI", + scopes: GROK_HOOK_SCOPES, + eventTypes: GROK_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "user": + return resolve(homedir(), ".grok", "hooks", "failproofai.json"); + case "project": + case "local": + // grok has no "local" scope; fall back to project so callers don't crash. + return resolve(base, ".grok", "hooks", "failproofai.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const command = + scope === "project" + ? `npx -y failproofai --hook ${eventType} --cli grok` + : `"${binaryPath}" --hook ${eventType} --cli grok`; + return { + type: "command", + command, + // grok reads `timeout` in SECONDS. Its own default is 5s (600s for + // Stop/SubagentStop); 30 leaves room for a cold `npx` start without + // stalling the UI, and a timed-out hook fails OPEN on grok. + timeout: 30, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook: isMarkedHook, + + writeHookEntries(settings, binaryPath, scope) { + const s = settings as ClaudeSettings; + if (!s.hooks) s.hooks = {}; + + for (const eventType of GROK_HOOK_EVENT_TYPES) { + const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry; + if (!s.hooks[eventType]) s.hooks[eventType] = []; + const matchers: ClaudeHookMatcher[] = s.hooks[eventType]; + + let found = false; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record)); + if (idx >= 0) { + matcher.hooks[idx] = hookEntry; + found = true; + break; + } + } + // No `matcher` key: an omitted matcher matches every tool. Do NOT write + // `"*"` here — grok treats a matcher as a REGEX, and the same bare `"*"` + // is an invalid pattern that matches nothing on Goose. + if (!found) matchers.push({ hooks: [hookEntry] }); + } + }, + + removeHooksFromFile(settingsPath) { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return false; + for (const matchers of Object.values(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + if (matcher.hooks.some((h) => isMarkedHook(h as Record))) return true; + } + } + } catch { + // Corrupt settings — treat as not installed + } + return false; + }, + + detectInstalled() { + return binaryExists("grok"); + }, +}; + +// ── qwen (Qwen Code) integration ──────────────────────────────────────────── +// +// qwen keeps hooks under a Claude-style `"hooks"` key inside its normal +// settings file, which also holds `model`, `modelProviders`, auth, etc. — so +// reads/writes go through the merge-preserving readJsonFile/writeJsonFile +// helpers (like Claude/Copilot/Devin), never a whole-file replace. +// user → ~/.qwen/settings.json +// project → /.qwen/settings.json +// +// The one shape difference from every other integration: `timeout` is in +// MILLISECONDS (qwen's default is 60000). Do not "unify" it with the +// seconds-based CLIs — 30 would mean 30ms and every hook would time out. +// +// Deny is Claude's own `hookSpecificOutput.permissionDecision` on PreToolUse +// (so no evaluator branch is needed for it); only Stop diverges. See the +// `cli === "qwen"` branch in policy-evaluator.ts. +const qwen: Integration = { + id: "qwen", + displayName: "Qwen Code", + scopes: QWEN_HOOK_SCOPES, + eventTypes: QWEN_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "user": + return resolve(homedir(), ".qwen", "settings.json"); + case "project": + case "local": + // qwen has no "local" scope; fall back to project so callers don't crash. + return resolve(base, ".qwen", "settings.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const command = + scope === "project" + ? `npx -y failproofai --hook ${eventType} --cli qwen` + : `"${binaryPath}" --hook ${eventType} --cli qwen`; + return { + type: "command", + command, + name: `failproofai-${eventType}`, + // MILLISECONDS — qwen is the only integration that is not seconds-based. + // 30000 = 30s, matching the wall-clock budget the others use. + timeout: 30000, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook: isMarkedHook, + + writeHookEntries(settings, binaryPath, scope) { + const s = settings as ClaudeSettings; + if (!s.hooks) s.hooks = {}; + + for (const eventType of QWEN_HOOK_EVENT_TYPES) { + const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry; + if (!s.hooks[eventType]) s.hooks[eventType] = []; + const matchers: ClaudeHookMatcher[] = s.hooks[eventType]; + + let found = false; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record)); + if (idx >= 0) { + matcher.hooks[idx] = hookEntry; + found = true; + break; + } + } + // Matcher omitted = match every tool. qwen also accepts `""`/`"*"`, but an + // omitted key is the one form that is unambiguous across all of them. + if (!found) matchers.push({ hooks: [hookEntry] }); + } + }, + + removeHooksFromFile(settingsPath) { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return false; + for (const matchers of Object.values(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + if (matcher.hooks.some((h) => isMarkedHook(h as Record))) return true; + } + } + } catch { + // Corrupt settings — treat as not installed + } + return false; + }, + + detectInstalled() { + return binaryExists("qwen"); + }, +}; + const INTEGRATIONS: Partial> = { claude: claudeCode, codex, @@ -2295,6 +2572,8 @@ const INTEGRATIONS: Partial> = { devin, antigravity, goose, + grok, + qwen, }; export function getIntegration(id: IntegrationType): Integration { diff --git a/src/hooks/normalize-cli-payload.ts b/src/hooks/normalize-cli-payload.ts index b501b9043..d610d1a04 100644 --- a/src/hooks/normalize-cli-payload.ts +++ b/src/hooks/normalize-cli-payload.ts @@ -10,6 +10,56 @@ */ import type { IntegrationType } from "./types"; +/** + * True when `parsed` carries grok's camelCase envelope. + * + * This exists because grok EXECUTES OTHER CLIS' HOOK CONFIGS. Its discovery + * scans `~/.claude/settings.json`, `~/.claude/settings.local.json`, + * `/.claude/settings.json` and the `.cursor` equivalents by default + * (`[compat.claude] hooks = true`) — and `/.claude/settings.json` is + * exactly the file `policies --install --cli claude --scope project` writes. So + * on any machine with both tools, grok runs OUR hooks and hands them `--cli + * claude` on the command line while piping ITS OWN camelCase payload. + * + * Verified live against grok 1.0.3: the hook fired, `tool_name` and + * `tool_input` were both undefined, and every builtin that reads either — + * block-sudo, block-env-files, block-secrets-write, block-force-push — allowed. + * Installed, running, costing latency, enforcing nothing; strictly worse than + * no coverage, because the install reports success. Detecting the envelope lets + * handler.ts re-route the event onto grok's path (tool maps AND response + * shapes — grok ignores Claude's `hookSpecificOutput.permissionDecision`, also + * verified by A/B). + * + * The test is on the PAYLOAD, never on an env var: `GROK_HOOK_EVENT` is set by + * grok's runner but is still just an env var, and mistaking a real Claude event + * for a grok one would break Claude's own enforcement. `workspaceRoot` + + * `hookEventName` with no `hook_event_name` is a shape Claude never sends. + */ +export function isGrokEnvelope(parsed: Record): boolean { + return ( + typeof parsed.hookEventName === "string" && + typeof parsed.workspaceRoot === "string" && + parsed.hook_event_name === undefined + ); +} + +/** + * The CLI whose contract actually governs this event. + * + * Identical to the declared `--cli` for every integration except the grok + * cross-execution case above, where the flag says `claude` and the wire says + * grok. Returning "grok" there routes the rest of the pipeline — tool-name and + * tool-input canonicalization, and the response shape policy-evaluator emits — + * onto the contract that will actually be honored. + */ +export function resolveEffectiveCli( + cli: IntegrationType, + parsed: Record, +): IntegrationType { + if (cli === "claude" && isGrokEnvelope(parsed)) return "grok"; + return cli; +} + export function normalizeCliPayload(cli: IntegrationType, parsed: Record): void { // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields // the handler downstream reads to canonical snake_case BEFORE any @@ -47,6 +97,33 @@ export function normalizeCliPayload(cli: IntegrationType, parsed: Record` + // provenance tag), so the generic path below is already correct for it. + if (session?.cli === "grok") { + const stderrMsg = allowEntries + .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`) + .join("\n"); + const grokStopReason = typeof payload.reason === "string" ? payload.reason : undefined; + const atRealTurnEnd = + eventType === "SubagentStop" || + (eventType === "Stop" && (!grokStopReason || grokStopReason === "end_turn")); + if (atRealTurnEnd) { + return { + exitCode: 0, + stdout: JSON.stringify({ + decision: "block", + reason: `Instruction from failproofai: ${combined}`, + }), + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "instruct", + }; + } + return { + exitCode: 0, + stdout: "", + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "allow", + }; + } + const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || diff --git a/src/hooks/resolve-transcript-path.ts b/src/hooks/resolve-transcript-path.ts index 6a19e3261..df73a5e89 100644 --- a/src/hooks/resolve-transcript-path.ts +++ b/src/hooks/resolve-transcript-path.ts @@ -31,6 +31,8 @@ import { findCodexTranscript } from "../../lib/codex-sessions"; import { findCopilotTranscript } from "../../lib/copilot-sessions"; import { findCursorTranscript } from "../../lib/cursor-sessions"; import { findPiTranscript } from "../../lib/pi-sessions"; +import { findGrokTranscript } from "../../lib/grok-sessions"; +import { findQwenTranscript } from "../../lib/qwen-sessions"; import { findFactoryTranscript } from "../../lib/factory-sessions"; import { findAntigravityTranscript } from "../../lib/antigravity-sessions"; import type { IntegrationType } from "./types"; @@ -74,6 +76,14 @@ export function resolveTranscriptPath( // its live-hook payload carries no transcript file, so hand back a virtual // path (like devin) — audit/download read the DB directly. return `goose-db://${sessionId}`; + case "grok": + // grok writes real JSONL at ~/.grok/sessions// + // /chat_history.jsonl. + return findGrokTranscript(sessionId) ?? undefined; + case "qwen": + // qwen writes real JSONL at ~/.qwen/projects//chats/ + // .jsonl (note the extra `chats/` level vs Claude/Factory). + return findQwenTranscript(sessionId) ?? undefined; case "opencode": return `opencode-db://${sessionId}`; case "hermes": diff --git a/src/hooks/tool-name-canonicalize.ts b/src/hooks/tool-name-canonicalize.ts index b784d5d91..d0e73b10d 100644 --- a/src/hooks/tool-name-canonicalize.ts +++ b/src/hooks/tool-name-canonicalize.ts @@ -25,6 +25,9 @@ import { ANTIGRAVITY_TOOL_INPUT_MAP, GOOSE_TOOL_MAP, GOOSE_TOOL_INPUT_MAP, + GROK_TOOL_MAP, + GROK_TOOL_INPUT_MAP, + QWEN_TOOL_MAP, } from "./types"; /** @@ -56,6 +59,12 @@ export function canonicalizeToolName( // Goose: shell→Bash, write/edit/view→file ops, todo__todo_write→TodoWrite, … // (verified live against goose v1.43.0). Handles bare + `__` names. if (cli === "goose") return GOOSE_TOOL_MAP[raw] ?? raw; + // grok: run_terminal_command→Bash, write/read_file/search_replace→file ops, … + // (every entry observed on the wire against grok 1.0.3). + if (cli === "grok") return GROK_TOOL_MAP[raw] ?? raw; + // qwen: run_shell_command→Bash, write_file/read_file/edit→file ops, … + // (verified live against qwen-code 0.21.12). + if (cli === "qwen") return QWEN_TOOL_MAP[raw] ?? raw; return raw; } @@ -95,6 +104,11 @@ export function canonicalizeToolInput( // Goose file tools (write/edit/view) deliver the path as `path`, read_image as // `source`; map to `file_path` so path builtins fire (verified goose v1.43.0). else if (cli === "goose") perToolMap = GOOSE_TOOL_INPUT_MAP[toolName]; + // grok's read_file delivers `target_file` and list_dir `target_directory`; + // every other grok tool is already canonical. Without the Read entry a live + // `.env` read walks past block-env-files (verified grok 1.0.3). qwen needs no + // entry at all — all six of its tools deliver canonical keys. + else if (cli === "grok") perToolMap = GROK_TOOL_INPUT_MAP[toolName]; if (!perToolMap) return rawInput; const out: Record = {}; for (const [k, v] of Object.entries(rawInput as Record)) { diff --git a/src/hooks/types.ts b/src/hooks/types.ts index b1021195b..f8257be33 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -5,7 +5,7 @@ export const HOOK_SCOPES = ["user", "project", "local"] as const; export type HookScope = (typeof HOOK_SCOPES)[number]; -export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const; +export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen"] as const; export type IntegrationType = (typeof INTEGRATION_TYPES)[number]; export const CODEX_HOOK_SCOPES = ["user", "project"] as const; @@ -985,6 +985,289 @@ export const GOOSE_TOOL_INPUT_MAP: Record> = { LS: { path: "file_path" }, }; +// --------------------------------------------------------------------------- +// grok (xAI's `grok` CLI) — 13th integration. Dual-pillar (live hooks + audit), +// user + project scope, Claude/Codex-style external shell hooks. The entire +// contract below was VERIFIED LIVE against grok 1.0.3 (1a29d5bc12) with a +// recorder hook on all 14 events plus deny / stop-gate probes. +// +// 1. **The envelope is camelCase**, so — like Antigravity — normalizeCliPayload +// has a `grok` branch. `toolName`/`toolInput`/`sessionId`/`transcriptPath` +// → snake_case, `workspaceRoot` → `cwd`, `toolResult` → `tool_response` +// (grok does NOT use Claude's `tool_response`), `stopHookActive` → +// `stop_hook_active`. NOTE `hookEventName`'s *value* is snake_case +// ("pre_tool_use") while the `--hook` arg is PascalCase, so the arg is the +// canonical source and there is NO GROK_EVENT_MAP. +// +// 2. **Deny = `{"decision":"deny","reason"}` on stdout at exit 0.** VERIFIED +// live, and it beat `--yolo` (permissionMode `bypassPermissions`). grok +// does NOT read Claude's `hookSpecificOutput.permissionDecision` shape — +// also verified live, by A/B: the identical hook emitting Claude's shape +// let `echo` run, emitting grok's shape blocked it. This is why +// isGrokEnvelope() exists (see normalize-cli-payload.ts). +// +// 3. **Stop fires TWICE per session** — once per real turn end +// (`reason: "end_turn"`) and once at shutdown (`reason: "shutdown"`), +// whose decision grok parses and then IGNORES (no turn is left to +// continue). So the Stop branch in policy-evaluator.ts gates on +// `reason === "end_turn"`; blocking on the shutdown fire would emit a deny +// that is counted as enforcement and can never be acted on. Captured +// sequence, one turn: end_turn/stopHookActive=false (we blocked) → the +// agent ran the required command → end_turn/stopHookActive=true (allowed) +// → shutdown. Cap: 8 continuations per turn, then grok forces the stop. +// +// 4. **Project hooks require a GIT REPO** — undocumented, verified live: in a +// *trusted* non-git directory holding a valid `.grok/hooks/*.json`, grok +// logs `project_sources=0` and the hook never fires; after `git init` in +// the same directory it logs `project_sources=4` and fires. A project-scope +// install into a non-git dir is a silent no-op, so the installer warns. +// Project scope additionally requires folder trust (`--trust` / +// `/hooks-trust`); user scope (`~/.grok/hooks/`) is always trusted. +// +// Settings paths (VERIFIED): +// user → ~/.grok/hooks/failproofai.json (always trusted) +// project → /.grok/hooks/failproofai.json (needs git + folder trust) +// +// `timeout` is in SECONDS (grok's default is 5; 600 for Stop/SubagentStop). +// Env injected on every hook: GROK_HOOK_EVENT, GROK_HOOK_NAME, GROK_SESSION_ID, +// GROK_WORKSPACE_ROOT, and CLAUDE_PROJECT_DIR (a Claude-compatible alias) — so +// the dogfood config can use $CLAUDE_PROJECT_DIR like .claude/settings.json. +// +// Audit pillar: `~/.grok/sessions///` — +// PERCENT encoding (`%2Fhome%2Fyou%2Frepo`), not Claude's dash style. Each +// session dir holds chat_history.jsonl + events.jsonl + summary.json (the last +// carries `info.cwd`, `session_summary`, `num_messages`, `current_model_id`). +// See lib/grok-sessions.ts. `GROK_HOME` overrides the home dir for tests. +export const GROK_HOOK_SCOPES = ["user", "project"] as const; +export type GrokHookScope = (typeof GROK_HOOK_SCOPES)[number]; + +// All 14 of grok's events, which is its entire surface. Every name here was +// accepted by a live grok 1.0.3 (`hooks: loaded from global source … count=14`, +// `loaded hooks hook_count=14`, no unknown-key warning) — grok silently SKIPS +// unrecognized event keys, so acceptance is the thing to verify, and it was. +// `Notification` and `StopFailure` were additionally observed firing. +// +// The six beyond the original eight are all OBSERVATION — grok's own ACP +// handshake advertises `blockingEvents: ["pre_tool_use","stop","subagent_stop"]` +// and that is the complete list, so nothing added here can ever deny. They are +// installed for custom-policy surface and audit signal, and because the cost of +// an event that never fires is zero. +export const GROK_HOOK_EVENT_TYPES = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionDenied", + "Stop", + "StopFailure", + "Notification", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionEnd", +] as const; +export type GrokHookEventType = (typeof GROK_HOOK_EVENT_TYPES)[number]; + +/** + * grok's tool ids → Claude PascalCase canonical names so existing builtins + * (which match `toolName === "Bash"`) fire unchanged. Every entry below was + * observed on the wire, not read from a doc — which matters here, because + * grok's own docs disagree with themselves: the hooks doc calls the shell tool + * `run_terminal_command` and the headless doc calls it `run_terminal_cmd`. The + * wire says `run_terminal_command`; the alias is kept so a matcher written + * against either name still canonicalizes. Unknown tools pass through via the + * `?? raw` fallback in handler.ts:canonicalizeToolName. + */ +export const GROK_TOOL_MAP: Record = { + run_terminal_command: "Bash", + run_terminal_cmd: "Bash", + write: "Write", + read_file: "Read", + search_replace: "Edit", + grep: "Grep", + list_dir: "LS", + web_search: "WebSearch", + web_fetch: "WebFetch", + spawn_subagent: "Task", +}; + +/** + * Per-tool input-key translation, keyed by the *canonical* tool name. Only two + * of grok's tools deviate, and both were found by capture rather than by + * reading: `read_file` delivers the path as `target_file` and `list_dir` as + * `target_directory`. The `read_file` entry is the load-bearing one — without + * it a live `.env` read sails past block-env-files / block-read-outside-cwd, + * the exact bug COPILOT_TOOL_INPUT_MAP was added to fix. Everything else is + * already canonical: Bash `command`, Write `file_path`/`content`, Edit + * `file_path`/`old_string`/`new_string`, Grep `pattern`/`path`. + */ +export const GROK_TOOL_INPUT_MAP: Record> = { + Read: { target_file: "file_path" }, + LS: { target_directory: "path" }, +}; + +// --------------------------------------------------------------------------- +// qwen (Alibaba's Qwen Code, `qwen`) — 14th integration. Dual-pillar, user + +// project scope. The CHEAPEST integration in the codebase: qwen is a near-pure +// Claude clone on the wire, so it needs NO event map, NO payload normalization, +// and NO tool-input map. Verified live against @qwen-code/qwen-code 0.21.12. +// +// 1. **Payload is pure Claude snake_case** — `hook_event_name` (PascalCase +// *value*, unlike grok), `session_id`, `transcript_path`, `cwd`, +// `permission_mode`, `tool_name`, `tool_input`, `tool_response`, +// `stop_hook_active`. Nothing to normalize. +// +// 2. **Deny = `hookSpecificOutput.permissionDecision`** ("allow" | "deny" | +// "ask"), which is Claude's own PreToolUse shape — so the generic Claude +// branch in policy-evaluator.ts already emits the right thing and qwen +// needs no PreToolUse special-case. VERIFIED live: it beat `-y` (yolo) and +// the reason reached the model verbatim. ("ask" degrades to deny in +// headless and in background subagents.) Stop takes the top-level +// `{decision:"block",reason}` shape instead, which is why the qwen branch +// below exists at all. +// +// 3. **`stop_hook_active` is TRUE on the FIRST Stop fire**, before anything +// has blocked — verified live. It is therefore NOT a usable "already +// retrying" signal on qwen, and no failproofai loop guard may depend on +// it. (Unlike grok, qwen fires no session-end Stop: both fires are real.) +// +// 4. **`UserPromptSubmit` fires per MODEL INVOCATION, not per user prompt** — +// one user turn produced FOUR of them (initial query + one per tool-result +// continuation). qwen's own docs confirm it covers UserQuery/ToolResult/ +// Hook sends and warn that `prompt` is not necessarily user input. Any +// UserPromptSubmit policy fires N× per turn here; `submitted_prompt` is +// present only for interactive-TUI submissions (absent in headless, ACP, +// serve, SDK). +// +// Settings paths (VERIFIED): the `hooks` key inside qwen's normal settings. +// user → ~/.qwen/settings.json +// project → /.qwen/settings.json +// +// `timeout` is in MILLISECONDS (default 60000) — qwen is the ONLY integration +// that is not seconds-based, so buildHookEntry must not be "simplified" to +// share the others' value. `disableAllHooks: true` (top level) and `--safe-mode` +// both disable every hook. +// +// Audit pillar: `~/.qwen/projects//chats/.jsonl` — +// Claude-style encoded-cwd folders, one JSONL per session, lines carrying +// `{sessionId, timestamp, type: user|assistant|system|tool_result, cwd}`. A real +// cwd per line means audit groups by project like Claude/Devin/Goose. See +// lib/qwen-sessions.ts. `QWEN_HOME` overrides the home dir for tests. +export const QWEN_HOOK_SCOPES = ["user", "project"] as const; +export type QwenHookScope = (typeof QWEN_HOOK_SCOPES)[number]; + +// Every event below has a real `executeHooks("")` dispatch site in the +// shipped qwen bundle (verified by reading it, not the docs — which is also how +// `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch` turned up: +// all three are dispatched but absent from qwen's documented event table). +// TodoCreated/TodoCompleted/PostToolBatch/InstructionsLoaded/Notification were +// additionally observed firing in a live 0.21.12 session. +// +// Deliberately NOT subscribed: +// • `MessageDisplay` — fires per streaming chunk, i.e. a hook process per +// chunk. The one entry here that could make hooks feel slow. +// • `PostToolBatch` — fired 6× in the same task PostToolUse fired 5×, and +// carries the same tool calls in batch form. Measured at +76% hook +// invocations for a task, against no builtin that reads it. One line to add +// later if a custom policy ever wants batch granularity. +// • `SessionDelete` — no canonical equivalent, and little to enforce on. +export const QWEN_HOOK_EVENT_TYPES = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "PermissionDenied", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", + "InstructionsLoaded", + "UserPromptExpansion", + "TodoCreated", + "TodoCompleted", + "SessionEnd", +] as const; +export type QwenHookEventType = (typeof QWEN_HOOK_EVENT_TYPES)[number]; + +/** + * qwen event name → canonical HookEventType. + * + * Seventeen of nineteen are already canonical and map to themselves; this map + * exists for the two that are not. qwen calls its task list "todos", so + * `TodoCreated`/`TodoCompleted` are the same concept failproofai and Claude + * call `TaskCreated`/`TaskCompleted`, and mapping them lets a policy written + * once fire on both. + * + * These two are also the only ADDED events on either CLI that can actually + * block. qwen runs todo hooks in two phases and the payload says which: + * during `phase: "validation"` a `{decision:"block"|"deny", reason}` prevents + * the write and the reason goes back to the model; during `phase: "postWrite"` + * the todo is already persisted and a block is ignored. Both phases were + * observed live (`phase: "validation"` on every capture). + * + * Exhaustive `Record` so tsc fails the build + * if an event is added here without deciding what it canonicalizes to. + */ +export const QWEN_EVENT_MAP: Record = { + SessionStart: "SessionStart", + UserPromptSubmit: "UserPromptSubmit", + PreToolUse: "PreToolUse", + PostToolUse: "PostToolUse", + PostToolUseFailure: "PostToolUseFailure", + PermissionRequest: "PermissionRequest", + PermissionDenied: "PermissionDenied", + Stop: "Stop", + StopFailure: "StopFailure", + SubagentStart: "SubagentStart", + SubagentStop: "SubagentStop", + PreCompact: "PreCompact", + PostCompact: "PostCompact", + Notification: "Notification", + InstructionsLoaded: "InstructionsLoaded", + UserPromptExpansion: "UserPromptExpansion", + TodoCreated: "TaskCreated", + TodoCompleted: "TaskCompleted", + SessionEnd: "SessionEnd", +}; + +/** + * qwen's runtime tool ids → Claude PascalCase canonical names. All six were + * observed live. qwen also accepts its own display names (`WriteFile`, + * `ReadFile`) as matcher aliases, so those are mapped too for configs written + * against the older names. Unknown tools pass through via the `?? raw` + * fallback. + * + * There is deliberately NO QWEN_TOOL_INPUT_MAP: every tool already delivers + * canonical keys — `run_shell_command` `{command}`, `read_file` `{file_path}`, + * `write_file` `{file_path, content}`, `edit` `{file_path, old_string, + * new_string}`, `grep_search` `{pattern, path}`, `list_directory` `{path}`. + */ +export const QWEN_TOOL_MAP: Record = { + run_shell_command: "Bash", + read_file: "Read", + ReadFile: "Read", + read_many_files: "Read", + write_file: "Write", + WriteFile: "Write", + edit: "Edit", + replace: "Edit", + grep_search: "Grep", + search_file_content: "Grep", + glob: "Glob", + list_directory: "LS", + web_fetch: "WebFetch", + google_web_search: "WebSearch", + task: "Task", + todo_write: "TodoWrite", +}; + export const HOOK_EVENT_TYPES = [ "SessionStart", "SessionEnd",