Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1062,3 +1062,12 @@ are usually marketing / human-PR docs, not agent guidance.
`complete` and emits a single `Finish` chunk.
- **Tool naming collisions** are silent — if you register two tools with the same
`name()`, the second wins. Prefer unique, namespaced names (`fs.read`, `http.fetch`).
- **Wire-shape types are codegen'd to TypeScript.** Rust types crossing the SPA
boundary (REST replies, WS frames) use `#[derive(ts_rs::TS)]` so the frontend
imports a generated `.ts` instead of hand-maintaining a duplicate. Annotations
live on the type in its owning domain crate (`harness-channel`,
`harness-project`, `harness-observability` — never `harness-core`).
Regenerate with `make ts-codegen` after changing an annotated type; the
output under `apps/jarvis-web/src/types/generated/` is committed to git so
the SPA-only build doesn't need a Rust toolchain. See
`docs/conventions/rust-ts-codegen.md`.
41 changes: 38 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ aes = "0.8"
cbc = "0.1"
rand = "0.8"
base64 = "0.22"
# Rust → TypeScript codegen for wire-shape types shared with the
# web frontend. `cargo test --workspace` runs the embedded `export`
# tests `ts-rs` injects for each `#[derive(TS)]` type and writes a
# `.ts` file under `apps/jarvis-web/src/types/generated/`. The
# frontend imports from there instead of hand-maintaining
# duplicates. See `docs/conventions/rust-ts-codegen.md`.
ts-rs = { version = "10", features = ["serde-compat", "no-serde-warnings"] }
open = "5"
tiktoken-rs = "0.6"
libc = "0.2"
Expand Down
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ test: ## Run the workspace test suite
.PHONY: check
check: lint test ## Run clippy + tests, what CI runs

# ---------------------------------------------------------------------------
# Rust → TypeScript type codegen (see docs/conventions/rust-ts-codegen.md)
# ---------------------------------------------------------------------------
# Every `#[derive(TS)]` type emits its own `<TypeName>.ts` under
# `apps/jarvis-web/src/types/generated/` when the embedded export
# test runs. Crates with annotated types today: harness-channel,
# harness-project. Add more by following the convention doc.
#
# Output goes in git so the SPA-only Vite build doesn't need a
# Rust toolchain. `make ts-codegen` is the canonical "I changed a
# wire type, regenerate" target; CI's `make test` covers it as a
# side effect.
.PHONY: ts-codegen
ts-codegen: ## Regenerate TS types from Rust (`#[derive(TS)]`)
$(CARGO) test -p harness-channel -p harness-project --lib --quiet
@printf "\ngenerated:\n"
@ls apps/jarvis-web/src/types/generated/ | sed 's/^/ /'

# ---------------------------------------------------------------------------
# Docker / Compose
# ---------------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions apps/jarvis-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ pub struct Args {
#[arg(long)]
pub no_git_read: bool,

/// Enable the agent-maintained `memory.{list,read,write,delete}`
/// tools (M3.1). Off by default — opt in when you want the CLI
/// session to persist notes under `<workspace>/.jarvis/memory/`
/// and inject MEMORY.md into the system prompt at startup.
/// `memory.write` / `memory.delete` are approval-gated.
#[arg(long)]
pub enable_memory: bool,

/// Enable the P10 git-sync tools (`memory.sync`,
/// `memory.sync_status`). The memory dir must be a git working
/// tree with a configured remote; the tools wrap
/// `git pull --rebase && git push` so notes propagate between
/// machines / teammates. Off by default — only useful once
/// you've actually set up a remote.
#[arg(long)]
pub enable_memory_sync: bool,

/// Enable the `enter_plan_mode` tool so the model can volunteer
/// to switch into Plan Mode before risky changes. Default: on
/// (the CLI's `fs.edit` is on by default, so coding-mode
/// criteria are met). Pass `--no-enter-plan-mode` to disable
/// and keep Plan-Mode entry strictly operator-driven.
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = false)]
pub no_enter_plan_mode: bool,

/// Pipe mode: read the prompt from `--prompt` (or stdin if
/// omitted), run one turn with `AlwaysDeny` so no tool that
/// needs a human can fire, print the final assistant text,
Expand Down
40 changes: 40 additions & 0 deletions apps/jarvis-cli/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ pub(crate) async fn load_project_prelude(needle: &str) -> Result<String> {
))
}

fn resolve_memory_user_root() -> Option<std::path::PathBuf> {
if let Ok(v) = std::env::var("JARVIS_MEMORY_USER_ROOT") {
let trimmed = v.trim();
if trimmed.is_empty() {
return None;
}
return Some(std::path::PathBuf::from(trimmed));
}
std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(std::path::PathBuf::from))
.map(|h| h.join(".jarvis"))
}

