1c5521cf2e
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>
103 lines
3.7 KiB
JavaScript
103 lines
3.7 KiB
JavaScript
/**
|
||
* @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 };
|