Skip to content

feat(harness): wire the permission engine + make Plan Mode enforced - #524

Open
TYRMars wants to merge 29 commits into
mainfrom
claude/project-optimization-harness-2dd590
Open

feat(harness): wire the permission engine + make Plan Mode enforced#524
TYRMars wants to merge 29 commits into
mainfrom
claude/project-optimization-harness-2dd590

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Why

Jarvis's permission and Plan-Mode layer was built on both ends but disconnected in the middle — the same "ported but never wired" shape as the earlier HITL work:

  • RuleApprover / MemoryPermissionStore / modeDefault / the glob matcher were complete and unit-tested in packages/server/src/permissions-routes.ts and exported from @jarvis/server — but constructed nowhere. So JARVIS_PERMISSION_MODE was parsed, logged at startup, and reported in /v1/server/info while no approver ever read it: auto, bypass and plan all behaved exactly like ask, and /v1/permissions* always 503'd.
  • ToolRegistry.specsFiltered was dead code and Tool.isTerminal was metadata the agent loop never read, so exit_plan did not end a turn.
  • The web SPA already shipped ModeBadge, PlanModeBanner, PlanProposedCard, BypassBanner and DecisionSourceChip, and sent set_mode / accept_plan / refine_plan — the server answered unknown frame type.

Net effect before this change: an operator who set JARVIS_PERMISSION_MODE=plan got no protection at all, and one who set auto got no convenience.

Closes P1 item 5 of docs/proposals/opencode-borrow.zh-CN.md.

What changed

@jarvis/core

  • New mode.ts — the AgentMode vocabulary plus an AsyncLocalStorage mode-signal channel shaped like the existing plan.ts / hitl.ts.
  • AgentConfig.toolFilter is applied twice: once to build the request catalogue (specsFiltered), and again at dispatch in runOne. The second application is the load-bearing one — it's what makes Plan Mode a guard rather than a hint, since a write tool named from conversation history or hallucination is refused instead of run.
  • Tool.isTerminal now ends the turn on both the blocking and streaming paths, skipping the rest of the batch and not returning to the model. A denied terminal call does not terminate, since it never ran.

