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:
@@ -76,15 +76,21 @@ export class ClaudeHookConfig {
|
||||
existing.hooks[event] = [...filtered, ...configs];
|
||||
}
|
||||
|
||||
// Insert our statusLine script into the pipe chain (if not already there).
|
||||
// Our script is a passthrough: reads stdin, POSTs to server (background), outputs stdin.
|
||||
// - Has custom statusLine → pipe through our script first
|
||||
// - No custom statusLine → don't touch it, preserve Claude Code built-in
|
||||
const wrapperScript = this._ensureStatusLineScript(statuslineUrl);
|
||||
// Wire up the statusLine so we receive context-window/model/cost data.
|
||||
// - Has custom statusLine → pipe our passthrough script in front of it (POST + original coexist)
|
||||
// - No custom statusLine → install our own renderer (POST + render a simple status line),
|
||||
// since Claude's built-in status line isn't invokable as a standalone command
|
||||
const existingCmd = existing.statusLine?.command || '';
|
||||
if (existingCmd && !existingCmd.includes(wrapperScript)) {
|
||||
const wrapperScript = this._statusLineScriptPath();
|
||||
const defaultScript = this._defaultStatusLineScriptPath();
|
||||
if (existingCmd && !existingCmd.includes(wrapperScript) && !existingCmd.includes(defaultScript)) {
|
||||
this._ensureStatusLineScript(statuslineUrl);
|
||||
existing.statusLine = { type: 'command', command: `${wrapperScript} | ${existingCmd}` };
|
||||
console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`);
|
||||
} else if (!existingCmd) {
|
||||
this._ensureDefaultStatusLineScript(statuslineUrl);
|
||||
existing.statusLine = { type: 'command', command: defaultScript };
|
||||
console.log(`[hooks] Installed default statusLine to POST to ${statuslineUrl}`);
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
||||
@@ -124,9 +130,13 @@ export class ClaudeHookConfig {
|
||||
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
||||
}
|
||||
|
||||
// --- Restore statusLine: remove our script from the pipe chain ---
|
||||
// --- Restore statusLine ---
|
||||
const wrapperScript = this._statusLineScriptPath();
|
||||
if (existing.statusLine?.command?.includes(wrapperScript)) {
|
||||
const defaultScript = this._defaultStatusLineScriptPath();
|
||||
if (existing.statusLine?.command?.includes(defaultScript)) {
|
||||
// We installed this standalone — there was nothing to restore
|
||||
delete existing.statusLine;
|
||||
} else if (existing.statusLine?.command?.includes(wrapperScript)) {
|
||||
// Remove our script + pipe from the command string
|
||||
const restored = existing.statusLine.command
|
||||
.replace(`${wrapperScript} | `, '')
|
||||
@@ -185,6 +195,52 @@ printf '%s' "$input"
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
/** Path to our standalone statusLine renderer (used when the user has no custom statusLine) */
|
||||
private _defaultStatusLineScriptPath(): string {
|
||||
return join(homedir(), '.clawtap', 'hooks', 'claude-statusline-default.sh');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update our standalone statusLine renderer.
|
||||
* POSTs the raw input to ClawTap (background) and prints a simple status line
|
||||
* (model · cwd · context %) to stdout, since Claude requires a command to render
|
||||
* its own output when `statusLine.type` is `command`.
|
||||
*/
|
||||
private _ensureDefaultStatusLineScript(statuslineUrl: string): string {
|
||||
const scriptPath = this._defaultStatusLineScriptPath();
|
||||
const scriptDir = join(homedir(), '.clawtap', 'hooks');
|
||||
mkdirSync(scriptDir, { recursive: true });
|
||||
|
||||
const portCheck = this._portCheckCmd();
|
||||
const curlInsecure = this.useHttps ? ' -k' : '';
|
||||
const script = `#!/bin/bash
|
||||
input=$(cat)
|
||||
# POST to ClawTap server (non-blocking, skip if server not running)
|
||||
if ${portCheck}; then
|
||||
printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null &
|
||||
fi
|
||||
# Render a simple status line: model · cwd · context %
|
||||
printf '%s' "$input" | node -e '
|
||||
let data = "";
|
||||
process.stdin.on("data", c => data += c);
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(data);
|
||||
const parts = [];
|
||||
if (j.model?.display_name) parts.push(j.model.display_name);
|
||||
const dir = j.workspace?.current_dir || j.cwd;
|
||||
if (dir) parts.push(dir.replace(process.env.HOME || "", "~"));
|
||||
const pct = j.context_window?.used_percentage;
|
||||
if (pct != null) parts.push(\`ctx \${Math.round(pct)}%\`);
|
||||
process.stdout.write(parts.join(" · "));
|
||||
} catch {}
|
||||
});
|
||||
'
|
||||
`;
|
||||
writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
private _isOurHookEntry(entry: HookEntry, portTag: string): boolean {
|
||||
const hooks = entry.hooks || [];
|
||||
return hooks.some(h =>
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ClaudeAdapter extends IAdapter {
|
||||
const events: string[] = [
|
||||
'streaming-text', 'thinking', 'tool-start', 'tool-done',
|
||||
'tool-updates', 'new-messages', 'session-idle',
|
||||
'permission-request', 'ask-question', 'mode-changed',
|
||||
'permission-request', 'ask-question', 'interactive-prompt', 'mode-changed',
|
||||
'session-ended', 'session-error', 'compacting', 'compact-done',
|
||||
'processing-started',
|
||||
];
|
||||
@@ -192,7 +192,7 @@ export class ClaudeAdapter extends IAdapter {
|
||||
async attachSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.attachSession(sid, cwd, options); }
|
||||
async destroySession(sid: string): Promise<void> { return this._tmux.destroySession(sid); }
|
||||
async sendMessage(sid: string, text: string, options?: QueryOptions): Promise<void> { return this._tmux.sendMessage(sid, text, options); }
|
||||
async respondPlan(sid: string, optionIndex: number, text?: string): Promise<void> { return this._tmux.respondPlan(sid, optionIndex, text); }
|
||||
async respondPlan(sid: string, optionIndex: number, text?: string, requestId?: string): Promise<void> { return this._tmux.respondPlan(sid, optionIndex, text, requestId); }
|
||||
async switchModel(sid: string, model: string): Promise<void> { return this._tmux.switchModel(sid, model); }
|
||||
async interrupt(sid: string): Promise<void> { return this._tmux.interrupt(sid); }
|
||||
flushMessages(sid: string): void { this._tmux.flushMessages(sid); }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -616,8 +616,13 @@ export class CodexTmuxAdapter extends EventEmitter {
|
||||
if (!session) return;
|
||||
|
||||
if (selectedOption != null) {
|
||||
// Codex uses single-key shortcuts (y, a, p, d, n)
|
||||
tmuxManager.sendKeys(session.windowId, selectedOption, false).catch(() => {});
|
||||
// Codex uses single-key shortcuts (y, a, p, d, n) or numbered options.
|
||||
// The overlay also sends 'deny' as a generic close/dismiss signal —
|
||||
// that is NOT a Codex keystroke and must never be typed into the CLI
|
||||
// (it would literally type the word "deny" into the terminal).
|
||||
if (/^[yandp]$/i.test(selectedOption) || /^\d+$/.test(selectedOption)) {
|
||||
tmuxManager.sendKeys(session.windowId, selectedOption, false).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (textValue != null) {
|
||||
tmuxManager.sendKeys(session.windowId, textValue, true).catch(() => {});
|
||||
|
||||
@@ -100,7 +100,7 @@ export class IAdapter extends EventEmitter {
|
||||
// --- Messaging ---
|
||||
|
||||
async sendMessage(sessionId: string, text: string, options?: QueryOptions): Promise<void> { throw new Error('Not implemented: sendMessage'); }
|
||||
async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise<void> {}
|
||||
async respondPlan(sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise<void> {}
|
||||
async interrupt(sessionId: string): Promise<void> { throw new Error('Not implemented: interrupt'); }
|
||||
async switchModel(sessionId: string, model: string): Promise<void> {}
|
||||
flushMessages(sessionId: string): void {}
|
||||
|
||||
@@ -59,6 +59,10 @@ export async function login(
|
||||
return { token };
|
||||
}
|
||||
|
||||
export function resetLoginAttempts(ip?: string): void {
|
||||
rateLimit.reset(ip);
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return jwt.verify(token, jwtSecret) as Record<string, unknown>;
|
||||
|
||||
@@ -178,6 +178,12 @@ function stmts(): PreparedStatements {
|
||||
rateLimitCleanup: d.prepare(
|
||||
`DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-1 hour')`
|
||||
),
|
||||
rateLimitReset: d.prepare(
|
||||
`DELETE FROM login_attempts WHERE ip = ?`
|
||||
),
|
||||
rateLimitResetAll: d.prepare(
|
||||
`DELETE FROM login_attempts`
|
||||
),
|
||||
// preferences
|
||||
preferencesGet: d.prepare(
|
||||
`SELECT value FROM user_preferences WHERE key = ?`
|
||||
@@ -297,6 +303,14 @@ export const rateLimit = {
|
||||
cleanup(): void {
|
||||
stmts().rateLimitCleanup.run();
|
||||
},
|
||||
|
||||
reset(ip?: string): void {
|
||||
if (ip) {
|
||||
stmts().rateLimitReset.run(ip);
|
||||
} else {
|
||||
stmts().rateLimitResetAll.run();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// --- User Preferences Operations ---
|
||||
|
||||
+22
-1
@@ -10,6 +10,7 @@ import {
|
||||
initAuth,
|
||||
login,
|
||||
authMiddleware,
|
||||
resetLoginAttempts,
|
||||
} from './auth.js';
|
||||
import './adapters/init.js';
|
||||
import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js';
|
||||
@@ -31,7 +32,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import multer from 'multer';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
|
||||
@@ -73,6 +74,18 @@ async function start(): Promise<void> {
|
||||
res.json({ status: 'ok', branch: config.gitBranch, version: pkg.version });
|
||||
});
|
||||
|
||||
// Serve the self-signed cert so phones can download + trust it directly
|
||||
// (no auth — the device needs this before it can trust the HTTPS connection).
|
||||
app.get('/cert', (_req: Request, res: Response) => {
|
||||
const certPath = join(config.clawtapDir, 'cert.pem');
|
||||
if (!config.https || !existsSync(certPath)) {
|
||||
return res.status(404).json({ error: 'No certificate configured. Run `clawtap cert` to generate one.' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="clawtap-ca.pem"');
|
||||
res.sendFile(certPath, { dotfiles: 'allow' });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req: Request, res: Response) => {
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password) {
|
||||
@@ -86,6 +99,14 @@ async function start(): Promise<void> {
|
||||
res.json({ token: (result as { token: string }).token });
|
||||
});
|
||||
|
||||
// Manual reset of login rate limiting. Restricted to localhost (via authMiddleware's
|
||||
// bypass) since a locked-out user has no token but can run `clawtap reset-login` locally.
|
||||
app.post('/api/auth/reset-attempts', authMiddleware, (req: Request, res: Response) => {
|
||||
const { ip } = req.body as { ip?: string };
|
||||
resetLoginAttempts(ip);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/sessions', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { dir, limit } = req.query as { dir?: string; limit?: string };
|
||||
|
||||
@@ -17,7 +17,10 @@ export interface PendingQuestion {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
originalInput: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
/** Distinguishes AskUserQuestion prompts from ExitPlanMode plan-approval prompts. */
|
||||
promptType: 'question' | 'plan';
|
||||
// Questions never auto-expire (see addQuestion) — no timer to track.
|
||||
timer: null;
|
||||
}
|
||||
|
||||
export class PermissionManager {
|
||||
@@ -49,6 +52,10 @@ export class PermissionManager {
|
||||
this._trackPendingId(sessionId, requestId);
|
||||
}
|
||||
|
||||
hasPermission(requestId: string): boolean {
|
||||
return this.pendingPermissions.has(requestId);
|
||||
}
|
||||
|
||||
resolvePermission(requestId: string): PendingPermission | undefined {
|
||||
const pending = this.pendingPermissions.get(requestId);
|
||||
if (!pending) return undefined;
|
||||
@@ -60,25 +67,31 @@ export class PermissionManager {
|
||||
|
||||
// === Questions ===
|
||||
|
||||
addQuestion(requestId: string, sessionId: string, data: { originalInput: Record<string, unknown> }): void {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingQuestions.delete(requestId);
|
||||
this._removePendingId(sessionId, requestId);
|
||||
}, this.timeoutMs);
|
||||
|
||||
addQuestion(requestId: string, sessionId: string, data: { originalInput: Record<string, unknown>; promptType?: 'question' | 'plan' }): void {
|
||||
// Unlike permissions (which pair with a client-side auto-deny countdown
|
||||
// that reaches the same end state), nothing ever answers a question on
|
||||
// a timeout — the CLI just keeps waiting indefinitely. Silently dropping
|
||||
// our tracking after timeoutMs would make ClawTap forget a prompt that's
|
||||
// still live on screen, producing a "ghost" the UI can never show again.
|
||||
// Questions are cleaned up via resolveQuestion/dismissAll (session end),
|
||||
// never by elapsed time.
|
||||
this.pendingQuestions.set(requestId, {
|
||||
sessionId,
|
||||
requestId,
|
||||
originalInput: data.originalInput,
|
||||
timer,
|
||||
promptType: data.promptType || 'question',
|
||||
timer: null,
|
||||
});
|
||||
this._trackPendingId(sessionId, requestId);
|
||||
}
|
||||
|
||||
hasQuestion(requestId: string): boolean {
|
||||
return this.pendingQuestions.has(requestId);
|
||||
}
|
||||
|
||||
resolveQuestion(requestId: string): PendingQuestion | undefined {
|
||||
const pending = this.pendingQuestions.get(requestId);
|
||||
if (!pending) return undefined;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingQuestions.delete(requestId);
|
||||
this._removePendingId(pending.sessionId, requestId);
|
||||
return pending;
|
||||
@@ -118,7 +131,7 @@ export class PermissionManager {
|
||||
const perm = this.pendingPermissions.get(reqId);
|
||||
if (perm) { clearTimeout(perm.timer); this.pendingPermissions.delete(reqId); }
|
||||
const q = this.pendingQuestions.get(reqId);
|
||||
if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); }
|
||||
if (q) { this.pendingQuestions.delete(reqId); }
|
||||
}
|
||||
this.sessionPendingIds.delete(sessionId);
|
||||
}
|
||||
@@ -139,7 +152,7 @@ export class PermissionManager {
|
||||
resolved.push(reqId);
|
||||
}
|
||||
const q = this.pendingQuestions.get(reqId);
|
||||
if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); }
|
||||
if (q) { this.pendingQuestions.delete(reqId); }
|
||||
}
|
||||
this.sessionPendingIds.delete(sessionId);
|
||||
return resolved;
|
||||
|
||||
+24
-11
@@ -394,7 +394,7 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
|
||||
case WS.SET_MODEL:
|
||||
return handleSetModel(conn, msg.sessionId as string, msg.model as string);
|
||||
case WS.PLAN_RESPONSE:
|
||||
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined);
|
||||
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined, msg.requestId as string | undefined);
|
||||
case WS.PROMPT_RESPONSE:
|
||||
return handlePromptResponse(conn, msg.requestId as string, {
|
||||
selectedOption: msg.selectedOption as string | undefined,
|
||||
@@ -488,12 +488,13 @@ export async function handleAbort(conn: ClientConnection, sessionId?: string): P
|
||||
if (sid && adapter) await adapter.interrupt(sid);
|
||||
}
|
||||
|
||||
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> {
|
||||
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise<void> {
|
||||
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
|
||||
if (!sid || !adapter) return;
|
||||
await adapter.respondPlan(sid, optionIndex, text);
|
||||
await adapter.respondPlan(sid, optionIndex, text, requestId);
|
||||
if (requestId) broadcast(sid, { type: WS.PROMPT_DISMISSED, requestId });
|
||||
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients
|
||||
// Options: 0=bypass (YOLO), 1=manually approve, 2=text feedback
|
||||
// Options: 0=bypass (YOLO), 1=manually approve, 3=text feedback
|
||||
const labels = ['Plan approved (YOLO).', 'Plan approved.'];
|
||||
const msg = optionIndex === PLAN_OPTION.TEXT_FEEDBACK ? (text || 'Rejected.') : (labels[optionIndex] || 'Plan approved.');
|
||||
broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] });
|
||||
@@ -572,6 +573,17 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
|
||||
send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools });
|
||||
}
|
||||
for (const req of pending.pendingRequests) {
|
||||
if (req.type === 'plan') {
|
||||
send(conn, {
|
||||
type: WS.INTERACTIVE_PROMPT,
|
||||
requestId: req.requestId,
|
||||
promptType: 'plan',
|
||||
title: 'Plan ready',
|
||||
toolName: req.toolName,
|
||||
toolInput: req.input,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { type: _type, ...rest } = req as Record<string, unknown>;
|
||||
send(conn, { type: WS.PERMISSION_REQUEST, ...rest });
|
||||
}
|
||||
@@ -667,13 +679,14 @@ function registerClient(conn: ClientConnection, sessionId: string): void {
|
||||
if (set) {
|
||||
set.delete(c);
|
||||
if (set.size === 0) {
|
||||
const adapterName = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
|
||||
const adapter = getAdapter(adapterName);
|
||||
// Only release pending permissions if session is idle — if processing,
|
||||
// the client may be refreshing and will reconnect shortly to see the overlay
|
||||
if (adapter && !adapter.isProcessing(sid)) {
|
||||
adapter.releaseAllPending(sid);
|
||||
}
|
||||
// Do NOT release pending permissions/questions here. They only exist
|
||||
// because the CLI itself posted a prompt and is genuinely waiting on
|
||||
// an answer — that's real, live CLI state, not stale UI residue.
|
||||
// Wiping our tracking just makes ClawTap forget about a prompt that's
|
||||
// still sitting on screen, so a reconnecting client sees no dialog at
|
||||
// all (a "ghost" prompt the CLI is waiting on but nothing can answer).
|
||||
// Let pending entries persist until answered, resolved by the CLI, or
|
||||
// naturally expired by PermissionManager's internal timeout.
|
||||
console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface AdapterInfo {
|
||||
export interface ReconnectState {
|
||||
tools: Record<string, ToolStatus>;
|
||||
pendingRequests: Array<{
|
||||
type: 'permission' | 'question';
|
||||
type: 'permission' | 'question' | 'plan';
|
||||
requestId: string;
|
||||
toolName?: string;
|
||||
input?: Record<string, unknown>;
|
||||
|
||||
+8
-5
@@ -49,13 +49,16 @@ export type WsType = typeof WS[keyof typeof WS];
|
||||
|
||||
/**
|
||||
* CLI plan approval selector option indices (ExitPlanMode).
|
||||
* Claude Code v2.1.x shows 3 options:
|
||||
* 0: "Yes, auto-accept edits" → BYPASS
|
||||
* 1: "Yes, manually approve edits" → MANUALLY_APPROVE
|
||||
* 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK
|
||||
* Claude Code v2.1.177 shows 4 options:
|
||||
* 0: "Yes, and bypass permissions" → BYPASS
|
||||
* 1: "Yes, manually approve edits" → MANUALLY_APPROVE
|
||||
* 2: "No, refine with Ultraplan on Claude Code on the web"
|
||||
* 3: "Tell Claude what to change" → TEXT_FEEDBACK
|
||||
* _selectOption clamps to the last option when overshooting, so
|
||||
* TEXT_FEEDBACK also lands correctly on older 3-option layouts.
|
||||
*/
|
||||
export const PLAN_OPTION = {
|
||||
BYPASS: 0,
|
||||
MANUALLY_APPROVE: 1,
|
||||
TEXT_FEEDBACK: 2,
|
||||
TEXT_FEEDBACK: 3,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user