Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cddf406
fix(computer): declare all input fields in schema + fix element_at re…
Jun 9, 2026
9e49dfa
fix(computer): prune element_at hit-test + correct osa error classifi…
Jun 9, 2026
fde76a1
fix(computer): make wait_for honor its own timeout_ms
Jun 9, 2026
c84ad17
fix(computer): wait_for depth 8 + include description text for better…
Jun 9, 2026
f852903
feat(keymap): discover machine key bindings into a snapshot
Jun 9, 2026
35b4328
feat(keymap): detect keybinding conflicts + add /keys command
Jun 9, 2026
2f37c16
feat(keymap): one-time startup notice for keybinding conflicts
Jun 9, 2026
2ef2d00
refactor(keymap): extract pure conflict-hint debounce decision + test
Jun 9, 2026
043aeae
test(keymap): cover full conflict-hint path (warn/debounce/resolve)
Jun 9, 2026
0fde2dc
docs: document keybinding conflict detection and /keys
Jun 9, 2026
69a7202
docs(scrollwm): integration exploration + synthesis plan
Jun 26, 2026
f752d20
feat(onboarding): show 'Searched, not found' sources under login deci…
Jun 26, 2026
d1f61b3
test(onboarding): inject welcome kind in render tests for not-found p…
Jun 26, 2026
bf96e93
feat(onboarding): opt-in to install ScrollWM during first-run setup
Jun 26, 2026
4ab63b4
feat(scrollwm): add jcode-scrollwm control-client crate
Jun 26, 2026
aa28116
feat(swarm): focus headed swarm-agent windows via ScrollWM (opt-in)
Jun 26, 2026
8055c8c
docs(scrollwm): cross-repo CONTRACT.md + plan status; fix loopback te…
Jun 26, 2026
1677d56
docs(readme): document ScrollWM window-management integration for hea…
Jun 26, 2026
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
14 changes: 14 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ members = [
"crates/jcode-swarm-core",
"crates/jcode-protocol",
"crates/jcode-selfdev-types",
"crates/jcode-scrollwm",
"crates/jcode-session-types",
"crates/jcode-setup-hints",
"crates/jcode-storage",
Expand Down Expand Up @@ -226,6 +227,7 @@ jcode-plan = { path = "crates/jcode-plan" }
jcode-swarm-core = { path = "crates/jcode-swarm-core" }
jcode-protocol = { path = "crates/jcode-protocol" }
jcode-selfdev-types = { path = "crates/jcode-selfdev-types" }
jcode-scrollwm = { path = "crates/jcode-scrollwm" }
jcode-session-types = { path = "crates/jcode-session-types" }
jcode-setup-hints = { path = "crates/jcode-setup-hints" }
jcode-storage = { path = "crates/jcode-storage" }
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ Spawn two or more agents in the same repo, and they will automatically be manage

Agents are also able to spawn their own swarms autonomously. They have a swarm tool which allows them to spawn in their own teamates to accomplish tasks in parallel. Doing so turns the main agent into a coordinator and the spawned agents into workers. Groups of agents, their messaging channels, their completion statuses, etc are all automatically managed. This can be done headlessly or headed.

### Window management on macOS: ScrollWM

When you spawn **headed** swarm agents on macOS, jcode can tile them with [ScrollWM](https://github.com/1jehuang/scrollwm), a scrolling window manager (PaperWM-style). With `agents.scrollwm.enabled = true` (and ScrollWM running), jcode focuses each freshly spawned agent's window in the scrolling strip so a wall of agent terminals stays navigable. It's best-effort: a no-op when ScrollWM isn't installed, and it never rearranges your desktop unless you opt into `arrange_on_spawn`.

jcode's first-run onboarding offers to install ScrollWM for you (one permission: Accessibility). Or install both at once: `brew install --cask 1jehuang/jstack/jstack`. See [`docs/scrollwm-integration/CONTRACT.md`](docs/scrollwm-integration/CONTRACT.md) for the integration details.

---

## OAuth and Providers
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ jcode-plan = { path = "../jcode-plan" }
jcode-swarm-core = { path = "../jcode-swarm-core" }
jcode-protocol = { path = "../jcode-protocol" }
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
jcode-scrollwm = { path = "../jcode-scrollwm" }
jcode-session-types = { path = "../jcode-session-types" }
jcode-setup-hints = { path = "../jcode-setup-hints" }
jcode-storage = { path = "../jcode-storage" }
Expand Down
162 changes: 162 additions & 0 deletions crates/jcode-app-core/src/external_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,141 @@ pub fn pending_external_auth_review_candidates() -> Result<Vec<ExternalAuthRevie
Ok(candidates)
}

/// One external credential source the auto-import sweep probes for, plus
/// whether an artifact was actually located on disk.
///
/// "Present" here means *only* presence of the credential artifact, NOT
/// whether it was consented/imported. A present-but-already-trusted source is
/// still `present: true`; it simply won't show up as a pending candidate. The
/// onboarding "searched, not found" UI lists the targets with `present == false`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSearchTarget {
/// Stable family id ("codex", "claude_code", "cursor", ...).
pub family: &'static str,
/// Human label for the row ("Codex", "Claude Code", "GitHub Copilot").
pub label: String,
/// Representative path we looked at (best-effort; some families probe
/// several files and this is the canonical one).
pub path: String,
/// True when a credential artifact exists on disk.
pub present: bool,
}