fn build_tools(args: &Args, workspace: &Path) -> ToolRegistry {
let cfg = BuiltinsConfig {
fs_root: workspace.to_path_buf(),
Expand All @@ -165,6 +179,21 @@ fn build_tools(args: &Args, workspace: &Path) -> ToolRegistry {
enable_fs_write: args.allow_fs_write,
enable_shell_exec: args.allow_shell,
enable_git_read: !args.no_git_read,
// CLI defaults: enter_plan_mode on (coding REPL benefits from
// the model being able to volunteer a plan-first pass);
// memory tools off until opted in.
enable_enter_plan_mode: !args.no_enter_plan_mode,
enable_memory: args.enable_memory,
// P9: user-scope memory follows the operator across
// workspaces. Default to `~/.jarvis` so the same notes
// are visible from any CLI invocation; `JARVIS_MEMORY_USER_ROOT`
// overrides (e.g. point at a Dropbox path) and an empty
// value disables. No-op when `enable_memory == false`.
memory_user_root: resolve_memory_user_root(),
// P10: git-as-transport sync. No-op when `enable_memory`
// is false (the underlying tree only exists when memory
// tools are registered).
enable_memory_sync: args.enable_memory_sync,
..Default::default()
};
let mut tools = ToolRegistry::new();
Expand Down Expand Up @@ -615,6 +644,17 @@ async fn run_one_turn(
event.reason,
);
}
AgentEvent::ModeChanged { mode } => {
// CLI mirrors the WS handler: surface the
// mode change inline so the operator sees
// why the next turn behaves differently.
if delta_open { println!(); delta_open = false; }
eprintln!(
"{} permission mode → {:?}",
yellow("⇄"),
mode,
);
}
AgentEvent::Error { message } => {
if delta_open { println!(); }
return TurnOutcome::Error(message);
Expand Down
4 changes: 4 additions & 0 deletions apps/jarvis-web/src/components/AppChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
import { ApprovalCard } from "./Approvals/ApprovalCard";
import { BypassBanner } from "./Approvals/BypassBanner";
import { ModeBadge } from "./Approvals/ModeBadge";
import { ModeChangedToast } from "./Approvals/ModeChangedToast";
import { PlanModeBanner } from "./Approvals/PlanModeBanner";
import { PlanProposedCard } from "./Approvals/PlanProposedCard";
import { ModelMenu } from "./ModelMenu/ModelMenu";
import { UsageBadge } from "./UsageBadge";
import { ComposerShoulder } from "./ComposerShoulder";
import { ComposerProjectRail } from "./Composer/ComposerProjectRail";
import { OpenSidebarButton, WorkspacePanelMenu } from "./Workspace/WorkspaceToggles";
import { BackgroundTasksButton } from "./BackgroundTasks/BackgroundTasksButton";
import { pickedRouting } from "../services/socket";
import { slashCommands } from "../services/slash_commands";
import { useAppStore } from "../store/appStore";
Expand Down Expand Up @@ -48,6 +50,7 @@
<ChatHeader />
</div>
<div className="header-actions">
<BackgroundTasksButton />
<WorkspacePanelMenu />
</div>
</header>
Expand All @@ -56,6 +59,7 @@
<BypassBanner />
<PlanModeBanner />
<FallbackBanner />
<ModeChangedToast />

<MessageList />

Expand Down Expand Up @@ -153,7 +157,7 @@
label="Voice input"
onClick={() => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;

Check warning on line 160 in apps/jarvis-web/src/components/AppChatPane.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

Check warning on line 160 in apps/jarvis-web/src/components/AppChatPane.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
if (!SpeechRecognition) {
showBanner("Voice input is not supported by this browser.");
return;
Expand All @@ -162,7 +166,7 @@
recognition.lang = navigator.language || "en-US";
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event: any) => {

Check warning on line 169 in apps/jarvis-web/src/components/AppChatPane.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const transcript = event.results?.[0]?.[0]?.transcript;
if (!transcript) return;
const spacer = value.trim().length ? " " : "";
Expand Down
66 changes: 66 additions & 0 deletions apps/jarvis-web/src/components/Approvals/ModeChangedToast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Renders the M2.3 toast when the server reports a non-user
// mode change. Operator-initiated changes (via:"user" / absent)
// stay silent — verified separately so a future regression that
// pops a toast on every click is caught.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { useAppStore } from "../../store/appStore";
import { ModeChangedToast } from "./ModeChangedToast";

beforeEach(() => {
vi.useFakeTimers();
useAppStore.getState().setRecentModeChange(null);
});
afterEach(() => {
vi.useRealTimers();
});

describe("ModeChangedToast", () => {
it("renders when the change came from the agent (via:tool)", () => {
act(() => {
useAppStore.getState().setRecentModeChange({
mode: "plan",
via: "tool",
at: Date.now(),
});
});
render(<ModeChangedToast />);
expect(screen.getByRole("status")).toHaveTextContent(
/Agent.*switched permission mode to.*plan.*read-only/i,
);
});

it("stays silent for operator-initiated changes (via:user)", () => {
act(() => {
useAppStore.getState().setRecentModeChange({
mode: "auto",
via: "user",
at: Date.now(),
});
});
const { container } = render(<ModeChangedToast />);
expect(container).toBeEmptyDOMElement();
});

it("renders nothing when no recent change is recorded", () => {
const { container } = render(<ModeChangedToast />);
expect(container).toBeEmptyDOMElement();
});

it("can be dismissed via the × button", () => {
act(() => {
useAppStore.getState().setRecentModeChange({
mode: "plan",
via: "tool",
at: Date.now(),
});
});
render(<ModeChangedToast />);
const close = screen.getByLabelText("Dismiss");
act(() => {
close.click();
});
expect(useAppStore.getState().recentModeChange).toBeNull();
});
});
Loading
Loading