From 9bd57e6eb93dc25d2c48bef5f8319b78259da5db Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 21 Jul 2026 23:29:44 +0200 Subject: [PATCH 1/6] fix(claude): route native subagents by actor identity Replace missing-row lifecycle inference with actor-first routing based on Claude agent IDs and session-scoped spawn ownership. Correlate Agent tool calls to SubagentStart through prompt IDs, persist validated agent-owner mappings for resumed tasks, and keep unknown actors fail-closed. Serialize running-task mutations and clean ownership, polling, and stop-claim state at session end. Claim each full SubagentStop invocation before transcript updates or polling so duplicate hook registrations cannot race message delivery against teardown. Preserve legitimate repeated stops by fingerprinting the complete payload. Harden nested cleanup and async launch handling, update the pinned Claude mock version, and expand unit and real-tool coverage for native discovery, nesting, resume, concurrency, duplicate stops, queued delivery, and relay behavior. --- .github/workflows/ci.yml | 16 +- scripts/install-mock-tools.ps1 | 2 +- scripts/install-mock-tools.sh | 4 +- src/db/kv.rs | 14 + src/db/mod.rs | 28 + src/hooks/claude.rs | 2098 +++++++++++++++++++++++++++++--- src/instances.rs | 117 ++ tests/support/claude_mock.rs | 9 +- tests/test_relay_roundtrip.rs | 2 +- 9 files changed, 2092 insertions(+), 198 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 982e4dbc..a24a3cdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,13 +81,13 @@ jobs: test: real_tool_codex - os: ubuntu-latest tool: claude - package: "@anthropic-ai/claude-code@2.1.185" - cache: claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: claude-2.1.216 test: real_tool_claude - os: ubuntu-latest tool: relay - package: "@anthropic-ai/claude-code@2.1.185" - cache: relay-claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: relay-claude-2.1.216 test: test_relay_roundtrip - os: windows-latest tool: codex @@ -96,13 +96,13 @@ jobs: test: real_tool_codex - os: windows-latest tool: claude - package: "@anthropic-ai/claude-code@2.1.185" - cache: claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: claude-2.1.216 test: real_tool_claude - os: windows-latest tool: relay - package: "@anthropic-ai/claude-code@2.1.185" - cache: relay-claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: relay-claude-2.1.216 test: test_relay_roundtrip steps: - uses: actions/checkout@v6 diff --git a/scripts/install-mock-tools.ps1 b/scripts/install-mock-tools.ps1 index 6bc0c1df..932ffdac 100644 --- a/scripts/install-mock-tools.ps1 +++ b/scripts/install-mock-tools.ps1 @@ -19,7 +19,7 @@ $cache = if ($env:HCOM_MOCK_TOOLS_NPM_CACHE) { if (-not $Packages -or $Packages.Count -eq 0) { $Packages = @( "@openai/codex@0.141.0", - "@anthropic-ai/claude-code@2.1.185" + "@anthropic-ai/claude-code@2.1.216" ) } diff --git a/scripts/install-mock-tools.sh b/scripts/install-mock-tools.sh index 4cde348f..12eed097 100755 --- a/scripts/install-mock-tools.sh +++ b/scripts/install-mock-tools.sh @@ -12,7 +12,7 @@ if [[ "$#" -gt 0 ]]; then else packages=( "@openai/codex@0.141.0" - "@anthropic-ai/claude-code@2.1.185" + "@anthropic-ai/claude-code@2.1.216" ) fi @@ -32,7 +32,7 @@ for package in "${packages[@]}"; do claude_version="${package##*@}" ;; @anthropic-ai/claude-code) - claude_version="2.1.185" + claude_version="2.1.216" ;; @anthropic-ai/claude-code-*) has_claude_native=1 diff --git a/src/db/kv.rs b/src/db/kv.rs index 57634e6c..50f25922 100644 --- a/src/db/kv.rs +++ b/src/db/kv.rs @@ -55,6 +55,20 @@ impl HcomDb { .collect(); Ok(rows) } + + /// Delete all kv entries whose key starts with prefix. Returns count deleted. + pub fn kv_delete_prefix(&self, prefix: &str) -> Result { + let escaped = prefix + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + let pattern = format!("{}%", escaped); + let n = self.conn.execute( + "DELETE FROM kv WHERE key LIKE ? ESCAPE '\\'", + params![pattern], + )?; + Ok(n) + } } #[cfg(test)] diff --git a/src/db/mod.rs b/src/db/mod.rs index ca0ea087..d699ae73 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -122,6 +122,34 @@ impl HcomDb { &self.conn } + /// Run `f` inside a `BEGIN IMMEDIATE` transaction, committing on success. + /// + /// `BEGIN IMMEDIATE` takes the write lock up front (rather than on first + /// write), so a read-then-write sequence inside `f` is atomic with + /// respect to other hcom processes: each hook invocation opens its own + /// connection, so a plain SELECT-then-UPDATE across two statements is a + /// lost-update race between concurrent processes without this. + /// + /// `f` is handed the `Transaction` itself and must run its queries + /// through it (not through `self.conn()`), so the RAII guard is actually + /// what's touching the database. This also makes rollback panic-safe: a + /// manual `BEGIN`/`COMMIT`/`ROLLBACK` pair leaves the connection stuck + /// inside an open transaction if `f` panics (the `ROLLBACK` arm never + /// runs); `rusqlite::Transaction`'s `Drop` rolls back unconditionally + /// unless `commit()` was reached. + pub fn with_transaction( + &self, + f: impl FnOnce(&rusqlite::Transaction) -> Result, + ) -> Result { + let txn = rusqlite::Transaction::new_unchecked( + &self.conn, + rusqlite::TransactionBehavior::Immediate, + )?; + let result = f(&txn)?; + txn.commit()?; + Ok(result) + } + /// Access the filesystem path backing this DB handle. pub fn path(&self) -> &std::path::Path { &self.db_path diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 1cebccdb..2356ab0a 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -235,6 +235,49 @@ fn route_claude_hook( return (result.0, result.1, None, timing); } + // Hook actor: `raw.agent_id` is Claude Code's own signal for "this hook + // fired inside a subagent's own execution context" (set on ordinary + // PreToolUse/PostToolUse and on SubagentStart/SubagentStop), absent on + // the root/main thread. Checked before Task/Agent transitions and before + // any running_tasks-based decision, because parent and every nested + // subagent share one session_id — session_id can't tell them apart, and + // running_tasks.active is parent-side bookkeeping, not identity (using it + // for routing is what let PR #82's reap-on-missing-row either wrongly + // freeze or wrongly unfreeze the parent). + let raw_agent_id = payload + .raw + .get("agent_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if let Some(agent_id) = raw_agent_id { + // Every Claude subagent carries agent_id on its hooks regardless of + // whether its root ever ran `hcom start` — that's a property of + // Claude's hook schema, not of hcom participation. Act only if the + // shared session_id actually has an hcom root binding; otherwise this + // whole branch (including the `hcom start --name ...` hint at + // SubagentStart) must be a silent no-op, same as any other + // nonparticipant hook. + let Ok(Some(root_name)) = db.get_session_binding(&session_id) else { + return (0, String::new(), None, timing); + }; + let subagent_check_start = Instant::now(); + let (exit_code, stdout, delivery_ack) = route_subagent_actor_hook( + db, + hook_type, + payload, + &agent_id, + &session_id, + &root_name, + &mut timing, + ); + timing.subagent_check_ms = Some(subagent_check_start.elapsed().as_secs_f64() * 1000.0); + return (exit_code, stdout, delivery_ack, timing); + } + + // ---- Root/main-thread hook (no agent_id) from here on ---- + // Task transitions let tool_name = payload.tool_name.as_str(); // Claude Code renamed the Task tool to Agent (Task kept as legacy alias), @@ -242,86 +285,36 @@ fn route_claude_hook( let is_task_tool = tool_name == "Task" || tool_name == "Agent"; if hook_type == HOOK_PRE && is_task_tool { let task_start = Instant::now(); - let (stdout, exit_code) = start_task(db, &session_id, &payload.raw); + let (stdout, exit_code) = match db.get_session_binding(&session_id).ok().flatten() { + Some(instance_name) => start_task(db, &instance_name, &session_id, &payload.raw), + None => (String::new(), 0), + }; timing.task_ms = Some(task_start.elapsed().as_secs_f64() * 1000.0); return (exit_code, stdout, None, timing); } if hook_type == HOOK_POST && is_task_tool { let task_start = Instant::now(); - let stdout = end_task(db, &session_id, &payload.raw, false).unwrap_or_default(); + let stdout = match db.get_session_binding(&session_id).ok().flatten() { + Some(instance_name) => { + end_task(db, &instance_name, &payload.raw, false).unwrap_or_default() + } + None => String::new(), + }; timing.task_ms = Some(task_start.elapsed().as_secs_f64() * 1000.0); return (0, stdout, None, timing); } - // Subagent context check - let subagent_check_start = Instant::now(); - let is_in_subagent_ctx = instances::in_subagent_context(db, &session_id); - timing.subagent_check_ms = Some(subagent_check_start.elapsed().as_secs_f64() * 1000.0); - - if is_in_subagent_ctx { - timing.context = Some("subagent"); - - if hook_type == HOOK_USERPROMPTSUBMIT { - let transcript_path = payload.transcript_path.as_deref().unwrap_or(""); - cleanup_dead_subagents(db, &session_id, transcript_path); - // Fall through to parent handler for PTY message delivery - } - - if hook_type == HOOK_SUBAGENT_START { - let agent_id = payload - .raw - .get("agent_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let agent_type = payload - .raw - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if !agent_id.is_empty() && !agent_type.is_empty() { - track_subagent(db, &session_id, agent_id, agent_type); - // Eagerly allocate an instance row so the subagent shows up - // in the TUI and is a valid `hcom send` target from birth — - // without injecting any hcom context into the subagent itself. - // The subagent stays dormant (name_announced=0) until either a - // message arrives at SubagentStop or it runs `hcom start`. - ensure_subagent_row(db, &session_id, agent_id, agent_type); - } - let output = subagent_start(&payload.raw); - if let Some(out) = output { - return ( - 0, - serde_json::to_string(&out).unwrap_or_default(), - None, - timing, - ); - } - return (0, String::new(), None, timing); - } - - if hook_type == HOOK_SUBAGENT_STOP { - let (exit_code, stdout) = subagent_stop(db, &payload.raw, &session_id); - return (exit_code, stdout, None, timing); - } - - if hook_type == HOOK_NOTIFY { - return (0, String::new(), None, timing); - } - - if hook_type == HOOK_PRE || hook_type == HOOK_POST { - if tool_name == "Bash" { - let tool_input = &payload.tool_input; - let command = tool_input - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if hook_type == HOOK_POST && extract_name(command).is_some() { - let (exit_code, stdout, delivery_ack) = subagent_posttooluse(db, &payload.raw); - return (exit_code, stdout, delivery_ack, timing); - } - } - return (0, String::new(), None, timing); - } + if hook_type == HOOK_USERPROMPTSUBMIT { + // Reap subagents that died without a clean SubagentStop before + // honoring root delivery below. This is root-side bookkeeping (a + // definite, user-present moment — a heuristic false positive is + // recoverable on the next turn), not actor routing, so it stays keyed + // off running_tasks.active/parse — see the module doc above for why + // that distinction matters. `cleanup_dead_subagents` no-ops quickly + // when there is nothing tracked. + let transcript_path = payload.transcript_path.as_deref().unwrap_or(""); + cleanup_dead_subagents(db, &session_id, transcript_path); + // Fall through to parent handler for PTY message delivery } // Resolve parent instance @@ -395,7 +388,8 @@ fn route_claude_hook( handle_userpromptsubmit(db, ctx, payload, instance_name, &updates, &instance_data) } HOOK_SESSIONEND => { - let (code, stdout) = handle_sessionend(db, instance_name, &payload.raw, &updates); + let (code, stdout) = + handle_sessionend(db, instance_name, &session_id, &payload.raw, &updates); (code, stdout, None) } _ => (0, String::new(), None), @@ -405,6 +399,146 @@ fn route_claude_hook( (exit_code, stdout, delivery_ack, timing) } +/// Route a hook whose payload carries a non-empty `agent_id` — i.e. one that +/// fired inside a subagent's own execution context. See the call site in +/// `route_claude_hook` for why `agent_id`, not `running_tasks.active`, is the +/// actor signal, and for the participation gate that must run before this +/// (root_name is already a proven hcom binding by the time we're called). +/// +/// Returns (exit_code, stdout, delivery_ack). +fn route_subagent_actor_hook( + db: &HcomDb, + hook_type: &str, + payload: &HookPayload, + agent_id: &str, + session_id: &str, + root_name: &str, + timing: &mut DispatchTiming, +) -> (i32, String, Option) { + timing.context = Some("subagent"); + + // SubagentStart is the hook that *creates* this agent_id's row + // (ensure_subagent_row) — an unknown row here is the expected, normal + // first sighting of this subagent, not a fail-closed condition. + if hook_type == HOOK_SUBAGENT_START { + let agent_type = payload + .raw + .get("agent_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !agent_id.is_empty() && !agent_type.is_empty() { + let parent_instance = + match resolve_spawn_owner(db, &payload.raw, session_id, agent_id, root_name) { + SpawnOwner::LegacyRoot(name) + | SpawnOwner::Resolved(name) + | SpawnOwner::Resumed(name) => name, + SpawnOwner::Unresolved => { + // prompt_id was present (Claude Code >= 2.1.196) but + // no valid owner resolved by either the fresh-spawn + // or resume path, and this agent_id has never been + // seen before — a missing/stale/foreign mapping means + // correlation failed, not that root is a safe guess. + // Fail closed entirely: no tracking, no row, no + // bootstrap hint. + log::log_warn( + "hooks", + "subagent.spawn_owner.unresolved", + &format!( + "agent_id={} prompt_id={:?}", + agent_id, + payload.raw.get("prompt_id").and_then(|v| v.as_str()) + ), + ); + timing.result = Some("spawn_owner_unresolved"); + return (0, String::new(), None); + } + }; + // Remember this agent_id's owner regardless of which path + // resolved it, so a later Claude-initiated resume of this same + // agent_id (new prompt_id, no corresponding PreToolUse) can + // still find its true owner instead of failing closed. + let _ = db.kv_set( + &agent_owner_kv_key(session_id, agent_id), + Some(&parent_instance), + ); + track_subagent(db, &parent_instance, agent_id, agent_type); + // Eagerly allocate an instance row so the subagent shows up + // in the TUI and is a valid `hcom send` target from birth — + // without injecting any hcom context into the subagent itself. + // The subagent stays dormant (name_announced=0) until either a + // message arrives at SubagentStop or it runs `hcom start`. + ensure_subagent_row(db, &parent_instance, session_id, agent_id, agent_type); + } + let output = subagent_start(&payload.raw); + if let Some(out) = output { + return (0, serde_json::to_string(&out).unwrap_or_default(), None); + } + return (0, String::new(), None); + } + + // SubagentStop resolves its own row by agent_id internally and already + // fails safe when it's missing (running_tasks cleanup scanned across all + // instances, no delivery — see its doc comment); no extra gate needed + // here. + if hook_type == HOOK_SUBAGENT_STOP { + let (exit_code, stdout) = subagent_stop(db, session_id, &payload.raw); + return (exit_code, stdout, None); + } + + // Every other subagent-context hook needs a known, resolvable identity. + // Row existence is identity/participation state (allocated, best-effort, + // at SubagentStart) — never a liveness probe. An unknown/missing row + // means this agent_id has no valid slot right now: fail closed with a + // silent no-op, and never fall through to parent/root handling — that + // fallthrough is exactly the unsafe shortcut PR #82 proposed (reaping a + // tracked-but-rowless subagent and treating that as proof it's dead). + let Ok(Some(subagent_instance)) = db.get_instance_by_agent_id(agent_id) else { + timing.result = Some("unknown_subagent_actor"); + return (0, String::new(), None); + }; + + if hook_type == HOOK_NOTIFY { + return (0, String::new(), None); + } + + // Nested Agent/Task tool call, spawned from *inside* this subagent: + // mutate this subagent's own running_tasks row, not the root's (see the + // module-level rationale above for why session_id can't be used here). + let tool_name = payload.tool_name.as_str(); + let is_task_tool = tool_name == "Task" || tool_name == "Agent"; + if hook_type == HOOK_PRE && is_task_tool { + let (stdout, exit_code) = start_task(db, &subagent_instance, session_id, &payload.raw); + return (exit_code, stdout, None); + } + if hook_type == HOOK_POST && is_task_tool { + let stdout = end_task(db, &subagent_instance, &payload.raw, false).unwrap_or_default(); + return (0, stdout, None); + } + + if (hook_type == HOOK_PRE || hook_type == HOOK_POST) && tool_name == "Bash" { + if hook_type == HOOK_POST { + let command = payload + .tool_input + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // A subagent may only route its own inbox: the --name it passed + // to `hcom start`/hook delivery must match the agent_id Claude + // itself stamped on this hook, or one subagent could spoof + // another's --name and read its inbox (see subagent_posttooluse). + if let Some(name_flag) = extract_name(command) + && name_flag == agent_id + { + let (exit_code, stdout, delivery_ack) = subagent_posttooluse(db, &payload.raw); + return (exit_code, stdout, delivery_ack); + } + } + return (0, String::new(), None); + } + + (0, String::new(), None) +} + /// Get correct session_id, handling Claude Code's fork bug. /// /// For --fork-session, hook_data.session_id reports the parent's old ID. @@ -667,36 +801,167 @@ fn bind_and_bootstrap( Ok(Some(result)) } -/// PreToolUse Task: enter subagent context. +/// kv-table key correlating a Task/Agent tool call's `prompt_id` — scoped to +/// the root session it happened in — with the instance that made it. See +/// `resolve_spawn_owner`. +/// +/// Session-scoped (not just `prompt_id`) because a session's mappings live +/// for the whole session (see `end_task` / `handle_sessionend` for why they +/// can't be deleted per-Task-completion), and prompt_id values are only +/// unique within one Claude conversation. +fn spawn_owner_kv_key(root_session_id: &str, prompt_id: &str) -> String { + format!("spawn_owner:{root_session_id}:{prompt_id}") +} + +/// kv-table key prefix covering every spawn-owner mapping for one root +/// session. See `handle_sessionend`. +fn spawn_owner_kv_prefix(root_session_id: &str) -> String { + format!("spawn_owner:{root_session_id}:") +} + +/// kv-table key remembering which instance owns a given `agent_id`, scoped +/// to the root session — independent of any one `prompt_id`. See +/// `resolve_spawn_owner`'s resume fallback. +fn agent_owner_kv_key(root_session_id: &str, agent_id: &str) -> String { + format!("agent_owner:{root_session_id}:{agent_id}") +} + +/// kv-table key prefix covering every agent-owner mapping for one root +/// session. See `handle_sessionend`. +fn agent_owner_kv_prefix(root_session_id: &str) -> String { + format!("agent_owner:{root_session_id}:") +} + +/// Outcome of resolving which instance spawned a SubagentStart's child. +enum SpawnOwner { + /// `prompt_id` was absent from the payload entirely — Claude Code + /// < 2.1.196, where this correlation doesn't exist on the wire at all. + /// Root attribution is the only thing ever knowable without it; this is + /// not nested-spawn support, just the honest pre-2.1.196 behavior. + LegacyRoot(String), + /// `prompt_id` was present and resolved to a live owner that's verified + /// to belong to this root session. + Resolved(String), + /// `prompt_id` was present but didn't resolve, and this `agent_id` was + /// already known (see `agent_owner_kv_key`) — Claude resuming a + /// previously-spawned subagent under the same `agent_id` stamps a *new* + /// `prompt_id` on the resumed SubagentStart with no corresponding + /// PreToolUse to map it (live-verified: resume never re-runs the + /// spawning Task/Agent PreToolUse), so the fresh-`prompt_id` lookup + /// always misses on resume. Falling back to this agent_id's own + /// remembered owner is safe specifically because the agent_id is + /// already known — see `Unresolved` for why an *unknown* agent_id must + /// not get the same treatment. + Resumed(String), + /// `prompt_id` was present but no valid owner resolved by either path, + /// and this `agent_id` has never been seen before: no mapping + /// (correlation failed or hasn't landed yet), or the mapped instance is + /// gone or belongs to a different session (stale/foreign). Root is + /// deliberately not used as a fallback here — on a Claude Code version + /// that *does* send `prompt_id`, a miss on a genuinely new agent_id + /// means something is wrong, not that root is a safe guess. + Unresolved, +} + +/// Resolve which instance actually spawned a SubagentStart's child. +/// +/// Claude stamps the same `prompt_id` on a Task/Agent tool's PreToolUse and +/// on the SubagentStart(s) it produces — including parallel siblings spawned +/// by the same call — live-verified against Claude Code 2.1.216 (requires at +/// least 2.1.196; see `spawn_owner_kv_key`). `start_task` records +/// `(root_session_id, prompt_id) -> acting instance` for exactly this +/// lookup, so a SubagentStart spawned by a *nested* subagent resolves to +/// that subagent, not the session-bound root — parent and every nested +/// subagent share one Claude session_id, so session_id alone can never tell +/// them apart. +/// +/// Falls back to `agent_owner_kv_key(root_session_id, agent_id)` — this +/// `agent_id`'s own remembered owner from when it was first spawned — when +/// the `prompt_id` lookup misses; see `SpawnOwner::Resumed`. +fn resolve_spawn_owner( + db: &HcomDb, + raw: &Value, + root_session_id: &str, + agent_id: &str, + root_name: &str, +) -> SpawnOwner { + let Some(prompt_id) = raw + .get("prompt_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + return SpawnOwner::LegacyRoot(root_name.to_string()); + }; + + let by_prompt = db + .kv_get(&spawn_owner_kv_key(root_session_id, prompt_id)) + .ok() + .flatten(); + if let Some(owner) = by_prompt + && validate_spawn_owner(db, &owner, root_session_id, root_name) + { + return SpawnOwner::Resolved(owner); + } + + let by_agent = db + .kv_get(&agent_owner_kv_key(root_session_id, agent_id)) + .ok() + .flatten(); + if let Some(owner) = by_agent + && validate_spawn_owner(db, &owner, root_session_id, root_name) + { + return SpawnOwner::Resumed(owner); + } + + SpawnOwner::Unresolved +} + +/// Verify a resolved spawn-owner instance actually belongs to this root +/// session's hierarchy, rather than trusting the kv mapping blindly — a +/// stale entry could name an instance that's since been deleted or (in +/// principle) reused for something else. +fn validate_spawn_owner(db: &HcomDb, owner: &str, root_session_id: &str, root_name: &str) -> bool { + if owner == root_name { + return true; + } + match db.get_instance_full(owner) { + Ok(Some(data)) => data.parent_session_id.as_deref() == Some(root_session_id), + _ => false, + } +} + +/// PreToolUse Task: enter subagent context for `instance_name` — the acting +/// instance's own row (root parent, or the spawning subagent for a nested +/// Agent/Task call; see the call sites in `route_claude_hook` / +/// `route_subagent_actor_hook`). /// /// Returns (stdout, exit_code). -fn start_task(db: &HcomDb, session_id: &str, raw: &Value) -> (String, i32) { +fn start_task( + db: &HcomDb, + instance_name: &str, + root_session_id: &str, + raw: &Value, +) -> (String, i32) { log::log_info( "hooks", "start_task.enter", - &format!("session_id={}", session_id), + &format!("instance={}", instance_name), ); - let instance_name = match db.get_session_binding(session_id) { - Ok(Some(name)) => name, - _ => return (String::new(), 0), - }; - - // Set running_tasks.active = true - let instance_data = match db.get_instance_full(&instance_name) { - Ok(Some(data)) => data, - _ => return (String::new(), 0), - }; - - let mut running_tasks = instances::parse_running_tasks(instance_data.running_tasks.as_deref()); - running_tasks.active = true; - let rt_json = serde_json::json!({ - "active": running_tasks.active, - "subagents": running_tasks.subagents, + instances::mutate_running_tasks(db, instance_name, |rt| { + rt.active = true; }); - let mut updates = serde_json::Map::new(); - updates.insert("running_tasks".into(), Value::String(rt_json.to_string())); - instances::update_instance_position(db, &instance_name, &updates); + + if let Some(prompt_id) = raw + .get("prompt_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + let _ = db.kv_set( + &spawn_owner_kv_key(root_session_id, prompt_id), + Some(instance_name), + ); + } let tool_input = raw .get("tool_input") @@ -705,7 +970,7 @@ fn start_task(db: &HcomDb, session_id: &str, raw: &Value) -> (String, i32) { let detail = family::extract_tool_detail("claude", "Task", &tool_input); lifecycle::set_status( db, - &instance_name, + instance_name, ST_ACTIVE, "tool:Task", lifecycle::StatusUpdate { @@ -742,31 +1007,47 @@ fn start_task(db: &HcomDb, session_id: &str, raw: &Value) -> (String, i32) { (String::new(), 0) } -/// PostToolUse Task: deliver freeze-period messages. +/// PostToolUse Task: deliver freeze-period messages for `instance_name` — the +/// acting instance's own row (see `start_task`). /// /// Returns Option — JSON stdout if messages were delivered. /// Dispatcher writes this to stdout before returning exit code. -fn end_task(db: &HcomDb, session_id: &str, _raw: &Value, interrupted: bool) -> Option { - let instance_name = match db.get_session_binding(session_id) { - Ok(Some(name)) => name, - _ => return None, - }; +fn end_task(db: &HcomDb, instance_name: &str, raw: &Value, interrupted: bool) -> Option { + if interrupted { + return None; + } - let instance_data = match db.get_instance_full(&instance_name) { + // Since Claude Code 2.1.198, Agent/Task calls background by default: this + // PostToolUse fires immediately with tool_response.status="async_launched" + // when the call is merely dispatched to the background, not when the + // subagent actually finishes. Treating that as completion would emit a + // false "Subagents have finished and are no longer active" summary and + // advance last_event_id before the subagent's freeze window is over, so + // skip delivery rather than assume anything about how/when completion is + // reported — root-scoped messages still flow through the root's own + // interleaved PostToolUse hooks regardless (see route_claude_hook). + // Not yet live-verified: what a genuine completion for a backgrounded + // call looks like on the wire. + let is_async_launch = raw + .get("tool_response") + .and_then(|v| v.get("status")) + .and_then(|v| v.as_str()) + == Some("async_launched"); + if is_async_launch { + return None; + } + + let instance_data = match db.get_instance_full(instance_name) { Ok(Some(data)) => data, _ => return None, }; - if interrupted { - return None; - } - let freeze_event_id = instance_data.last_event_id; - let (last_event_id, stdout) = deliver_freeze_messages(db, &instance_name, freeze_event_id); + let (last_event_id, stdout) = deliver_freeze_messages(db, instance_name, freeze_event_id); let mut updates = serde_json::Map::new(); updates.insert("last_event_id".into(), serde_json::json!(last_event_id)); - instances::update_instance_position(db, &instance_name, &updates); + instances::update_instance_position(db, instance_name, &updates); stdout } @@ -1297,6 +1578,7 @@ fn handle_notify( fn handle_sessionend( db: &HcomDb, instance_name: &str, + session_id: &str, raw: &Value, updates: &serde_json::Map, ) -> (i32, String) { @@ -1314,82 +1596,83 @@ fn handle_sessionend( Some(updates) }, ); + + // Root session is over: no more children can spawn under any of its + // prompt_id mappings (see `spawn_owner_kv_key`), no further resumes can + // arrive for its agent_ids (see `agent_owner_kv_key`), and no further + // SubagentStop invocations can fire for it (see `subagent_stop_claim_key`). + // None of these can be cleaned up incrementally as they're used (parallel + // siblings sharing one prompt_id would race a later SubagentStart against + // an earlier sibling's cleanup — see `end_task`; a resumed agent_id needs + // its mapping to *outlive* its own stop; a stop claim needs to survive + // exactly as long as its invocation could possibly be duplicated), so the + // whole session's worth of each is swept here instead. A session that + // ends without this hook firing (crash, `hcom kill`) just leaves harmless + // unreachable entries. + for (label, prefix) in [ + ("spawn_owner", spawn_owner_kv_prefix(session_id)), + ("agent_owner", agent_owner_kv_prefix(session_id)), + ( + "subagent_stop_claim", + subagent_stop_claim_prefix(session_id), + ), + ] { + if let Err(e) = db.kv_delete_prefix(&prefix) { + log::log_warn( + "hooks", + "sessionend.kv_cleanup_failed", + &format!("kind={} session_id={} err={}", label, session_id, e), + ); + } + } + (0, String::new()) } -/// Track subagent in parent's running_tasks. -fn track_subagent(db: &HcomDb, parent_session_id: &str, agent_id: &str, agent_type: &str) { +/// Track subagent in `parent_instance`'s running_tasks. `parent_instance` is +/// the already-resolved spawning actor (root or a nested subagent — see +/// `resolve_spawn_owner`), not derived from session_id here. +fn track_subagent(db: &HcomDb, parent_instance: &str, agent_id: &str, agent_type: &str) { log::log_info( "hooks", "track_subagent.enter", &format!( - "session_id={} agent_id={} agent_type={}", - parent_session_id, agent_id, agent_type + "parent={} agent_id={} agent_type={}", + parent_instance, agent_id, agent_type ), ); - let instance_name = match db.get_session_binding(parent_session_id) { - Ok(Some(name)) => name, - _ => return, - }; - - let instance_data = match db.get_instance_full(&instance_name) { - Ok(Some(data)) => data, - _ => return, - }; - - let mut running_tasks = instances::parse_running_tasks(instance_data.running_tasks.as_deref()); - running_tasks.active = true; - - // Add subagent if not already tracked - let already_tracked = running_tasks - .subagents - .iter() - .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(agent_id)); - - if !already_tracked { - running_tasks.subagents.push(serde_json::json!({ - "agent_id": agent_id, - "type": agent_type, - })); - let rt_json = serde_json::json!({ - "active": running_tasks.active, - "subagents": running_tasks.subagents, - }); - let mut updates = serde_json::Map::new(); - updates.insert("running_tasks".into(), Value::String(rt_json.to_string())); - instances::update_instance_position(db, &instance_name, &updates); - } + // Dedup-check-and-insert happens inside the transaction (see + // `mutate_running_tasks`), so concurrent SubagentStart hooks for sibling + // subagents of the same parent (parallel Task calls) cannot race and + // silently drop each other's entry. + instances::mutate_running_tasks(db, parent_instance, |rt| { + rt.active = true; + let already_tracked = rt + .subagents + .iter() + .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(agent_id)); + if !already_tracked { + rt.subagents.push(serde_json::json!({ + "agent_id": agent_id, + "type": agent_type, + })); + } + }); } /// Remove subagent from parent's running_tasks. fn remove_subagent_from_parent(db: &HcomDb, parent_name: &str, agent_id: &str) { - let parent_data = match db.get_instance_full(parent_name) { - Ok(Some(data)) => data, - _ => return, - }; - - let mut running_tasks = instances::parse_running_tasks(parent_data.running_tasks.as_deref()); - - if running_tasks.subagents.is_empty() { - return; - } - - running_tasks - .subagents - .retain(|s| s.get("agent_id").and_then(|v| v.as_str()) != Some(agent_id)); - - if running_tasks.subagents.is_empty() { - running_tasks.active = false; - } - - let rt_json = serde_json::json!({ - "active": running_tasks.active, - "subagents": running_tasks.subagents, + instances::mutate_running_tasks(db, parent_name, |rt| { + if rt.subagents.is_empty() { + return; + } + rt.subagents + .retain(|s| s.get("agent_id").and_then(|v| v.as_str()) != Some(agent_id)); + if rt.subagents.is_empty() { + rt.active = false; + } }); - let mut updates = serde_json::Map::new(); - updates.insert("running_tasks".into(), Value::String(rt_json.to_string())); - instances::update_instance_position(db, parent_name, &updates); } /// Maximum depth when searching for nested subagent transcripts. @@ -1611,12 +1894,22 @@ fn subagent_start(raw: &Value) -> Option { /// /// Idempotent: calls into `allocate_subagent_instance`, which returns the /// existing row's name if one already exists for this agent_id. -fn ensure_subagent_row(db: &HcomDb, parent_session_id: &str, agent_id: &str, agent_type: &str) { - let parent_name = match db.get_session_binding(parent_session_id) { - Ok(Some(name)) => name, - _ => return, - }; - let parent_data = match db.get_instance_full(&parent_name) { +/// +/// `parent_instance` (the row's `parent_name`) is the already-resolved +/// spawning actor, which may be a nested subagent — see +/// `resolve_spawn_owner`. `root_session_id` is always the real Claude +/// session_id (the root's own), never a subagent's, because subagent rows +/// never get their own `session_id` (see `allocate_subagent_instance`) — it +/// is the only value the `parent_session_id` FK column (`REFERENCES +/// instances(session_id)`) can ever validly point at, at any nesting depth. +fn ensure_subagent_row( + db: &HcomDb, + parent_instance: &str, + root_session_id: &str, + agent_id: &str, + agent_type: &str, +) { + let parent_data = match db.get_instance_full(parent_instance) { Ok(Some(data)) => data, _ => return, }; @@ -1625,8 +1918,8 @@ fn ensure_subagent_row(db: &HcomDb, parent_session_id: &str, agent_id: &str, age let alloc = instance_names::SubagentAllocation { agent_id, agent_type, - parent_name: &parent_name, - parent_session_id: Some(parent_session_id), + parent_name: parent_instance, + parent_session_id: Some(root_session_id), parent_tag, status: ST_INACTIVE, status_context: Some("subagent:dormant"), @@ -1639,7 +1932,7 @@ fn ensure_subagent_row(db: &HcomDb, parent_session_id: &str, agent_id: &str, age "subagent.row.ensured", &format!( "name={} parent={} agent_id={} type={}", - name, parent_name, agent_id, agent_type + name, parent_instance, agent_id, agent_type ), ); } @@ -1653,11 +1946,77 @@ fn ensure_subagent_row(db: &HcomDb, parent_session_id: &str, agent_id: &str, age } } +/// Deterministic, bounded fingerprint of a raw hook payload for use inside a +/// SQLite key. Payloads can carry unbounded fields (e.g. a transcript +/// excerpt), so the payload itself must never be embedded directly into a +/// key — hash it instead. Not for cryptographic use, just content-addressing +/// two invocations as "the same bytes" or not. +fn hash_raw_payload(raw: &Value) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(raw.to_string().as_bytes()); + let hash = hasher.finalize(); + hash.iter().map(|b| format!("{b:02x}")).take(16).collect() +} + +/// kv-table key claiming exactly-once processing for *one specific* +/// SubagentStop invocation — not the agent_id alone, and not `prompt_id` +/// alone either. Claude legitimately re-fires SubagentStop for the same +/// agent_id (and, as far as we've established, possibly the same +/// `prompt_id` too — SubagentStop is explicitly designed to fire again after +/// an exit_code=2 delivery within the same agent prompt/continuation, and we +/// have not confirmed Claude changes `prompt_id` for that re-fire) across the +/// message-poll loop and on resume, so a claim keyed on either alone risks +/// wrongly suppressing a later, genuinely different stop. The full raw +/// payload is hashed instead: two hook registrations firing the *identical* +/// event (e.g. hcom installed in both global and repo Claude settings, seen +/// live) produce byte-identical stdin and collapse onto the same key, while +/// any real difference between invocations (delivered message content, +/// background/transcript state) changes the hash and gets its own key. +/// `prompt_id` is folded in only as a readable, non-load-bearing component. +fn subagent_stop_claim_key(root_session_id: &str, agent_id: &str, raw: &Value) -> String { + let prompt_id = raw.get("prompt_id").and_then(|v| v.as_str()).unwrap_or(""); + format!( + "subagent_stop_claimed:{root_session_id}:{agent_id}:{prompt_id}:{}", + hash_raw_payload(raw) + ) +} + +/// kv-table key prefix covering every SubagentStop claim for one root +/// session. See `handle_sessionend`. +fn subagent_stop_claim_prefix(root_session_id: &str) -> String { + format!("subagent_stop_claimed:{root_session_id}:") +} + +/// Atomically claim one SubagentStop invocation for processing — true if +/// this call is the first to claim it, false if a duplicate hook delivery of +/// the identical invocation already did. Must be checked before *any* +/// processing of the invocation (idle-gate, `poll_messages`, teardown), not +/// only before teardown: with duplicate hook registrations, both +/// invocations otherwise enter `poll_messages` independently — one can +/// receive a delivered message (exit_code=2) while the other times out +/// (exit_code=0) and tears the row down while Claude is still acting on the +/// delivery, so the reply fails because the identity is gone. `INSERT OR +/// IGNORE` is a single statement, so SQLite's own write lock makes the claim +/// atomic across concurrent processes without needing an explicit +/// transaction. +fn claim_subagent_stop(db: &HcomDb, root_session_id: &str, agent_id: &str, raw: &Value) -> bool { + db.conn() + .execute( + "INSERT OR IGNORE INTO kv (key, value) VALUES (?, '1')", + rusqlite::params![subagent_stop_claim_key(root_session_id, agent_id, raw)], + ) + // DB error: fail open (proceed with processing) rather than risk + // wedging a dead subagent forever over an unrelated I/O hiccup. + .map(|n| n > 0) + .unwrap_or(true) +} + /// SubagentStop: message polling using agent_id, cleanup on exit. /// /// Returns (exit_code, stdout). exit_code=2 means message delivered /// (SubagentStop fires again). exit_code=0 means cleanup and stop. -fn subagent_stop(db: &HcomDb, raw: &Value, session_id: &str) -> (i32, String) { +fn subagent_stop(db: &HcomDb, root_session_id: &str, raw: &Value) -> (i32, String) { let agent_id = match raw.get("agent_id").and_then(|v| v.as_str()) { Some(id) if !id.is_empty() => id, _ => return (0, String::new()), @@ -1683,14 +2042,22 @@ fn subagent_stop(db: &HcomDb, raw: &Value, session_id: &str) -> (i32, String) { let Some((subagent_name, existing_transcript, parent_name, name_announced)) = row else { // No instance = SubagentStart never allocated one (shouldn't happen for - // in-ctx subagents, but kept as a defensive fallback). Clean up the - // parent's running_tasks entry so it doesn't wedge active. - if let Ok(Some(parent)) = db.get_session_binding(session_id) { - remove_subagent_from_parent(db, &parent, agent_id); - } + // in-ctx subagents, but kept as a defensive fallback). The tracking + // entry could be on the session-bound root *or* a nested subagent + // that spawned this one (see `resolve_spawn_owner`) — scan for + // whichever instance actually tracks `agent_id` rather than assuming + // root, or the true owner is left wedged active forever. + instances::remove_tracked_subagent_wherever_found(db, agent_id); return (0, String::new()); }; + // Claim this exact invocation before any further processing — see + // `claim_subagent_stop` for why this can't wait until the teardown + // decision: a duplicate delivery must not even enter `poll_messages`. + if !claim_subagent_stop(db, root_session_id, agent_id, raw) { + return (0, String::new()); + } + // Store transcript_path if not already set if existing_transcript.is_empty() && let Some(tp) = raw.get("agent_transcript_path").and_then(|v| v.as_str()) @@ -3682,4 +4049,1367 @@ mod tests { drop(_guard); } + + // ---- Hook-actor routing (raw.agent_id, not running_tasks.active) ---- + // + // These are dispatcher-level tests: they drive `route_claude_hook` itself + // (the function `dispatch_claude_hook` calls after reading stdin), not the + // individual handler functions, because the bug class here is a routing + // bug — which branch a given hook payload falls into — not a bug inside + // any one handler. They need `isolated_test_env()` because + // `route_claude_hook` exercises real log::log_info call sites (start_task, + // track_subagent, sessionstart, ...), which resolve the hcom log path + // through the global `Config`; without isolation that would touch the + // real `~/.hcom` of whatever machine runs the test. + + fn make_ctx() -> HcomContext { + HcomContext::from_env(&std::collections::HashMap::new(), PathBuf::from("/tmp")) + } + + fn make_isolated_test_db() -> (tempfile::TempDir, EnvGuard, HcomDb) { + let (dir, hcom_dir, _test_home, guard) = isolated_test_env(); + let db = HcomDb::open_raw(&hcom_dir.join("test.db")).unwrap(); + db.init_db().unwrap(); + (dir, guard, db) + } + + /// Same fixture as `make_delivery_test_db` (instance 'nova' + a pending + /// broadcast message from 'luna'), but under `isolated_test_env()` so + /// dispatcher-level tests that log are safe to run. + fn make_isolated_delivery_test_db() -> (tempfile::TempDir, EnvGuard, HcomDb) { + let (dir, guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.conn() + .execute( + "INSERT INTO events (type, timestamp, instance, data) + VALUES ('message', '2026-01-01T00:00:00Z', 'luna', '{\"from\":\"luna\",\"text\":\"hello\",\"scope\":\"broadcast\"}')", + [], + ) + .unwrap(); + (dir, guard, db) + } + + /// `root_session_id` matches what real `ensure_subagent_row`-created rows + /// carry as `parent_session_id` (always the true root session_id, at any + /// nesting depth — see its doc comment), so fixtures built with this + /// helper pass `validate_spawn_owner`'s hierarchy check the same way a + /// real row would. + fn insert_subagent_row( + db: &HcomDb, + name: &str, + agent_id: &str, + parent_name: &str, + root_session_id: &str, + ) { + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id, agent_id, parent_name, parent_session_id) + VALUES (?, 'claude', 'active', 'subagent', 0, 0, 0, ?, ?, ?)", + rusqlite::params![name, agent_id, parent_name, root_session_id], + ) + .unwrap(); + } + + /// Property: a subagent PostToolUse whose agent_id has no resolvable + /// `instances` row (SubagentStart's row allocation is best-effort and may + /// not have happened, or the row is gone) must never deliver anything — + /// and, critically, must never fall through to root/parent delivery. That + /// fallthrough is exactly what PR #82 proposed (reap on row-missing, then + /// treat root as unfrozen) — unsafe because row-missing is + /// identity/participation state, not a liveness signal. + #[test] + #[serial] + fn test_subagent_posttooluse_unknown_row_never_falls_through_to_parent() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_delivery_test_db(); + db.set_session_binding("sess-1", "nova").unwrap(); + // Parent tracks an agent_id that never got an instances row. + db.conn() + .execute( + "UPDATE instances SET running_tasks = ? WHERE name = 'nova'", + rusqlite::params![ + r#"{"active":true,"subagents":[{"agent_id":"ghost-agent","type":"general"}]}"# + ], + ) + .unwrap(); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "ghost-agent", + "tool_name": "Bash", + "tool_input": {"command": "hcom send --name ghost-agent -- hi"}, + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let (exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload); + + assert_eq!(exit_code, 0); + assert!( + stdout.is_empty(), + "unknown subagent actor must not deliver anything, got: {stdout}" + ); + assert!(ack.is_none()); + // nova's own pending broadcast must remain untouched — no fallthrough. + assert_eq!(delivery_cursor(&db), 0); + } + + /// Property: a subagent-context hook whose row *does* resolve, but which + /// doesn't match any actionable branch (e.g. an ordinary Edit tool call), + /// stays a silent no-op rather than falling through to parent handling. + #[test] + #[serial] + fn test_subagent_hook_unrelated_tool_stays_silent() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + insert_subagent_row(&db, "nova_task_1", "sub-agent-1", "nova", "sess-1"); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "sub-agent-1", + "tool_name": "Edit", + "tool_input": {"file_path": "/tmp/x"}, + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let (exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload); + + assert_eq!(exit_code, 0); + assert!(stdout.is_empty()); + assert!(ack.is_none()); + } + + /// Property: the root's own PostToolUse must deliver even while a + /// genuinely live background subagent is tracked active. Since Claude + /// Code 2.1.198, Agent calls background by default, so the root can have + /// its own interleaved tool calls while a subagent still runs — gating + /// root delivery on `running_tasks.active` (the pre-existing design) + /// would freeze the parent for that whole window. + #[test] + #[serial] + fn test_root_posttooluse_delivers_while_subagent_active() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_delivery_test_db(); + db.set_session_binding("sess-1", "nova").unwrap(); + insert_subagent_row(&db, "nova_task_1", "live-agent-1", "nova", "sess-1"); + db.conn() + .execute( + "UPDATE instances SET running_tasks = ? WHERE name = 'nova'", + rusqlite::params![ + r#"{"active":true,"subagents":[{"agent_id":"live-agent-1","type":"general"}]}"# + ], + ) + .unwrap(); + + // Root's own PostToolUse — no agent_id: this genuinely is nova's own + // tool call, not a hook firing inside the live subagent. + let raw = serde_json::json!({ + "session_id": "sess-1", + "tool_name": "Read", + "tool_input": {}, + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let (_exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload); + + assert!( + !stdout.is_empty(), + "root PostToolUse must deliver even while a background subagent is active" + ); + assert!(stdout.contains("hello")); + assert!(ack.is_some()); + } + + /// Property: a nested Agent/Task PreToolUse — one that fires *inside* a + /// subagent's own execution context because that subagent itself called + /// the Agent tool — must mark the spawning subagent's own running_tasks + /// active, not the root parent's. Parent and every nested subagent share + /// the same Claude session_id, so resolving the actor via + /// `get_session_binding(session_id)` (the pre-existing design) always + /// lands on the root regardless of which level actually spawned the call. + #[test] + #[serial] + fn test_nested_task_pre_updates_subagent_not_root() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + insert_subagent_row(&db, "nova_task_1", "sub-agent-1", "nova", "sess-1"); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "sub-agent-1", + "tool_name": "Task", + "tool_input": {"prompt": "spawn a nested helper"}, + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let _ = route_claude_hook(&db, &ctx, HOOK_PRE, &mut payload); + + let sub_rt = instances::parse_running_tasks( + db.get_instance_full("nova_task_1") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + sub_rt.active, + "the spawning subagent's own running_tasks must be marked active" + ); + + let root_rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + !root_rt.active, + "the root parent's running_tasks must not be touched by a nested Task call" + ); + } + + /// Property: PostToolUse for the Agent/Task tool fires with + /// `tool_response.status == "async_launched"` when Claude merely + /// dispatched the call to the background (default since Claude Code + /// 2.1.198) — this is not completion, so it must not deliver a + /// "Subagents have finished" summary and must not advance the delivery + /// cursor. This test only asserts that conservative non-behavior; it + /// does not model or imply what a genuine completion for a backgrounded + /// call looks like on the wire (not yet live-verified — see + /// `test_task_posttooluse_foreground_completed_delivers` for the + /// separate, independently-verified foreground path). + #[test] + #[serial] + fn test_task_posttooluse_async_launch_skips_delivery() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_delivery_test_db(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + let raw_async = serde_json::json!({ + "session_id": "sess-1", + "tool_name": "Agent", + "tool_response": {"status": "async_launched"}, + }); + let mut payload_async = HookPayload::from_claude(raw_async); + let (_exit_code, stdout_async, ack_async, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_async); + assert!( + stdout_async.is_empty(), + "async_launched must not be treated as Task completion, got: {stdout_async}" + ); + assert!(ack_async.is_none()); + assert_eq!( + delivery_cursor(&db), + 0, + "async_launched must not advance the delivery cursor" + ); + } + + /// Property: a foreground (synchronous, non-backgrounded) Agent/Task + /// PostToolUse — no `async_launched` status — is a genuine completion and + /// must deliver freeze-period messages normally. Independent of the + /// async_launched test above: this is not "the same call, later", it's + /// the separately-exercised foreground path. + #[test] + #[serial] + fn test_task_posttooluse_foreground_completed_delivers() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_delivery_test_db(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + let raw_done = serde_json::json!({ + "session_id": "sess-1", + "tool_name": "Agent", + "tool_response": {"status": "completed"}, + }); + let mut payload_done = HookPayload::from_claude(raw_done); + let (_exit_code, stdout_done, _ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_done); + assert!( + stdout_done.contains("hello"), + "a foreground Task completion must deliver freeze messages, got: {stdout_done}" + ); + assert!(delivery_cursor(&db) > 0); + } + + /// Property: the `--name` a subagent passes to a Bash hcom command must + /// match the agent_id Claude itself stamped on this hook (raw.agent_id), + /// or one subagent could spoof another's `--name` and read its inbox. + #[test] + #[serial] + fn test_subagent_bash_name_mismatch_does_not_leak_other_inbox() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + insert_subagent_row(&db, "nova_task_1", "agent-a", "nova", "sess-1"); + insert_subagent_row(&db, "nova_task_2", "agent-b", "nova", "sess-1"); + // A direct mention pending for subagent B's inbox only. + db.conn() + .execute( + "INSERT INTO events (type, timestamp, instance, data) + VALUES ('message', '2026-01-01T00:00:00Z', 'luna', '{\"from\":\"luna\",\"text\":\"secret for b\",\"scope\":\"mentions\",\"mentions\":[\"nova_task_2\"]}')", + [], + ) + .unwrap(); + let ctx = make_ctx(); + + // Hook fires inside subagent A's own context (agent_id=agent-a), but + // the Bash command spoofs --name agent-b. + let raw_spoof = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-a", + "tool_name": "Bash", + "tool_input": {"command": "hcom send --name agent-b -- hi"}, + }); + let mut payload_spoof = HookPayload::from_claude(raw_spoof); + let (exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_spoof); + assert_eq!(exit_code, 0); + assert!( + stdout.is_empty(), + "mismatched --name must not deliver another subagent's inbox, got: {stdout}" + ); + assert!(ack.is_none()); + + // The legitimate case still works: agent_id and --name match. + let raw_ok = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-b", + "tool_name": "Bash", + "tool_input": {"command": "hcom send --name agent-b -- hi"}, + }); + let mut payload_ok = HookPayload::from_claude(raw_ok); + let (_exit_code, stdout_ok, ack_ok, _timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_ok); + assert!( + stdout_ok.contains("secret for b"), + "matching --name must deliver its own inbox, got: {stdout_ok}" + ); + assert!(ack_ok.is_some()); + } + + /// Property: `running_tasks` is a whole-JSON-blob column mutated by + /// separate hook processes (each hook invocation opens its own DB + /// connection). Concurrent SubagentStart hooks for sibling subagents of + /// the same parent (parallel Task calls) must not race a plain + /// read-then-write into a lost update. + #[test] + #[serial] + fn test_mutate_running_tasks_concurrent_tracking_no_lost_updates() { + crate::config::Config::init(); + let (_dir, hcom_dir, _test_home, _guard) = isolated_test_env(); + let db_path = hcom_dir.join("test.db"); + let db = HcomDb::open_raw(&db_path).unwrap(); + db.init_db().unwrap(); + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + + let n: usize = 8; + let handles: Vec<_> = (0..n) + .map(|i| { + let path = db_path.clone(); + std::thread::spawn(move || { + let db = HcomDb::open_raw(&path).unwrap(); + track_subagent(&db, "nova", &format!("agent-{i}"), "general"); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + let rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert_eq!( + rt.subagents.len(), + n, + "concurrent SubagentStart tracking must not lose sibling entries to a read-modify-write race, got {:?}", + rt.subagents + ); + assert!(rt.active); + for i in 0..n { + let want = format!("agent-{i}"); + assert!( + rt.subagents + .iter() + .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(want.as_str())), + "missing {want}" + ); + } + } + + /// Property: concurrent SubagentStop-driven removals for sibling + /// subagents must not race a plain read-then-write into a lost update + /// either — an entry silently surviving a lost removal is exactly what + /// leaves `running_tasks.active` stuck true (the original stale-freeze + /// symptom). + #[test] + #[serial] + fn test_mutate_running_tasks_concurrent_removal_no_lost_updates() { + crate::config::Config::init(); + let (_dir, hcom_dir, _test_home, _guard) = isolated_test_env(); + let db_path = hcom_dir.join("test.db"); + let db = HcomDb::open_raw(&db_path).unwrap(); + db.init_db().unwrap(); + + let n: usize = 8; + let subagents: Vec = (0..n) + .map(|i| serde_json::json!({"agent_id": format!("agent-{i}"), "type": "general"})) + .collect(); + let rt_json = serde_json::json!({"active": true, "subagents": subagents}); + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id, running_tasks) + VALUES ('nova', 'claude', 'listening', 'start', 0, 0, 0, ?)", + rusqlite::params![rt_json.to_string()], + ) + .unwrap(); + + let handles: Vec<_> = (0..n) + .map(|i| { + let path = db_path.clone(); + std::thread::spawn(move || { + let db = HcomDb::open_raw(&path).unwrap(); + remove_subagent_from_parent(&db, "nova", &format!("agent-{i}")); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + let rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + rt.subagents.is_empty(), + "concurrent removal must not lose sibling removals to a read-modify-write race, got {:?}", + rt.subagents + ); + assert!(!rt.active); + } + + /// Property: every Claude subagent carries `agent_id` on its hooks + /// regardless of whether its root ever ran `hcom start` — that's a + /// property of Claude's hook schema, not of hcom participation. + /// SubagentStart must stay a silent no-op (no `hcom start --name ...` + /// hint, no allocated row, no running_tasks mutation) when the shared + /// session_id has no hcom root binding at all. + #[test] + #[serial] + fn test_subagent_start_nonparticipant_root_stays_silent() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + // No session binding: "sess-1" is not an hcom participant. + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "child-1", + "agent_type": "general", + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let (exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload); + + assert_eq!(exit_code, 0); + assert!( + stdout.is_empty(), + "nonparticipant SubagentStart must not inject an hcom hint, got: {stdout}" + ); + assert!(ack.is_none()); + assert!( + db.get_instance_by_agent_id("child-1").unwrap().is_none(), + "nonparticipant SubagentStart must not allocate an instances row" + ); + } + + /// Property: a SubagentStart's own `agent_id` names the *new* child, not + /// who spawned it, so a nested spawn (subagent A calling the Agent tool) + /// can only be attributed correctly via the `prompt_id` Claude repeats + /// across the spawning PreToolUse and the resulting SubagentStart(s) (see + /// `resolve_spawn_owner`). Covers the full sequence: nested Pre on A, + /// then SubagentStart for child B with a matching prompt_id, must + /// attribute B to A (not root) — and a genuinely top-level spawn (root's + /// own Pre, no agent_id) must still attribute to root. + #[test] + #[serial] + fn test_nested_subagent_start_resolves_via_prompt_id_not_root() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id, running_tasks) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0, ?)", + rusqlite::params![ + r#"{"active":true,"subagents":[{"agent_id":"agent-a","type":"general"}]}"# + ], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + insert_subagent_row(&db, "nova_task_1", "agent-a", "nova", "sess-1"); + let ctx = make_ctx(); + + // Nested Task PreToolUse fires *inside* subagent A (agent_id=agent-a), + // carrying the prompt_id Claude repeats on the SubagentStart for the + // child it spawns. + let raw_pre = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-a", + "prompt_id": "p1", + "tool_name": "Task", + "tool_input": {"prompt": "spawn a nested helper"}, + }); + let mut payload_pre = HookPayload::from_claude(raw_pre); + let _ = route_claude_hook(&db, &ctx, HOOK_PRE, &mut payload_pre); + + // SubagentStart for the new child B: its own agent_id names B, not A + // — only the shared prompt_id says who spawned it. + let raw_start = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-b", + "agent_type": "general", + "prompt_id": "p1", + }); + let mut payload_start = HookPayload::from_claude(raw_start); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_start); + + let b_name = db.get_instance_by_agent_id("agent-b").unwrap().unwrap(); + let b_row = db.get_instance_full(&b_name).unwrap().unwrap(); + assert_eq!( + b_row.parent_name.as_deref(), + Some("nova_task_1"), + "the nested child must be attributed to the spawning subagent, not root" + ); + + let a_rt = instances::parse_running_tasks( + db.get_instance_full("nova_task_1") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + a_rt.subagents + .iter() + .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some("agent-b")), + "the spawning subagent must track its own child" + ); + + let root_rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + !root_rt + .subagents + .iter() + .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some("agent-b")), + "root must not be credited with a grandchild it never spawned" + ); + assert_eq!( + root_rt.subagents.len(), + 1, + "root's own directly-tracked subagent (A) must be untouched" + ); + + // A genuinely top-level spawn (root's own Pre, no agent_id) must + // still resolve via the same correlation to root itself. + let raw_pre2 = serde_json::json!({ + "session_id": "sess-1", + "prompt_id": "p2", + "tool_name": "Task", + "tool_input": {"prompt": "spawn a top-level helper"}, + }); + let mut payload_pre2 = HookPayload::from_claude(raw_pre2); + let _ = route_claude_hook(&db, &ctx, HOOK_PRE, &mut payload_pre2); + + let raw_start2 = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-c", + "agent_type": "general", + "prompt_id": "p2", + }); + let mut payload_start2 = HookPayload::from_claude(raw_start2); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_start2); + + let c_name = db.get_instance_by_agent_id("agent-c").unwrap().unwrap(); + let c_row = db.get_instance_full(&c_name).unwrap().unwrap(); + assert_eq!( + c_row.parent_name.as_deref(), + Some("nova"), + "a genuinely top-level spawn must still attribute to root" + ); + } + + /// Property: a SubagentStart with no `prompt_id` field at all (Claude + /// Code < 2.1.196, where this correlation doesn't exist on the wire) + /// must still attach to root — the pre-2.1.196 legacy behavior, not + /// nested-spawn support. + #[test] + #[serial] + fn test_subagent_start_without_prompt_id_uses_legacy_root_attribution() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-legacy", + "agent_type": "general", + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload); + + let name = db + .get_instance_by_agent_id("agent-legacy") + .unwrap() + .unwrap(); + let row = db.get_instance_full(&name).unwrap().unwrap(); + assert_eq!(row.parent_name.as_deref(), Some("nova")); + } + + /// Property: `prompt_id` is present (Claude Code >= 2.1.196) but no + /// mapping resolves — start_task's write hasn't landed yet, or never + /// will. Correlation failure must fail closed: no allocated row, no + /// running_tasks mutation anywhere (root included), no bootstrap hint. + /// Root is not a safe fallback here — unlike the no-`prompt_id`-at-all + /// case, a version that *does* send `prompt_id` and still misses means + /// something is actually wrong. + #[test] + #[serial] + fn test_subagent_start_with_prompt_id_but_no_mapping_fails_closed() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + // No preceding Task/Agent PreToolUse ever recorded this prompt_id. + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "orphan-1", + "agent_type": "general", + "prompt_id": "p-missing", + }); + let mut payload = HookPayload::from_claude(raw); + let (exit_code, stdout, ack, _timing) = + route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload); + + assert_eq!(exit_code, 0); + assert!( + stdout.is_empty(), + "an unresolved prompt_id must not inject an hcom hint, got: {stdout}" + ); + assert!(ack.is_none()); + assert!( + db.get_instance_by_agent_id("orphan-1").unwrap().is_none(), + "an unresolved prompt_id must not allocate an instances row" + ); + let root_rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + root_rt.subagents.is_empty(), + "an unresolved prompt_id must not fall back to crediting root" + ); + } + + /// Property: multiple children spawned in parallel by one actor within + /// one turn share the same `prompt_id`. If an earlier sibling's own + /// Task/Agent PostToolUse completes *before* a later sibling's + /// SubagentStart arrives, the shared mapping must survive — completion + /// of one sibling must not delete a mapping the others still need (the + /// interleaving race `end_task` used to be vulnerable to when it deleted + /// the mapping per-completion; see `spawn_owner_kv_key`). + #[test] + #[serial] + fn test_parallel_siblings_survive_interleaved_sibling_completion() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + // Root issues (what will become) two parallel Task calls in one + // turn — Claude stamps both with the same prompt_id. + let raw_pre = serde_json::json!({ + "session_id": "sess-1", + "prompt_id": "p1", + "tool_name": "Task", + "tool_input": {"prompt": "spawn two parallel helpers"}, + }); + let mut payload_pre = HookPayload::from_claude(raw_pre); + let _ = route_claude_hook(&db, &ctx, HOOK_PRE, &mut payload_pre); + + // First sibling starts. + let raw_start1 = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-1", + "agent_type": "general", + "prompt_id": "p1", + }); + let mut payload_start1 = HookPayload::from_claude(raw_start1); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_start1); + + // That Task tool_use's own PostToolUse completes — interleaved + // *before* the second sibling's SubagentStart arrives. + let raw_post = serde_json::json!({ + "session_id": "sess-1", + "prompt_id": "p1", + "tool_name": "Task", + "tool_response": {"status": "completed"}, + }); + let mut payload_post = HookPayload::from_claude(raw_post); + let _ = route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_post); + + // Second sibling starts, same shared prompt_id. + let raw_start2 = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-2", + "agent_type": "general", + "prompt_id": "p1", + }); + let mut payload_start2 = HookPayload::from_claude(raw_start2); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_start2); + + let name1 = db.get_instance_by_agent_id("agent-1").unwrap().unwrap(); + let name2 = db.get_instance_by_agent_id("agent-2").unwrap(); + assert!( + name2.is_some(), + "the second sibling must still resolve its owner after the first sibling's PostToolUse completed" + ); + let row1 = db.get_instance_full(&name1).unwrap().unwrap(); + let row2 = db.get_instance_full(&name2.unwrap()).unwrap().unwrap(); + assert_eq!(row1.parent_name.as_deref(), Some("nova")); + assert_eq!( + row2.parent_name.as_deref(), + Some("nova"), + "both siblings must attach to the same true owner despite the interleaved completion" + ); + } + + /// Property: spawn-owner mappings are cleaned up per-session at + /// SessionEnd (see `handle_sessionend`), not per-Task-completion — must + /// remove only the ending session's own keys, never another session's. + #[test] + #[serial] + fn test_sessionend_cleans_only_its_own_session_spawn_owner_keys() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + db.kv_set(&spawn_owner_kv_key("sess-1", "p1"), Some("nova")) + .unwrap(); + db.kv_set(&spawn_owner_kv_key("sess-other", "p1"), Some("other-root")) + .unwrap(); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "reason": "clear", + }); + let mut payload = HookPayload::from_claude(raw); + let _ = route_claude_hook(&db, &ctx, HOOK_SESSIONEND, &mut payload); + + assert!( + db.kv_get(&spawn_owner_kv_key("sess-1", "p1")) + .unwrap() + .is_none(), + "SessionEnd must clean up its own session's spawn-owner mappings" + ); + assert_eq!( + db.kv_get(&spawn_owner_kv_key("sess-other", "p1")).unwrap(), + Some("other-root".to_string()), + "SessionEnd must not touch a different session's spawn-owner mappings" + ); + } + + #[test] + fn test_spawn_owner_kv_key_is_session_scoped() { + assert_eq!(spawn_owner_kv_key("sess-1", "p1"), "spawn_owner:sess-1:p1"); + assert_ne!( + spawn_owner_kv_key("sess-1", "p1"), + spawn_owner_kv_key("sess-2", "p1") + ); + assert!(spawn_owner_kv_key("sess-1", "p1").starts_with(&spawn_owner_kv_prefix("sess-1"))); + } + + /// Property: when SubagentStop's own `instances` row is missing, the + /// stale running_tasks entry could be on the session-bound root *or* a + /// nested subagent that actually spawned it — removal must find and + /// clear it wherever it really is, not assume root. + #[test] + #[serial] + fn test_subagent_stop_missing_row_removes_from_actual_nested_owner() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id, running_tasks) + VALUES ('nova', 'claude', 'listening', 'start', 0, 0, 0, ?)", + rusqlite::params![ + r#"{"active":true,"subagents":[{"agent_id":"agent-a","type":"general"}]}"# + ], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id, agent_id, parent_name, running_tasks) + VALUES ('nova_task_1', 'claude', 'active', 'subagent', 0, 0, 0, 'agent-a', 'nova', ?)", + rusqlite::params![ + r#"{"active":true,"subagents":[{"agent_id":"agent-b","type":"general"}]}"# + ], + ) + .unwrap(); + // B (agent-b) has no instances row at all — allocation never + // happened, or the row is gone. + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-b", + }); + let mut payload = HookPayload::from_claude(raw); + let ctx = make_ctx(); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_STOP, &mut payload); + + let a_rt = instances::parse_running_tasks( + db.get_instance_full("nova_task_1") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert!( + a_rt.subagents.is_empty(), + "the actual nested owner (A) must have the dead child reaped" + ); + assert!(!a_rt.active); + + let root_rt = instances::parse_running_tasks( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .running_tasks + .as_deref(), + ); + assert_eq!( + root_rt.subagents.len(), + 1, + "root must be untouched — it never tracked the nested child directly" + ); + } + + // ---- Resumed-agent correlation (agent_owner) ---- + + /// Property: a resumed subagent — Claude re-firing SubagentStart for a + /// previously-known `agent_id` under a *new* `prompt_id` with no + /// corresponding PreToolUse to map it (live-verified: resume doesn't + /// re-run the spawning Task/Agent PreToolUse) — must reattach to its + /// original owner via the session-scoped agent_owner memory, not fail + /// closed. Sequential: the original subagent fully spawns and stops + /// (row deleted) before the resume fires. + #[test] + #[serial] + fn test_resumed_agent_id_reattaches_via_agent_owner_after_original_stopped() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + // Original spawn. + let raw_pre = serde_json::json!({ + "session_id": "sess-1", + "prompt_id": "p1", + "tool_name": "Task", + "tool_input": {"prompt": "do a thing"}, + }); + let mut payload_pre = HookPayload::from_claude(raw_pre); + let _ = route_claude_hook(&db, &ctx, HOOK_PRE, &mut payload_pre); + + let raw_start = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "agent_type": "general", + "prompt_id": "p1", + }); + let mut payload_start = HookPayload::from_claude(raw_start); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_start); + let original_name = db.get_instance_by_agent_id("agent-x").unwrap().unwrap(); + assert_eq!( + db.get_instance_full(&original_name) + .unwrap() + .unwrap() + .parent_name + .as_deref(), + Some("nova") + ); + + // It stops (dormant + no direct message => immediate idle stop). + let raw_stop = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "prompt_id": "p1", + }); + let mut payload_stop = HookPayload::from_claude(raw_stop); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_STOP, &mut payload_stop); + assert!( + db.get_instance_by_agent_id("agent-x").unwrap().is_none(), + "row must be gone after stop" + ); + + // Resume: same agent_id, new prompt_id, no PreToolUse for it. + let raw_resume = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "agent_type": "general", + "prompt_id": "p2", + }); + let mut payload_resume = HookPayload::from_claude(raw_resume); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_resume); + + let resumed_name = db.get_instance_by_agent_id("agent-x").unwrap(); + assert!( + resumed_name.is_some(), + "resume must not fail closed just because its new prompt_id has no mapping" + ); + let resumed_row = db + .get_instance_full(&resumed_name.unwrap()) + .unwrap() + .unwrap(); + assert_eq!( + resumed_row.parent_name.as_deref(), + Some("nova"), + "resume must reattach to the true original owner via agent_owner memory" + ); + } + + /// Property: the resumed subagent's next PostToolUse must resolve its + /// identity (not the reported `unknown_subagent_actor` fail-closed path) + /// once resume has reattached it — otherwise `hcom list`/`send` can't see + /// a subagent Claude's own TUI still shows as live. + #[test] + #[serial] + fn test_resumed_agent_posttooluse_resolves_actor_not_unknown() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + // Pre-seed agent_owner as if this agent_id was spawned + stopped + // once already (see the sequential test above for the full path). + db.kv_set(&agent_owner_kv_key("sess-1", "agent-x"), Some("nova")) + .unwrap(); + let ctx = make_ctx(); + + let raw_resume = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "agent_type": "general", + "prompt_id": "p2", + }); + let mut payload_resume = HookPayload::from_claude(raw_resume); + let _ = route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload_resume); + assert!(db.get_instance_by_agent_id("agent-x").unwrap().is_some()); + + let raw_post = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "tool_name": "Read", + "tool_input": {}, + }); + let mut payload_post = HookPayload::from_claude(raw_post); + let (exit_code, _stdout, _ack, timing) = + route_claude_hook(&db, &ctx, HOOK_POST, &mut payload_post); + assert_eq!(exit_code, 0); + assert_ne!( + timing.result, + Some("unknown_subagent_actor"), + "the resumed agent's own row must resolve, not fail closed as unknown" + ); + } + + /// Property: concurrent duplicate hook delivery (the other live finding) + /// combined with a resume must still converge on the one true owner — + /// no split attribution, no lost row allocation. + #[test] + #[serial] + fn test_concurrent_resumed_subagent_start_resolves_to_same_owner() { + crate::config::Config::init(); + let (_dir, hcom_dir, _test_home, _guard) = isolated_test_env(); + let db_path = hcom_dir.join("test.db"); + let db = HcomDb::open_raw(&db_path).unwrap(); + db.init_db().unwrap(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + db.kv_set(&agent_owner_kv_key("sess-1", "agent-x"), Some("nova")) + .unwrap(); + + let n = 4; + let handles: Vec<_> = (0..n) + .map(|_| { + let path = db_path.clone(); + std::thread::spawn(move || { + let db = HcomDb::open_raw(&path).unwrap(); + let ctx = make_ctx(); + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "agent_type": "general", + "prompt_id": "p-resume", + }); + let mut payload = HookPayload::from_claude(raw); + route_claude_hook(&db, &ctx, HOOK_SUBAGENT_START, &mut payload) + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + let name = db.get_instance_by_agent_id("agent-x").unwrap(); + assert!( + name.is_some(), + "concurrent resumed SubagentStart must not fail closed" + ); + let row = db.get_instance_full(&name.unwrap()).unwrap().unwrap(); + assert_eq!(row.parent_name.as_deref(), Some("nova")); + } + + // ---- Duplicate hook delivery idempotency (SubagentStop) ---- + + /// Property: the claim key must collapse a byte-identical duplicate + /// invocation, but must NOT collapse a same-`prompt_id` invocation whose + /// payload actually differs (SubagentStop legitimately re-fires within + /// one prompt/continuation after an exit_code=2 delivery — we have not + /// established Claude changes `prompt_id` for that re-fire), and must + /// not collapse a genuinely different `prompt_id` either. + #[test] + fn test_subagent_stop_claim_key_semantics() { + let raw = serde_json::json!({"agent_id": "a1", "prompt_id": "p1", "x": 1}); + let raw_dup = serde_json::json!({"agent_id": "a1", "prompt_id": "p1", "x": 1}); + assert_eq!( + subagent_stop_claim_key("sess-1", "a1", &raw), + subagent_stop_claim_key("sess-1", "a1", &raw_dup), + "byte-identical payloads must collapse to the same key" + ); + + let raw_same_prompt_diff_payload = + serde_json::json!({"agent_id": "a1", "prompt_id": "p1", "x": 2}); + assert_ne!( + subagent_stop_claim_key("sess-1", "a1", &raw), + subagent_stop_claim_key("sess-1", "a1", &raw_same_prompt_diff_payload), + "same prompt_id with different payload content must not collapse" + ); + + let raw_diff_prompt = serde_json::json!({"agent_id": "a1", "prompt_id": "p2", "x": 1}); + assert_ne!( + subagent_stop_claim_key("sess-1", "a1", &raw), + subagent_stop_claim_key("sess-1", "a1", &raw_diff_prompt), + "different prompt_id must not collapse" + ); + } + + /// Property: `claim_subagent_stop` is a true exactly-once latch — first + /// call wins, an identical repeat loses, and a same-prompt-but-different + /// payload (a legitimate re-fire, not a duplicate) gets its own claim. + #[test] + #[serial] + fn test_claim_subagent_stop_collapses_duplicate_invocation() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + let raw = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1"}); + assert!( + claim_subagent_stop(&db, "sess-1", "agent-x", &raw), + "first claim must succeed" + ); + assert!( + !claim_subagent_stop(&db, "sess-1", "agent-x", &raw), + "duplicate identical invocation must not re-claim" + ); + + let raw_different_payload = + serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1", "note": "different"}); + assert!( + claim_subagent_stop(&db, "sess-1", "agent-x", &raw_different_payload), + "same prompt_id but different payload content must be its own claim" + ); + } + + /// Property: `subagent_stop` must check the claim *before* the idle gate + /// and before `poll_messages` — a duplicate invocation must skip all + /// processing, not just the final teardown. This is what the live + /// teardown-window failure traced back to: with duplicate hook + /// registrations, one invocation could receive a delivered message + /// (exit_code=2) while the other, unclaimed, independently timed out and + /// deleted the row out from under it. + #[test] + #[serial] + fn test_subagent_stop_skips_all_processing_when_already_claimed() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + insert_subagent_row(&db, "nova_task_1", "agent-x", "nova", "sess-1"); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "prompt_id": "p1", + }); + // Simulate a concurrent duplicate having already claimed this exact + // invocation. + assert!(claim_subagent_stop(&db, "sess-1", "agent-x", &raw)); + + let (exit_code, stdout) = subagent_stop(&db, "sess-1", &raw); + assert_eq!(exit_code, 0); + assert!(stdout.is_empty()); + assert!( + db.get_instance_by_agent_id("agent-x").unwrap().is_some(), + "an already-claimed duplicate must not delete the row" + ); + let stopped_events: i64 = db + .conn() + .query_row( + "SELECT COUNT(*) FROM events WHERE type = 'life' + AND json_extract(data, '$.action') = 'stopped' + AND json_extract(data, '$.snapshot.agent_id') = 'agent-x'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stopped_events, 0, + "an already-claimed duplicate must not log a second life.stopped event" + ); + } + + /// Property: genuinely concurrent duplicate SubagentStop hook delivery + /// (separate DB connections, as separate hook processes would be) for + /// the same dormant subagent must produce exactly one teardown — one + /// life.stopped event, one row deletion — not one per invocation. + #[test] + #[serial] + fn test_concurrent_duplicate_subagent_stop_produces_one_teardown() { + crate::config::Config::init(); + let (_dir, hcom_dir, _test_home, _guard) = isolated_test_env(); + let db_path = hcom_dir.join("test.db"); + let db = HcomDb::open_raw(&db_path).unwrap(); + db.init_db().unwrap(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + insert_subagent_row(&db, "nova_task_1", "agent-x", "nova", "sess-1"); + + let n = 4; + let handles: Vec<_> = (0..n) + .map(|_| { + let path = db_path.clone(); + std::thread::spawn(move || { + let db = HcomDb::open_raw(&path).unwrap(); + // Identical payload from every "duplicate hook registration". + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "prompt_id": "p1", + }); + subagent_stop(&db, "sess-1", &raw) + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + assert!( + db.get_instance_by_agent_id("agent-x").unwrap().is_none(), + "the subagent must end up stopped exactly like a single invocation would" + ); + let stopped_events: i64 = db + .conn() + .query_row( + "SELECT COUNT(*) FROM events WHERE type = 'life' + AND json_extract(data, '$.action') = 'stopped' + AND json_extract(data, '$.snapshot.agent_id') = 'agent-x'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stopped_events, 1, + "concurrent duplicate delivery must log exactly one life.stopped event, not {n}" + ); + } + + /// Property: SessionEnd sweeps agent_owner and subagent_stop_claim + /// entries the same way it sweeps spawn_owner — only for its own + /// session. + #[test] + #[serial] + fn test_sessionend_cleans_agent_owner_and_stop_claim_keys_too() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + let ctx = make_ctx(); + + db.kv_set(&agent_owner_kv_key("sess-1", "agent-x"), Some("nova")) + .unwrap(); + db.kv_set(&agent_owner_kv_key("sess-other", "agent-x"), Some("other")) + .unwrap(); + let stop_raw = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1"}); + db.kv_set( + &subagent_stop_claim_key("sess-1", "agent-x", &stop_raw), + Some("1"), + ) + .unwrap(); + db.kv_set( + &subagent_stop_claim_key("sess-other", "agent-x", &stop_raw), + Some("1"), + ) + .unwrap(); + + let raw = serde_json::json!({"session_id": "sess-1", "reason": "clear"}); + let mut payload = HookPayload::from_claude(raw); + let _ = route_claude_hook(&db, &ctx, HOOK_SESSIONEND, &mut payload); + + assert!( + db.kv_get(&agent_owner_kv_key("sess-1", "agent-x")) + .unwrap() + .is_none() + ); + assert!( + db.kv_get(&subagent_stop_claim_key("sess-1", "agent-x", &stop_raw)) + .unwrap() + .is_none() + ); + assert_eq!( + db.kv_get(&agent_owner_kv_key("sess-other", "agent-x")) + .unwrap(), + Some("other".to_string()), + "a different session's agent_owner keys must survive" + ); + assert_eq!( + db.kv_get(&subagent_stop_claim_key("sess-other", "agent-x", &stop_raw)) + .unwrap(), + Some("1".to_string()), + "a different session's stop-claim keys must survive" + ); + } } diff --git a/src/instances.rs b/src/instances.rs index 5b00e773..f2ad225f 100644 --- a/src/instances.rs +++ b/src/instances.rs @@ -6,6 +6,7 @@ use crate::db::{HcomDb, InstanceRow}; use crate::shared::ST_INACTIVE; +use rusqlite::OptionalExtension; pub fn is_remote_instance(data: &InstanceRow) -> bool { data.origin_device_id.is_some() @@ -77,6 +78,122 @@ pub fn parse_running_tasks(json_str: Option<&str>) -> RunningTasks { } } +/// Atomically read-modify-write an instance's `running_tasks` JSON column. +/// +/// `running_tasks` is a whole-JSON-blob field (`{"active":bool,"subagents":[...]}`) +/// mutated by SubagentStart/SubagentStop/Task-tool hooks. Each hook invocation +/// is a separate process with its own DB connection, so a plain read-then-write +/// (SELECT then UPDATE as two statements) races: parallel SubagentStart/ +/// SubagentStop for sibling subagents of the same parent can each read the +/// same starting JSON and clobber each other's update. Wrapping the +/// read-modify-write in a `BEGIN IMMEDIATE` transaction serializes concurrent +/// mutators through SQLite's write lock instead. +pub fn mutate_running_tasks(db: &HcomDb, name: &str, mutate: impl FnOnce(&mut RunningTasks)) { + let result = db.with_transaction(|txn| { + let current: Option = txn + .query_row( + "SELECT running_tasks FROM instances WHERE name = ?", + rusqlite::params![name], + |row| row.get(0), + ) + .optional()?; + + let mut running_tasks = parse_running_tasks(current.as_deref()); + mutate(&mut running_tasks); + let rt_json = serde_json::json!({ + "active": running_tasks.active, + "subagents": running_tasks.subagents, + }); + txn.execute( + "UPDATE instances SET running_tasks = ? WHERE name = ?", + rusqlite::params![rt_json.to_string(), name], + )?; + Ok(()) + }); + + if let Err(e) = result { + crate::log::log_error( + "core", + "db.error", + &format!("mutate_running_tasks: {} - {}", name, e), + ); + } +} + +/// Find whichever instance currently tracks `agent_id` in its +/// `running_tasks.subagents`, and atomically remove it from there. +/// +/// Used when a caller can't name the owner directly (e.g. SubagentStop's own +/// `instances` row is missing, so `parent_name` can't be read off it) — the +/// owner could be the session-bound root *or* a nested subagent that spawned +/// this one, so this scans rather than assuming root. No-ops if nothing +/// tracks `agent_id`. +pub fn remove_tracked_subagent_wherever_found(db: &HcomDb, agent_id: &str) { + let result = db.with_transaction(|txn| { + let owner: Option = { + let mut stmt = txn.prepare( + "SELECT name, running_tasks FROM instances + WHERE running_tasks IS NOT NULL AND running_tasks != ''", + )?; + // Collect first so a row-decode error surfaces as a hard error + // (propagated via `?` below) instead of being silently treated + // as "no owner found" — this is correctness-critical cleanup, a + // read failure must not look identical to a clean miss. + let rows: Vec<(String, Option)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + })? + .collect::>>()?; + rows.into_iter() + .find(|(_, rt)| { + parse_running_tasks(rt.as_deref()) + .subagents + .iter() + .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(agent_id)) + }) + .map(|(name, _)| name) + }; + + let Some(owner) = owner else { + return Ok(()); + }; + + let current: Option = txn + .query_row( + "SELECT running_tasks FROM instances WHERE name = ?", + rusqlite::params![owner], + |row| row.get(0), + ) + .optional()?; + let mut rt = parse_running_tasks(current.as_deref()); + rt.subagents + .retain(|s| s.get("agent_id").and_then(|v| v.as_str()) != Some(agent_id)); + if rt.subagents.is_empty() { + rt.active = false; + } + let rt_json = serde_json::json!({ + "active": rt.active, + "subagents": rt.subagents, + }); + txn.execute( + "UPDATE instances SET running_tasks = ? WHERE name = ?", + rusqlite::params![rt_json.to_string(), owner], + )?; + Ok(()) + }); + + if let Err(e) = result { + crate::log::log_error( + "core", + "db.error", + &format!( + "remove_tracked_subagent_wherever_found: {} - {}", + agent_id, e + ), + ); + } +} + /// Update instance position atomically. /// If instance doesn't exist, UPDATE silently affects 0 rows. pub fn update_instance_position( diff --git a/tests/support/claude_mock.rs b/tests/support/claude_mock.rs index 1082e1d7..77fc336d 100644 --- a/tests/support/claude_mock.rs +++ b/tests/support/claude_mock.rs @@ -18,11 +18,16 @@ use super::real_tool::{ FORK_PROOF, INBOUND_PROOF, INITIAL_PROOF, RESUME_PROOF, ScenarioIds, ToolCase, ToolMeta, }; +// Pinned at >= 2.1.198 (not just >= 2.1.196 for `prompt_id`): 2.1.198 is also +// where Agent/Task calls started backgrounding by default +// (tool_response.status="async_launched"), which hcom's hook routing must +// handle. Pinning below 2.1.198 would let real-tool CI pass without ever +// exercising either behavior. const CLAUDE_META: ToolMeta = ToolMeta { tool: "claude", binary: "claude", - pinned_version: "2.1.185", - install_command: "npm install --global @anthropic-ai/claude-code@2.1.185", + pinned_version: "2.1.216", + install_command: "npm install --global @anthropic-ai/claude-code@2.1.216", }; pub const MODEL: &str = "claude-sonnet-4-6"; diff --git a/tests/test_relay_roundtrip.rs b/tests/test_relay_roundtrip.rs index 43d5b83e..e42d49f4 100644 --- a/tests/test_relay_roundtrip.rs +++ b/tests/test_relay_roundtrip.rs @@ -1260,7 +1260,7 @@ fn test_relay_roundtrip() { logln!(log, "\n[Phase 7] Device A: remote launch on Device B..."); let claude_version = - std::env::var("HCOM_TEST_CLAUDE_VERSION").unwrap_or_else(|_| "2.1.185".to_string()); + std::env::var("HCOM_TEST_CLAUDE_VERSION").unwrap_or_else(|_| "2.1.216".to_string()); assert_tool_pinned( "claude", &claude_version, From ba4e77ffa297029972cefcb5b0eac1a5050ac658 Mon Sep 17 00:00:00 2001 From: aannoo Date: Wed, 22 Jul 2026 00:45:25 +0200 Subject: [PATCH 2/6] fix(claude): make subagent alloc atomic and stop-claim transient Two correctness bugs in the native-subagent actor routing surfaced by a PR review, both confirmed empirically: Allocation race: allocate_subagent_instance read max_n, computed the next suffix, and INSERTed across separate statements with a single max_n+2 retry. Each SubagentStart is its own process/connection, so parallel same-type siblings of one parent all read the same max_n and collide on UNIQUE(name); only the first two survive. Repro: 8 concurrent siblings -> 6 dropped, each a rowless "unknown_subagent_actor" (invisible, no delivery). Fixed by running the idempotency check + suffix scan + INSERT inside one BEGIN IMMEDIATE transaction. Also allocate the row before tracking it in the parent's running_tasks, so there is no tracked-but-rowless window. SubagentStop claim tombstone: the claim key hashed the full raw payload and persisted until SessionEnd. Captured live payloads show prompt_id is stable across re-fires and last_assistant_message repeats, so two genuinely distinct stops can be byte-identical (observed: a second stop returned in 1.33ms via the already-claimed skip, dropping poll + delivery + teardown). Since Claude blocks the subagent until its SubagentStop hook returns, same-payload stops only overlap when they are the same logical stop dispatched to duplicate hook registrations. Make the claim a concurrency guard released on completion (StopClaimGuard) so that case still dedups while a later distinct re-fire re-claims. Adds a concurrent-allocation repro test, a claim release/reclaim test, and an integration test asserting subagent_stop releases its own claim. --- src/hooks/claude.rs | 192 +++++++++++++++++++++++++++++++++++------- src/instance_names.rs | 171 ++++++++++++++++++++++++------------- 2 files changed, 273 insertions(+), 90 deletions(-) diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 2356ab0a..e2aaee55 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -461,13 +461,19 @@ fn route_subagent_actor_hook( &agent_owner_kv_key(session_id, agent_id), Some(&parent_instance), ); - track_subagent(db, &parent_instance, agent_id, agent_type); - // Eagerly allocate an instance row so the subagent shows up - // in the TUI and is a valid `hcom send` target from birth — - // without injecting any hcom context into the subagent itself. - // The subagent stays dormant (name_announced=0) until either a - // message arrives at SubagentStop or it runs `hcom start`. + // Allocate the instance row BEFORE tracking it in the parent's + // running_tasks. Row-first means a tracked subagent always has a + // real, addressable row: the reverse order can leave a + // tracked-but-rowless entry (parent thinks a child is active, but + // every one of that child's actor hooks silently no-ops as an + // unknown actor and it can never receive a message). Eager + // allocation also makes the subagent show up in the TUI and be a + // valid `hcom send` target from birth, without injecting any hcom + // context into the subagent itself. It stays dormant + // (name_announced=0) until a message arrives at SubagentStop or it + // runs `hcom start`. ensure_subagent_row(db, &parent_instance, session_id, agent_id, agent_type); + track_subagent(db, &parent_instance, agent_id, agent_type); } let output = subagent_start(&payload.raw); if let Some(out) = output { @@ -1988,23 +1994,38 @@ fn subagent_stop_claim_prefix(root_session_id: &str) -> String { format!("subagent_stop_claimed:{root_session_id}:") } -/// Atomically claim one SubagentStop invocation for processing — true if -/// this call is the first to claim it, false if a duplicate hook delivery of -/// the identical invocation already did. Must be checked before *any* -/// processing of the invocation (idle-gate, `poll_messages`, teardown), not -/// only before teardown: with duplicate hook registrations, both -/// invocations otherwise enter `poll_messages` independently — one can -/// receive a delivered message (exit_code=2) while the other times out -/// (exit_code=0) and tears the row down while Claude is still acting on the -/// delivery, so the reply fails because the identity is gone. `INSERT OR -/// IGNORE` is a single statement, so SQLite's own write lock makes the claim -/// atomic across concurrent processes without needing an explicit +/// Atomically claim one SubagentStop invocation for processing — true if this +/// call won the claim, false if a *concurrent* duplicate delivery of the +/// identical invocation is already holding it. Must be checked before *any* +/// processing (idle-gate, `poll_messages`, teardown), not only before +/// teardown: with duplicate hook registrations, both invocations otherwise +/// enter `poll_messages` independently — one can receive a delivered message +/// (exit_code=2) while the other times out (exit_code=0) and tears the row +/// down while Claude is still acting on the delivery, so the reply fails +/// because the identity is gone. +/// +/// The claim is a *concurrency guard*, held only for the duration of one +/// invocation's processing and released by `StopClaimGuard` on completion — +/// NOT a session-long tombstone. That distinction is load-bearing: the raw +/// payload is only a content fingerprint, and two *genuinely distinct* stops +/// can be byte-for-byte identical (stable `prompt_id`, repeated +/// `last_assistant_message`, same transcript path — observed live: a subagent +/// that ends two separate message turns with the same final text produces +/// identical payloads). A permanent claim would silently suppress the second +/// such stop (no poll, no delivery, no teardown). Because Claude blocks the +/// subagent until its SubagentStop hook returns, two same-payload stops can +/// only overlap in time when they are the *same* logical stop dispatched to +/// two registered hooks — exactly the case a release-on-completion claim still +/// dedups, while a later sequential re-fire re-claims cleanly. +/// +/// `INSERT OR IGNORE` is a single statement, so SQLite's own write lock makes +/// the claim atomic across concurrent processes without an explicit /// transaction. -fn claim_subagent_stop(db: &HcomDb, root_session_id: &str, agent_id: &str, raw: &Value) -> bool { +fn claim_subagent_stop(db: &HcomDb, claim_key: &str) -> bool { db.conn() .execute( "INSERT OR IGNORE INTO kv (key, value) VALUES (?, '1')", - rusqlite::params![subagent_stop_claim_key(root_session_id, agent_id, raw)], + rusqlite::params![claim_key], ) // DB error: fail open (proceed with processing) rather than risk // wedging a dead subagent forever over an unrelated I/O hiccup. @@ -2012,6 +2033,24 @@ fn claim_subagent_stop(db: &HcomDb, root_session_id: &str, agent_id: &str, raw: .unwrap_or(true) } +/// Releases a won SubagentStop claim when the invocation's processing ends, so +/// a later, genuinely distinct stop with a byte-identical payload can re-claim +/// and be processed instead of being suppressed forever. See +/// `claim_subagent_stop` for why the claim must be transient, not permanent. +struct StopClaimGuard<'a> { + db: &'a HcomDb, + key: String, +} + +impl Drop for StopClaimGuard<'_> { + fn drop(&mut self) { + let _ = self + .db + .conn() + .execute("DELETE FROM kv WHERE key = ?", rusqlite::params![self.key]); + } +} + /// SubagentStop: message polling using agent_id, cleanup on exit. /// /// Returns (exit_code, stdout). exit_code=2 means message delivered @@ -2054,9 +2093,13 @@ fn subagent_stop(db: &HcomDb, root_session_id: &str, raw: &Value) -> (i32, Strin // Claim this exact invocation before any further processing — see // `claim_subagent_stop` for why this can't wait until the teardown // decision: a duplicate delivery must not even enter `poll_messages`. - if !claim_subagent_stop(db, root_session_id, agent_id, raw) { + // `_claim_guard` releases the claim on every return path below, so a + // later distinct stop with a byte-identical payload is not suppressed. + let claim_key = subagent_stop_claim_key(root_session_id, agent_id, raw); + if !claim_subagent_stop(db, &claim_key) { return (0, String::new()); } + let _claim_guard = StopClaimGuard { db, key: claim_key }; // Store transcript_path if not already set if existing_transcript.is_empty() @@ -5211,32 +5254,114 @@ mod tests { ); } - /// Property: `claim_subagent_stop` is a true exactly-once latch — first - /// call wins, an identical repeat loses, and a same-prompt-but-different - /// payload (a legitimate re-fire, not a duplicate) gets its own claim. + /// Property: while a claim is held, an identical repeat loses (concurrency + /// guard), and a same-prompt-but-different payload gets its own claim. #[test] #[serial] fn test_claim_subagent_stop_collapses_duplicate_invocation() { crate::config::Config::init(); let (_dir, _guard, db) = make_isolated_test_db(); let raw = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1"}); + let key = subagent_stop_claim_key("sess-1", "agent-x", &raw); + assert!(claim_subagent_stop(&db, &key), "first claim must succeed"); assert!( - claim_subagent_stop(&db, "sess-1", "agent-x", &raw), - "first claim must succeed" - ); - assert!( - !claim_subagent_stop(&db, "sess-1", "agent-x", &raw), - "duplicate identical invocation must not re-claim" + !claim_subagent_stop(&db, &key), + "duplicate identical invocation must not re-claim while held" ); let raw_different_payload = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1", "note": "different"}); assert!( - claim_subagent_stop(&db, "sess-1", "agent-x", &raw_different_payload), + claim_subagent_stop( + &db, + &subagent_stop_claim_key("sess-1", "agent-x", &raw_different_payload) + ), "same prompt_id but different payload content must be its own claim" ); } + /// Regression (finding #1): the claim is a transient concurrency guard, not + /// a session-long tombstone. Two *genuinely distinct* SubagentStop + /// invocations can carry byte-identical payloads (stable prompt_id + + /// repeated last_assistant_message — reproduced live). While one is + /// in-flight the identical duplicate must lose (concurrency dedup), but + /// once `StopClaimGuard` releases it, a later identical stop must be able + /// to re-claim and be processed — not suppressed forever. + #[test] + #[serial] + fn test_stop_claim_released_lets_later_identical_stop_reclaim() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + let raw = serde_json::json!({ + "agent_id": "agent-x", + "prompt_id": "p1", + "last_assistant_message": "Done.", + }); + let key = subagent_stop_claim_key("sess-1", "agent-x", &raw); + + assert!(claim_subagent_stop(&db, &key), "first invocation claims"); + assert!( + !claim_subagent_stop(&db, &key), + "a concurrent duplicate still in-flight must lose the claim" + ); + // First invocation finishes -> guard releases. + { + let _guard = StopClaimGuard { + db: &db, + key: key.clone(), + }; + } + assert!( + db.kv_get(&key).unwrap().is_none(), + "guard drop must release the claim key" + ); + assert!( + claim_subagent_stop(&db, &key), + "a later, genuinely distinct stop with a byte-identical payload must re-claim, not be suppressed" + ); + } + + /// Integration guard for finding #1: `subagent_stop` itself must release + /// the claim it took, so the tombstone can't outlive the invocation. Uses + /// a zero timeout so the poll returns immediately without blocking. + #[test] + #[serial] + fn test_subagent_stop_releases_its_claim_on_completion() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id, subagent_timeout) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0, 0)", + [], + ) + .unwrap(); + // name_announced=1 -> skip the dormant idle gate and go straight to the + // poll, which returns immediately (timeout 0) with no message. + insert_subagent_row(&db, "nova_task_1", "agent-x", "nova", "sess-1"); + db.conn() + .execute( + "UPDATE instances SET name_announced = 1 WHERE name = 'nova_task_1'", + [], + ) + .unwrap(); + + let raw = serde_json::json!({ + "session_id": "sess-1", + "agent_id": "agent-x", + "prompt_id": "p1", + "last_assistant_message": "Done.", + }); + let _ = subagent_stop(&db, "sess-1", &raw); + + assert!( + db.kv_get(&subagent_stop_claim_key("sess-1", "agent-x", &raw)) + .unwrap() + .is_none(), + "subagent_stop must not leave its claim behind as a permanent tombstone" + ); + } + /// Property: `subagent_stop` must check the claim *before* the idle gate /// and before `poll_messages` — a duplicate invocation must skip all /// processing, not just the final teardown. This is what the live @@ -5264,8 +5389,11 @@ mod tests { "prompt_id": "p1", }); // Simulate a concurrent duplicate having already claimed this exact - // invocation. - assert!(claim_subagent_stop(&db, "sess-1", "agent-x", &raw)); + // invocation (and still in-flight, so the claim is unreleased). + assert!(claim_subagent_stop( + &db, + &subagent_stop_claim_key("sess-1", "agent-x", &raw) + )); let (exit_code, stdout) = subagent_stop(&db, "sess-1", &raw); assert_eq!(exit_code, 0); diff --git a/src/instance_names.rs b/src/instance_names.rs index a6294cbe..d1240b17 100644 --- a/src/instance_names.rs +++ b/src/instance_names.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use rusqlite::OptionalExtension; use std::collections::HashSet; use crate::db::HcomDb; @@ -407,60 +408,67 @@ pub struct SubagentAllocation<'a> { /// If an instance row already exists for `agent_id`, returns its name without /// re-inserting (so SubagentStart can run before `hcom start --name` without /// creating duplicates, and vice versa). Otherwise computes the next free -/// suffix, INSERTs the row with `SQLite UNIQUE(name)` as the collision guard, -/// and retries once with `max_n + 2` on constraint violation. +/// suffix and INSERTs the row. +/// +/// The whole idempotency-check + suffix-scan + INSERT runs inside one +/// `BEGIN IMMEDIATE` transaction. Each SubagentStart is a separate process +/// with its own DB connection, so parallel same-type siblings of one parent +/// would otherwise each read the same `max_n` and pick the same `_N` suffix: +/// the first INSERT wins, every other loses on `UNIQUE(name)`. A single +/// `max_n + 2` retry only rescues the second racer — a third-plus sibling is +/// dropped entirely (no row → every later actor hook silently no-ops as an +/// unknown actor). `BEGIN IMMEDIATE` takes the write lock up front, so the +/// scan and the INSERT are atomic with respect to other processes and each +/// racer computes its suffix against the rows the winners already committed. pub fn allocate_subagent_instance(db: &HcomDb, info: &SubagentAllocation) -> Result { - // Return early if a row already exists for this agent_id. - let existing: Option = db - .conn() - .query_row( - "SELECT name FROM instances WHERE agent_id = ?", - rusqlite::params![info.agent_id], - |row| row.get(0), - ) - .ok(); - if let Some(name) = existing { - return Ok(name); - } - let sanitized = sanitize_subagent_type(info.agent_type); let pattern = format!("{}_{}_", info.parent_name, sanitized); let like_pattern = format!("{pattern}%"); - let names: Vec = { - let mut stmt = db - .conn() - .prepare("SELECT name FROM instances WHERE name LIKE ?")?; - stmt.query_map(rusqlite::params![like_pattern], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect() - }; - - let mut max_n: u32 = 0; - for name in &names { - if let Some(suffix) = name.strip_prefix(&pattern) - && let Ok(n) = suffix.parse::() - { - max_n = max_n.max(n); - } - } - - let candidate = format!("{pattern}{}", max_n + 1); let initial_event_id = db.get_last_event_id(); let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let now = crate::shared::time::now_epoch_f64(); - let insert_sql = "INSERT INTO instances \ - (name, session_id, parent_session_id, parent_name, tag, agent_id, \ - created_at, last_event_id, directory, last_stop, status, status_context) \ - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)"; + db.with_transaction(|txn| { + // Idempotency check must live inside the transaction too: otherwise a + // concurrent SubagentStart and `hcom start --name ` for the + // same agent_id could both miss it and insert two rows. + let existing: Option = txn + .query_row( + "SELECT name FROM instances WHERE agent_id = ?", + rusqlite::params![info.agent_id], + |row| row.get(0), + ) + .optional()?; + if let Some(name) = existing { + return Ok(name); + } + + let names: Vec = { + let mut stmt = txn.prepare("SELECT name FROM instances WHERE name LIKE ?")?; + stmt.query_map(rusqlite::params![like_pattern], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect() + }; + + let mut max_n: u32 = 0; + for name in &names { + if let Some(suffix) = name.strip_prefix(&pattern) + && let Ok(n) = suffix.parse::() + { + max_n = max_n.max(n); + } + } - let do_insert = |name: &str| -> rusqlite::Result { - db.conn().execute( - insert_sql, + let candidate = format!("{pattern}{}", max_n + 1); + txn.execute( + "INSERT INTO instances \ + (name, session_id, parent_session_id, parent_name, tag, agent_id, \ + created_at, last_event_id, directory, last_stop, status, status_context) \ + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", rusqlite::params![ - name, + candidate, info.parent_session_id, info.parent_name, info.parent_tag, @@ -471,22 +479,9 @@ pub fn allocate_subagent_instance(db: &HcomDb, info: &SubagentAllocation) -> Res info.status, info.status_context, ], - ) - }; - - match do_insert(&candidate) { - Ok(_) => Ok(candidate), - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - let retry = format!("{pattern}{}", max_n + 2); - do_insert(&retry).map_err(|e| { - anyhow::anyhow!("Failed to create unique subagent name after retry: {e}") - })?; - Ok(retry) - } - Err(e) => Err(anyhow::anyhow!("Failed to insert subagent instance: {e}")), - } + )?; + Ok(candidate) + }) } #[cfg(test)] @@ -579,6 +574,66 @@ mod subagent_alloc_tests { assert_eq!(name, "luna_reviewer_2"); } + #[test] + fn allocate_concurrent_siblings_all_get_rows() { + // Repro for the concurrent-sibling allocation race: N separate + // processes (here: threads with their own connections to the same + // file DB, mirroring separate hook invocations) each allocate a + // subagent of the SAME type under the SAME parent at once. Every one + // must end up with its own instance row — none may be dropped. + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("race.db"); + { + let db = HcomDb::open_raw(&path).unwrap(); + db.init_db().unwrap(); + } + + const N: usize = 8; + let barrier = std::sync::Arc::new(std::sync::Barrier::new(N)); + let handles: Vec<_> = (0..N) + .map(|i| { + let path = path.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let db = HcomDb::open_raw(&path).unwrap(); + let agent_id = format!("aid-{i}"); + barrier.wait(); + allocate_subagent_instance( + &db, + &SubagentAllocation { + agent_id: &agent_id, + agent_type: "reviewer", + parent_name: "luna", + parent_session_id: None, + parent_tag: None, + status: "inactive", + status_context: Some("subagent:dormant"), + }, + ) + }) + }) + .collect(); + + let mut ok = 0; + for h in handles { + if h.join().unwrap().is_ok() { + ok += 1; + } + } + + let db = HcomDb::open_raw(&path).unwrap(); + let rows: i64 = db + .conn() + .query_row( + "SELECT COUNT(*) FROM instances WHERE parent_name = 'luna'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(ok, N, "every concurrent allocation must succeed"); + assert_eq!(rows, N as i64, "every subagent must get its own row"); + } + #[test] fn allocate_writes_status_and_context_columns() { let (_tmp, db) = setup_db(); From 31b4f3f3e6c0f9c42d752257dfe8c831f9be06ab Mon Sep 17 00:00:00 2001 From: aannoo Date: Wed, 22 Jul 2026 11:31:32 +0200 Subject: [PATCH 3/6] chore: upgrade dependencies and toolchains --- .cargo/audit.toml | 4 +- .github/build-setup.yml | 8 +- .github/workflows/build-wheels.yml | 26 +- .github/workflows/ci.yml | 83 +- .github/workflows/publish-pypi.yml | 7 +- .github/workflows/release.yml | 48 +- .node-version | 1 + Cargo.lock | 972 +++++++-------- Cargo.toml | 6 +- dist-workspace.toml | 9 +- package-lock.json | 1795 ++++++++++++++++++++-------- package.json | 18 +- pyproject.toml | 2 +- rust-toolchain.toml | 2 +- scripts/install-mock-tools.ps1 | 4 +- scripts/install-mock-tools.sh | 6 +- scripts/typecheck.sh | 4 +- tests/support/claude_mock.rs | 4 +- tests/support/codex_mock.rs | 4 +- tests/test_relay_roundtrip.rs | 2 +- 20 files changed, 1856 insertions(+), 1149 deletions(-) create mode 100644 .node-version diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 0476ece6..efda3d4f 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -12,8 +12,8 @@ # 0.103.x (the patched copy, also in Cargo.lock). The vulnerable 0.102 code is # linked but never executed. The relay is also an opt-in remote feature. # -# Revisit when rumqttc ships a webpki-0.103 release, or if relay TLS adds custom -# certificate verification (CRLs / name constraints). +# Revisit by 2026-10-21, when rumqttc ships a webpki-0.103 release, or if relay +# TLS adds custom certificate verification (CRLs / name constraints). [advisories] ignore = [ diff --git a/.github/build-setup.yml b/.github/build-setup.yml index b08a8077..fefdf6c2 100644 --- a/.github/build-setup.yml +++ b/.github/build-setup.yml @@ -1,21 +1,21 @@ - name: Set up JDK 17 (required by Android SDK tools) if: ${{ contains(matrix.targets, 'aarch64-linux-android') }} - uses: actions/setup-java@v5.2.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '17' distribution: temurin - name: Set up Android SDK if: ${{ contains(matrix.targets, 'aarch64-linux-android') }} - uses: android-actions/setup-android@v4.0.1 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Install Android NDK if: ${{ contains(matrix.targets, 'aarch64-linux-android') }} - run: yes | sdkmanager "ndk;25.2.9519653" > /dev/null + run: yes | sdkmanager "ndk;27.3.13750724" > /dev/null - name: Configure Android target if: ${{ contains(matrix.targets, 'aarch64-linux-android') }} shell: bash run: | rustup target add aarch64-linux-android - NDK_BIN="$ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin" + NDK_BIN="$ANDROID_HOME/ndk/27.3.13750724/toolchains/llvm/prebuilt/linux-x86_64/bin" echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$NDK_BIN/aarch64-linux-android24-clang" >> "$GITHUB_ENV" echo "CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android24-clang" >> "$GITHUB_ENV" echo "AR_aarch64_linux_android=$NDK_BIN/llvm-ar" >> "$GITHUB_ENV" diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index d244fe14..3ede18bd 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -37,10 +37,12 @@ jobs: manylinux: auto runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build wheel - uses: PyO3/maturin-action@v1.50.1 + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: ${{ matrix.target }} args: --release --out dist @@ -48,7 +50,7 @@ jobs: docker-options: ${{ matrix.docker-options || '' }} - name: Upload wheel - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheel-${{ matrix.target }} path: dist/*.whl @@ -56,24 +58,26 @@ jobs: build-android: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - - uses: actions/setup-java@v5.2.0 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '17' distribution: temurin - - uses: android-actions/setup-android@v4.0.1 + - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - - name: Install NDK r25b - run: yes | sdkmanager "ndk;25.2.9519653" > /dev/null + - name: Install NDK r27d + run: yes | sdkmanager "ndk;27.3.13750724" > /dev/null - name: Build wheel run: | rustup target add aarch64-linux-android - pip install maturin==1.12.6 + pip install maturin==1.14.1 - NDK_BIN="$ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin" + NDK_BIN="$ANDROID_HOME/ndk/27.3.13750724/toolchains/llvm/prebuilt/linux-x86_64/bin" export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang" export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang" export AR_aarch64_linux_android="$NDK_BIN/llvm-ar" @@ -81,7 +85,7 @@ jobs: maturin build --release --target aarch64-linux-android --out dist - name: Upload wheel - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheel-aarch64-linux-android path: dist/*.whl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 982e4dbc..29528a6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,35 +2,52 @@ name: CI on: [push, pull_request] +permissions: + contents: read + jobs: rust-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - # Pinned so a new stable release can't fail CI on a fresh lint with no - # code change. Bump deliberately in its own PR. Local dev stays floating. - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # Keep lint behavior reproducible across fresh runners and local installs. + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # 1.97.1 with: toolchain: "1.97.1" components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2.9.1 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo fmt --all -- --check - run: cargo clippy --all-targets --locked -- -D warnings - run: cargo test --locked + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # 1.88 + with: + toolchain: "1.88" + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo check --locked --all-targets + # Native Windows test gate. Release artifacts are built by cargo-dist on tags; # the pinned real-tool lifecycle has its own matrix below. windows-build: runs-on: windows-latest steps: - - uses: actions/checkout@v6 - # Pinned so a new stable release can't fail CI on a fresh lint with no - # code change. Bump deliberately in its own PR. Local dev stays floating. - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # Keep lint behavior reproducible across fresh runners and local installs. + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # 1.97.1 with: toolchain: "1.97.1" components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2.9.1 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo fmt --all -- --check - run: cargo clippy --all-targets --locked -- -D warnings - run: cargo test --all-targets --locked @@ -40,12 +57,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version: 22.23.1 cache: npm - run: bash ./scripts/typecheck.sh @@ -57,11 +74,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install pinned cargo-dist - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" - run: dist generate --check real-tool-tests: @@ -76,44 +93,44 @@ jobs: include: - os: ubuntu-24.04 tool: codex - package: "@openai/codex@0.141.0" - cache: codex-0.141.0 + package: "@openai/codex@0.145.0" + cache: codex-0.145.0 test: real_tool_codex - os: ubuntu-latest tool: claude - package: "@anthropic-ai/claude-code@2.1.185" - cache: claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: claude-2.1.216 test: real_tool_claude - os: ubuntu-latest tool: relay - package: "@anthropic-ai/claude-code@2.1.185" - cache: relay-claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: relay-claude-2.1.216 test: test_relay_roundtrip - os: windows-latest tool: codex - package: "@openai/codex@0.141.0" - cache: codex-0.141.0 + package: "@openai/codex@0.145.0" + cache: codex-0.145.0 test: real_tool_codex - os: windows-latest tool: claude - package: "@anthropic-ai/claude-code@2.1.185" - cache: claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: claude-2.1.216 test: real_tool_claude - os: windows-latest tool: relay - package: "@anthropic-ai/claude-code@2.1.185" - cache: relay-claude-2.1.185 + package: "@anthropic-ai/claude-code@2.1.216" + cache: relay-claude-2.1.216 test: test_relay_roundtrip steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # 1.97.1 with: toolchain: "1.97.1" - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version: 22.23.1 - name: Configure Codex Linux sandbox if: runner.os == 'Linux' && matrix.tool == 'codex' run: | @@ -123,8 +140,8 @@ jobs: /usr/share/apparmor/extra-profiles/bwrap-userns-restrict \ /etc/apparmor.d/bwrap-userns-restrict sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict - - uses: Swatinem/rust-cache@v2.9.1 - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | target/mock-tools diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b0466a8a..32097c43 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,13 +9,14 @@ on: permissions: id-token: write + contents: read jobs: publish: runs-on: ubuntu-latest steps: - name: Download all wheels - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: wheel-* path: dist/ @@ -25,6 +26,8 @@ jobs: run: ls -la dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: password: ${{ secrets.PYPI_API_TOKEN }} + verbose: false + print-hash: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d650985f..f46dbcbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive @@ -64,9 +64,9 @@ jobs: # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" - name: Cache dist - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: cargo-dist-cache path: ~/.cargo/bin/dist @@ -82,7 +82,7 @@ jobs: cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json @@ -116,7 +116,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive @@ -128,22 +128,22 @@ jobs: echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi - name: "Set up JDK 17 (required by Android SDK tools)" - uses: "actions/setup-java@v5.2.0" + uses: "actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654" if: "${{ contains(matrix.targets, 'aarch64-linux-android') }}" with: "distribution": "temurin" "java-version": "17" - name: "Set up Android SDK" - uses: "android-actions/setup-android@v4.0.1" + uses: "android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699" if: "${{ contains(matrix.targets, 'aarch64-linux-android') }}" - name: "Install Android NDK" if: "${{ contains(matrix.targets, 'aarch64-linux-android') }}" - run: "yes | sdkmanager \"ndk;25.2.9519653\" > /dev/null" + run: "yes | sdkmanager \"ndk;27.3.13750724\" > /dev/null" - name: "Configure Android target" if: "${{ contains(matrix.targets, 'aarch64-linux-android') }}" run: | rustup target add aarch64-linux-android - NDK_BIN="$ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin" + NDK_BIN="$ANDROID_HOME/ndk/27.3.13750724/toolchains/llvm/prebuilt/linux-x86_64/bin" echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$NDK_BIN/aarch64-linux-android24-clang" >> "$GITHUB_ENV" echo "CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android24-clang" >> "$GITHUB_ENV" echo "AR_aarch64_linux_android=$NDK_BIN/llvm-ar" >> "$GITHUB_ENV" @@ -152,7 +152,7 @@ jobs: run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -179,7 +179,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | @@ -206,19 +206,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -236,7 +236,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: artifacts-build-global path: | @@ -257,19 +257,19 @@ jobs: outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -282,14 +282,14 @@ jobs: cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: artifacts @@ -322,14 +322,14 @@ jobs: GITHUB_EMAIL: "admin+bot@axo.dev" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: true repository: "aannoo/homebrew-hcom" token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # So we have access to the formula - name: Fetch homebrew formulae - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: Formula/ @@ -367,7 +367,7 @@ jobs: secrets: inherit # publish jobs get escalated permissions permissions: - "contents": "write" + "contents": "read" "id-token": "write" announce: @@ -384,7 +384,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..f9e7451e --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.23.1 diff --git a/Cargo.lock b/Cargo.lock index 4f7cd345..4eaadacd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,12 +4,12 @@ version = 4 [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.1.7", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] @@ -88,15 +88,24 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "atomic" @@ -109,15 +118,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -125,14 +134,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -164,9 +174,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -179,30 +189,36 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -215,9 +231,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -239,50 +255,39 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.1", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", - "zeroize", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -293,20 +298,20 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", - "zeroize", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -314,9 +319,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -326,14 +331,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.2", ] [[package]] @@ -351,6 +356,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -359,9 +370,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -420,13 +431,19 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -454,17 +471,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -477,6 +495,15 @@ dependencies = [ "phf", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -497,7 +524,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -508,7 +535,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -522,9 +549,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -545,7 +569,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -564,9 +588,9 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -613,9 +637,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "equivalent" @@ -664,11 +688,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -722,12 +752,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -742,15 +766,15 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -759,21 +783,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-sink", @@ -817,32 +841,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "hashbrown" @@ -852,7 +865,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -860,12 +873,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -887,7 +905,7 @@ dependencies = [ "lru", "nix 0.31.3", "portable-pty", - "rand 0.10.1", + "rand 0.10.2", "ratatui", "regex", "rumqttc", @@ -901,7 +919,7 @@ dependencies = [ "shell-words", "signal-hook 0.4.4", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "toml", "toml_edit", "unicode-segmentation", @@ -909,7 +927,7 @@ dependencies = [ "uuid", "vt100", "webpki-roots", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -926,9 +944,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -957,12 +975,6 @@ dependencies = [ "cc", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -977,8 +989,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -992,11 +1002,11 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -1009,7 +1019,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1035,23 +1045,22 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1063,7 +1072,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1079,22 +1088,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libc" -version = "0.2.186" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1116,7 +1125,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -1142,17 +1151,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1167,9 +1176,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -1194,9 +1203,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -1210,7 +1219,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -1222,9 +1231,9 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "memoffset", ] @@ -1235,9 +1244,9 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", ] @@ -1265,7 +1274,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1304,12 +1313,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -1331,6 +1334,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1356,9 +1383,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -1366,9 +1393,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -1376,25 +1403,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -1424,7 +1450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -1437,7 +1463,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1463,20 +1489,19 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", + "cpufeatures 0.3.0", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-pty" @@ -1505,30 +1530,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1547,21 +1562,21 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1570,9 +1585,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] [[package]] name = "rand_core" @@ -1582,34 +1594,38 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", + "palette", + "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -1617,9 +1633,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -1629,19 +1645,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termion" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" dependencies = [ "instability", "ratatui-core", @@ -1650,9 +1677,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -1660,17 +1687,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", @@ -1683,7 +1711,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -1694,14 +1722,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1711,9 +1739,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1722,9 +1750,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -1742,12 +1770,12 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1764,7 +1792,7 @@ dependencies = [ "rustls-native-certs", "rustls-pemfile", "rustls-webpki 0.102.8", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-rustls", "tokio-stream", @@ -1777,7 +1805,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1801,7 +1829,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1810,9 +1838,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", @@ -1825,9 +1853,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -1846,9 +1874,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -1878,9 +1906,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1888,15 +1916,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -1912,19 +1931,13 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1949,9 +1962,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1959,29 +1972,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.2", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2012,28 +2025,27 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2076,9 +2088,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -2135,15 +2147,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2151,18 +2163,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "sqlite-wasm-rs" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -2184,23 +2196,23 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2222,9 +2234,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -2238,12 +2261,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2283,7 +2319,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2328,11 +2364,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -2343,25 +2379,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.2", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "libc", @@ -2374,15 +2410,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2395,13 +2431,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2427,22 +2463,23 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -2464,9 +2501,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -2486,15 +2523,15 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2510,9 +2547,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -2531,20 +2568,14 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.1.7", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2561,12 +2592,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -2620,27 +2651,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2651,9 +2673,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2661,65 +2683,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2839,7 +2827,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2850,7 +2838,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2883,16 +2871,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2910,31 +2889,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2943,101 +2905,53 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -3051,108 +2965,20 @@ dependencies = [ "winapi", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 1f132e8d..4caa4b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,12 +41,12 @@ uuid = { version = "1.23.0", features = ["v4"] } rumqttc = { version = "0.25.1", default-features = false, features = ["use-rustls"] } base64 = "0.22" sha2 = "0.11.0" -chacha20poly1305 = "0.10" +chacha20poly1305 = "0.11.0" bytes = "1" rustls = "0.23.37" webpki-roots = "1.0.6" rustls-native-certs = "0.8.3" -lru = "0.16" +lru = "0.18.1" # Unix-only: PTY, signals, fd polling, file locks, process groups. [target.'cfg(unix)'.dependencies] @@ -56,7 +56,7 @@ signal-hook = "0.4.4" # Windows-only: process/job-object control, winsock select, file locking. [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.60", features = [ +windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_System_Threading", diff --git a/dist-workspace.toml b/dist-workspace.toml index 73dd375e..928cecbe 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -4,7 +4,7 @@ members = ["cargo:."] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.31.0" +cargo-dist-version = "0.32.0" # CI backends to support ci = "github" # Target platforms to build apps for (Rust target-triple syntax) @@ -23,7 +23,7 @@ post-announce-jobs = ["./post-announce-compare-link"] # Extra setup steps to run before dist builds github-build-setup = "../build-setup.yml" # Custom permissions for reusable workflow jobs -github-custom-job-permissions = { "publish-pypi" = { id-token = "write", contents = "write" } } +github-custom-job-permissions = { "publish-pypi" = { id-token = "write", contents = "read" } } # A GitHub repo to push Homebrew formulas to tap = "aannoo/homebrew-hcom" # Whether dist should create a Github Release or use an existing draft @@ -36,3 +36,8 @@ install-updater = false [dist.github-custom-runners.aarch64-linux-android] runner = "ubuntu-22.04" host = "x86_64-unknown-linux-gnu" + +[dist.github-action-commits] +"actions/checkout" = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" +"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" diff --git a/package-lock.json b/package-lock.json index c30a16b5..d7d4c041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,19 +6,22 @@ "": { "name": "hcom-plugin-typecheck", "devDependencies": { - "@earendil-works/pi-coding-agent": "0.80.3", - "@oh-my-pi/pi-coding-agent": "16.2.13", - "@opencode-ai/plugin": "1.17.13", - "@opencode-ai/sdk": "1.17.13", - "@types/bun": "1.2.22", - "@types/node": "24.3.1", - "typescript": "6.0.3" + "@earendil-works/pi-coding-agent": "0.81.1", + "@oh-my-pi/pi-coding-agent": "17.0.6", + "@opencode-ai/plugin": "1.18.4", + "@opencode-ai/sdk": "1.18.4", + "@types/bun": "1.3.14", + "@types/node": "22.20.1", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22.22.2 <23" } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.0.tgz", - "integrity": "sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz", + "integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -39,19 +42,19 @@ } }, "node_modules/@ark/schema": { - "version": "0.56.1", - "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.1.tgz", - "integrity": "sha512-1Cf2g9nKD8K/3JGRu+gCCfYw5d4qR8YLLjDs5W5kpmaButCYWAPFUJqSXyBATPjglzCd4tIkp398iPYVs8MjRA==", + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.2.tgz", + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", "dev": true, "license": "MIT", "dependencies": { - "@ark/util": "0.56.1" + "@ark/util": "0.56.2" } }, "node_modules/@ark/util": { - "version": "0.56.1", - "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.1.tgz", - "integrity": "sha512-Tp1rTik3q5Z+jAeeDxr5JZpmVIw0miti1ykSEHyZv5Pw3TIJl2xbN6KTacOxITp0l3s9ytlrWd30Zvqcy5vzoQ==", + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.2.tgz", + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==", "dev": true, "license": "MIT" }, @@ -106,9 +109,9 @@ } }, "node_modules/@bufbuild/protobuf": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", - "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", + "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, @@ -135,16 +138,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz", - "integrity": "sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz", + "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-ai": "^0.80.3", - "@earendil-works/pi-tui": "^0.80.3", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -634,12 +637,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-ai": "^0.81.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -649,8 +652,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -667,15 +670,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -790,9 +793,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -810,9 +810,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +827,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -850,9 +844,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -870,9 +861,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1241,9 +1229,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2877,9 +2865,9 @@ ] }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "dev": true, "funding": [ { @@ -2890,37 +2878,37 @@ "license": "MIT" }, "node_modules/@oh-my-pi/hashline": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/hashline/-/hashline-16.2.13.tgz", - "integrity": "sha512-EVrq7q7wQmMNGHjo6kM3EXZUpYUT8zxx4QvGsmI4rTk5fDvHI7sam4NlPm67mgFE4rK0rLoc+7CyjKK4dX2org==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/hashline/-/hashline-17.0.6.tgz", + "integrity": "sha512-cvnR9fpeFiRntAOUNniQd1Ct57eXuuVOOWLLo/Sw//ROcTZxBBdBcPZ2lXgcVKv5vlhxRslkWhZPX59QJzmfYQ==", "dev": true, "license": "MIT", "dependencies": { "diff": "^9.0.0", - "lru-cache": "11.5.1" + "lru-cache": "11.5.2" }, "engines": { "bun": ">=1.3.14" } }, "node_modules/@oh-my-pi/omp-stats": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/omp-stats/-/omp-stats-16.2.13.tgz", - "integrity": "sha512-2dRmA2bOcwP0r5ZFdkHBXNw05tIAgyhPQQnQHEKZrQ/wjGLHn2fzI9Eaw7uE/GybJTYHcuPmb2f7/VK/3LRE2g==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/omp-stats/-/omp-stats-17.0.6.tgz", + "integrity": "sha512-4nf9+GJuTRJanYNo7JbEDPsm22eH05yZSYHvDZ3WeU2JOaF+ebSIO9xgiiAqOPL9ogGMyMCIKFGDNp7v0XxBVg==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-ai": "16.2.13", - "@oh-my-pi/pi-catalog": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "@tailwindcss/node": "^4.3.0", + "@oh-my-pi/pi-ai": "17.0.6", + "@oh-my-pi/pi-catalog": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", - "lucide-react": "^1.17.0", + "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", - "tailwindcss": "^4.3.0" + "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" @@ -2930,18 +2918,18 @@ } }, "node_modules/@oh-my-pi/pi-agent-core": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-agent-core/-/pi-agent-core-16.2.13.tgz", - "integrity": "sha512-JgV46rg5PebIyBIa52I/R06Vy/eNe8IqpCzJtgUMkrIySIGujlELHYyckg0GvXryUbK2QjWWWdOGvGmqV67oxw==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-agent-core/-/pi-agent-core-17.0.6.tgz", + "integrity": "sha512-UhryJs+IuemTDiZ+wm7+fh8T+W9F9yWAUjbfB+8om2D9fBWlethRp27uA4DwnCD315kAZ9SjezqyFsbKe/UEmQ==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-ai": "16.2.13", - "@oh-my-pi/pi-catalog": "16.2.13", - "@oh-my-pi/pi-natives": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "@oh-my-pi/pi-wire": "16.2.13", - "@oh-my-pi/snapcompact": "16.2.13", + "@oh-my-pi/pi-ai": "17.0.6", + "@oh-my-pi/pi-catalog": "17.0.6", + "@oh-my-pi/pi-natives": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "@oh-my-pi/pi-wire": "17.0.6", + "@oh-my-pi/snapcompact": "17.0.6", "@opentelemetry/api": "^1.9.1" }, "engines": { @@ -2949,17 +2937,17 @@ } }, "node_modules/@oh-my-pi/pi-ai": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-ai/-/pi-ai-16.2.13.tgz", - "integrity": "sha512-/+qgh8f1CeyTTspigsLTjLnTALtSccg006WTQtmIPuvhm/a/3OJFrpWaa77dlMdUL1fpaUJMTZbXvHwQwdBKeg==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-ai/-/pi-ai-17.0.6.tgz", + "integrity": "sha512-h6UXajqeKk2x2daVEB3I/pTdhHm+goyJFjgbJlMszPw4x7041Vs6i8r/HPnzrNbiciZyQrG6phmlBk+gYUk/pg==", "dev": true, "license": "MIT", "dependencies": { - "@bufbuild/protobuf": "^2.12.0", - "@oh-my-pi/pi-catalog": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "@oh-my-pi/pi-wire": "16.2.13", - "arktype": "^2.2.0", + "@bufbuild/protobuf": "^2.12.1", + "@oh-my-pi/pi-catalog": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "@oh-my-pi/pi-wire": "17.0.6", + "arktype": "2.2.3", "zod": "^4" }, "engines": { @@ -2967,15 +2955,15 @@ } }, "node_modules/@oh-my-pi/pi-catalog": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-catalog/-/pi-catalog-16.2.13.tgz", - "integrity": "sha512-eo7WOBmrgWun+A9DX/BNR1D4q9GzoT5kq/mgDqqBulktNFVR2Cp7csCpxsPr2hvDUhKq20u6hL/nKclUjXIJVQ==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-catalog/-/pi-catalog-17.0.6.tgz", + "integrity": "sha512-RunNuysuPCO/9r+VjMrvw7rl6TNVEK6U9p3lOHUlHUoRCOJI+570qbkv3HM98zJwwUQEj4O7SGhhdV6kNXzxzA==", "dev": true, "license": "MIT", "dependencies": { - "@bufbuild/protobuf": "^2.12.0", - "@oh-my-pi/pi-utils": "16.2.13", - "arktype": "^2.2.0", + "@bufbuild/protobuf": "^2.12.1", + "@oh-my-pi/pi-utils": "17.0.6", + "arktype": "2.2.3", "zod": "^4" }, "engines": { @@ -2983,45 +2971,51 @@ } }, "node_modules/@oh-my-pi/pi-coding-agent": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/-/pi-coding-agent-16.2.13.tgz", - "integrity": "sha512-ZKi9xul+Ef6wmq4VoETCmEFrAOOqx0Wq24MxHM6vL7al2NW/q7RCJc3StlLmHs8zN06C2tI/XQTMJbm5UDX0fA==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/-/pi-coding-agent-17.0.6.tgz", + "integrity": "sha512-lYLAfsoNvdTPXZ8TgC8FiXRYbsT4fTqUzMhaDV7kGLwkFQqBS0Q8/6DjTnXWrwTkQGbYlksOkBpQzvOWm/slrQ==", "dev": true, "license": "MIT", "dependencies": { - "@agentclientprotocol/sdk": "0.25.0", + "@agentclientprotocol/sdk": "1.2.1", "@babel/parser": "^7.29.7", "@mozilla/readability": "^0.6.0", - "@oh-my-pi/hashline": "16.2.13", - "@oh-my-pi/omp-stats": "16.2.13", - "@oh-my-pi/pi-agent-core": "16.2.13", - "@oh-my-pi/pi-ai": "16.2.13", - "@oh-my-pi/pi-catalog": "16.2.13", - "@oh-my-pi/pi-mnemopi": "16.2.13", - "@oh-my-pi/pi-natives": "16.2.13", - "@oh-my-pi/pi-tui": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "@oh-my-pi/pi-wire": "16.2.13", - "@oh-my-pi/snapcompact": "16.2.13", + "@oh-my-pi/hashline": "17.0.6", + "@oh-my-pi/omp-stats": "17.0.6", + "@oh-my-pi/pi-agent-core": "17.0.6", + "@oh-my-pi/pi-ai": "17.0.6", + "@oh-my-pi/pi-catalog": "17.0.6", + "@oh-my-pi/pi-mnemopi": "17.0.6", + "@oh-my-pi/pi-natives": "17.0.6", + "@oh-my-pi/pi-tui": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "@oh-my-pi/pi-wire": "17.0.6", + "@oh-my-pi/snapcompact": "17.0.6", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.7.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@puppeteer/browsers": "^3.0.4", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/sdk-trace-node": "^2.9.0", + "@puppeteer/browsers": "^3.0.6", "@types/turndown": "5.0.6", "@xterm/headless": "^6.0.0", - "arktype": "^2.2.0", + "arktype": "2.2.3", "chalk": "^5.6.2", "diff": "^9.0.0", - "fast-xml-parser": "^5.9.0", + "fast-xml-parser": "^5.9.3", "handlebars": "^4.7.9", - "linkedom": "^0.18.12", - "lru-cache": "11.5.1", + "header-generator": "^2.1.82", + "linkedom": "^0.18.13", + "lru-cache": "11.5.2", "mammoth": "^1.12.0", - "mupdf": "^1.27.0", - "puppeteer-core": "^25.1.0", + "mupdf": "^1.28.0", + "puppeteer-core": "25.3.0", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "zod": "^4" @@ -3038,16 +3032,16 @@ } }, "node_modules/@oh-my-pi/pi-mnemopi": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-mnemopi/-/pi-mnemopi-16.2.13.tgz", - "integrity": "sha512-qA6uURnOFuOIkd6+xr7PjAj8rcWVq6uzyxws/9fIIRAVNiKNbzyYUbz8Ku+L2hsRpAGC5enRbWFPOaviJuEEuA==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-mnemopi/-/pi-mnemopi-17.0.6.tgz", + "integrity": "sha512-ZQJClNFEw/aT9Xfebt1Ud8SsR27HuWGaaB26219pTl50EvtHOhddLrBVLuaETfyCHr0Y0KWrlnlOxlwnHEpMUg==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-ai": "16.2.13", - "@oh-my-pi/pi-catalog": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "lru-cache": "11.5.1" + "@oh-my-pi/pi-ai": "17.0.6", + "@oh-my-pi/pi-catalog": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "lru-cache": "11.5.2" }, "bin": { "mnemopi": "src/cli.ts" @@ -3069,26 +3063,26 @@ } }, "node_modules/@oh-my-pi/pi-natives": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives/-/pi-natives-16.2.13.tgz", - "integrity": "sha512-UyqqnW1LP2mUFdXcNnGuVLgw5M1CWqzSYqHV50tzt1GEkf4WaERs51/EjtOhqb8ViMhO+OkXEKtrQdggwXJruA==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives/-/pi-natives-17.0.6.tgz", + "integrity": "sha512-QHpZmfkEM0O7nfez516pIZGjkR5Ez5Td8XPcRW54y7zzTXkjQA6ndBvcce8ckjXCOrHU1pGTGc7D1ZQlI1/Vug==", "dev": true, "license": "MIT", "engines": { "bun": ">=1.3.14" }, "optionalDependencies": { - "@oh-my-pi/pi-natives-darwin-arm64": "16.2.13", - "@oh-my-pi/pi-natives-darwin-x64": "16.2.13", - "@oh-my-pi/pi-natives-linux-arm64": "16.2.13", - "@oh-my-pi/pi-natives-linux-x64": "16.2.13", - "@oh-my-pi/pi-natives-win32-x64": "16.2.13" + "@oh-my-pi/pi-natives-darwin-arm64": "17.0.6", + "@oh-my-pi/pi-natives-darwin-x64": "17.0.6", + "@oh-my-pi/pi-natives-linux-arm64": "17.0.6", + "@oh-my-pi/pi-natives-linux-x64": "17.0.6", + "@oh-my-pi/pi-natives-win32-x64": "17.0.6" } }, "node_modules/@oh-my-pi/pi-natives-darwin-arm64": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-darwin-arm64/-/pi-natives-darwin-arm64-16.2.13.tgz", - "integrity": "sha512-8rWCepUQBkS604kC4AYCQP+lpKOIsxJ0ExPS/wU8bx5qztyfxg8O4X2oOIOxenOm7WegaZFu5lwvRuXDoAEorQ==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-darwin-arm64/-/pi-natives-darwin-arm64-17.0.6.tgz", + "integrity": "sha512-1xX2dOTLIDvYwLMFxH3C5pC+2TCRsWEfbFL/89rQMdGK55ksaToYEMdG7M2KgkIC4cku5Xa0CWkCjcZeYXrMiw==", "cpu": [ "arm64" ], @@ -3103,9 +3097,9 @@ } }, "node_modules/@oh-my-pi/pi-natives-darwin-x64": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-darwin-x64/-/pi-natives-darwin-x64-16.2.13.tgz", - "integrity": "sha512-k4qp+en6eXCfdn9nUzL8x4dDl3lcUcp6J7+GmotdwFXhTGzxk5yskJxRYzdPd1bxAK+3lCvUkAfmWIjl09O/pg==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-darwin-x64/-/pi-natives-darwin-x64-17.0.6.tgz", + "integrity": "sha512-cRFzRQZH8J1SXd3ibbABqxJFlnGtdjyODGCzHJIrdnLnPa1+d/1PldGlNuGenemOKDzitfIe8n2NTTPY4982vw==", "cpu": [ "x64" ], @@ -3120,9 +3114,9 @@ } }, "node_modules/@oh-my-pi/pi-natives-linux-arm64": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-linux-arm64/-/pi-natives-linux-arm64-16.2.13.tgz", - "integrity": "sha512-hszV4B89seTnM5Hvv2ITOzlbDVsCkJpZLnYoNLqgObX3ovrqwRQwVbBnX/4wM2U4QqOh91qCK1mpb/ZfZ1qTJQ==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-linux-arm64/-/pi-natives-linux-arm64-17.0.6.tgz", + "integrity": "sha512-Ug6GcZvMx5qBR1UtY2SiPf85TqI4mOiXGREOCpWR3LXTStow5ZV7Q48LAllqvXoMdMCvmGfq4+iYn2Z6p2Kkww==", "cpu": [ "arm64" ], @@ -3137,9 +3131,9 @@ } }, "node_modules/@oh-my-pi/pi-natives-linux-x64": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-linux-x64/-/pi-natives-linux-x64-16.2.13.tgz", - "integrity": "sha512-CnYycmySUNe6f6eXOZOaqEIgohU9FbTCmYVyS3MyLhlxA8f+iduwsmyxHma+eX+IhoOkN2VE67JtCHy9DTnK2A==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-linux-x64/-/pi-natives-linux-x64-17.0.6.tgz", + "integrity": "sha512-oVNOtYzT0X8ZZu6D8fGJd2Z9r2w/UdK5ip63KSRBFksXApUhAEyOcjo2OyM+oSRz/Joc0zYAaHod5QIa2X9u5Q==", "cpu": [ "x64" ], @@ -3154,9 +3148,9 @@ } }, "node_modules/@oh-my-pi/pi-natives-win32-x64": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-win32-x64/-/pi-natives-win32-x64-16.2.13.tgz", - "integrity": "sha512-8wZqn1AT2twHCx5+kDsljUdEdeZfICAhYrT4+T7DuYTZiieZNjgz7hKnFajFE62mIM5TRptD8cBHdAMiUlLX4g==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-natives-win32-x64/-/pi-natives-win32-x64-17.0.6.tgz", + "integrity": "sha512-Ci2DIa2aeSJ9CDa0wUgG6fOymkSCU2+aR1m0T4tUEZQO3FNUtQLB6g8hYlpebu0/9lADnG8rPPqEfrhC2NxnZQ==", "cpu": [ "x64" ], @@ -3171,29 +3165,29 @@ } }, "node_modules/@oh-my-pi/pi-tui": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-tui/-/pi-tui-16.2.13.tgz", - "integrity": "sha512-qbzZco/gIePVM5Guw6Jw5EVywAXOATq9/27GLqKN8pGMRG/sQy5ajoE8FAMNAS+wtb8BAbT1E+9kx3NHfrzbog==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-tui/-/pi-tui-17.0.6.tgz", + "integrity": "sha512-qyQONnFfZFrhbmWxexh2kupXX+HPNiODWaspxeCBQWVVjlNEC9w6GhzfP3y23PfrpWgzYGgS+7KaDQCBpq7irw==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-natives": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "lru-cache": "11.5.1", - "marked": "^18.0.5" + "@oh-my-pi/pi-natives": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "lru-cache": "11.5.2", + "marked": "^18.0.6" }, "engines": { "bun": ">=1.3.14" } }, "node_modules/@oh-my-pi/pi-utils": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-utils/-/pi-utils-16.2.13.tgz", - "integrity": "sha512-vhX1BxHAwncCNJFQfqJ/OEwtW1RoETuyfNPFJUbzQAU5I4xsQwfgUJ8GILExf7CJwuuoaDBzmrnscwkv+ySKOQ==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-utils/-/pi-utils-17.0.6.tgz", + "integrity": "sha512-K20KkGhHv5hA4T1hRuGL/lzbxuwHg5MNJZ8dgSzmKE6gwYMJPAMNknOioQCiUz3Gy4qFY0YSOI/SCW+uyhbDBA==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-natives": "16.2.13", + "@oh-my-pi/pi-natives": "17.0.6", "handlebars": "^4.7.9", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" @@ -3203,9 +3197,9 @@ } }, "node_modules/@oh-my-pi/pi-wire": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-wire/-/pi-wire-16.2.13.tgz", - "integrity": "sha512-tr/TwVhfHMXhqKuQ1eYSLNf97BvYwGQEBlio+M5FZDxtXHxo5bUPG9dkT0j38K8hUeWxRdkbrXbcYZbM7dgucA==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/pi-wire/-/pi-wire-17.0.6.tgz", + "integrity": "sha512-c/QJsRWzNslkm70rh2DRk+0EdP/Sc964Oc/HP2kpdkzTkCvLA7LCoH+RiznKE7xap8k7adj1N8me6EcSih7r6Q==", "dev": true, "license": "MIT", "engines": { @@ -3213,37 +3207,37 @@ } }, "node_modules/@oh-my-pi/snapcompact": { - "version": "16.2.13", - "resolved": "https://registry.npmjs.org/@oh-my-pi/snapcompact/-/snapcompact-16.2.13.tgz", - "integrity": "sha512-qi5i4TC7zPxj0+thnaMvcP36nhKus6k8cz6p9BP+090TylAjMG7kF2A1ccH0RVxmqa7gg8G2d1J1zqC8k6QVdw==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/@oh-my-pi/snapcompact/-/snapcompact-17.0.6.tgz", + "integrity": "sha512-13KbdxqQvD1UswgB871dM/N1wDHMewUcSEGkyv/IVXkEWAgoT32qKA5MIAtm7YMCecGxcylP+N18Ysaas02JWA==", "dev": true, "license": "MIT", "dependencies": { - "@oh-my-pi/pi-ai": "16.2.13", - "@oh-my-pi/pi-natives": "16.2.13", - "@oh-my-pi/pi-utils": "16.2.13", - "@oh-my-pi/pi-wire": "16.2.13" + "@oh-my-pi/pi-ai": "17.0.6", + "@oh-my-pi/pi-natives": "17.0.6", + "@oh-my-pi/pi-utils": "17.0.6", + "@oh-my-pi/pi-wire": "17.0.6" }, "engines": { "bun": ">=1.3.14" } }, "node_modules/@opencode-ai/plugin": { - "version": "1.17.13", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.13.tgz", - "integrity": "sha512-JjoIs6qTPl0IrXo+J1GTqX9lcb2h2dMoW6BWMR2IbTT3MY2FQ/lvBXDzkx0d3zVRaYN+NTmV4u8Nth396xq+sQ==", + "version": "1.18.4", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.4.tgz", + "integrity": "sha512-Mkq128aLJo4E8Sb2bX8zrRlQ+I2WPaJ/n1kzaor8nTi/K/zNP4t8LGKwyMbuRoD/lhw4veSbzDOASSSypv3mcQ==", "dev": true, "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.17.13", + "@opencode-ai/sdk": "1.18.4", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -3268,9 +3262,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.17.13", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.13.tgz", - "integrity": "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==", + "version": "1.18.4", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.4.tgz", + "integrity": "sha512-p/3P0KtWknoLvpsk8QrUzCKd4Q1A5Z3RmACEvwuQBGxnUy4AGo379lUYMgt7fczFn53079oroLuo6BosQri+HA==", "dev": true, "license": "MIT", "dependencies": { @@ -3288,9 +3282,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3301,9 +3295,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", - "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.10.0.tgz", + "integrity": "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3314,9 +3308,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3329,18 +3323,16 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.218.0.tgz", - "integrity": "sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw==", + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.220.0.tgz", + "integrity": "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-logs": "0.220.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3349,14 +3341,89 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.220.0.tgz", + "integrity": "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.220.0.tgz", + "integrity": "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3366,15 +3433,51 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.220.0.tgz", + "integrity": "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3385,14 +3488,14 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", - "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz", + "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.218.0" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-transformer": "0.220.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3402,18 +3505,18 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz", + "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3423,13 +3526,13 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3439,32 +3542,31 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "node_modules/@opentelemetry/resources": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", - "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.8.0", + "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3475,9 +3577,9 @@ } }, "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3491,15 +3593,15 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz", + "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3510,13 +3612,13 @@ } }, "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3527,14 +3629,14 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.10.0.tgz", + "integrity": "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3543,14 +3645,31 @@ "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3561,14 +3680,15 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", - "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -3579,9 +3699,9 @@ } }, "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3594,16 +3714,34 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", - "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.10.0.tgz", + "integrity": "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "2.8.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" + "@opentelemetry/context-async-hooks": "2.10.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/sdk-trace-base": "2.10.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -3613,9 +3751,9 @@ } }, "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3628,10 +3766,27 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3742,6 +3897,19 @@ } } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -3761,50 +3929,39 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@types/bun": { - "version": "1.2.22", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.22.tgz", - "integrity": "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.2.22" + "bun-types": "1.3.14" } }, "node_modules/@types/node": { - "version": "24.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", - "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.10.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.2.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/triple-beam": { @@ -3821,78 +3978,417 @@ "dev": true, "license": "MIT" }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=10.0.0" + "node": ">=16.20.0" } }, - "node_modules/@xterm/headless": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", - "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/adm-zip": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", - "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12.0" + "node": ">=16.20.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=16.20.0" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=16.20.0" } }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" ], - "license": "MIT" + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/argparse": { - "version": "1.0.10", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, @@ -3902,25 +4398,25 @@ } }, "node_modules/arkregex": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.7.tgz", - "integrity": "sha512-O/Ltrn9EUSn3ui0KVzfyrWGDUsHlzKxDVBtpQxL/6JmLRMAZAebfSNf/A/J5Ny5S6QIwrXX+RfXsu888HMs35A==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.8.tgz", + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", "dev": true, "license": "MIT", "dependencies": { - "@ark/util": "0.56.1" + "@ark/util": "0.56.2" } }, "node_modules/arktype": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.2.tgz", - "integrity": "sha512-YYf1xhL2dh5aPZFlsY0RAsxv5HZqfLGLptH2ZP3JidTmsGRW8VOymhPjjMTkerL12vR2YtX0SK4c1mATtae8SA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.3.tgz", + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", "dev": true, "license": "MIT", "dependencies": { - "@ark/schema": "0.56.1", - "@ark/util": "0.56.1", - "arkregex": "0.0.7" + "@ark/schema": "0.56.2", + "@ark/util": "0.56.2", + "arkregex": "0.0.8" } }, "node_modules/async": { @@ -3951,6 +4447,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -3959,11 +4468,18 @@ "license": "MIT" }, "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } }, "node_modules/boolean": { "version": "3.2.0", @@ -3974,19 +4490,81 @@ "license": "MIT", "optional": true }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/bun-types": { - "version": "1.2.22", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.22.tgz", - "integrity": "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" - }, - "peerDependencies": { - "@types/react": "^19" + "engines": { + "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4128,32 +4706,37 @@ } }, "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/sponsors/fb55" } }, "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">= 6" + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/sponsors/fb55" } }, @@ -4164,14 +4747,6 @@ "dev": true, "license": "MIT" }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -4264,24 +4839,28 @@ "license": "BSD-2-Clause" }, "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", "dev": true, "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", "dev": true, "funding": [ { @@ -4289,39 +4868,63 @@ "url": "https://github.com/sponsors/fb55" } ], - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } }, "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "domelementtype": "^2.3.0" + "domelementtype": "^3.0.0" }, "engines": { - "node": ">= 4" + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/fb55/domhandler?sponsor=1" } }, "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", @@ -4351,6 +4954,13 @@ "yaml": "^2.9.0" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -4366,9 +4976,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4380,13 +4990,13 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -4487,9 +5097,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "dev": true, "funding": [ { @@ -4499,17 +5109,33 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", - "xml-naming": "^0.1.0" + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4549,6 +5175,17 @@ "dev": true, "license": "MIT" }, + "node_modules/generative-bayesian-network": { + "version": "2.1.83", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.83.tgz", + "integrity": "sha512-LssI9es+oUoezoHloFGw0Hts0YEfujjBOE8KNl70oBt4HPjD/4rpUqcgZ/M7RCmgKvkmCyPB2KWowyJHuKyhfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4674,6 +5311,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/header-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.82.tgz", + "integrity": "sha512-4NjPB0+bAKjPoponSmTOkK58IEF2W22sOJA5O48k/MxbCZgOm+jrU4WVR53Z2I6xFgIPkVrQmKtt1LAbWtfqXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.82", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4701,6 +5354,78 @@ "entities": "^7.0.1" } }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/htmlparser2/node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -4738,6 +5463,16 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4752,9 +5487,9 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", "dev": true, "funding": [ { @@ -5114,16 +5849,16 @@ } }, "node_modules/linkedom": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", - "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", "dev": true, "license": "ISC", "dependencies": { - "css-select": "^5.1.0", + "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", - "htmlparser2": "^10.0.0", + "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "engines": { @@ -5138,6 +5873,14 @@ } } }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -5177,9 +5920,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5187,9 +5930,9 @@ } }, "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", "dev": true, "license": "ISC", "peerDependencies": { @@ -5232,9 +5975,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", + "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", "dev": true, "license": "MIT", "bin": { @@ -5372,16 +6115,30 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "boolbase": "^1.0.0" + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/fb55/nth-check?sponsor=1" } }, @@ -5447,6 +6204,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5455,9 +6232,9 @@ "license": "(MIT AND Zlib)" }, "node_modules/path-expression-matcher": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", - "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "dev": true, "funding": [ { @@ -5490,6 +6267,13 @@ "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/platform": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", @@ -5506,9 +6290,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -5979,9 +6763,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -6031,8 +6815,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/turndown": { "version": "7.2.4", @@ -6077,17 +6860,38 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/uglify-js": { @@ -6119,12 +6923,43 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6146,6 +6981,16 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", diff --git a/package.json b/package.json index 595b3fd3..b64997a6 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,20 @@ "name": "hcom-plugin-typecheck", "private": true, "description": "Type-checks the embedded agent plugins against their upstream SDK types.", + "engines": { + "node": ">=22.22.2 <23" + }, + "packageManager": "npm@10.9.8", "scripts": { "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-coding-agent": "0.80.3", - "@oh-my-pi/pi-coding-agent": "16.2.13", - "@opencode-ai/plugin": "1.17.13", - "@opencode-ai/sdk": "1.17.13", - "@types/bun": "1.2.22", - "@types/node": "24.3.1", - "typescript": "6.0.3" + "@earendil-works/pi-coding-agent": "0.81.1", + "@oh-my-pi/pi-coding-agent": "17.0.6", + "@opencode-ai/plugin": "1.18.4", + "@opencode-ai/sdk": "1.18.4", + "@types/bun": "1.3.14", + "@types/node": "22.20.1", + "typescript": "7.0.2" } } diff --git a/pyproject.toml b/pyproject.toml index a4928d93..33b3570b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["maturin>=1.12.6,<2.0"] +requires = ["maturin>=1.14.1,<2.0"] build-backend = "maturin" [project] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d0ead5ec..59277fee 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.97.1" components = ["clippy", "rustfmt"] diff --git a/scripts/install-mock-tools.ps1 b/scripts/install-mock-tools.ps1 index 6bc0c1df..68522365 100644 --- a/scripts/install-mock-tools.ps1 +++ b/scripts/install-mock-tools.ps1 @@ -18,8 +18,8 @@ $cache = if ($env:HCOM_MOCK_TOOLS_NPM_CACHE) { if (-not $Packages -or $Packages.Count -eq 0) { $Packages = @( - "@openai/codex@0.141.0", - "@anthropic-ai/claude-code@2.1.185" + "@openai/codex@0.145.0", + "@anthropic-ai/claude-code@2.1.216" ) } diff --git a/scripts/install-mock-tools.sh b/scripts/install-mock-tools.sh index 4cde348f..6d570712 100755 --- a/scripts/install-mock-tools.sh +++ b/scripts/install-mock-tools.sh @@ -11,8 +11,8 @@ if [[ "$#" -gt 0 ]]; then packages=("$@") else packages=( - "@openai/codex@0.141.0" - "@anthropic-ai/claude-code@2.1.185" + "@openai/codex@0.145.0" + "@anthropic-ai/claude-code@2.1.216" ) fi @@ -32,7 +32,7 @@ for package in "${packages[@]}"; do claude_version="${package##*@}" ;; @anthropic-ai/claude-code) - claude_version="2.1.185" + claude_version="2.1.216" ;; @anthropic-ai/claude-code-*) has_claude_native=1 diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh index 82878a94..614ae1c1 100644 --- a/scripts/typecheck.sh +++ b/scripts/typecheck.sh @@ -27,6 +27,8 @@ cd "$project_root" if [[ "${CI:-}" == "true" ]]; then npm ci --ignore-scripts else - npm install --ignore-scripts --prefer-offline + # CI enforces the pinned Node 22 runtime. Local typechecking can also run on a + # newer Node even when the user's global npm config enables engine-strict. + npm install --ignore-scripts --prefer-offline --engine-strict=false fi npm run typecheck diff --git a/tests/support/claude_mock.rs b/tests/support/claude_mock.rs index 1082e1d7..22e9a34d 100644 --- a/tests/support/claude_mock.rs +++ b/tests/support/claude_mock.rs @@ -21,8 +21,8 @@ use super::real_tool::{ const CLAUDE_META: ToolMeta = ToolMeta { tool: "claude", binary: "claude", - pinned_version: "2.1.185", - install_command: "npm install --global @anthropic-ai/claude-code@2.1.185", + pinned_version: "2.1.216", + install_command: "npm install --global @anthropic-ai/claude-code@2.1.216", }; pub const MODEL: &str = "claude-sonnet-4-6"; diff --git a/tests/support/codex_mock.rs b/tests/support/codex_mock.rs index 21095cb1..70b8939f 100644 --- a/tests/support/codex_mock.rs +++ b/tests/support/codex_mock.rs @@ -23,8 +23,8 @@ use super::real_tool::{ const CODEX_META: ToolMeta = ToolMeta { tool: "codex", binary: "codex", - pinned_version: "0.141.0", - install_command: "npm install --global @openai/codex@0.141.0", + pinned_version: "0.145.0", + install_command: "npm install --global @openai/codex@0.145.0", }; /// Codex adapter for the shared real-tool lifecycle. Codex has no fake-response diff --git a/tests/test_relay_roundtrip.rs b/tests/test_relay_roundtrip.rs index 43d5b83e..e42d49f4 100644 --- a/tests/test_relay_roundtrip.rs +++ b/tests/test_relay_roundtrip.rs @@ -1260,7 +1260,7 @@ fn test_relay_roundtrip() { logln!(log, "\n[Phase 7] Device A: remote launch on Device B..."); let claude_version = - std::env::var("HCOM_TEST_CLAUDE_VERSION").unwrap_or_else(|_| "2.1.185".to_string()); + std::env::var("HCOM_TEST_CLAUDE_VERSION").unwrap_or_else(|_| "2.1.216".to_string()); assert_tool_pinned( "claude", &claude_version, From 67678e12d0b3529b092d087d4d4e39548e4b1f79 Mon Sep 17 00:00:00 2001 From: aannoo Date: Wed, 22 Jul 2026 12:51:38 +0200 Subject: [PATCH 4/6] refactor(claude): tidy subagent routing and track only allocated children Maintainability pass over the actor-routing hooks: - extract handle_subagent_start / build_subagent_start_output - encapsulate the SubagentStop dedup in SubagentStopClaim::acquire - add RunningTasks::{track_subagent,remove_subagent,tracks_subagent} and a shared write_running_tasks helper - rename with_transaction -> with_immediate_transaction, remove_tracked_subagent_wherever_found -> remove_tracked_subagent_by_agent_id - factor prefix_like_pattern in db::kv and cover kv_delete_prefix - return (exit_code, stdout) consistently; drop the always-false end_task interrupted arg Also make ensure_subagent_row return whether the row now exists and only track the child on success, so an allocation failure can no longer leave the parent tracking a rowless, unaddressable subagent. --- src/db/kv.rs | 40 +++-- src/db/mod.rs | 29 +-- src/hooks/claude.rs | 404 ++++++++++++++++-------------------------- src/instance_names.rs | 20 +-- src/instances.rs | 93 ++++++---- 5 files changed, 250 insertions(+), 336 deletions(-) diff --git a/src/db/kv.rs b/src/db/kv.rs index 50f25922..4ee02434 100644 --- a/src/db/kv.rs +++ b/src/db/kv.rs @@ -5,6 +5,14 @@ use rusqlite::params; use super::HcomDb; +fn prefix_like_pattern(prefix: &str) -> String { + let escaped = prefix + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + format!("{escaped}%") +} + impl HcomDb { /// Get value from kv table. pub fn kv_get(&self, key: &str) -> Result> { @@ -38,12 +46,7 @@ impl HcomDb { /// Get all kv entries whose key starts with prefix. Returns Vec<(key, value)>. pub fn kv_prefix(&self, prefix: &str) -> Result> { - // Escape LIKE wildcards (%, _, \) in prefix to avoid unintended matches - let escaped = prefix - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_"); - let pattern = format!("{}%", escaped); + let pattern = prefix_like_pattern(prefix); let mut stmt = self .conn .prepare_cached("SELECT key, value FROM kv WHERE key LIKE ? ESCAPE '\\'")?; @@ -58,11 +61,7 @@ impl HcomDb { /// Delete all kv entries whose key starts with prefix. Returns count deleted. pub fn kv_delete_prefix(&self, prefix: &str) -> Result { - let escaped = prefix - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_"); - let pattern = format!("{}%", escaped); + let pattern = prefix_like_pattern(prefix); let n = self.conn.execute( "DELETE FROM kv WHERE key LIKE ? ESCAPE '\\'", params![pattern], @@ -121,4 +120,23 @@ mod tests { cleanup_test_db(db_path); } + + #[test] + fn test_kv_delete_prefix() { + let (db, db_path) = setup_full_test_db(); + + db.kv_set("claim:100%_done:1", Some("one")).unwrap(); + db.kv_set("claim:100%_done:2", Some("two")).unwrap(); + db.kv_set("claim:100x_done:1", Some("other")).unwrap(); + + assert_eq!(db.kv_delete_prefix("claim:100%_done:").unwrap(), 2); + assert!(db.kv_get("claim:100%_done:1").unwrap().is_none()); + assert!(db.kv_get("claim:100%_done:2").unwrap().is_none()); + assert_eq!( + db.kv_get("claim:100x_done:1").unwrap(), + Some("other".to_string()) + ); + + cleanup_test_db(db_path); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index d699ae73..73851b3e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -18,7 +18,7 @@ use anyhow::{Context, Result}; use chrono::Utc; -use rusqlite::Connection; +use rusqlite::{Connection, Transaction, TransactionBehavior}; use crate::shared::time::now_epoch_f64; @@ -122,29 +122,16 @@ impl HcomDb { &self.conn } - /// Run `f` inside a `BEGIN IMMEDIATE` transaction, committing on success. + /// Run `f` inside a `BEGIN IMMEDIATE` transaction and commit on success. /// - /// `BEGIN IMMEDIATE` takes the write lock up front (rather than on first - /// write), so a read-then-write sequence inside `f` is atomic with - /// respect to other hcom processes: each hook invocation opens its own - /// connection, so a plain SELECT-then-UPDATE across two statements is a - /// lost-update race between concurrent processes without this. - /// - /// `f` is handed the `Transaction` itself and must run its queries - /// through it (not through `self.conn()`), so the RAII guard is actually - /// what's touching the database. This also makes rollback panic-safe: a - /// manual `BEGIN`/`COMMIT`/`ROLLBACK` pair leaves the connection stuck - /// inside an open transaction if `f` panics (the `ROLLBACK` arm never - /// runs); `rusqlite::Transaction`'s `Drop` rolls back unconditionally - /// unless `commit()` was reached. - pub fn with_transaction( + /// The immediate write lock makes read-modify-write sequences atomic + /// across the separate database connections used by hook processes. + /// Queries inside `f` must use the provided transaction. + pub fn with_immediate_transaction( &self, - f: impl FnOnce(&rusqlite::Transaction) -> Result, + f: impl FnOnce(&Transaction<'_>) -> Result, ) -> Result { - let txn = rusqlite::Transaction::new_unchecked( - &self.conn, - rusqlite::TransactionBehavior::Immediate, - )?; + let txn = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; let result = f(&txn)?; txn.commit()?; Ok(result) diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index e2aaee55..f90b50ad 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -39,6 +39,10 @@ const HOOK_SUBAGENT_STOP: &str = "subagent-stop"; const HOOK_SESSIONEND: &str = "sessionend"; const HOOK_POLL: &str = "poll"; +fn is_subagent_tool(tool_name: &str) -> bool { + matches!(tool_name, "Agent" | "Task") +} + /// Handle a Claude hook — entry point from router. /// /// Reads JSON from stdin, builds context, dispatches to appropriate handler. @@ -235,15 +239,9 @@ fn route_claude_hook( return (result.0, result.1, None, timing); } - // Hook actor: `raw.agent_id` is Claude Code's own signal for "this hook - // fired inside a subagent's own execution context" (set on ordinary - // PreToolUse/PostToolUse and on SubagentStart/SubagentStop), absent on - // the root/main thread. Checked before Task/Agent transitions and before - // any running_tasks-based decision, because parent and every nested - // subagent share one session_id — session_id can't tell them apart, and - // running_tasks.active is parent-side bookkeeping, not identity (using it - // for routing is what let PR #82's reap-on-missing-row either wrongly - // freeze or wrongly unfreeze the parent). + // Claude identifies the hook's actor with agent_id. All actors share the + // root session_id, while running_tasks is lifecycle state rather than + // identity, so actor routing must happen before task-state handling. let raw_agent_id = payload .raw .get("agent_id") @@ -278,26 +276,20 @@ fn route_claude_hook( // ---- Root/main-thread hook (no agent_id) from here on ---- - // Task transitions let tool_name = payload.tool_name.as_str(); - // Claude Code renamed the Task tool to Agent (Task kept as legacy alias), - // so match both to keep subagent lifecycle tracking working across versions (useless change!). - let is_task_tool = tool_name == "Task" || tool_name == "Agent"; - if hook_type == HOOK_PRE && is_task_tool { + if hook_type == HOOK_PRE && is_subagent_tool(tool_name) { let task_start = Instant::now(); - let (stdout, exit_code) = match db.get_session_binding(&session_id).ok().flatten() { + let (exit_code, stdout) = match db.get_session_binding(&session_id).ok().flatten() { Some(instance_name) => start_task(db, &instance_name, &session_id, &payload.raw), - None => (String::new(), 0), + None => (0, String::new()), }; timing.task_ms = Some(task_start.elapsed().as_secs_f64() * 1000.0); return (exit_code, stdout, None, timing); } - if hook_type == HOOK_POST && is_task_tool { + if hook_type == HOOK_POST && is_subagent_tool(tool_name) { let task_start = Instant::now(); let stdout = match db.get_session_binding(&session_id).ok().flatten() { - Some(instance_name) => { - end_task(db, &instance_name, &payload.raw, false).unwrap_or_default() - } + Some(instance_name) => end_task(db, &instance_name, &payload.raw).unwrap_or_default(), None => String::new(), }; timing.task_ms = Some(task_start.elapsed().as_secs_f64() * 1000.0); @@ -305,13 +297,8 @@ fn route_claude_hook( } if hook_type == HOOK_USERPROMPTSUBMIT { - // Reap subagents that died without a clean SubagentStop before - // honoring root delivery below. This is root-side bookkeeping (a - // definite, user-present moment — a heuristic false positive is - // recoverable on the next turn), not actor routing, so it stays keyed - // off running_tasks.active/parse — see the module doc above for why - // that distinction matters. `cleanup_dead_subagents` no-ops quickly - // when there is nothing tracked. + // Reap subagents that died without a clean SubagentStop before root + // delivery. This is lifecycle cleanup, not actor identification. let transcript_path = payload.transcript_path.as_deref().unwrap_or(""); cleanup_dead_subagents(db, &session_id, transcript_path); // Fall through to parent handler for PTY message delivery @@ -417,87 +404,18 @@ fn route_subagent_actor_hook( ) -> (i32, String, Option) { timing.context = Some("subagent"); - // SubagentStart is the hook that *creates* this agent_id's row - // (ensure_subagent_row) — an unknown row here is the expected, normal - // first sighting of this subagent, not a fail-closed condition. if hook_type == HOOK_SUBAGENT_START { - let agent_type = payload - .raw - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if !agent_id.is_empty() && !agent_type.is_empty() { - let parent_instance = - match resolve_spawn_owner(db, &payload.raw, session_id, agent_id, root_name) { - SpawnOwner::LegacyRoot(name) - | SpawnOwner::Resolved(name) - | SpawnOwner::Resumed(name) => name, - SpawnOwner::Unresolved => { - // prompt_id was present (Claude Code >= 2.1.196) but - // no valid owner resolved by either the fresh-spawn - // or resume path, and this agent_id has never been - // seen before — a missing/stale/foreign mapping means - // correlation failed, not that root is a safe guess. - // Fail closed entirely: no tracking, no row, no - // bootstrap hint. - log::log_warn( - "hooks", - "subagent.spawn_owner.unresolved", - &format!( - "agent_id={} prompt_id={:?}", - agent_id, - payload.raw.get("prompt_id").and_then(|v| v.as_str()) - ), - ); - timing.result = Some("spawn_owner_unresolved"); - return (0, String::new(), None); - } - }; - // Remember this agent_id's owner regardless of which path - // resolved it, so a later Claude-initiated resume of this same - // agent_id (new prompt_id, no corresponding PreToolUse) can - // still find its true owner instead of failing closed. - let _ = db.kv_set( - &agent_owner_kv_key(session_id, agent_id), - Some(&parent_instance), - ); - // Allocate the instance row BEFORE tracking it in the parent's - // running_tasks. Row-first means a tracked subagent always has a - // real, addressable row: the reverse order can leave a - // tracked-but-rowless entry (parent thinks a child is active, but - // every one of that child's actor hooks silently no-ops as an - // unknown actor and it can never receive a message). Eager - // allocation also makes the subagent show up in the TUI and be a - // valid `hcom send` target from birth, without injecting any hcom - // context into the subagent itself. It stays dormant - // (name_announced=0) until a message arrives at SubagentStop or it - // runs `hcom start`. - ensure_subagent_row(db, &parent_instance, session_id, agent_id, agent_type); - track_subagent(db, &parent_instance, agent_id, agent_type); - } - let output = subagent_start(&payload.raw); - if let Some(out) = output { - return (0, serde_json::to_string(&out).unwrap_or_default(), None); - } - return (0, String::new(), None); + return handle_subagent_start(db, payload, agent_id, session_id, root_name, timing); } - // SubagentStop resolves its own row by agent_id internally and already - // fails safe when it's missing (running_tasks cleanup scanned across all - // instances, no delivery — see its doc comment); no extra gate needed - // here. + // SubagentStop resolves its own row and handles a missing row safely. if hook_type == HOOK_SUBAGENT_STOP { let (exit_code, stdout) = subagent_stop(db, session_id, &payload.raw); return (exit_code, stdout, None); } - // Every other subagent-context hook needs a known, resolvable identity. - // Row existence is identity/participation state (allocated, best-effort, - // at SubagentStart) — never a liveness probe. An unknown/missing row - // means this agent_id has no valid slot right now: fail closed with a - // silent no-op, and never fall through to parent/root handling — that - // fallthrough is exactly the unsafe shortcut PR #82 proposed (reaping a - // tracked-but-rowless subagent and treating that as proof it's dead). + // Every other subagent hook requires a known actor row. A missing row is + // not proof that the hook belongs to the root, so fail closed. let Ok(Some(subagent_instance)) = db.get_instance_by_agent_id(agent_id) else { timing.result = Some("unknown_subagent_actor"); return (0, String::new(), None); @@ -507,17 +425,14 @@ fn route_subagent_actor_hook( return (0, String::new(), None); } - // Nested Agent/Task tool call, spawned from *inside* this subagent: - // mutate this subagent's own running_tasks row, not the root's (see the - // module-level rationale above for why session_id can't be used here). + // Nested Agent/Task calls mutate the acting subagent, not the shared root. let tool_name = payload.tool_name.as_str(); - let is_task_tool = tool_name == "Task" || tool_name == "Agent"; - if hook_type == HOOK_PRE && is_task_tool { - let (stdout, exit_code) = start_task(db, &subagent_instance, session_id, &payload.raw); + if hook_type == HOOK_PRE && is_subagent_tool(tool_name) { + let (exit_code, stdout) = start_task(db, &subagent_instance, session_id, &payload.raw); return (exit_code, stdout, None); } - if hook_type == HOOK_POST && is_task_tool { - let stdout = end_task(db, &subagent_instance, &payload.raw, false).unwrap_or_default(); + if hook_type == HOOK_POST && is_subagent_tool(tool_name) { + let stdout = end_task(db, &subagent_instance, &payload.raw).unwrap_or_default(); return (0, stdout, None); } @@ -545,6 +460,64 @@ fn route_subagent_actor_hook( (0, String::new(), None) } +fn handle_subagent_start( + db: &HcomDb, + payload: &HookPayload, + agent_id: &str, + session_id: &str, + root_name: &str, + timing: &mut DispatchTiming, +) -> (i32, String, Option) { + let agent_type = payload + .raw + .get("agent_type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + if !agent_type.is_empty() { + let parent_instance = + match resolve_spawn_owner(db, &payload.raw, session_id, agent_id, root_name) { + SpawnOwner::LegacyRoot(name) + | SpawnOwner::Resolved(name) + | SpawnOwner::Resumed(name) => name, + SpawnOwner::Unresolved => { + log::log_warn( + "hooks", + "subagent.spawn_owner.unresolved", + &format!( + "agent_id={} prompt_id={:?}", + agent_id, + payload + .raw + .get("prompt_id") + .and_then(|value| value.as_str()) + ), + ); + timing.result = Some("spawn_owner_unresolved"); + return (0, String::new(), None); + } + }; + + let _ = db.kv_set( + &agent_owner_kv_key(session_id, agent_id), + Some(&parent_instance), + ); + + // Allocate the row first, and only track the child once it has one, so + // a tracked actor is always addressable. Tracking a child whose row + // allocation failed would leave the parent pointing at a subagent every + // one of whose later hooks fails closed as an unknown actor. + if ensure_subagent_row(db, &parent_instance, session_id, agent_id, agent_type) { + track_subagent(db, &parent_instance, agent_id, agent_type); + } + } + + let stdout = build_subagent_start_output(&payload.raw) + .and_then(|output| serde_json::to_string(&output).ok()) + .unwrap_or_default(); + (0, stdout, None) +} + /// Get correct session_id, handling Claude Code's fork bug. /// /// For --fork-session, hook_data.session_id reports the parent's old ID. @@ -852,9 +825,8 @@ enum SpawnOwner { /// already known (see `agent_owner_kv_key`) — Claude resuming a /// previously-spawned subagent under the same `agent_id` stamps a *new* /// `prompt_id` on the resumed SubagentStart with no corresponding - /// PreToolUse to map it (live-verified: resume never re-runs the - /// spawning Task/Agent PreToolUse), so the fresh-`prompt_id` lookup - /// always misses on resume. Falling back to this agent_id's own + /// PreToolUse to map it, so the fresh-`prompt_id` lookup always misses on + /// resume. Falling back to this agent_id's own /// remembered owner is safe specifically because the agent_id is /// already known — see `Unresolved` for why an *unknown* agent_id must /// not get the same treatment. @@ -872,9 +844,8 @@ enum SpawnOwner { /// Resolve which instance actually spawned a SubagentStart's child. /// /// Claude stamps the same `prompt_id` on a Task/Agent tool's PreToolUse and -/// on the SubagentStart(s) it produces — including parallel siblings spawned -/// by the same call — live-verified against Claude Code 2.1.216 (requires at -/// least 2.1.196; see `spawn_owner_kv_key`). `start_task` records +/// on the SubagentStart(s) it produces, including parallel siblings spawned +/// by the same call. `start_task` records /// `(root_session_id, prompt_id) -> acting instance` for exactly this /// lookup, so a SubagentStart spawned by a *nested* subagent resolves to /// that subagent, not the session-bound root — parent and every nested @@ -941,13 +912,13 @@ fn validate_spawn_owner(db: &HcomDb, owner: &str, root_session_id: &str, root_na /// Agent/Task call; see the call sites in `route_claude_hook` / /// `route_subagent_actor_hook`). /// -/// Returns (stdout, exit_code). +/// Returns (exit_code, stdout). fn start_task( db: &HcomDb, instance_name: &str, root_session_id: &str, raw: &Value, -) -> (String, i32) { +) -> (i32, String) { log::log_info( "hooks", "start_task.enter", @@ -1007,10 +978,10 @@ fn start_task( "updatedInput": updated, } }); - return (serde_json::to_string(&output).unwrap_or_default(), 0); + return (0, serde_json::to_string(&output).unwrap_or_default()); } - (String::new(), 0) + (0, String::new()) } /// PostToolUse Task: deliver freeze-period messages for `instance_name` — the @@ -1018,11 +989,7 @@ fn start_task( /// /// Returns Option — JSON stdout if messages were delivered. /// Dispatcher writes this to stdout before returning exit code. -fn end_task(db: &HcomDb, instance_name: &str, raw: &Value, interrupted: bool) -> Option { - if interrupted { - return None; - } - +fn end_task(db: &HcomDb, instance_name: &str, raw: &Value) -> Option { // Since Claude Code 2.1.198, Agent/Task calls background by default: this // PostToolUse fires immediately with tool_response.status="async_launched" // when the call is merely dispatched to the background, not when the @@ -1032,8 +999,6 @@ fn end_task(db: &HcomDb, instance_name: &str, raw: &Value, interrupted: bool) -> // skip delivery rather than assume anything about how/when completion is // reported — root-scoped messages still flow through the root's own // interleaved PostToolUse hooks regardless (see route_claude_hook). - // Not yet live-verified: what a genuine completion for a backgrounded - // call looks like on the wire. let is_async_launch = raw .get("tool_response") .and_then(|v| v.get("status")) @@ -1653,31 +1618,14 @@ fn track_subagent(db: &HcomDb, parent_instance: &str, agent_id: &str, agent_type // subagents of the same parent (parallel Task calls) cannot race and // silently drop each other's entry. instances::mutate_running_tasks(db, parent_instance, |rt| { - rt.active = true; - let already_tracked = rt - .subagents - .iter() - .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(agent_id)); - if !already_tracked { - rt.subagents.push(serde_json::json!({ - "agent_id": agent_id, - "type": agent_type, - })); - } + rt.track_subagent(agent_id, agent_type); }); } /// Remove subagent from parent's running_tasks. fn remove_subagent_from_parent(db: &HcomDb, parent_name: &str, agent_id: &str) { instances::mutate_running_tasks(db, parent_name, |rt| { - if rt.subagents.is_empty() { - return; - } - rt.subagents - .retain(|s| s.get("agent_id").and_then(|v| v.as_str()) != Some(agent_id)); - if rt.subagents.is_empty() { - rt.active = false; - } + rt.remove_subagent(agent_id); }); } @@ -1872,7 +1820,7 @@ fn cleanup_dead_subagents(db: &HcomDb, session_id: &str, transcript_path: &str) } /// SubagentStart: surface agent_id to subagent. -fn subagent_start(raw: &Value) -> Option { +fn build_subagent_start_output(raw: &Value) -> Option { let agent_id = raw.get("agent_id").and_then(|v| v.as_str())?; if agent_id.is_empty() { return None; @@ -1908,16 +1856,19 @@ fn subagent_start(raw: &Value) -> Option { /// never get their own `session_id` (see `allocate_subagent_instance`) — it /// is the only value the `parent_session_id` FK column (`REFERENCES /// instances(session_id)`) can ever validly point at, at any nesting depth. +/// Allocate this subagent's `instances` row. Returns `true` once a row exists +/// and the actor is addressable (allocation is idempotent, so an already-present +/// row also counts), `false` if the parent is unknown or allocation failed. fn ensure_subagent_row( db: &HcomDb, parent_instance: &str, root_session_id: &str, agent_id: &str, agent_type: &str, -) { +) -> bool { let parent_data = match db.get_instance_full(parent_instance) { Ok(Some(data)) => data, - _ => return, + _ => return false, }; let parent_tag = parent_data.tag.as_deref(); @@ -1941,6 +1892,7 @@ fn ensure_subagent_row( name, parent_instance, agent_id, agent_type ), ); + true } Err(e) => { log::log_warn( @@ -1948,6 +1900,7 @@ fn ensure_subagent_row( "subagent.row.alloc_failed", &format!("agent_id={} err={}", agent_id, e), ); + false } } } @@ -1994,55 +1947,32 @@ fn subagent_stop_claim_prefix(root_session_id: &str) -> String { format!("subagent_stop_claimed:{root_session_id}:") } -/// Atomically claim one SubagentStop invocation for processing — true if this -/// call won the claim, false if a *concurrent* duplicate delivery of the -/// identical invocation is already holding it. Must be checked before *any* -/// processing (idle-gate, `poll_messages`, teardown), not only before -/// teardown: with duplicate hook registrations, both invocations otherwise -/// enter `poll_messages` independently — one can receive a delivered message -/// (exit_code=2) while the other times out (exit_code=0) and tears the row -/// down while Claude is still acting on the delivery, so the reply fails -/// because the identity is gone. -/// -/// The claim is a *concurrency guard*, held only for the duration of one -/// invocation's processing and released by `StopClaimGuard` on completion — -/// NOT a session-long tombstone. That distinction is load-bearing: the raw -/// payload is only a content fingerprint, and two *genuinely distinct* stops -/// can be byte-for-byte identical (stable `prompt_id`, repeated -/// `last_assistant_message`, same transcript path — observed live: a subagent -/// that ends two separate message turns with the same final text produces -/// identical payloads). A permanent claim would silently suppress the second -/// such stop (no poll, no delivery, no teardown). Because Claude blocks the -/// subagent until its SubagentStop hook returns, two same-payload stops can -/// only overlap in time when they are the *same* logical stop dispatched to -/// two registered hooks — exactly the case a release-on-completion claim still -/// dedups, while a later sequential re-fire re-claims cleanly. -/// -/// `INSERT OR IGNORE` is a single statement, so SQLite's own write lock makes -/// the claim atomic across concurrent processes without an explicit -/// transaction. -fn claim_subagent_stop(db: &HcomDb, claim_key: &str) -> bool { - db.conn() - .execute( - "INSERT OR IGNORE INTO kv (key, value) VALUES (?, '1')", - rusqlite::params![claim_key], - ) - // DB error: fail open (proceed with processing) rather than risk - // wedging a dead subagent forever over an unrelated I/O hiccup. - .map(|n| n > 0) - .unwrap_or(true) -} - -/// Releases a won SubagentStop claim when the invocation's processing ends, so -/// a later, genuinely distinct stop with a byte-identical payload can re-claim -/// and be processed instead of being suppressed forever. See -/// `claim_subagent_stop` for why the claim must be transient, not permanent. -struct StopClaimGuard<'a> { +/// Transient claim that prevents concurrent duplicate SubagentStop hooks from +/// polling or tearing down the same actor at the same time. The claim is +/// released on drop so a later, distinct stop with identical content can run. +struct SubagentStopClaim<'a> { db: &'a HcomDb, key: String, } -impl Drop for StopClaimGuard<'_> { +impl<'a> SubagentStopClaim<'a> { + fn acquire(db: &'a HcomDb, root_session_id: &str, agent_id: &str, raw: &Value) -> Option { + let key = subagent_stop_claim_key(root_session_id, agent_id, raw); + let claimed = db + .conn() + .execute( + "INSERT OR IGNORE INTO kv (key, value) VALUES (?, '1')", + rusqlite::params![key], + ) + // Fail open rather than leave a stopped actor wedged by a DB error. + .map(|inserted| inserted > 0) + .unwrap_or(true); + + claimed.then_some(Self { db, key }) + } +} + +impl Drop for SubagentStopClaim<'_> { fn drop(&mut self) { let _ = self .db @@ -2086,20 +2016,14 @@ fn subagent_stop(db: &HcomDb, root_session_id: &str, raw: &Value) -> (i32, Strin // that spawned this one (see `resolve_spawn_owner`) — scan for // whichever instance actually tracks `agent_id` rather than assuming // root, or the true owner is left wedged active forever. - instances::remove_tracked_subagent_wherever_found(db, agent_id); + instances::remove_tracked_subagent_by_agent_id(db, agent_id); return (0, String::new()); }; - // Claim this exact invocation before any further processing — see - // `claim_subagent_stop` for why this can't wait until the teardown - // decision: a duplicate delivery must not even enter `poll_messages`. - // `_claim_guard` releases the claim on every return path below, so a - // later distinct stop with a byte-identical payload is not suppressed. - let claim_key = subagent_stop_claim_key(root_session_id, agent_id, raw); - if !claim_subagent_stop(db, &claim_key) { + // A duplicate delivery must not enter polling or teardown. + let Some(_stop_claim) = SubagentStopClaim::acquire(db, root_session_id, agent_id, raw) else { return (0, String::new()); - } - let _claim_guard = StopClaimGuard { db, key: claim_key }; + }; // Store transcript_path if not already set if existing_transcript.is_empty() @@ -3122,7 +3046,7 @@ mod tests { #[test] fn test_subagent_start_with_agent_id() { let raw = serde_json::json!({"agent_id": "agent-uuid-123"}); - let result = subagent_start(&raw); + let result = build_subagent_start_output(&raw); assert!(result.is_some()); let output = result.unwrap(); let ctx = output["hookSpecificOutput"]["additionalContext"] @@ -3134,13 +3058,13 @@ mod tests { #[test] fn test_subagent_start_no_agent_id() { let raw = serde_json::json!({}); - assert!(subagent_start(&raw).is_none()); + assert!(build_subagent_start_output(&raw).is_none()); } #[test] fn test_subagent_start_empty_agent_id() { let raw = serde_json::json!({"agent_id": ""}); - assert!(subagent_start(&raw).is_none()); + assert!(build_subagent_start_output(&raw).is_none()); } #[test] @@ -4162,10 +4086,8 @@ mod tests { /// Property: a subagent PostToolUse whose agent_id has no resolvable /// `instances` row (SubagentStart's row allocation is best-effort and may /// not have happened, or the row is gone) must never deliver anything — - /// and, critically, must never fall through to root/parent delivery. That - /// fallthrough is exactly what PR #82 proposed (reap on row-missing, then - /// treat root as unfrozen) — unsafe because row-missing is - /// identity/participation state, not a liveness signal. + /// and, critically, must never fall through to root/parent delivery. + /// Row absence is identity state, not proof that the actor is the root. #[test] #[serial] fn test_subagent_posttooluse_unknown_row_never_falls_through_to_parent() { @@ -4341,11 +4263,8 @@ mod tests { /// dispatched the call to the background (default since Claude Code /// 2.1.198) — this is not completion, so it must not deliver a /// "Subagents have finished" summary and must not advance the delivery - /// cursor. This test only asserts that conservative non-behavior; it - /// does not model or imply what a genuine completion for a backgrounded - /// call looks like on the wire (not yet live-verified — see - /// `test_task_posttooluse_foreground_completed_delivers` for the - /// separate, independently-verified foreground path). + /// cursor. Foreground completion is covered separately by + /// `test_task_posttooluse_foreground_completed_delivers`. #[test] #[serial] fn test_task_posttooluse_async_launch_skips_delivery() { @@ -5030,8 +4949,7 @@ mod tests { /// Property: a resumed subagent — Claude re-firing SubagentStart for a /// previously-known `agent_id` under a *new* `prompt_id` with no - /// corresponding PreToolUse to map it (live-verified: resume doesn't - /// re-run the spawning Task/Agent PreToolUse) — must reattach to its + /// corresponding PreToolUse to map it — must reattach to its /// original owner via the session-scoped agent_owner memory, not fail /// closed. Sequential: the original subagent fully spawns and stops /// (row deleted) before the resume fires. @@ -5262,30 +5180,25 @@ mod tests { crate::config::Config::init(); let (_dir, _guard, db) = make_isolated_test_db(); let raw = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1"}); - let key = subagent_stop_claim_key("sess-1", "agent-x", &raw); - assert!(claim_subagent_stop(&db, &key), "first claim must succeed"); + let _first_claim = SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw) + .expect("first claim must succeed"); assert!( - !claim_subagent_stop(&db, &key), + SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw).is_none(), "duplicate identical invocation must not re-claim while held" ); let raw_different_payload = serde_json::json!({"agent_id": "agent-x", "prompt_id": "p1", "note": "different"}); - assert!( - claim_subagent_stop( - &db, - &subagent_stop_claim_key("sess-1", "agent-x", &raw_different_payload) - ), - "same prompt_id but different payload content must be its own claim" - ); + let _different_claim = + SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw_different_payload) + .expect("same prompt_id but different payload content must be its own claim"); } - /// Regression (finding #1): the claim is a transient concurrency guard, not - /// a session-long tombstone. Two *genuinely distinct* SubagentStop - /// invocations can carry byte-identical payloads (stable prompt_id + - /// repeated last_assistant_message — reproduced live). While one is + /// The claim is a transient concurrency guard, not a session-long + /// tombstone. Two distinct SubagentStop invocations can carry identical + /// payloads. While one is /// in-flight the identical duplicate must lose (concurrency dedup), but - /// once `StopClaimGuard` releases it, a later identical stop must be able + /// once `SubagentStopClaim` releases it, a later identical stop must be able /// to re-claim and be processed — not suppressed forever. #[test] #[serial] @@ -5299,30 +5212,23 @@ mod tests { }); let key = subagent_stop_claim_key("sess-1", "agent-x", &raw); - assert!(claim_subagent_stop(&db, &key), "first invocation claims"); + let first_claim = SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw) + .expect("first invocation claims"); assert!( - !claim_subagent_stop(&db, &key), + SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw).is_none(), "a concurrent duplicate still in-flight must lose the claim" ); - // First invocation finishes -> guard releases. - { - let _guard = StopClaimGuard { - db: &db, - key: key.clone(), - }; - } + drop(first_claim); assert!( db.kv_get(&key).unwrap().is_none(), "guard drop must release the claim key" ); - assert!( - claim_subagent_stop(&db, &key), - "a later, genuinely distinct stop with a byte-identical payload must re-claim, not be suppressed" - ); + let _later_claim = SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw) + .expect("a later, genuinely distinct stop with a byte-identical payload must re-claim"); } - /// Integration guard for finding #1: `subagent_stop` itself must release - /// the claim it took, so the tombstone can't outlive the invocation. Uses + /// `subagent_stop` must release its claim so it cannot outlive the + /// invocation. Uses /// a zero timeout so the poll returns immediately without blocking. #[test] #[serial] @@ -5390,10 +5296,8 @@ mod tests { }); // Simulate a concurrent duplicate having already claimed this exact // invocation (and still in-flight, so the claim is unreleased). - assert!(claim_subagent_stop( - &db, - &subagent_stop_claim_key("sess-1", "agent-x", &raw) - )); + let _existing_claim = SubagentStopClaim::acquire(&db, "sess-1", "agent-x", &raw) + .expect("the simulated concurrent invocation must hold the claim"); let (exit_code, stdout) = subagent_stop(&db, "sess-1", &raw); assert_eq!(exit_code, 0); diff --git a/src/instance_names.rs b/src/instance_names.rs index d1240b17..0f72b449 100644 --- a/src/instance_names.rs +++ b/src/instance_names.rs @@ -410,16 +410,8 @@ pub struct SubagentAllocation<'a> { /// creating duplicates, and vice versa). Otherwise computes the next free /// suffix and INSERTs the row. /// -/// The whole idempotency-check + suffix-scan + INSERT runs inside one -/// `BEGIN IMMEDIATE` transaction. Each SubagentStart is a separate process -/// with its own DB connection, so parallel same-type siblings of one parent -/// would otherwise each read the same `max_n` and pick the same `_N` suffix: -/// the first INSERT wins, every other loses on `UNIQUE(name)`. A single -/// `max_n + 2` retry only rescues the second racer — a third-plus sibling is -/// dropped entirely (no row → every later actor hook silently no-ops as an -/// unknown actor). `BEGIN IMMEDIATE` takes the write lock up front, so the -/// scan and the INSERT are atomic with respect to other processes and each -/// racer computes its suffix against the rows the winners already committed. +/// The idempotency check, suffix scan, and INSERT share one `BEGIN IMMEDIATE` +/// transaction so concurrent sibling hooks cannot choose the same suffix. pub fn allocate_subagent_instance(db: &HcomDb, info: &SubagentAllocation) -> Result { let sanitized = sanitize_subagent_type(info.agent_type); let pattern = format!("{}_{}_", info.parent_name, sanitized); @@ -430,7 +422,7 @@ pub fn allocate_subagent_instance(db: &HcomDb, info: &SubagentAllocation) -> Res .unwrap_or_default(); let now = crate::shared::time::now_epoch_f64(); - db.with_transaction(|txn| { + db.with_immediate_transaction(|txn| { // Idempotency check must live inside the transaction too: otherwise a // concurrent SubagentStart and `hcom start --name ` for the // same agent_id could both miss it and insert two rows. @@ -576,11 +568,7 @@ mod subagent_alloc_tests { #[test] fn allocate_concurrent_siblings_all_get_rows() { - // Repro for the concurrent-sibling allocation race: N separate - // processes (here: threads with their own connections to the same - // file DB, mirroring separate hook invocations) each allocate a - // subagent of the SAME type under the SAME parent at once. Every one - // must end up with its own instance row — none may be dropped. + // Separate connections mirror concurrent hook processes. let tmp = TempDir::new().unwrap(); let path = tmp.path().join("race.db"); { diff --git a/src/instances.rs b/src/instances.rs index f2ad225f..02a2a684 100644 --- a/src/instances.rs +++ b/src/instances.rs @@ -6,7 +6,7 @@ use crate::db::{HcomDb, InstanceRow}; use crate::shared::ST_INACTIVE; -use rusqlite::OptionalExtension; +use rusqlite::{OptionalExtension, Transaction}; pub fn is_remote_instance(data: &InstanceRow) -> bool { data.origin_device_id.is_some() @@ -57,6 +57,34 @@ pub struct RunningTasks { pub subagents: Vec, } +impl RunningTasks { + pub fn tracks_subagent(&self, agent_id: &str) -> bool { + self.subagents.iter().any(|subagent| { + subagent.get("agent_id").and_then(|value| value.as_str()) == Some(agent_id) + }) + } + + pub fn track_subagent(&mut self, agent_id: &str, agent_type: &str) { + self.active = true; + if !self.tracks_subagent(agent_id) { + self.subagents.push(serde_json::json!({ + "agent_id": agent_id, + "type": agent_type, + })); + } + } + + pub fn remove_subagent(&mut self, agent_id: &str) { + let original_len = self.subagents.len(); + self.subagents.retain(|subagent| { + subagent.get("agent_id").and_then(|value| value.as_str()) != Some(agent_id) + }); + if self.subagents.len() != original_len && self.subagents.is_empty() { + self.active = false; + } + } +} + pub fn parse_running_tasks(json_str: Option<&str>) -> RunningTasks { let Some(s) = json_str else { return RunningTasks::default(); @@ -78,6 +106,23 @@ pub fn parse_running_tasks(json_str: Option<&str>) -> RunningTasks { } } +fn write_running_tasks( + txn: &Transaction<'_>, + name: &str, + running_tasks: &RunningTasks, +) -> anyhow::Result<()> { + let serialized = serde_json::json!({ + "active": running_tasks.active, + "subagents": &running_tasks.subagents, + }) + .to_string(); + txn.execute( + "UPDATE instances SET running_tasks = ? WHERE name = ?", + rusqlite::params![serialized, name], + )?; + Ok(()) +} + /// Atomically read-modify-write an instance's `running_tasks` JSON column. /// /// `running_tasks` is a whole-JSON-blob field (`{"active":bool,"subagents":[...]}`) @@ -89,7 +134,7 @@ pub fn parse_running_tasks(json_str: Option<&str>) -> RunningTasks { /// read-modify-write in a `BEGIN IMMEDIATE` transaction serializes concurrent /// mutators through SQLite's write lock instead. pub fn mutate_running_tasks(db: &HcomDb, name: &str, mutate: impl FnOnce(&mut RunningTasks)) { - let result = db.with_transaction(|txn| { + let result = db.with_immediate_transaction(|txn| { let current: Option = txn .query_row( "SELECT running_tasks FROM instances WHERE name = ?", @@ -100,15 +145,7 @@ pub fn mutate_running_tasks(db: &HcomDb, name: &str, mutate: impl FnOnce(&mut Ru let mut running_tasks = parse_running_tasks(current.as_deref()); mutate(&mut running_tasks); - let rt_json = serde_json::json!({ - "active": running_tasks.active, - "subagents": running_tasks.subagents, - }); - txn.execute( - "UPDATE instances SET running_tasks = ? WHERE name = ?", - rusqlite::params![rt_json.to_string(), name], - )?; - Ok(()) + write_running_tasks(txn, name, &running_tasks) }); if let Err(e) = result { @@ -128,8 +165,8 @@ pub fn mutate_running_tasks(db: &HcomDb, name: &str, mutate: impl FnOnce(&mut Ru /// owner could be the session-bound root *or* a nested subagent that spawned /// this one, so this scans rather than assuming root. No-ops if nothing /// tracks `agent_id`. -pub fn remove_tracked_subagent_wherever_found(db: &HcomDb, agent_id: &str) { - let result = db.with_transaction(|txn| { +pub fn remove_tracked_subagent_by_agent_id(db: &HcomDb, agent_id: &str) { + let result = db.with_immediate_transaction(|txn| { let owner: Option = { let mut stmt = txn.prepare( "SELECT name, running_tasks FROM instances @@ -145,12 +182,7 @@ pub fn remove_tracked_subagent_wherever_found(db: &HcomDb, agent_id: &str) { })? .collect::>>()?; rows.into_iter() - .find(|(_, rt)| { - parse_running_tasks(rt.as_deref()) - .subagents - .iter() - .any(|s| s.get("agent_id").and_then(|v| v.as_str()) == Some(agent_id)) - }) + .find(|(_, rt)| parse_running_tasks(rt.as_deref()).tracks_subagent(agent_id)) .map(|(name, _)| name) }; @@ -165,31 +197,16 @@ pub fn remove_tracked_subagent_wherever_found(db: &HcomDb, agent_id: &str) { |row| row.get(0), ) .optional()?; - let mut rt = parse_running_tasks(current.as_deref()); - rt.subagents - .retain(|s| s.get("agent_id").and_then(|v| v.as_str()) != Some(agent_id)); - if rt.subagents.is_empty() { - rt.active = false; - } - let rt_json = serde_json::json!({ - "active": rt.active, - "subagents": rt.subagents, - }); - txn.execute( - "UPDATE instances SET running_tasks = ? WHERE name = ?", - rusqlite::params![rt_json.to_string(), owner], - )?; - Ok(()) + let mut running_tasks = parse_running_tasks(current.as_deref()); + running_tasks.remove_subagent(agent_id); + write_running_tasks(txn, &owner, &running_tasks) }); if let Err(e) = result { crate::log::log_error( "core", "db.error", - &format!( - "remove_tracked_subagent_wherever_found: {} - {}", - agent_id, e - ), + &format!("remove_tracked_subagent_by_agent_id: {} - {}", agent_id, e), ); } } From f877967376abd91de8de5691c8c73da5c084e660 Mon Sep 17 00:00:00 2001 From: aannoo Date: Wed, 22 Jul 2026 12:58:29 +0200 Subject: [PATCH 5/6] fix(claude): cascade teardown to nested native subagent children Native subagent rows carry session_id=NULL and inherit the root session as their parent_session_id, so stop_instance's session-keyed cascade never links a nested parent to its own children. Stopping a nested parent A therefore tore down A while its child B stayed alive, reparented to a name that can later be reused by an unrelated actor. Add a parent_name-keyed cascade alongside the session one, running unconditionally. The existing MAX_STOP_DEPTH guard covers cycles and the already-stopped early-return makes the overlapping root case a no-op. Reachable only when a subagent is granted the Task/Agent tool (non-default), but makes teardown correct by construction. Regression test covers A->B cascade while the live root is left untouched. --- src/hooks/claude.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/hooks/common.rs | 19 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index f90b50ad..89caf3ca 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -4159,6 +4159,47 @@ mod tests { assert!(ack.is_none()); } + /// Property: stopping a nested native parent must recursively tear down its + /// own children. Native subagent rows carry session_id=NULL and inherit the + /// root session as parent_session_id, so the session-keyed teardown cascade + /// never links a nested parent to its children — only parent_name does. + /// Without the parent_name cascade, stopping parent A would delete A while + /// its child B stayed alive, reparented to a reusable name. + #[test] + #[serial] + fn test_stop_nested_parent_cascades_to_children() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'claude', 'listening', 'start', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + // A is a native subagent of root nova; B is a native subagent of A. + // Both share the root session as parent_session_id and have no session + // of their own (session_id=NULL, as insert_subagent_row leaves it). + insert_subagent_row(&db, "nova_a_1", "agent-a", "nova", "sess-1"); + insert_subagent_row(&db, "nova_a_1_b_1", "agent-b", "nova_a_1", "sess-1"); + + crate::hooks::stop_instance(&db, "nova_a_1", "test", "task_completed"); + + assert!( + db.get_instance_by_agent_id("agent-a").unwrap().is_none(), + "nested parent A must be torn down" + ); + assert!( + db.get_instance_by_agent_id("agent-b").unwrap().is_none(), + "child B must be cascaded, not orphaned with parent_name=A" + ); + assert!( + db.get_instance_full("nova").unwrap().is_some(), + "the still-live root must not be touched" + ); + } + /// Property: the root's own PostToolUse must deliver even while a /// genuinely live background subagent is tracked active. Since Claude /// Code 2.1.198, Agent calls background by default, so the root can have diff --git a/src/hooks/common.rs b/src/hooks/common.rs index 154cfe34..8ce68927 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -1113,6 +1113,25 @@ fn stop_instance_inner( } } + // Recursively stop native subagents whose immediate parent is this + // instance. Native subagent rows carry session_id=NULL and inherit the + // *root* session as their parent_session_id, so the session-keyed cascade + // above never links a nested parent to its own children — only parent_name + // does. Without this, stopping a nested parent leaves its children alive and + // reparented to a name that can later be reused. MAX_STOP_DEPTH guards + // against a parent_name cycle; a row already stopped above is a no-op here. + let native_children: Vec = db + .conn() + .prepare("SELECT name FROM instances WHERE parent_name = ?") + .and_then(|mut stmt| { + stmt.query_map(params![instance_name], |row| row.get::<_, String>(0)) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + }) + .unwrap_or_default(); + for child in native_children { + stop_instance_inner(db, &child, initiated_by, "parent_stopped", false, depth + 1); + } + // Clean notify endpoints and process bindings for this instance let _ = db.delete_notify_endpoints(instance_name); let _ = db.conn().execute( From 5b6ab68017eb4523e29f324c025796eb0ba5b574 Mon Sep 17 00:00:00 2001 From: aannoo Date: Wed, 22 Jul 2026 13:30:22 +0200 Subject: [PATCH 6/6] Add configurable live terminal title modes Support combined, label, and off modes for terminal and tab titles. In combined mode, capture and sanitize complete child titles and update hcom's status title live through the existing safe OSC path. Wire the setting through TOML, environment variables, CLI validation/help, README documentation, and tests. --- README.md | 1 + src/commands/config.rs | 45 +++++++++++++++++++ src/commands/help.rs | 4 ++ src/config.rs | 41 +++++++++++++++++ src/pty/mod.rs | 40 ++++++++++++++--- src/pty/screen.rs | 99 +++++++++++++++++++++++++++++++++++++++++ src/pty/shared.rs | 97 +++++++++++++++++++++++++++++++++++----- src/pty/win.rs | 28 +++++++++++- src/shared/constants.rs | 41 +++++++++++++++++ src/shared/mod.rs | 3 ++ 10 files changed, 382 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2da91ec5..11a96d5c 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ hcom config -i # per-agent override at runtime | `auto_approve` | Auto-approve safe hcom commands (send/list/events/…) | | `auto_subscribe` | Event subscription presets: `collision`, `created`, `stopped`, `blocked` | | `name_export` | Export instance name to a custom env var | +| `title_mode` | Terminal/tab title behavior: `combined` (default), `label`, or `off` | | `terminal` | Where new agent windows open (`hcom config terminal --info`) | | `timeout` | Idle timeout for headless/vanilla Claude (seconds) | | `subagent_timeout` | Keep-alive for Claude subagents (seconds) | diff --git a/src/commands/config.rs b/src/commands/config.rs index 4dc5d7f1..999a8df5 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -154,6 +154,11 @@ pub const CONFIG_KEYS: &[(&str, &str, &str)] = &[ "Export instance name to custom env var", "string", ), + ( + "HCOM_TITLE_MODE", + "Terminal title mode (combined | label | off)", + "string", + ), ( "HCOM_RELAY", "Relay MQTT broker URL (config file only)", @@ -195,6 +200,7 @@ const INSTANCE_KEYS: &[(&str, &str)] = &[ fn toml_path_for_key(field_name: &str) -> Option<&'static str> { match field_name { "terminal" => Some("terminal.active"), + "title_mode" => Some("terminal.title_mode"), "tag" => Some("launch.tag"), "hints" => Some("launch.hints"), "notes" => Some("launch.notes"), @@ -366,6 +372,16 @@ fn config_set_at_path(path: &Path, key: &str, value: &str) -> Result<(), String> } } + if field_name == "title_mode" + && !value.is_empty() + && !crate::shared::VALID_TITLE_MODES.contains(&value) + { + return Err(format!( + "title_mode must be one of: {}. Got '{value}'", + crate::shared::VALID_TITLE_MODES.join(", ") + )); + } + if let Some(dotted_path) = toml_path_for_key(&field_name) { set_nested_toml(&mut doc, dotted_path, value); } else { @@ -433,6 +449,7 @@ pub fn config_get(key: &str) -> (String, &'static str) { "HCOM_SUBAGENT_TIMEOUT" => "30", "HCOM_AUTO_APPROVE" => "true", "HCOM_AUTO_TRUST_WORKSPACE" => "true", + "HCOM_TITLE_MODE" => "combined", _ => "", }; (default.to_string(), "default") @@ -1526,6 +1543,34 @@ Notes: - See 'hcom events --help' for subscription management", ), + "HCOM_TITLE_MODE" => Some( + "\ +HCOM_TITLE_MODE - What appears in the terminal/tab title for hcom-launched agents + +Default: combined + +Purpose: + Controls whether hcom replaces, combines, or leaves alone the wrapped tool's + terminal title. In combined mode, hcom keeps its live status and appends the + tool's own live title (for example, a Codex spinner). + +Values: + combined - Show '{icon} name - {tool title}' and update it live. + label - Show hcom's status label only: '{icon} name [tool]'. + off - Pass the tool's own terminal title through unchanged; hcom writes none. + +Usage: + hcom config title_mode combined # hcom status + live tool title (default) + hcom config title_mode label # hcom status label only + hcom config title_mode off # use the tool's title + hcom config title_mode # reset to the default + +Notes: + - This affects terminal/tab titles, not the visible PTY output. + - Tools that do not emit terminal titles have no live child title to append. + - The same setting can be provided with HCOM_TITLE_MODE in the environment.", + ), + "HCOM_NAME_EXPORT" => Some( "\ HCOM_NAME_EXPORT - Export instance name to custom env var diff --git a/src/commands/help.rs b/src/commands/help.rs index 626fd545..685727e1 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -487,6 +487,10 @@ const CONFIG_HELP: &[HelpEntry] = &[ "Auto-trust launch dir (skip folder-trust prompt)", ), (" name_export", "Export agent name to custom env var"), + ( + " title_mode", + "Terminal/tab title: combined, label, or off", + ), ("", "hcom config --info for details"), ("", ""), ("", "Precedence: defaults < config.toml < env vars"), diff --git a/src/config.rs b/src/config.rs index 46423719..4f307f22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -149,6 +149,7 @@ const TOML_KEY_MAP: &[(&str, &str)] = &[ ("auto_approve", "preferences.auto_approve"), ("name_export", "preferences.name_export"), ("auto_trust_workspace", "launch.auto_trust_workspace"), + ("title_mode", "terminal.title_mode"), ]; /// Mapping: HcomConfig field name -> HCOM_* env var key. @@ -186,6 +187,7 @@ const FIELD_TO_ENV: &[(&str, &str)] = &[ ("auto_subscribe", "HCOM_AUTO_SUBSCRIBE"), ("name_export", "HCOM_NAME_EXPORT"), ("auto_trust_workspace", "HCOM_AUTO_TRUST_WORKSPACE"), + ("title_mode", "HCOM_TITLE_MODE"), ]; /// Relay fields — file-only, no env var override. @@ -297,6 +299,11 @@ pub struct HcomConfig { pub auto_subscribe: String, pub name_export: String, pub auto_trust_workspace: bool, + /// Terminal-title behavior: `"combined"` (default) shows + /// `{icon} name - {tool's live title}`, `"label"` shows hcom's + /// `{icon} name [tool]` only, `"off"` leaves the tool's own title untouched. + /// See [`crate::shared::TitleMode`]. + pub title_mode: String, } impl Default for HcomConfig { @@ -330,6 +337,7 @@ impl Default for HcomConfig { auto_subscribe: "collision".to_string(), name_export: String::new(), auto_trust_workspace: true, + title_mode: "combined".to_string(), } } } @@ -414,6 +422,17 @@ impl HcomConfig { ); } + if !crate::shared::VALID_TITLE_MODES.contains(&self.title_mode.as_str()) { + errors.insert( + "title_mode".into(), + format!( + "title_mode must be one of: {}. Got '{}'", + crate::shared::VALID_TITLE_MODES.join(", "), + self.title_mode + ), + ); + } + // Validate shell-quoted args fields for (field, value) in [ ("claude_args", &self.claude_args), @@ -504,6 +523,7 @@ impl HcomConfig { "auto_trust_workspace" => { Some(if self.auto_trust_workspace { "1" } else { "0" }.into()) } + "title_mode" => Some(self.title_mode.clone()), _ => None, } } @@ -554,6 +574,10 @@ impl HcomConfig { "auto_subscribe" => self.auto_subscribe = value.to_string(), "name_export" => self.name_export = value.to_string(), "auto_trust_workspace" => self.auto_trust_workspace = !is_falsy(value), + // Stored leniently; `TitleMode::from_config` maps unknown → default. + // The CLI set path (`config_set_at_path`) validates against + // `VALID_TITLE_MODES` before this is ever written to the file. + "title_mode" => self.title_mode = value.to_string(), _ => return Err(format!("unknown field: {field}")), } Ok(()) @@ -667,6 +691,7 @@ impl HcomConfig { "codex_system_prompt", "auto_subscribe", "name_export", + "title_mode", ]; for str_field in &str_fields { if let Some(val) = get_var(str_field) { @@ -979,6 +1004,7 @@ pub fn load_toml_presets(path: &std::path::Path) -> Option { fn default_toml_structure() -> toml::Value { let toml_str = r#"[terminal] active = "default" +title_mode = "combined" [relay] url = "" @@ -2080,6 +2106,20 @@ mod tests { assert!(!config.relay_enabled); } + #[test] + fn test_load_from_sources_title_mode_and_env_override() { + let mut file_config = HashMap::new(); + file_config.insert( + "title_mode".to_string(), + TomlFieldValue::Str("label".to_string()), + ); + let mut env = HashMap::new(); + env.insert("HCOM_TITLE_MODE".to_string(), "off".to_string()); + + let config = HcomConfig::load_from_sources(&file_config, Some(&env)).unwrap(); + assert_eq!(config.title_mode, "off"); + } + #[test] fn test_load_from_sources_env_overrides_toml() { let mut file_config = HashMap::new(); @@ -2371,6 +2411,7 @@ auto_approve = false let structure = default_toml_structure(); // Verify key paths exist assert!(get_nested(&structure, "terminal.active").is_some()); + assert!(get_nested(&structure, "terminal.title_mode").is_some()); assert!(get_nested(&structure, "launch.tag").is_some()); assert!(get_nested(&structure, "launch.claude.args").is_some()); assert!(get_nested(&structure, "relay.url").is_some()); diff --git a/src/pty/mod.rs b/src/pty/mod.rs index f3a8c16c..c6540fc8 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -777,6 +777,15 @@ impl Proxy { // Track last written title to detect changes (delivery thread updates Arcs) let mut last_written_name = String::new(); let mut last_written_status = String::new(); + // Terminal-title behavior. Read once — a session's config doesn't change + // under it. In `Off` we neither strip the tool's titles nor write our own; + // in `Combined` we append the tool's live title (read from `self.screen`, + // which this thread owns — no extra Arc needed). + let title_mode = crate::config::HcomConfig::load(None) + .map(|c| crate::shared::TitleMode::from_config(&c.title_mode)) + .unwrap_or(crate::shared::TitleMode::Combined); + let title_enabled = title_mode != crate::shared::TitleMode::Off; + let mut last_written_child = String::new(); // Track incomplete UTF-8 sequences to defer title writes. // When PTY output ends with partial multi-byte character, writing our title OSC @@ -985,7 +994,9 @@ impl Proxy { eagain_retries = 0; // reset on successful read let data = &buf[..n]; raw_chunks.push(data.to_vec()); - let (filtered, had_title) = if stdout_is_tty { + // In Off mode, don't strip the tool's own titles — + // let them reach the terminal untouched. + let (filtered, had_title) = if stdout_is_tty && title_enabled { title_filter.filter(data) } else { (data.to_vec(), false) @@ -1241,7 +1252,7 @@ impl Proxy { // but only when that write left no incomplete UTF-8 or escape sequence // (`title_write_safe`) — splitting one would corrupt the stream. // pending_utf8/pending_escape carry that state across read boundaries. - if stdout_is_tty && title_write_safe(pending_utf8, pending_escape) { + if stdout_is_tty && title_enabled && title_write_safe(pending_utf8, pending_escape) { let (name, status) = { let n = self .current_name @@ -1257,11 +1268,30 @@ impl Proxy { .unwrap_or_default(); (n, s) }; - if !name.is_empty() && (name != last_written_name || status != last_written_status) + // The wrapped tool's live title (Combined only). Owned by this + // thread via self.screen, so no lock — read fresh each iteration + // and fold into the change check so a new child title re-emits. + let child = if title_mode == crate::shared::TitleMode::Combined { + self.screen.child_title().unwrap_or("") + } else { + "" + }; + if !name.is_empty() + && (name != last_written_name + || status != last_written_status + || child != last_written_child) { - let escape = - shared::build_title_escape(&name, &status, self.config.target.name()); + let child_opt = (!child.is_empty()).then_some(child); + let escape = shared::build_title_escape( + &name, + &status, + self.config.target.name(), + title_mode, + child_opt, + ); write_all(&stdout_fd, escape.as_bytes())?; + last_written_child.clear(); + last_written_child.push_str(child); last_written_name = name; last_written_status = status; } diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 51dd230c..8d121e0e 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -81,6 +81,44 @@ fn last_osc_title(buffer: &[u8]) -> Option { last_title } +/// Max `char`s of a wrapped tool's title to embed in hcom's own title. +/// Codex/gemini cap their own titles at 80–240; this keeps the combined string +/// readable in tab bars after hcom's `{icon} name [tool]` prefix. +const MAX_CHILD_TITLE_CHARS: usize = 160; + +/// Normalize a wrapped tool's raw title into a single bounded line safe to embed +/// inside hcom's own OSC sequence. +/// +/// The input is untrusted display text (model output, project paths, etc.). We +/// drop control characters (which could terminate or reshape our OSC) and other +/// C0/C1 codepoints, collapse whitespace runs to a single space, trim the ends, +/// and bound the result to [`MAX_CHILD_TITLE_CHARS`]. Mirrors codex's own +/// `sanitize_terminal_title` so passthrough matches what the tool would render. +fn sanitize_child_title(title: &str) -> String { + let mut out = String::new(); + let mut pending_space = false; + for ch in title.chars() { + if ch.is_whitespace() { + pending_space = !out.is_empty(); + continue; + } + // Strip C0/C1 controls and invisible/bidi format chars — anything that + // could break the OSC framing or visually reorder the title. + if ch.is_control() || matches!(ch, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}') { + continue; + } + if pending_space { + out.push(' '); + pending_space = false; + } + if out.chars().count() >= MAX_CHILD_TITLE_CHARS { + break; + } + out.push(ch); + } + out +} + /// Trim whitespace including NBSP (U+00A0) from both ends fn trim_with_nbsp(s: &str) -> &str { s.trim_matches(|c: char| c.is_whitespace() || c == '\u{00A0}') @@ -110,6 +148,11 @@ pub struct ScreenTracker { cols: u16, ready_pattern: String, waiting_approval: bool, + // Last complete, sanitized OSC 0/2 title the wrapped tool set, cached for the + // Combined title passthrough. Only ever holds a fully-terminated title (see + // `process`), so a title evicted mid-scan from `output_buffer` leaves the last + // good value intact rather than showing a fragment. + last_child_title: Option, last_output: Instant, last_change: Instant, output_buffer: Vec, @@ -147,6 +190,7 @@ impl ScreenTracker { cols, ready_pattern: String::from_utf8_lossy(ready_pattern).into_owned(), waiting_approval: false, + last_child_title: None, last_output: Instant::now(), last_change: Instant::now(), output_buffer: Vec::with_capacity(4096), @@ -210,8 +254,15 @@ impl ScreenTracker { // Codex emits an ungated OSC terminal title on every state refresh. Treat // approval as a level so a later Working/idle title clears it promptly. + // last_osc_title only returns fully-terminated titles, so caching the + // sanitized value here never stores a fragment (see `last_child_title`). if let Some(title) = last_osc_title(&self.output_buffer) { self.waiting_approval = title.contains(CODEX_ACTION_REQUIRED); + let sanitized = sanitize_child_title(&title); + // An empty, complete title is meaningful: tools use it to clear + // their title on exit or when resetting state. Do not retain an + // obsolete spinner forever in combined mode. + self.last_child_title = Some(sanitized); } // Feed to vt100 parser. vt100 has known panics on malformed/edge-case @@ -308,6 +359,13 @@ impl ScreenTracker { self.waiting_approval } + /// The wrapped tool's last complete, sanitized terminal title, if any. + /// Used by combined title mode to append the tool's own live title + /// to hcom's `{icon} name [tool]` label. + pub fn child_title(&self) -> Option<&str> { + self.last_child_title.as_deref() + } + /// Codex approval fallback for blocker dialogs visible on screen. /// /// Terminal-title detection is primary. This catches dialog variants that @@ -1044,6 +1102,7 @@ mod tests { cols, ready_pattern: ready_pattern.to_string(), waiting_approval: false, + last_child_title: None, last_output: Instant::now(), last_change: Instant::now(), output_buffer: Vec::new(), @@ -1057,6 +1116,46 @@ mod tests { } } + #[test] + fn sanitize_child_title_strips_controls_and_collapses_whitespace() { + assert_eq!( + sanitize_child_title(" Working\t\non task "), + "Working on task" + ); + // Embedded ESC / BEL (the only bytes that could break out of our OSC) + // are dropped; the harmless leftover text stays. + assert_eq!(sanitize_child_title("a\x1b]2;evil\x07b"), "a]2;evilb"); + assert_eq!(sanitize_child_title(""), ""); + } + + #[test] + fn sanitize_child_title_bounds_length() { + let long = "x".repeat(MAX_CHILD_TITLE_CHARS + 50); + assert_eq!( + sanitize_child_title(&long).chars().count(), + MAX_CHILD_TITLE_CHARS + ); + } + + #[test] + fn process_captures_child_osc_title() { + let mut t = make_tracker(24, 80, ""); + assert_eq!(t.child_title(), None); + t.process(b"before\x1b]0;\xe2\xa0\x8b Working\x07after"); + assert_eq!(t.child_title(), Some("⠋ Working")); + } + + #[test] + fn child_title_keeps_last_complete_through_eviction() { + let mut t = make_tracker(24, 80, ""); + t.process(b"\x1b]0;First title\x07"); + assert_eq!(t.child_title(), Some("First title")); + // Flood past the 4KB rolling buffer with plain output containing no + // complete title; the last good title must survive rather than clear. + t.process(&vec![b'.'; 8192]); + assert_eq!(t.child_title(), Some("First title")); + } + // ---- vt100 panic containment (issue #73) ---- #[test] diff --git a/src/pty/shared.rs b/src/pty/shared.rs index c74f9c57..4264eb98 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -570,8 +570,29 @@ pub(super) fn finalize_launch_failure_after_exit( } /// Build the OSC 1/2 title-set escape for `name`/`status` under `tool_name`. -pub(super) fn build_title_escape(name: &str, status: &str, tool_name: &str) -> String { - let title = crate::shared::format_pane_title(status, name, tool_name); +/// +/// - [`TitleMode::Label`] → `◉ luna [claude]` (hcom's status label only). +/// - [`TitleMode::Combined`] → `◉ luna - ⠋ Working` — hcom's `{icon} name` plus +/// the wrapped tool's live title after ` - ` (dropping the `[tool]` tag). The +/// child text is already sanitized upstream by `ScreenTracker` (control/escape +/// bytes stripped, whitespace collapsed, length bounded) so it cannot break +/// out of the OSC we wrap it in. +/// +/// [`TitleMode::Off`] never reaches here — the caller skips writing entirely and +/// lets the tool's own title pass through — so it falls back to the label. +pub(super) fn build_title_escape( + name: &str, + status: &str, + tool_name: &str, + mode: crate::shared::TitleMode, + child_title: Option<&str>, +) -> String { + let title = match mode { + crate::shared::TitleMode::Combined => { + crate::shared::format_pane_title_combined(status, name, child_title) + } + _ => crate::shared::format_pane_title(status, name, tool_name), + }; format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title) } @@ -749,6 +770,10 @@ pub(super) struct OutputModeFilter { buf: Vec, dsr_seen: bool, pending_utf8: u8, + /// When true (title_mode `off`), the tool's own OSC 0/1/2 titles are passed + /// through to the terminal instead of stripped. DSR/ground-state tracking is + /// unaffected. Default false preserves the strip-and-override behavior. + passthrough_titles: bool, } #[derive(Default, PartialEq)] @@ -770,6 +795,12 @@ enum FilterState { #[cfg_attr(not(windows), allow(dead_code))] impl OutputModeFilter { + /// Pass the tool's own OSC 0/1/2 titles through instead of stripping them + /// (title_mode `off`). DSR/ground-state tracking is unaffected. + pub(super) fn set_passthrough_titles(&mut self, passthrough: bool) { + self.passthrough_titles = passthrough; + } + pub(super) fn filter(&mut self, input: &[u8], out: &mut Vec) { let output_start = out.len(); for &b in input { @@ -863,11 +894,16 @@ impl OutputModeFilter { FilterState::OscDigit(_digit) => { self.buf.push(b); if b == b';' { - // Confirmed OSC 0/1/2: discard the complete title, - // including a terminator that may arrive in a later read. + // Confirmed OSC 0/1/2: discard the complete title + // (including a terminator that may arrive in a later + // read) — unless title_mode `off`, where we pass the + // tool's own title through untouched. + if self.passthrough_titles { + out.extend_from_slice(&self.buf); + } self.buf.clear(); self.state = FilterState::StringSeq { - strip: true, + strip: !self.passthrough_titles, saw_esc: false, discarded: 0, }; @@ -1129,6 +1165,18 @@ mod tests { ); } + #[test] + fn output_mode_filter_passthrough_keeps_tool_title() { + // title_mode `off`: the tool's own OSC 0/2 title must reach the terminal + // intact, including across a read split, while DSR tracking still works. + let mut f = OutputModeFilter::default(); + f.set_passthrough_titles(true); + let mut out = Vec::new(); + f.filter(b"before\x1b]2;Clau", &mut out); + f.filter(b"de Code\x07after", &mut out); + assert_eq!(out, b"before\x1b]2;Claude Code\x07after".to_vec()); + } + #[test] fn output_mode_filter_preserves_non_title_osc_and_tracks_its_boundary() { let chunks: &[&[u8]] = &[b"\x1b]8;;https://exam", b"ple.test\x1b\\link"]; @@ -1191,9 +1239,10 @@ mod tests { } #[test] - fn build_title_escape_formats_osc_1_and_2() { - // listening icon is the green dot; assert exact OSC framing. - let esc = build_title_escape("alpha", "listening", "claude"); + fn build_title_escape_label_mode_formats_osc_1_and_2() { + use crate::shared::TitleMode; + // Label mode keeps the [tool] tag; assert exact OSC framing. + let esc = build_title_escape("alpha", "listening", "claude", TitleMode::Label, None); let icon = status_icon("listening"); let title = format!("{} alpha [claude]", icon); assert_eq!(esc, format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title)); @@ -1204,12 +1253,40 @@ mod tests { #[test] fn build_title_escape_uses_status_icon() { + use crate::shared::TitleMode; // Different statuses must change the embedded icon. - let listening = build_title_escape("a", "listening", "claude"); - let blocked = build_title_escape("a", "blocked", "claude"); + let listening = build_title_escape("a", "listening", "claude", TitleMode::Label, None); + let blocked = build_title_escape("a", "blocked", "claude", TitleMode::Label, None); assert_ne!(listening, blocked); } + #[test] + fn build_title_escape_combined_appends_child_and_drops_tool() { + use crate::shared::TitleMode; + // Combined mode: `{icon} name - {child}`, no `[tool]` tag. + let icon = status_icon("active"); + let esc = build_title_escape( + "luna", + "active", + "codex", + TitleMode::Combined, + Some("⠋ Working"), + ); + let title = format!("{} luna - ⠋ Working", icon); + assert_eq!(esc, format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title)); + assert!(!esc.contains("[codex]"), "combined mode drops the tool tag"); + } + + #[test] + fn build_title_escape_combined_without_child_is_icon_name() { + use crate::shared::TitleMode; + // No child title → just `{icon} name`, no dangling separator, no tag. + let icon = status_icon("active"); + let esc = build_title_escape("luna", "active", "codex", TitleMode::Combined, None); + let title = format!("{} luna", icon); + assert_eq!(esc, format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title)); + } + #[test] fn note_user_keystroke_cursor_is_noop_and_returns_false() { let target = PtyTarget::AdhocCommand("cursor".to_string()); diff --git a/src/pty/win.rs b/src/pty/win.rs index 77472272..4c63b479 100644 --- a/src/pty/win.rs +++ b/src/pty/win.rs @@ -528,6 +528,15 @@ impl Proxy { let headless = !std::io::stdout().is_terminal(); let mut last_name = String::new(); let mut last_status = String::new(); + // Terminal-title behavior. Read once; the child title comes from the + // reader-owned `screen`, no extra lock needed. In `Off` the filter + // passes the tool's own titles through and we write nothing. + let title_mode = crate::config::HcomConfig::load(None) + .map(|c| crate::shared::TitleMode::from_config(&c.title_mode)) + .unwrap_or(crate::shared::TitleMode::Combined); + let title_enabled = title_mode != crate::shared::TitleMode::Off; + filter.set_passthrough_titles(!title_enabled); + let mut last_child = String::new(); // `hcom term` snapshot refresh (see should_refresh_snapshot). Under // sustained output we render at most once per SNAPSHOT_THROTTLE; @@ -675,16 +684,31 @@ impl Proxy { // under heavy output) and name/status rarely change, so // the common path holds the two read locks briefly but // allocates nothing. + let child = if title_mode == crate::shared::TitleMode::Combined { + screen.child_title().unwrap_or("") + } else { + "" + }; if !headless + && title_enabled && filter.title_write_safe() && let (Ok(name), Ok(status)) = (current_name.read(), current_status.read()) && !name.is_empty() - && (*name != last_name || *status != last_status) + && (*name != last_name || *status != last_status || child != last_child) { - let esc = shared::build_title_escape(&name, &status, target.name()); + let child_opt = (!child.is_empty()).then_some(child); + let esc = shared::build_title_escape( + &name, + &status, + target.name(), + title_mode, + child_opt, + ); let _ = stdout.write_all(esc.as_bytes()); let _ = stdout.flush(); + last_child.clear(); + last_child.push_str(child); last_name = name.clone(); last_status = status.clone(); } diff --git a/src/shared/constants.rs b/src/shared/constants.rs index d36212ab..ccb04a50 100644 --- a/src/shared/constants.rs +++ b/src/shared/constants.rs @@ -95,6 +95,32 @@ pub fn status_icon(status: &str) -> &'static str { // Adhoc instance icon lives on `IntegrationSpec.adhoc_icon` for Tool::Adhoc. +/// Terminal-title behavior, from config `terminal.title_mode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TitleMode { + /// hcom leaves the title alone: the wrapped tool's own title passes through + /// untouched and hcom writes nothing. + Off, + /// hcom's status label only: `{icon} name [tool]` (the original behavior). + Label, + /// hcom status + the tool's live title: `{icon} name - {tool title}`. + Combined, +} + +/// Valid `terminal.title_mode` config values (for validation + help). +pub const VALID_TITLE_MODES: &[&str] = &["combined", "label", "off"]; + +impl TitleMode { + /// Parse a config value; unknown/empty falls back to the default (`Combined`). + pub fn from_config(s: &str) -> Self { + match s { + "off" => Self::Off, + "label" => Self::Label, + _ => Self::Combined, + } + } +} + /// Build the canonical pane-title label hcom writes into OSC 1/2 and pushes /// to host terminal label APIs (e.g. herdr's `pane.rename`). /// @@ -108,6 +134,21 @@ pub fn format_pane_title(status: &str, display: &str, tool: &str) -> String { format!("{} {} [{}]", status_icon(status), display, tool) } +/// Build the `TitleMode::Combined` label: `"{icon} {display}"`, with the wrapped +/// tool's live title appended after ` - ` when present. Drops the `[tool]` tag +/// (the passthrough title already identifies the tool's activity). Returns an +/// empty string when `display` is empty so callers can short-circuit. +pub fn format_pane_title_combined(status: &str, display: &str, child: Option<&str>) -> String { + if display.is_empty() { + return String::new(); + } + let base = format!("{} {}", status_icon(status), display); + match child { + Some(c) if !c.is_empty() => format!("{base} - {c}"), + _ => base, + } +} + /// Status foreground ANSI color. pub fn status_fg(status: &str) -> &'static str { match status { diff --git a/src/shared/mod.rs b/src/shared/mod.rs index b45c2109..f893ad02 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -28,9 +28,12 @@ pub use constants::{ ST_LAUNCHING, ST_LISTENING, SYSTEM_SENDER, + TitleMode, + VALID_TITLE_MODES, // Functions extract_mentions, format_pane_title, + format_pane_title_combined, status_bg, status_fg, status_icon,