@jarvis/server

  • Each chat socket builds a SocketModeHandle (seeded from the store's persisted default_mode) and a RuleApprover wrapping its ChannelApprover.
  • New frames in: set_mode / accept_plan / refine_plan. New frames out: permission_mode{mode,via?} / plan_proposed{plan}, plus HitSource attached to approval_decision.
  • Turn frames go to the client and into the chatRuns reconnect replay buffer in the same shape, so a replaying client sees what a live one saw.
  • PermissionMode becomes an alias of core's AgentMode so the two vocabularies can't drift.

packages/jarvis-app — constructs MemoryPermissionStore(config.permissionMode) onto AppState, so the env var gates real dispatch and the REST routes stop 503-ing.

Reviewer notes

Two judgement calls worth a look:

  1. Auto-allowed calls still emit an approval_request card. Core yields the request event before awaiting the approver, so it can't know the approver will approve instantly. Suppressing it would make auto-allowed tool calls invisible in the audit trail, so provenance rides on the decision frame instead (source: {kind: "mode_default" | "rule" | "user_prompt"}) — which is exactly what the pre-existing DecisionSourceChip was built to render.
  2. mode_changed is not forwarded raw. The WS applies it to its own mode handle and republishes as permission_mode{via:"tool"}, which is what the client actually listens for.

Behaviour change to be aware of: anyone currently running with JARVIS_PERMISSION_MODE=auto or bypass will now genuinely skip approval prompts. That is the documented intent of the flag, but it was inert until this commit.

Bugs fixed along the way

  • @jarvis/core was the only one of 25 packages with a hand-listed test script instead of the src/*.test.ts glob, so hitl.test.ts from the P0 HITL work had never run in CI. Switched to the glob; core goes 32 → 50 tests, all passing.
  • The WS test harness attached its message listener after awaiting open, silently dropping any frame the server sends on connect. Pre-existing latent flaw, exposed by the new permission_mode frame; fixed to match real browser client ordering.

Not in scope

  • POST /v1/permissions/rules doesn't broadcast permission_rules_changed to live sockets — that needs a socket registry, and the web already works around it with a local version counter.
  • The blocking /v1/chat/completions and SSE routes still call createAgent() with no approver, so gated tools run unconditionally there. That's the documented historical default rather than something this change introduces, but it does mean the permission engine covers the WS surface only.

Testing

make check clean — typecheck + eslint + 2161 tests, zero failures.

New coverage:

  • packages/core/src/mode.test.ts (12): catalogue filtering; dispatch refusal on both paths; filtered tools never reaching the approver; isTerminal stopping the turn and skipping the rest of the batch on both paths; denied terminal tools not terminating; the mode channel (no-op without a sink, delivery, relay as mode_changed); isAgentMode boundaries.
  • packages/server/src/server.test.ts (9 WS): mode announced on connect; set_mode echo + invalid-value error; auto resolving a gated tool with no operator input tagged mode_default; a deny rule blocking with source.kind=rule; plan refusing a write tool without prompting; exit_planplan_proposedaccept_plan switching mode and resuming; the model calling enter_plan_mode; refine_plan empty-feedback error; graceful degradation with no permission store.

Branch note

This branch sits 29 commits ahead of main; 28 of those are pre-existing unmerged work from earlier sessions (P8 Rust decommission, opencode-borrow P0, desktop shell polish). This session's change is the single commit dd3d63a — review that one if the rest has already been reviewed elsewhere.

🤖 Generated with Claude Code

TYRMars and others added 29 commits June 20, 2026 22:42
…odegen (P7.9)

Sever the web SPA's last build-time dependency on Rust — the load-bearing
prerequisite for decommissioning the Rust server. Wire-shape types crossing
the SPA boundary were generated by `ts_rs` into apps/jarvis-web/src/types/
generated/; that codegen is removed and the types now live Node-side.

- New @jarvis/shared-types: a pure type-only leaf (no runtime, no deps, single
  file with no relative imports) hosting the channel + workflow wire types the
  SPA actually imports. Consumable by both NodeNext (packages) and Bundler
  (standalone web) resolvers.
- @jarvis/workflow and @jarvis/channel re-export the types from shared-types
  and keep their runtime constructors/validators. ChannelInstance.config is now
  Record<string, unknown> (the accurate "config is a JSON object" wire shape),
  collapsing the server-side JsonValue bridging.
- Web SPA: tsconfig paths + vite alias to packages/shared-types/src; services/
  {workflows,channels}.ts repointed; all 29 generated/*.ts deleted. Fixed three
  call sites that set optional step fields to null (the real wire omits them).
- Rust: stripped #[derive(TS)] + #[ts(export/type)] + use ts_rs from
  harness-channel/harness-project/harness-workflow, the ts-rs workspace dep, the
  Makefile ts-codegen target, and updated CLAUDE.md + the convention docs.

Verified: cargo check + clippy -D warnings + cargo test (workspace) green;
pnpm -r typecheck + test green (24 packages, 0 fail); web tsc -b && vite build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion)

Close a Rust-decommission blocker from the P8.1 contract audit: the web
Settings → Tools section (services/tools.ts) calls GET /v1/tools and
PATCH /v1/tools/:name, which the Node server didn't serve. End-to-end real
feature — the registry's mute set is shared with createAgent, so muting a tool
hides it from the next turn's LLM catalogue.

- @jarvis/core: new tool-metadata.ts porting harness-core/src/tool_metadata.rs —
  ToolSource/ToolPackCategory/ToolRisk/ToolMetadata (camelCase fields,
  kebab-case enum values), derivePack + deriveRisk name-convention heuristics,
  toolMetadataFromTool + toolMetadataToWire (omits defaults like the Rust serde
  skips). ToolRegistry gains allNames/contains/resolveUnchecked/isMuted/
  mutedCount (the catalog needs muted tools; `contains` is the 404 check that
  `has` — which excludes muted — can't be).
- @jarvis/server: tools-routes.ts (GET catalog incl. muted, subagent.* source
  reclassify, source/pack/risk facet filters, sort by name; PATCH mute toggle →
  {name,enabled,muted_count}, 404 unknown, 400 non-boolean). AppState gains the
  shared `tools` registry handle; registered in server.ts.
- jarvis-app wires state.tools = toolBundle.registry (same instance createAgent
  builds from).

Verified: pnpm -r typecheck green; @jarvis/core 30 tests, @jarvis/server 466
tests (+5), eslint clean. Matches the web services/tools.ts wire contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Durable, prioritized P8.1 audit so the Rust-decommission can proceed across
sessions: docs/proposals/rust-decommission-p8-gaps.md (70 gaps deduped into
blockers / operator-only, each with the consuming client, port-size, and order).
Update the tasklist P8.1 line to reference it and record what's landed
(shared-types/ts_rs removal, GET/PATCH /v1/tools, full audit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (P8.1)

Close the top Rust-decommission blocker — the one that breaks TWO clients (web
sidebar + iOS) with a silent wrong shape, not a 404. Node returned the bare
ConversationStore.list(); the web ConvoListRow + iOS ConversationSummary expect
enriched rows.

GET /v1/conversations now derives, per row:
- title — first user message's first line (skips the auto-mode seed prompt,
  capped at 60 chars + ellipsis), mirroring first_user_title.
- source + requirement_id/title/status — joined from the RequirementStore (the
  most-recently-updated requirement whose conversation_ids lists the row).
- workspace_path — WorkspaceStore.lookup.
- lifecycle — carried through; plus ?lifecycle= and ?project_id= (slug/uuid)
  facet filters (unknown lifecycle → 400, unknown project → 404).

Also adds POST /v1/conversations/:id/lifecycle (returns {id,lifecycle,
previous_lifecycle}; abandoning best-effort-cancels the conversation's in-flight
requirement runs) and GET /v1/conversations/:id/work-context (primary
requirement + latest run + recent activities, degrades to nulls when unbound).
The bare-array contract + __-internal-id filtering are preserved.

Verified: @jarvis/server typecheck + 471 tests (+5), all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(P8.1)

Port the live git-state probes the web WorkspaceBadge + folder picker poll
(Rust workspace_snapshot). Reuse the existing workspace-routes git helpers
(resolveWorkspace / runGitOk / filterBranch). Returns {root, vcs, branch?,
head?, dirty?}; non-git roots report vcs:"none". GET /v1/workspace uses the
pinned root (503 when unset); /probe?path= inspects a candidate folder
(400 without a path).

Verified: @jarvis/server typecheck + tests (workspace 23, +3; git-repo test
skips cleanly without git on PATH).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…8.1)

Port the in-process chat-run registry the web polls for the turn-status badge,
reconnect event replay, and the Stop button (Rust chat_runs.rs, MVP subset).

- ChatRunRegistry (packages/server/src/chat-runs.ts): per persisted-conversation
  run record (status / started_at / latest_seq / current_tool / last_error) +
  a count-capped event ring buffer + a per-turn AbortController. Status is
  derived from the AgentEvent stream (tool_start→current_tool, approval_request→
  waiting_approval, …); terminal status is sticky; terminal runs are retained
  briefly then evicted.
- Routes: GET /v1/chat/runs (bare array — the web decodes `as ServerChatRun[]`;
  ?active drops terminal), GET /v1/chat/runs/:id/events?after= (bare array),
  POST /v1/chat/runs/:id/interrupt (404 when no active run).
- WS turn loop now starts a run, buffers every event, finishes completed/failed,
  and races the agent stream against the abort signal so an interrupt stops
  emission promptly and marks the run cancelled. AppState.chatRuns wired from
  jarvis-app.

Interrupt is cooperative (documented): it stops emission + marks cancelled but
does not hard-cancel the in-flight LLM call (no tokio::abort analogue) — matches
the audit's noted limitation. MVP also omits the live broadcast channel + byte
budget (poll-only, count cap).

Verified: @jarvis/server typecheck + 480 tests (+6); pnpm -r typecheck green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the dynamic MCP manager + REST surface the web Settings → McpSection drives
(Rust harness-mcp McpManager + mcp_routes.rs). The largest greenfield gap from
the contract audit — Node had no /v1/mcp surface at all.

- @jarvis/core: ToolRegistry.unregister / unregisterPrefix (the manager removes
  a server's whole `<prefix>.*` tool set on remove/reload).
- @jarvis/server: McpManager (packages/server/src/mcp-manager.ts) tracks servers
  by prefix over the SHARED ToolRegistry — add() connects + collision-checks +
  registers tools, remove() unregisters + shuts the client, health() probes,
  replace()/reload() restart (reload restores a Stopped slot on failure).
  Mutations serialize through a promise chain; a `connect` seam keeps it
  testable without spawning child processes. Wire config is snake_case
  (allow_tools/…) <-> internal camelCase via wireToConfig/configToWire.
- Routes (mcp-routes.ts): GET/POST /v1/mcp/servers, GET/PUT/DELETE
  /v1/mcp/servers/:prefix, POST /:prefix/health (always 200, {ok} in body),
  POST /:prefix/reload — error codes 409 conflict / 400 bad / 404 missing.
- jarvis-app now boots the configured JARVIS_MCP_SERVERS THROUGH the manager
  (replacing the direct connectAllMcp) so startup servers are listable +
  reloadable; AppState.mcpManager wired. @jarvis/server gains an @jarvis/mcp dep.

Verified: pnpm -r typecheck green; @jarvis/server 489 tests (+9 mcp), eslint
clean. Matches the web services/mcp.ts wire contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the gap inventory + tasklist: GET /v1/conversations enrichment (+work-
context/lifecycle), chat-runs, workspace git reads, and the MCP manager are done.
Re-order the remaining work (server/info, providers probe, routing, workspace
commit/PR, memory-sync) and note P8.2 cutover as the next phase gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ame/probe (P8.1)

Three operator/meta routes the web Settings·ServerSection / ProvidersSection +
SDK depend on (Rust routes.rs get_server_info/probe_provider, openapi.rs version).

- AppState gains a config-derived `serverInfo` snapshot (no secrets) + the
  configured `provider`. jarvis-app builds the snapshot from JarvisConfig.
- GET /v1/server/info merges the static snapshot with LIVE tools (registry),
  mcp prefixes (manager), and the provider catalog so runtime mute/mcp changes
  show on refresh. GET /v1/version → {name:"jarvis", version}.
- POST /v1/providers/:name/probe pings the provider with a 1-token completion
  (auth_ok/default_model_ok), optionally a tool-spec request (supports_tool_calls);
  always 200 with the ProbeResult in-body, matching the web contract.

Verified: @jarvis/server 493 tests (+4), jarvis-app 19, typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the operator model route-policy CRUD the web Settings·RoutingSection drives
(Rust route_policy.rs + routes.rs). NOT hollow: the `summarization` slot is wired
into SummarizingMemory, mirroring the Rust LlmRouteResolver.

- @jarvis/server: RoutePolicy/ModelTarget types + RoutePolicyStore (mutable
  in-process holder, snapshot omits empty slots, parseModelTarget for
  "provider/model"); routing-routes.ts (GET/PUT /v1/routing, PATCH/DELETE
  /v1/routing/:slot with slot + target validation → 400). AppState.routePolicy.
- @jarvis/memory: SummarizingMemory.withModelResolver() — an optional per-call
  model override; compact() uses it over the constructor model.
- jarvis-app: parse JARVIS_ROUTE_* into config.routeSlots, seed one shared
  RoutePolicyStore at boot, hand it to both the routes (AppState) and the
  summariser resolver, so a /v1/routing PATCH takes effect on the next summary.

Verified: routing 4 tests; @jarvis/server 497 (+4), memory 41, jarvis-app 19;
typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the workspace mutation cluster the web Commit/CreatePr dialogs use (Rust
workspace_diff.rs). Reuses the existing workspace-routes git helpers (resolveWorkspace
/ runGit-with-timeout / safeBranch / filterBranch).

- POST /v1/workspace/commit — stage-all (git add -A), guard "nothing staged",
  git commit -m <message via argv>, optional `git push -u origin <branch>`
  (60s net timeout; detached-HEAD + push failure surface as push_error, the
  commit still succeeds). → {ok, head, pushed, push_error}.
- GET /v1/workspace/pr/preview — suggested title (top commit subject, else a
  humanised branch name) + body (commit bullets) + gh availability probe. →
  {branch, base, gh_available, suggested_title, suggested_body}.
- POST /v1/workspace/pr — gh-check (400 + install hint when absent), push the
  branch, `gh pr create --base --title --body [--draft]` (always --body to dodge
  $EDITOR). → {ok, url, draft}.

Verified: @jarvis/server 500 tests (+3; git-repo cases skip without git on PATH),
typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the RequirementDetail checklist + link routes (Rust requirements_routes.rs).
TODOs are embedded in the Requirement row, so all CRUD is a read-modify-write +
Activity record (no separate store).

- GET /v1/requirements/:id/todos → {requirement_id, items}
- POST /v1/requirements/:id/todos → create (title/kind/status/command/created_by
  validated via *FromWire) → 201 {todo, requirement}
- PATCH /v1/requirements/:id/todos → batch status update by ids in ONE
  read-modify-write (avoids the lost-update race of N per-item PATCHes)
- PATCH /v1/requirements/:id/todos/:todo_id → update one item
- DELETE /v1/requirements/:id/todos/:todo_id → remove (idempotent)
- POST /v1/requirements/:id/conversations → idempotent conversation link

Verified: @jarvis/server 501 tests (+1), typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on (P8.1)

Port the requirement-run verification routes (Rust requirements_routes.rs +
verification.rs). The web RequirementDetail "Verify" button uses /verify.

- New packages/server/src/verification.ts: executePlan(workspace, plan, timeout)
  runs each command via `sh -c` / `cmd /C` in the run's workspace, captures
  exit_code + truncated stdout/stderr (16 KiB), aggregates Failed if any command
  exits non-zero or times out, else NeedsReview when require_human_review, else
  Passed.
- POST /v1/runs/:id/verify executes the plan (resolves workspace: worktree →
  bound workspace → root; 30s default per-command timeout), POST
  /v1/runs/:id/verification attaches an externally-computed result. Both share
  applyVerification (terminal flip passed→completed / failed→failed, upsert,
  Verified + Finished broadcasts, verification_finished + run_finished Activity).

Verified: @jarvis/server 503 tests (+2; verify-exec skips on Windows),
typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The git/iCloud memory-sync + include-directive subsystem (Rust
memory_sync_routes.rs) is a substantial port deferred for now. Register the 8
routes (sync_status/sync/sync_setup/sync_setup_icloud + includes GET/POST/DELETE/
refresh) returning 503 "not configured" instead of 404 — the web
MemorySyncSection + MemoryIncludesPanel are 503-aware and degrade to a "sync not
configured" state, so the Rust-decommission cutover stays non-breaking. Porting
the real subsystem is tracked in docs/proposals/rust-decommission-p8-gaps.md.

Verified: @jarvis/server 504 tests (+1), typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…time

Node reached blocker-level /v1 parity with the Rust server (504 server tests
green; conversations enrichment, tools, chat-runs, mcp, routing, server-info,
providers-probe, workspace reads + commit/PR, requirement todos + verify all
ported; memory-sync graceful-stubbed). With the cutover gate green
(pnpm -r typecheck + test, web build), retire Rust.

Removed (history preserved; tag `rust-archive-pre-takedown`):
- crates/harness-* (20 crates), apps/jarvis, apps/jarvis-cli, apps/jarvis-desktop
  (Tauri), Cargo.toml, Cargo.lock.
- Rust CI: .github/workflows/{rust,release,desktop-release}.yml (node*/ios kept).