/// Outcome of one import-detection sweep: what we found (the same pending
/// candidates the rest of the flow consumes) and the full set of sources we
/// probed, each flagged present/absent. `not_found()` derives the
/// "searched, but nothing here" list for the onboarding card.
#[derive(Debug, Clone, Default)]
pub struct ExternalAuthSearchReport {
/// Importable, unconsented candidates (mirrors
/// [`pending_external_auth_review_candidates`]).
pub found: Vec<ExternalAuthReviewCandidate>,
/// Every source family we probed, with its presence flag.
pub targets: Vec<AuthSearchTarget>,
}

impl ExternalAuthSearchReport {
/// The probed source families that did not have a credential artifact.
pub fn not_found(&self) -> Vec<&AuthSearchTarget> {
self.targets.iter().filter(|t| !t.present).collect()
}
}

/// Best-effort string form of a path-returning helper, for display only.
fn search_path_string(path: Result<std::path::PathBuf>, fallback: &str) -> String {
path.map(|p| p.display().to_string())
.unwrap_or_else(|_| fallback.to_string())
}

/// Build the canonical list of credential families the import sweep looks at,
/// each tagged with whether an artifact is present. Keyed on **presence only**
/// (via the existing `*_exists()` / `preferred_*().is_some()` helpers), so an
/// already-trusted login reads as present and never shows up as "not found".
///
/// Keep this in lockstep with [`pending_external_auth_review_candidates`]: every
/// family probed there should appear here.
pub fn external_auth_search_targets() -> Vec<AuthSearchTarget> {
use auth::external::ExternalAuthSource;

let copilot_present = {
use auth::copilot::ExternalCopilotAuthSource as C;
[C::ConfigJson, C::HostsJson, C::AppsJson]
.into_iter()
.any(|s| s.path().exists())
};

vec![
AuthSearchTarget {
family: "codex",
label: "Codex".to_string(),
path: search_path_string(auth::codex::legacy_auth_file_path(), "~/.codex/auth.json"),
present: auth::codex::legacy_auth_source_exists(),
},
AuthSearchTarget {
family: "claude_code",
label: "Claude Code".to_string(),
path: "~/.claude/.credentials.json".to_string(),
present: matches!(
auth::claude::preferred_external_auth_source(),
Some(auth::claude::ExternalClaudeAuthSource::ClaudeCode)
),
},
AuthSearchTarget {
family: "gemini_cli",
label: "Gemini CLI".to_string(),
path: search_path_string(
auth::gemini::gemini_cli_oauth_path(),
"~/.gemini/oauth_creds.json",
),
present: auth::gemini::gemini_cli_auth_source_exists(),
},
AuthSearchTarget {
family: "copilot",
label: "GitHub Copilot".to_string(),
path: "~/.copilot/config.json".to_string(),
present: copilot_present,
},
AuthSearchTarget {
family: "cursor",
label: "Cursor".to_string(),
path: search_path_string(auth::cursor::cursor_auth_file_path(), "~/.cursor/auth.json"),
present: auth::cursor::preferred_external_auth_source().is_some(),
},
AuthSearchTarget {
family: "opencode",
label: "OpenCode".to_string(),
path: search_path_string(
ExternalAuthSource::OpenCode.path(),
"~/.local/share/opencode/auth.json",
),
present: ExternalAuthSource::OpenCode
.path()
.map(|p| p.exists())
.unwrap_or(false),
},
AuthSearchTarget {
family: "pi",
label: "pi".to_string(),
path: search_path_string(ExternalAuthSource::Pi.path(), "~/.pi/agent/auth.json"),
present: ExternalAuthSource::Pi
.path()
.map(|p| p.exists())
.unwrap_or(false),
},
]
}

/// Run the import-detection sweep and return both the importable candidates and
/// the full searched-target list (with presence flags). Additive wrapper over
/// [`pending_external_auth_review_candidates`] used by onboarding to show a
/// "Searched, not found" panel beneath the import decision rows.
pub fn external_auth_search_report() -> Result<ExternalAuthSearchReport> {
Ok(ExternalAuthSearchReport {
found: pending_external_auth_review_candidates()?,
targets: external_auth_search_targets(),
})
}

pub fn parse_external_auth_review_selection(input: &str, count: usize) -> Result<Vec<usize>> {
let trimmed = input.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -684,4 +819,31 @@ mod render_markdown_tests {
vec![("openai", "import")]
);
}

#[test]
fn search_targets_cover_every_probed_family() {
// The "searched, not found" UI relies on this canonical family list
// staying in lockstep with `pending_external_auth_review_candidates`.
let targets = super::external_auth_search_targets();
let families: Vec<&str> = targets.iter().map(|t| t.family).collect();
for expected in [
"codex",
"claude_code",
"gemini_cli",
"copilot",
"cursor",
"opencode",
"pi",
] {
assert!(
families.contains(&expected),
"missing search family {expected}; got {families:?}"
);
}
// Every target carries a non-empty label and representative path.
for t in &targets {
assert!(!t.label.is_empty(), "empty label for {}", t.family);
assert!(!t.path.is_empty(), "empty path for {}", t.family);
}
}
}
62 changes: 62 additions & 0 deletions crates/jcode-app-core/src/server/comm_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,64 @@ async fn register_visible_spawned_member(
broadcast_swarm_status(swarm_id, swarm_members, swarms_by_id).await;
}

/// Best-effort ScrollWM reconcile after a headed agent window is spawned.
///
/// Gated on `agents.scrollwm.enabled`. Spawns a short, detached task (never
/// blocking or failing the spawn) that waits for ScrollWM's auto-adopt to place
/// the new window, then focuses the agent's strip column by matching its unique
/// session name in the window title. When ScrollWM is absent / not running /
/// not managing, every step is a quiet no-op. We never call `arrange` unless the
/// user explicitly opted into `arrange_on_spawn` (it adopts the whole Space).
fn maybe_reconcile_scrollwm_after_spawn(session_id: &str) {
// macOS only: ScrollWM is a macOS window manager.
if !cfg!(target_os = "macos") {
return;
}
let cfg = crate::config::config().agents.scrollwm;
if !cfg.enabled {
return;
}
// The agent's session name is unique and embedded in its window title
// (`resumed_window_title` -> `terminal_session_label_for_id`), so it is a
// reliable focus key without depending on volatile column indices.
let needle = crate::process_title::session_name(session_id);
if needle.trim().is_empty() {
return;
}
tokio::spawn(async move {
// Run the blocking socket I/O off the async reactor.
let _ = tokio::task::spawn_blocking(move || {
let sw = jcode_scrollwm::ScrollWm::discover();
if !sw.is_running() {
return;
}
// Optionally start managing (adopts the whole Space) before focusing.
if cfg.arrange_on_spawn {
match sw.status() {
Ok(status) if !status.managing => {
let _ = sw.arrange();
}
_ => {}
}
}
if !cfg.focus_active {
return;
}
// The window appears asynchronously (open -na Ghostty detaches and the
// new jcode process sets its OSC title ~0.3-2s later). Poll a bounded
// number of times for the agent's column, then give up quietly.
for _ in 0..6 {
match sw.focus_title(&needle) {
Ok(_) => return,
Err(jcode_scrollwm::ScrollWmError::NotRunning) => return,
Err(_) => std::thread::sleep(std::time::Duration::from_millis(500)),
}
}
})
.await;
});
}

#[expect(
clippy::too_many_arguments,
reason = "server-side swarm spawning needs session, swarm state, provider, and event sinks together"
Expand Down Expand Up @@ -598,6 +656,10 @@ pub(super) async fn spawn_swarm_agent(
swarm_event_tx,
)
.await;
// Best-effort: ask ScrollWM (if the user opted in and it is running) to
// focus the freshly-spawned agent window. Detached so socket/window I/O
// never blocks or fails the spawn.
maybe_reconcile_scrollwm_after_spawn(&new_session_id);
}
let swarm_state = SwarmState {
members: Arc::clone(swarm_members),
Expand Down
Loading
Loading