Files
AgentHub/server/lib/pane-mode.js
T
nntrivi2001 1c5521cf2e perf: rest animations, cheaper chat typing and server polls, async per-tool hooks
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
  (pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
  rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
  state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
  callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
  cell by cell (reusing one cell) only when a row could be a menu cursor row.

Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
  "yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
  per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.

Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
  plugin.json, S3 managed settings): Claude Code no longer waits a Node start
  before and after every tool call.

Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:55:53 +07:00

103 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file pane-mode.js
* @description A live `claude` pane's permission mode, read off the pane and
* changed the only way Claude Code offers to a running session: Shift+Tab,
* which steps through its cycle (Manual → Accept edits → Plan → Auto, Bypass
* too when the session was allowed it). Every step is VERIFIED on the mode
* line Claude Code prints under its prompt before the next key goes in, so it
* stops on exactly the mode asked for — and presses nothing at all while that
* line cannot be read (a menu or dialog is open, where Shift+Tab means
* something else). A mode not in this session's cycle brings it back to where
* it began. One change at a time per pane. Only this pane is touched: the
* mode belongs to the session, not to an account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tmux = require("./tmux");
// Claude Code 2.1.x: "⏸ manual mode on · ← for agents",
// "⏵⏵ accept edits on (shift+tab to cycle)". The marker keeps a sentence in
// the conversation that merely mentions a mode from counting.
const LINE =
/(?:⏸|⏵⏵)\s+(manual mode|accept edits|plan mode|auto mode|bypass permissions|don't ask) on\b/;
const MODES = {
"manual mode": "default",
"accept edits": "acceptEdits",
"plan mode": "plan",
"auto mode": "auto",
"bypass permissions": "bypassPermissions",
"don't ask": "dontAsk",
};
const SETTABLE = new Set(["default", "acceptEdits", "plan", "auto", "bypassPermissions"]);
// Longest cycle Claude Code has: its six modes.
const MAX_PRESSES = 6;
/** The mode on the pane's bottom lines, or null when none is shown. */
function parseMode(screen) {
const lines = String(screen)
.split("\n")
.filter((l) => l.trim())
.slice(-4)
.reverse();
for (const l of lines) {
const m = LINE.exec(l);
if (m) return MODES[m[1]];
}
return null;
}
function readMode(runId) {
try {
return parseMode(tmux.capturePane(runId));
} catch {
return null;
}
}
const fail = (code, message) => Object.assign(new Error(message), { code });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Between two reads of the pane. Each read is a SYNCHRONOUS capture-pane
// (through sudo for a member's pane) that holds up every other request and
// socket while it runs, so a mode change polls gently rather than 20×/s.
const POLL_MS = 150;
// The mode once it differs from `from` — or what the pane shows at the deadline.
async function nextMode(runId, from, waitMs) {
const until = Date.now() + waitMs;
for (;;) {
const now = readMode(runId);
if ((now && now !== from) || Date.now() >= until) return now;
await sleep(POLL_MS);
}
}
const busy = new Set();
/** Steps `runId`'s mode to `target`; resolves with the mode now shown. */
async function setMode(runId, target, { waitMs = 2000 } = {}) {
if (!SETTABLE.has(target))
throw fail("EBADMODE", `mode must be one of: ${[...SETTABLE].join(", ")}`);
if (busy.has(runId)) throw fail("EBUSY", "the mode is already being changed");
busy.add(runId);
try {
const start = readMode(runId);
if (!start) throw fail("EMODEUNKNOWN", "the pane shows no mode line — close its menu first");
let cur = start;
for (let i = 0; i < MAX_PRESSES && cur !== target; i++) {
tmux.sendKey(runId, "BTab");
const next = await nextMode(runId, cur, waitMs);
if (!next || next === cur) throw fail("EMODEUNKNOWN", "the mode did not change as expected");
cur = next;
if (cur === start) break;
}
if (cur !== target)
throw fail("EMODEUNAVAILABLE", `${target} is not available in this session`);
return cur;
} finally {
busy.delete(runId);
}
}
module.exports = { parseMode, readMode, setMode };