Repointed infra to Node:
- Makefile: cargo → pnpm (typecheck/lint/test/dev) over the workspace.
- Dockerfile: multi-stage Node (web bundle + pnpm deps + `node
  --experimental-strip-types packages/jarvis-app/src/main.ts serve`). NOTE:
  needs a real `docker build` verification — not covered by the unit gate.
- docker-compose: backend chosen from JARVIS_DB_URL scheme (no cargo features).
- CLAUDE.md: decommission banner + Commands rewritten to Node; the architecture
  sections remain a valid map via crate ↔ @jarvis/* (full prose rewrite = P8.5).

Remaining P8: 8.3 perf/load compare, 8.4 OTel verify, 8.5 docs rewrite,
8.6 security baseline; plus the full memory-sync subsystem (gaps doc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the last P8 decommission gap: the `/v1/memory/sync*` +
`/v1/memory/includes*` routes go from graceful 503 stubs to functional,
backed by ported `memory.{sync,sync_setup,sync_setup_icloud,sync_status}`
tool impls (from crates/harness-tools/src/memory_sync.rs).

- @jarvis/tools: 4 sync tools + MemorySyncBackend + iCloud root helpers,
  reusing the existing protocol-guarded runGit + validateGitUrl; remote/
  branch arg validation rejects flag-smuggling.
- @jarvis/server: AppState.memoryRuntime ({workspaceRoot,userRoot?,backend});
  memory-sync-routes.ts invokes the tool impls directly per request
  (mirrors the Rust REST contract: backend-mismatch 503s, 400 on tool err).
- composition root: JARVIS_ENABLE_MEMORY / JARVIS_MEMORY_USER_ROOT /
  JARVIS_MEMORY_SYNC_BACKEND parsed in config; agent-side memory.* tools
  registered when enabled, sync set adapts to backend.
- tests: git roundtrip (setup→status→sync→force) over a local bare remote,
  backend parsing, arg guards, + route-level 503/400/envelope coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarially-verified 6-dimension audit (27 confirmed / 27 dismissed).
Fixes the clear high/critical issues; accepts the unauthenticated /v1
surface as designed (local-first) and documents deferred hardening.

- OAuth: auth.json written 0o600; refresh errors no longer echo the
  response body (token-leak) — packages/llm/src/codex-auth.ts.
- http.fetch SSRF guard: scheme allowlist + loopback/private/link-local/
  metadata block, default-on, JARVIS_HTTP_ALLOW_PRIVATE opt-out; strips
  set-cookie/www-authenticate response headers — packages/tools/src/http.ts.
- code.grep: 1000-char regex cap (ReDoS blast-radius); sandbox: 256
  path-depth cap (syscall DoS).
- WeCom inbound: opt-in replay_window_secs timestamp validation.
- docs/security/p8-security-baseline.md records fixed/accepted/deferred.
- chore: drop a now-unused eslint-disable in meta-routes.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jarvis/core emits jarvis.agent.run + gen_ai.tool.call spans via
@opentelemetry/api — a no-op until a TracerProvider is registered, so
core stays a leaf and the env-free rule holds. The SDK/exporter bootstrap
lives in the jarvis-app composition root, off unless enabled.

- core/src/tracing.ts: startAgentSpan / endAgentSpan / withToolSpan;
  run() nests tool spans via active context, runStream() threads the
  agent span in explicitly (ambient context isn't kept across yields).
  Tool spans wrap the raw tool.invoke so thrown errors are recorded.
- jarvis-app/src/otel.ts: NodeSDK + OTLP/HTTP (or ConsoleSpanExporter for
  JARVIS_OTEL_CONSOLE) bootstrap; gated on JARVIS_OTEL_ENABLED /
  JARVIS_OTEL_CONSOLE / OTEL_EXPORTER_OTLP_ENDPOINT. Wired into runServe
  with SIGTERM/SIGINT flush.
- verify: in-memory-exporter test asserts both span names + attributes
  (model, iterations, tool name/id) from a real Agent.run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rust is gone, so there's no cross-runtime comparison; establish the Node
harness's own overhead as a reference. scripts/perf-baseline.ts (run via
`make perf`) measures streaming throughput + TTFT, blocking-loop turn
latency, and memory with a deterministic in-process stub provider.

Reference (node 22, arm64): ~4.3M chunks/sec relay, sub-µs turn latency,
~80MB RSS — harness overhead is negligible vs provider/network latency.
Documented in docs/observability/perf-baseline.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lose-out

- CLAUDE.md: banner → "P8 complete"; document JARVIS_ENABLE_MEMORY /
  MEMORY_USER_ROOT / MEMORY_SYNC_BACKEND, JARVIS_HTTP_ALLOW_PRIVATE, and
  the OTel knobs; expand the /v1/memory/* REST surface; drop dead RUST_LOG.
- rust-decommission-p8-gaps.md: backfill the blocker table ☐→✅ (all gaps
  closed, no stubs).
- nodejs-rewrite-tasklist.zh-CN.md: mark 8.1/8.3/8.4/8.5/8.6 done.
- .env.example: add memory/OTel/SSRF knobs; remove the dead RUST_LOG block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Asserts AppState.memoryRuntime is absent by default and populated
(workspaceRoot/userRoot/backend) when JARVIS_ENABLE_MEMORY +
JARVIS_MEMORY_SYNC_BACKEND are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Node-native

Replace the Rust-era framing across the top-level docs with what the repo
actually is — a Node/TypeScript pnpm workspace (no cargo/crates/Cargo.toml/
RUST_LOG; JSON-file persistence by default, not SQLite). Commands switch to
make/pnpm/`node --experimental-strip-types`; the architecture tree maps to
packages/* (@jarvis/*) + apps/*; env tables list only vars that exist in
config.ts. Adds the P8 surfaces (git/iCloud memory sync, OTel, http.fetch
SSRF guard) + links to the security + perf baselines. README.zh-CN is a
faithful translation of the new English. docker-compose: fix the stale
"--features postgres" comment (backend is URL-scheme-selected at runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up Rust-decommission cleanup:
- rm -rf target/ (134 GB of dead Rust build output; gitignored, not committed).
- Delete the broken cargo-era scripts superseded by the Makefile: start.sh,
  scripts/{build,dev,install}.sh (kept fix-node-pty.mjs + perf-baseline.ts).
- AGENTS.md (auto-loaded agent context): Node banner + Node-accurate overview/
  layout/commands/conventions; replace the stale 170-line Rust architecture
  duplicate with a pointer to CLAUDE.md (the authoritative version); drop dead
  RUST_LOG / --mcp-serve flag form.
- Fix broken `cargo run -p jarvis` run instructions → the node entrypoint in
  DB.md, apps/jarvis-ios/{README.md,bootstrap.command}, apps/jarvis-web/README.md.
- DB.md "adding a backend" → @jarvis/store TS paths; App.tsx comment → ui.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Borrows two loop-closing capabilities from opencode (see docs/proposals/opencode-borrow.zh-CN.md):

P0.1 HITL — wire the pause→interact loop the web UI already shipped. New
@jarvis/core HITL channel (requestHuman/withHitl + HumanLayer/ChannelHuman,
symmetric with the approval gate); agent loop emits hitl_request/hitl_response
between tool_start/tool_end; ask.text drops its deferred stub; chat WS builds a
per-socket ChannelHuman + handles the flat hitl_response frame and echoes it.

P0.2 LSP diagnostics — close the edit→verify loop. New dependency-free
@jarvis/lsp package (push-diagnostics only: spawn + hand-rolled Content-Length
JSON-RPC + settle window; PATH-probed, no auto-download). fs.{write,edit,patch}
append an errors-only <diagnostics> block via an injected best-effort hook;
gated by JARVIS_ENABLE_LSP. Composition root threads the OS PATH so the library
stays env-free.

Tests: core 32, lsp 8, tools 282, server 510, jarvis-app 25 — all green;
typecheck + eslint clean. End-to-end HITL frame round-trip + mock-LSP-server
diagnostics proven deterministically.

Also finalizes pre-existing feat/p8-finish WIP already in the working tree
(MCP/Composio config refactor in @jarvis/mcp + jarvis-app, mcp-manager, docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wn (P0.4)

P0.3 reactive context compaction:
- resolveMemoryBudget() derives a default compaction budget from the model's
  catalog contextWindow (budget = ctx - clamp(0.2*ctx, 4k, 20k)) when
  JARVIS_MEMORY_TOKENS is unset, instead of installing no memory at all;
  unknown models keep the historical no-memory behaviour. buildMemory +
  serverInfo.memory.budget_tokens use it.
- SummarizingMemory's DEFAULT_SUMMARY_PROMPT swapped from a free paragraph to
  opencode's structured anchor template (Goal/Constraints/Progress/Decisions/
  Next Steps/Critical Context/Relevant Files) for far better recall; summary
  cap 400->800. (Prior-summary update-merge deferred: the shared single-slot
  cache can't safely source a per-conversation prior summary.)

P0.4 native web read:
- http.fetch gains format:"markdown" -> converts HTML responses to clean
  Markdown via node-html-markdown (Node-native, no jsdom, lazy-imported);
  non-HTML (JSON/XML/text) passes through (looksLikeHtml gate). SSRF guard +
  sensitive-header stripping preserved.
- web.search: reuse the existing @jarvis/mcp bridge (point JARVIS_MCP_SERVERS
  at a search MCP server) rather than building a redundant native tool;
  documented in CLAUDE.md + the proposal.

Tests: tools 287, memory 42, jarvis-app 27 — all green; typecheck + eslint
clean. (Repo-wide: the only failures are @jarvis/store sqlite tests, an
environmental better-sqlite3 ABI mismatch from a Node 22/23 split in this
machine's pnpm vs test runtime — unrelated to these changes, not committed,
CI-unaffected.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	CLAUDE.md
#	Makefile
#	docs/proposals/nodejs-rewrite-tasklist.zh-CN.md
#	docs/proposals/rust-decommission-p8-gaps.md
#	packages/jarvis-app/src/config.ts
#	packages/jarvis-app/src/state.ts
#	packages/jarvis-app/src/tools.ts
#	packages/server/src/chat-routes.ts
#	packages/server/src/mcp-manager.test.ts
#	packages/server/src/mcp-manager.ts
#	packages/server/src/memory-sync-routes.test.ts
#	packages/server/src/memory-sync-routes.ts
#	packages/server/src/meta-routes.test.ts
#	packages/server/src/state.ts
Land accumulated opencode-borrow-p1 web SPA work plus this round of
macOS desktop-shell (.jarvis-desktop-shell) fixes:

- Glass invariant: glass sidebar + fully-opaque content on every route
  (auto-mode/worktrees panes added to the opaque lists, opaque dark
  --desktop-main-bg, opaque workspace rail).
- Sidebar restyled to the Codex reference: 16px text, airier rows; fix
  the #new-convo shortcut wrap; theme-aware nav-shortcut pills.
- Composer/welcome polish: flush-left toolbar, readable placeholder,
  legible "connected" cards; removed the three welcome connect cards and
  re-centered the heading.
- Removed the wallpaper strip above the chat pane.
- Full-screen-aware traffic-light inset: Electron main toggles a
  jarvis-fullscreen class so the sidebar top bar drops the reserved
  padding when macOS hides the traffic lights.
- Ignore local output/ test/debug artifacts.

Verified green: web eslint (0 errors) + 386 vitest tests, packages
typecheck, @jarvis/desktop 16 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The permission/Plan-Mode layer was built on both ends but disconnected in
the middle — the same "ported but never wired" shape as the earlier HITL
work:

* `RuleApprover` / `MemoryPermissionStore` / `modeDefault` / the glob
  matcher were complete and unit-tested in `permissions-routes.ts` and
  exported from `@jarvis/server`, but constructed nowhere. So
  `JARVIS_PERMISSION_MODE` was parsed, logged and reported in
  `/v1/server/info` while no approver read it: `auto`, `bypass` and
  `plan` all behaved exactly like `ask`, and `/v1/permissions*` always
  503'd.
* `ToolRegistry.specsFiltered` was dead code and `Tool.isTerminal` was
  metadata the agent loop never read, so `exit_plan` did not end a turn.
* The web SPA already shipped ModeBadge / PlanModeBanner /
  PlanProposedCard / DecisionSourceChip and sent `set_mode`,
  `accept_plan` and `refine_plan` — the server answered
  `unknown frame type`.

@jarvis/core
  New `mode.ts`: the `AgentMode` vocabulary plus an AsyncLocalStorage
  mode-signal channel shaped like `plan.ts` / `hitl.ts`.
  `AgentConfig.toolFilter` is applied twice — once to build the request
  catalogue via `specsFiltered`, and again at dispatch in `runOne`. The
  second application is what makes Plan Mode a guard rather than a hint:
  a write tool named from conversation history or hallucination is
  refused instead of run. `isTerminal` now ends the turn on both the
  blocking and streaming paths, skipping the rest of the batch; a denied
  terminal call does not terminate, since it never ran.

@jarvis/server
  Each chat socket builds a `SocketModeHandle` seeded from the store's
  persisted `default_mode` and a `RuleApprover` wrapping its
  `ChannelApprover`. Handles `set_mode` / `accept_plan` / `refine_plan`;
  emits `permission_mode` / `plan_proposed` and attaches `HitSource` to
  `approval_decision`. Turn frames go to the client and the reconnect
  replay buffer in the same shape. `PermissionMode` becomes an alias of
  core's `AgentMode` so the two cannot drift.

packages/jarvis-app
  Constructs `MemoryPermissionStore(config.permissionMode)` and puts it
  on `AppState`, so the env var gates real dispatch.

Two judgement calls: auto-allowed calls still emit an `approval_request`
card, because suppressing it would make them invisible in the audit
trail — provenance rides on the decision frame instead. And
`mode_changed` is not forwarded raw; the WS applies it and republishes
as `permission_mode{via:"tool"}`, which is what the client listens for.

Also fixes two bugs found on the way:

* `@jarvis/core` was the only one of 25 packages with a hand-listed test
  script instead of the `src/*.test.ts` glob, so `hitl.test.ts` from the
  P0 work had never run in CI. Core goes 32 -> 50 tests.
* The WS test harness attached its `message` listener after awaiting
  `open`, silently dropping any frame sent on connect. Pre-existing;
  exposed by the new `permission_mode` frame.

Closes P1 item 5 of docs/proposals/opencode-borrow.zh-CN.md.
`make check` clean: typecheck + eslint + 2161 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant