feat: plan-approval overlay, auth-expiry handling, cert download, login reset

- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by
  ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with
  approve/YOLO/reject/feedback wired through respondPlan(requestId).
- PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop
  releasing pending prompts on client disconnect — they reflect real CLI
  state and must survive reconnects.
- Detect server-rejected tokens (401/403 or failed WS handshake) and bounce
  back to the login screen via a new AUTH_EXPIRED_EVENT.
- Add `/cert` endpoint + Settings entry so phones can download/trust the
  self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts.
- Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint
  to clear login rate-limit lockouts.
- Codex adapter: only forward recognized keystrokes/option indices to tmux,
  never the synthetic "deny" dismissal signal.
- PWA: actively poll for service-worker updates (iOS standalone apps don't
  reliably check on navigation).
- update-service.sh: install tmux if missing, prompt to create the env file
  instead of failing, add --skip-firewall, and improve logging.
This commit is contained in:
2026-06-15 16:30:24 -04:00
parent 4e6dfb4726
commit 2ded310472
23 changed files with 656 additions and 97 deletions
+211 -28
View File
@@ -200,7 +200,7 @@ export class TmuxAdapter extends EventEmitter {
return { sessionId };
}
// Window gone — stop old watcher before replacing
this._teardownSession(existingSession);
this._teardownSession(sessionId, existingSession);
this.sessions.delete(sessionId);
}
@@ -220,7 +220,7 @@ export class TmuxAdapter extends EventEmitter {
// Register before _waitForReady (same pattern as startSession)
this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, mode));
await this._waitForReady(windowId);
await this._waitForReady(windowId, 30000, newSessionId);
this._startMonitor(newSessionId, windowId);
await this._ensureWatcher(newSessionId);
@@ -260,7 +260,7 @@ export class TmuxAdapter extends EventEmitter {
async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this._teardownSession(session);
this._teardownSession(sessionId, session);
await tmuxManager.killWindow(session.windowId);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
@@ -299,7 +299,11 @@ export class TmuxAdapter extends EventEmitter {
state.pendingRequests.push({ type: 'permission', requestId: perm.requestId, toolName: perm.toolName, input: perm.input });
}
for (const q of this._permissions.getQuestionsForSession(sessionId)) {
state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput });
if (q.promptType === 'plan') {
state.pendingRequests.push({ type: 'plan', requestId: q.requestId, toolName: 'ExitPlanMode', input: q.originalInput });
} else {
state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput });
}
}
return state;
}
@@ -432,6 +436,24 @@ export class TmuxAdapter extends EventEmitter {
return;
}
// ExitPlanMode: the CLI shows its plan-approval selector immediately,
// but the assistant message carrying this tool_use (with the plan text)
// often isn't flushed to the JSONL transcript until *after* the user
// responds — so the JSONL-driven PlanMode card has nothing to render yet.
// Emit the plan text from the hook payload directly via interactive-prompt.
if (body.tool_name === 'ExitPlanMode') {
const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._permissions.addQuestion(requestId, ctx.sessionId, { originalInput: body.tool_input || {}, promptType: 'plan' });
this.emit('interactive-prompt', ctx.sessionId, {
requestId,
promptType: 'plan',
title: 'Plan ready',
toolName: 'ExitPlanMode',
toolInput: body.tool_input,
});
return;
}
this.emit('tool-start', ctx.sessionId, {
toolId: body.tool_use_id,
toolName: body.tool_name,
@@ -491,7 +513,7 @@ export class TmuxAdapter extends EventEmitter {
const session = this.sessions.get(sessionId);
if (session) {
this._teardownSession(session);
this._teardownSession(sessionId, session);
this.sessions.delete(sessionId);
}
@@ -614,41 +636,109 @@ export class TmuxAdapter extends EventEmitter {
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
if (textValue != null) {
this.respondQuestion(requestId, textValue);
} else if (selectedOption != null) {
// Permission behaviors are named ('allow', 'allow_session', 'deny')
// Question options are numeric indices ('0', '1', '2')
const isPermission = ['allow', 'allow_session', 'deny'].includes(selectedOption);
if (isPermission) {
this.respondPermission(requestId, selectedOption as any);
} else {
// Numeric index — validate before consuming the pending entry
const index = parseInt(selectedOption);
if (isNaN(index)) return;
return;
}
if (selectedOption == null) return;
// Route by the pending entry's *actual* type — not by guessing from the
// string value. ('deny' is sent both as a real permission behavior and as
// the close-overlay dismiss signal; treating it as a permission behavior
// for a pending *question* would misroute it and either no-op or, worse,
// send something to the CLI the user never chose.)
if (this._permissions.hasQuestion(requestId)) {
const index = parseInt(selectedOption);
if (!isNaN(index)) {
const pending = this._permissions.resolveQuestion(requestId);
if (!pending) return;
const session = this.sessions.get(pending.sessionId);
if (!session) return;
this._selectOption(session.windowId, index).catch(() => {});
} else {
// Closing the overlay ('deny' or any non-numeric value): this is the
// user's explicit decision not to answer via mobile — relay it as Esc
// (cancel) on the exact selector we ourselves detected and forwarded.
// We're not guessing at CLI state here; we know precisely what's on
// screen because we're the ones who put it there.
const pending = this._permissions.resolveQuestion(requestId);
if (pending) {
const session = this.sessions.get(pending.sessionId);
if (session) {
tmuxManager.sendControl(session.windowId, 'Escape').catch(() => {});
}
}
}
return;
}
if (this._permissions.hasPermission(requestId)) {
this.respondPermission(requestId, selectedOption as any);
}
}
/**
* Respond to the CLI's plan approval selector.
* Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback
* See PLAN_OPTION in ws-types.ts for the current option layout.
*/
async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise<void> {
async respondPlan(sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || optionIndex < 0 || optionIndex > PLAN_OPTION.TEXT_FEEDBACK) return;
if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK && text) {
await this._selectOption(session.windowId, PLAN_OPTION.TEXT_FEEDBACK);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendKeys(session.windowId, text, true);
if (requestId) this._permissions.resolveQuestion(requestId);
if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK) {
// The feedback/"Tell Claude what to change" item is always last, but its
// index varies (e.g. an extra "refine with Ultraplan" option may or may
// not be present) — count the rendered options instead of assuming 3.
const lastIndex = await this._lastPlanOptionIndex(session.windowId);
if (lastIndex === null) {
// Couldn't verify the selector layout — refuse to guess. An overshoot
// could wrap the selection onto "Yes, bypass permissions" instead of
// the feedback option, so do nothing rather than risk that.
console.error(`[${sessionId}] respondPlan: could not locate plan selector on screen, aborting feedback selection`);
return;
}
await this._selectOption(session.windowId, lastIndex);
if (text) {
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendKeys(session.windowId, text, true);
}
} else {
await this._selectOption(session.windowId, optionIndex);
}
}
/**
* Count numbered options in the plan-approval selector currently on screen;
* returns the 0-based index of the last one, or null if the selector
* couldn't be located.
*
* Anchors on the ``-marked (currently selected) numbered line rather than
* the CLI's prompt wording — the plan's own markdown may contain numbered
* lists, which would otherwise be miscounted as selector options, and this
* approach survives the prompt text wrapping at narrow pane widths. The
* `` cursor can only be on a real selector item, so expanding the
* contiguous numbered-line block around it gives the true option count
* regardless of which item is currently highlighted.
*/
private async _lastPlanOptionIndex(windowId: string): Promise<number | null> {
try {
const content = await tmuxManager.capturePane(windowId);
const lines = content.split('\n');
const optionLine = /^\s*?\s*\d+\./;
const cursorLine = /^\s*\s*\d+\./;
let cursorIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (cursorLine.test(lines[i])) { cursorIdx = i; break; }
}
if (cursorIdx < 0) return null;
let lo = cursorIdx, hi = cursorIdx;
while (lo > 0 && optionLine.test(lines[lo - 1])) lo--;
while (hi < lines.length - 1 && optionLine.test(lines[hi + 1])) hi++;
const count = hi - lo + 1;
return count > 0 ? count - 1 : null;
} catch {
return null;
}
}
/**
* Navigate a CLI interactive selector by pressing Down `index` times, then Enter.
* Cursor starts on option 0 (first item), so index=0 just presses Enter.
@@ -692,7 +782,7 @@ export class TmuxAdapter extends EventEmitter {
for (const [sessionId, session] of this.sessions) {
if (!liveWindowIds.has(session.windowId)) {
console.log(`[tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
this._teardownSession(session);
this._teardownSession(sessionId, session);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
@@ -703,7 +793,7 @@ export class TmuxAdapter extends EventEmitter {
.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
for (const [id] of sorted.slice(10)) {
const s = this.sessions.get(id);
if (s) this._teardownSession(s);
if (s) this._teardownSession(id, s);
this.sessions.delete(id);
this.emit('session-ended', id);
}
@@ -736,9 +826,12 @@ export class TmuxAdapter extends EventEmitter {
};
}
private _teardownSession(session: SessionState): void {
private _teardownSession(sessionId: string, session: SessionState): void {
if (session.monitor) { session.monitor.stop(); session.monitor = null; }
if (session.watcher) { session.watcher.stop(); session.watcher = null; session.parser = null; }
// Drop any pending permission/question/plan prompts — the CLI window
// they referred to is gone, so they can never be answered.
this._permissions.dismissAll(sessionId);
}
async destroy(): Promise<void> {
@@ -746,8 +839,8 @@ export class TmuxAdapter extends EventEmitter {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
for (const [, session] of this.sessions) {
this._teardownSession(session);
for (const [sessionId, session] of this.sessions) {
this._teardownSession(sessionId, session);
}
this.sessions.clear();
await tmuxManager.killSession();
@@ -885,9 +978,18 @@ export class TmuxAdapter extends EventEmitter {
return windows.some(w => w.id === windowId);
}
private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise<void> {
const start = Date.now();
private async _waitForReady(windowId: string, timeoutMs: number = 30000, sessionId?: string): Promise<void> {
// PermissionManager silently drops pending questions after its internal
// timeout (120s, see `new PermissionManager()` above). Refresh ours well
// before that so a slow mobile response is never a no-op — we never pick
// an option on the user's behalf.
const RESUME_PROMPT_REFRESH_MS = 90_000;
let start = Date.now();
let attempt = 0;
let resumePromptRequestId: string | null = null;
let resumePromptRefreshedAt: number | null = null;
let genericPromptRequestId: string | null = null;
let genericPromptRefreshedAt: number | null = null;
while (Date.now() - start < timeoutMs) {
attempt++;
try {
@@ -915,6 +1017,87 @@ export class TmuxAdapter extends EventEmitter {
continue;
}
// Long-session resume prompt ("Resume from summary / Resume full session as-is / Don't ask me again").
// Forward it to the client so the user decides — don't auto-select.
const isResumePrompt = isSelectionPrompt
&& /Resume from summary/i.test(content)
&& /Resume full session as-is/i.test(content);
if (isResumePrompt && sessionId) {
const options = [
{ value: '0', label: 'Resume from summary (recommended)' },
{ value: '1', label: 'Resume full session as-is' },
{ value: '2', label: "Don't ask me again" },
];
if (!resumePromptRequestId) {
resumePromptRequestId = `claude-resume-${sessionId}`;
resumePromptRefreshedAt = Date.now();
const tail = lines.slice(-20);
const description = tail.join('\n').trim();
this._permissions.addQuestion(resumePromptRequestId, sessionId, { originalInput: { questions: [{ options }] } });
this.emit('interactive-prompt', sessionId, {
requestId: resumePromptRequestId,
promptType: 'question',
title: 'Resume long session',
description,
options,
});
console.log(`[adapter] Resume-summary prompt detected for ${sessionId}, awaiting user choice`);
} else if (Date.now() - resumePromptRefreshedAt! > RESUME_PROMPT_REFRESH_MS) {
// Re-arm the pending entry so PermissionManager's internal timeout
// never expires it while we're still waiting on the user.
this._permissions.resolveQuestion(resumePromptRequestId);
this._permissions.addQuestion(resumePromptRequestId, sessionId, { originalInput: { questions: [{ options }] } });
resumePromptRefreshedAt = Date.now();
}
// Keep the outer wait-loop alive while the prompt is on screen —
// the user may take a while to respond from their phone.
start = Date.now();
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
// Generic fallback: any other numbered selection prompt we don't
// specifically recognize (e.g. a resumed session paused mid-
// AskUserQuestion). Its option lines also start with , so without
// this check it would be misread as "ready" — and sendMessage would
// then type blindly into a live selector, where Enter could submit
// whatever option happens to be highlighted (auto-answering for the
// user). Forward it to mobile and keep waiting instead.
if (isSelectionPrompt && sessionId) {
const optionMatches = [...content.matchAll(/?\s*\d+\.\s+(.+?)\s*$/gm)];
// _selectOption navigates Down N times from whatever's currently
// highlighted (0-based, relative) — NOT the displayed label number.
// Using the label number as the value would be off-by-one and could
// select the wrong option entirely.
const options = optionMatches
.map((m, i) => ({ value: String(i), label: m[1].trim() }))
.filter(o => o.label.length > 0);
if (options.length > 0) {
if (!genericPromptRequestId) {
genericPromptRequestId = `claude-selector-${sessionId}`;
genericPromptRefreshedAt = Date.now();
const tail = lines.slice(-20);
const description = tail.join('\n').trim();
this._permissions.addQuestion(genericPromptRequestId, sessionId, { originalInput: { questions: [{ options }] } });
this.emit('interactive-prompt', sessionId, {
requestId: genericPromptRequestId,
promptType: 'question',
title: 'Pending prompt',
description,
options,
});
console.log(`[adapter] Unrecognized selection prompt detected for ${sessionId}, forwarding to client`);
} else if (Date.now() - genericPromptRefreshedAt! > RESUME_PROMPT_REFRESH_MS) {
this._permissions.resolveQuestion(genericPromptRequestId);
this._permissions.addQuestion(genericPromptRequestId, sessionId, { originalInput: { questions: [{ options }] } });
genericPromptRefreshedAt = Date.now();
}
start = Date.now();
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
}
if (hasPrompt && lineCount >= 3) {
console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300));