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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user