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:
@@ -184,6 +184,8 @@ tailscale serve --bg 3456
|
||||
clawtap cert
|
||||
```
|
||||
|
||||
If you're using a self-signed certificate, open `https://<your-ip>:3456/cert` on your phone to download and trust it directly (iOS: Install Profile → enable full trust in Certificate Trust Settings; Android: Settings → Security → Install certificate → CA certificate) — no need to AirDrop/email it manually.
|
||||
|
||||
**2. Install PWA:** Open the URL in Safari → Share → **Add to Home Screen**.
|
||||
|
||||
**3. Enable notifications:** On first login in standalone mode, ClawTap automatically prompts for notification permission. You can also toggle notifications manually in **Settings**.
|
||||
@@ -266,6 +268,7 @@ SQLite with WAL mode. Stores review history, push subscriptions, rate limiting,
|
||||
| Stale sessions | `tmux kill-session -t clawtap` |
|
||||
| Port in use | `lsof -i :3456` then `kill <PID>` |
|
||||
| Hooks not cleaned after crash | `clawtap hooks uninstall` or `clawtap stop` |
|
||||
| `ERR_SSL_PROTOCOL_ERROR` | Server is on HTTP — use `http://` URL, or run `clawtap cert` to enable HTTPS |
|
||||
| Push notifications not working | Ensure HTTPS + PWA installed from home screen |
|
||||
| Adapter not showing | Check that the CLI is installed: `which claude`, `which codex`, `which gemini` |
|
||||
| Gemini hooks failing | Verify timeout is in milliseconds (5000, not 5) |
|
||||
|
||||
+10
-1
@@ -75,6 +75,7 @@ Commands:
|
||||
hooks install Install hooks (all adapters, or use --adapter)
|
||||
hooks uninstall Remove hooks (all adapters, or use --adapter)
|
||||
cert Generate self-signed HTTPS certificate
|
||||
reset-login Clear too-many-login-attempts lockout
|
||||
|
||||
Options:
|
||||
-v, --version Show version
|
||||
@@ -152,7 +153,7 @@ HELP
|
||||
-out "$CERT_FILE" \
|
||||
-days 365 \
|
||||
-subj "/CN=ClawTap" \
|
||||
-addext "subjectAltName=IP:$(ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \
|
||||
-addext "subjectAltName=IP:$(hostname -I 2>/dev/null | awk '{print $1}' || ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \
|
||||
2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to generate certificate. Is openssl installed?"
|
||||
@@ -254,6 +255,14 @@ require_auth() {
|
||||
AUTH_TOKEN=""
|
||||
}
|
||||
|
||||
# "reset-login" → clear login rate-limit lockout
|
||||
if [ "$1" = "reset-login" ]; then
|
||||
curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/auth/reset-attempts" \
|
||||
-H "Content-Type: application/json" -d '{}' >/dev/null
|
||||
echo "Login rate limit reset."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No args → just start server, print URLs, exit
|
||||
if [ $# -eq 0 ]; then
|
||||
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || echo "")
|
||||
|
||||
@@ -79,5 +79,11 @@
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.2",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"bcrypt@6.0.0": true,
|
||||
"better-sqlite3@12.10.0": true,
|
||||
"esbuild@0.28.0": true,
|
||||
"esbuild@0.25.12": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { STORAGE } from './lib/storage-keys';
|
||||
import { isAuthenticated, clearToken } from './lib/api';
|
||||
import { isAuthenticated, clearToken, AUTH_EXPIRED_EVENT } from './lib/api';
|
||||
import { usePushNotifications } from './hooks/usePushNotifications';
|
||||
import { LoginView } from './components/LoginView';
|
||||
import { SessionsView } from './components/SessionsView';
|
||||
@@ -89,6 +89,13 @@ export function App() {
|
||||
setAuthed(false);
|
||||
}, []);
|
||||
|
||||
// Server rejected our token (expired/invalid) — drop back to login
|
||||
useEffect(() => {
|
||||
const handler = () => handleLogout();
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, handler);
|
||||
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handler);
|
||||
}, [handleLogout]);
|
||||
|
||||
const openChat = useCallback((sessionId?: string, cwd?: string, adapter?: string) => {
|
||||
if (!sessionId && cwd) {
|
||||
const v: View = { name: 'newchat', cwd };
|
||||
@@ -130,6 +137,28 @@ export function App() {
|
||||
return () => navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
|
||||
}, []);
|
||||
|
||||
// PWA: actively poll for service worker updates.
|
||||
// iOS standalone PWAs don't reliably perform the browser's normal
|
||||
// "check sw.js for byte changes on navigation" — without this, an
|
||||
// installed app can stay on a stale bundle indefinitely. Forcing
|
||||
// registration.update() on focus/interval triggers that check.
|
||||
useEffect(() => {
|
||||
if (!navigator.serviceWorker) return;
|
||||
const checkForUpdate = () => {
|
||||
navigator.serviceWorker.getRegistration().then(reg => reg?.update()).catch(() => {});
|
||||
};
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible') checkForUpdate();
|
||||
};
|
||||
checkForUpdate();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
const interval = setInterval(checkForUpdate, 60 * 60 * 1000);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// PWA iOS: Sync --app-height CSS variable to the true viewport height.
|
||||
// WebKit bug: after a keyboard open/close cycle, viewport-fit=cover's layout
|
||||
// extension is lost, causing 100dvh to shrink permanently. We track the max
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CollapsedReviewCard } from './CollapsedReviewCard';
|
||||
import { BlockMarker } from './BlockMarker';
|
||||
import { TaskFab } from './TaskFab';
|
||||
import { TaskBottomSheet } from './TaskBottomSheet';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { api } from '../lib/api';
|
||||
import { getBrand } from '../lib/adapter-brands';
|
||||
import { extractTextFromBlocks } from '../lib/content-utils';
|
||||
@@ -606,12 +607,22 @@ export function ChatView({
|
||||
<TaskBottomSheet snapshot={taskSnapshot} open={taskSheetOpen} onClose={() => setTaskSheetOpen(false)} />
|
||||
|
||||
{/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
|
||||
{interactivePrompt && (
|
||||
{interactivePrompt && interactivePrompt.promptType === 'plan' ? (
|
||||
<BottomSheet visible className="p-4 max-h-[80vh] overflow-y-auto" showHandle={false}>
|
||||
<PlanMode
|
||||
input={interactivePrompt.toolInput}
|
||||
onApprove={() => respondPlan(PLAN_OPTION.MANUALLY_APPROVE, undefined, interactivePrompt.requestId)}
|
||||
onApproveYolo={() => respondPlan(PLAN_OPTION.BYPASS, undefined, interactivePrompt.requestId)}
|
||||
onReject={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback || 'Rejected', interactivePrompt.requestId)}
|
||||
onSendFeedback={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback, interactivePrompt.requestId)}
|
||||
/>
|
||||
</BottomSheet>
|
||||
) : interactivePrompt ? (
|
||||
<InteractivePromptOverlay
|
||||
prompt={interactivePrompt}
|
||||
onRespond={respondPrompt}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info, ShieldCheck } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { AdapterIcon } from './AdapterIcon';
|
||||
import { SavedInstructionsView } from './SavedInstructionsView';
|
||||
@@ -16,6 +16,7 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
||||
const [subView, setSubView] = useState<'main' | 'instructions' | string>('main');
|
||||
const [adapters, setAdapters] = useState<Adapter[]>([]);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [certAvailable, setCertAvailable] = useState(false);
|
||||
const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications();
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
@@ -25,6 +26,9 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
||||
.then(r => r.json())
|
||||
.then((data: { version: string }) => setVersion(data.version))
|
||||
.catch(() => {});
|
||||
fetch('/cert', { method: 'HEAD' })
|
||||
.then(r => setCertAvailable(r.ok))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (subView === 'instructions') {
|
||||
@@ -87,6 +91,29 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Install HTTPS certificate (only relevant when serving over a self-signed cert) */}
|
||||
{window.location.protocol === 'https:' && (
|
||||
certAvailable ? (
|
||||
<a
|
||||
href="/cert"
|
||||
download
|
||||
className="w-full flex items-center px-4 min-h-[48px] border-b border-border hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className="mr-3"><ShieldCheck className="w-5 h-5 text-text-dim" /></span>
|
||||
<span className="flex-1 text-left text-text text-sm">Install HTTPS Certificate</span>
|
||||
<ChevronRight className="w-4 h-4 text-text-dim" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
|
||||
<span className="mr-3"><ShieldCheck className="w-5 h-5 text-text-dim" /></span>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="text-text text-sm">Install HTTPS Certificate</div>
|
||||
<div className="text-text-dim text-xs font-mono">No cert found — run `clawtap cert` on the server</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
{supported && (
|
||||
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
|
||||
|
||||
@@ -492,7 +492,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem(STORAGE.TOKEN);
|
||||
if (!token) return;
|
||||
const client = new WsClient(token, handleWsMessage, setWsStatus);
|
||||
const client = new WsClient(token, handleWsMessage, setWsStatus, () => {
|
||||
// Connection handshake failed — verify the token is still valid (a 401/403
|
||||
// here triggers the global auth-expired logout via api.ts).
|
||||
api.adapters().catch(() => {});
|
||||
});
|
||||
wsRef.current = client;
|
||||
if (initialSessionId) {
|
||||
client.setActiveSession(initialSessionId, selectedAdapter ?? undefined);
|
||||
@@ -601,17 +605,19 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
}, []);
|
||||
|
||||
// --- Plan Response ---
|
||||
const respondPlan = useCallback((optionIndex: number, text?: string) => {
|
||||
const respondPlan = useCallback((optionIndex: number, text?: string, requestId?: string) => {
|
||||
// Enter streaming mode — CLI will start executing tools after approval
|
||||
setStreaming(true);
|
||||
setPendingResponse(true);
|
||||
streamingRef.current = true;
|
||||
setToolStatuses(new Map());
|
||||
if (requestId) setInteractivePrompt(null);
|
||||
wsRef.current?.send({
|
||||
type: WS.PLAN_RESPONSE,
|
||||
sessionId,
|
||||
optionIndex,
|
||||
text,
|
||||
requestId,
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
+17
-1
@@ -14,6 +14,14 @@ export function clearToken() {
|
||||
localStorage.removeItem(STORAGE.TOKEN);
|
||||
}
|
||||
|
||||
/** Fired when the server rejects the stored token (expired/invalid). */
|
||||
export const AUTH_EXPIRED_EVENT = 'clawtap:auth-expired';
|
||||
|
||||
function handleAuthExpired() {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
@@ -36,6 +44,9 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if ((res.status === 401 || res.status === 403) && token) {
|
||||
handleAuthExpired();
|
||||
}
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
@@ -72,7 +83,12 @@ export const api = {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
if (!res.ok) {
|
||||
if ((res.status === 401 || res.status === 403) && token) {
|
||||
handleAuthExpired();
|
||||
}
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
|
||||
+8
-5
@@ -47,13 +47,16 @@ export const 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;
|
||||
|
||||
+11
-1
@@ -20,21 +20,26 @@ export class WsClient {
|
||||
private activeAdapter: string | null = null;
|
||||
private visibilityHandler: (() => void) | null = null;
|
||||
private hiddenSince: number | null = null;
|
||||
private openedThisAttempt = false;
|
||||
private onAuthCheckNeeded: (() => void) | null = null;
|
||||
|
||||
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) {
|
||||
constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler, onAuthCheckNeeded?: () => void) {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.url = `${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
this.onMessage = onMessage;
|
||||
this.onStatus = onStatus;
|
||||
this.onAuthCheckNeeded = onAuthCheckNeeded || null;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this.openedThisAttempt = false;
|
||||
this.onStatus('connecting');
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.openedThisAttempt = true;
|
||||
this.reconnectDelay = 1000;
|
||||
this.onStatus('connected');
|
||||
|
||||
@@ -68,6 +73,11 @@ export class WsClient {
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.onStatus('disconnected');
|
||||
// The handshake itself failed (e.g. server rejected the auth token) —
|
||||
// check whether our token is still valid rather than reconnecting forever.
|
||||
if (!this.openedThisAttempt) {
|
||||
this.onAuthCheckNeeded?.();
|
||||
}
|
||||
if (this.shouldReconnect) {
|
||||
this.onStatus('reconnecting');
|
||||
setTimeout(() => this.connect(), this.reconnectDelay);
|
||||
|
||||
+140
-13
@@ -1,21 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
die() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
SKIP_FIREWALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-firewall) SKIP_FIREWALL=1 ;;
|
||||
*) die "Unknown argument: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVICE_NAME="clawtap.service"
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
ENV_FILE="$HOME/.clawtap/env"
|
||||
UNIT_FILE="$UNIT_DIR/$SERVICE_NAME"
|
||||
|
||||
log "Repo: $REPO_DIR"
|
||||
log "Service unit: $UNIT_FILE"
|
||||
|
||||
# --- Install systemd unit if missing ---
|
||||
if [ ! -f "$UNIT_FILE" ]; then
|
||||
echo "Installing $SERVICE_NAME..."
|
||||
log "Installing $SERVICE_NAME..."
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "ERROR: $ENV_FILE not found."
|
||||
echo "Create it with at minimum:"
|
||||
echo " CLAWTAP_PASSWORD=your-password-here"
|
||||
exit 1
|
||||
log "$ENV_FILE not found — creating it now."
|
||||
mkdir -p "$HOME/.clawtap"
|
||||
read -rsp "Enter CLAWTAP_PASSWORD: " _pw
|
||||
echo
|
||||
echo "CLAWTAP_PASSWORD=$_pw" > "$ENV_FILE"
|
||||
log "Created $ENV_FILE"
|
||||
fi
|
||||
|
||||
mkdir -p "$UNIT_DIR"
|
||||
@@ -43,21 +59,132 @@ EOF
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
echo "Service installed and enabled."
|
||||
log "Service installed and enabled."
|
||||
else
|
||||
log "Service unit already exists, skipping install."
|
||||
fi
|
||||
|
||||
# --- Check dependencies ---
|
||||
log "Checking for tmux..."
|
||||
if command -v tmux &>/dev/null; then
|
||||
log "tmux found: $(tmux -V)"
|
||||
else
|
||||
log "tmux not found — installing..."
|
||||
if command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm tmux
|
||||
elif command -v apt-get &>/dev/null; then
|
||||
sudo apt-get install -y tmux
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install tmux
|
||||
else
|
||||
die "Cannot install tmux: no supported package manager found (pacman/apt/brew). Install it manually and re-run."
|
||||
fi
|
||||
log "tmux installed: $(tmux -V)"
|
||||
fi
|
||||
|
||||
# --- Open firewall port ---
|
||||
if [ "$SKIP_FIREWALL" = "1" ]; then
|
||||
log "Skipping firewall rule (--skip-firewall)."
|
||||
elif command -v ufw &>/dev/null; then
|
||||
_port="$(grep -E '^PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 || true)"
|
||||
_port="${_port:-3456}"
|
||||
log "Opening port $_port for local network..."
|
||||
sudo ufw allow from 192.168.0.0/16 to any port "$_port" comment 'ClawTap'
|
||||
else
|
||||
log "ufw not found, skipping firewall rule."
|
||||
fi
|
||||
|
||||
# --- Install dependencies (skip if package files haven't changed) ---
|
||||
cd "$REPO_DIR"
|
||||
_pkg_hash="$(cat package.json package-lock.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
_hash_file="$REPO_DIR/node_modules/.clawtap-install-hash"
|
||||
if [ -d node_modules ] && [ -f "$_hash_file" ] && [ "$(cat "$_hash_file")" = "$_pkg_hash" ]; then
|
||||
log "package.json/package-lock.json unchanged, skipping npm install."
|
||||
else
|
||||
log "Running npm install..."
|
||||
npm install
|
||||
|
||||
# Interactively approve/deny any packages with pending install scripts
|
||||
log "Checking for pending install scripts..."
|
||||
_pending=$(npm approve-scripts --allow-scripts-pending 2>&1 | grep -E '^\s+\S+@' | awk '{print $1}' || true)
|
||||
if [ -n "$_pending" ]; then
|
||||
log "The following packages have pending install scripts:"
|
||||
echo "$_pending"
|
||||
echo
|
||||
for _pkg in $_pending; do
|
||||
read -rp " Approve install scripts for $_pkg? [y/N] " _ans
|
||||
if [[ "$_ans" =~ ^[Yy]$ ]]; then
|
||||
npm approve-scripts "$_pkg"
|
||||
log "Approved $_pkg"
|
||||
else
|
||||
npm deny-scripts "$_pkg"
|
||||
log "Denied $_pkg"
|
||||
fi
|
||||
done
|
||||
log "Re-running npm install after approvals..."
|
||||
npm install
|
||||
else
|
||||
log "No pending install scripts."
|
||||
fi
|
||||
|
||||
echo "$_pkg_hash" > "$_hash_file"
|
||||
fi
|
||||
|
||||
# --- Build frontend ---
|
||||
echo "Building frontend..."
|
||||
cd "$REPO_DIR"
|
||||
log "Building frontend..."
|
||||
npm run build
|
||||
log "Frontend built successfully."
|
||||
|
||||
# --- Install CLI binary ---
|
||||
echo "Installing CLI binary..."
|
||||
sudo cp "$REPO_DIR/bin/clawtap" /usr/bin/clawtap
|
||||
log "Installing CLI binary to ~/.local/bin/clawtap..."
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cp "$REPO_DIR/bin/clawtap" "$HOME/.local/bin/clawtap"
|
||||
chmod +x "$HOME/.local/bin/clawtap"
|
||||
log "Binary installed."
|
||||
|
||||
# Ensure ~/.local/bin is in PATH for future shells
|
||||
_fish_config="$HOME/.config/fish/config.fish"
|
||||
if [ -f "$_fish_config" ] && ! grep -q '\.local/bin' "$_fish_config"; then
|
||||
echo 'fish_add_path $HOME/.local/bin' >> "$_fish_config"
|
||||
log "Added ~/.local/bin to PATH in $_fish_config"
|
||||
elif ! grep -q '\.local/bin' "$HOME/.bashrc" 2>/dev/null && ! grep -q '\.local/bin' "$HOME/.profile" 2>/dev/null; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.profile"
|
||||
log "Added ~/.local/bin to PATH in ~/.profile"
|
||||
else
|
||||
log "~/.local/bin already in PATH config, skipping."
|
||||
fi
|
||||
|
||||
# --- HTTPS certificate ---
|
||||
_cert="$HOME/.clawtap/cert.pem"
|
||||
_key="$HOME/.clawtap/key.pem"
|
||||
if [ -f "$_cert" ] && [ -f "$_key" ]; then
|
||||
log "HTTPS certificate already exists, skipping generation."
|
||||
else
|
||||
read -rp "[$(date '+%H:%M:%S')] Generate self-signed HTTPS certificate? (recommended for PWA/push) [Y/n] " _ans
|
||||
if [[ ! "$_ans" =~ ^[Nn]$ ]]; then
|
||||
_lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo '0.0.0.0')"
|
||||
log "Generating certificate for IP $_lan_ip..."
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$_key" \
|
||||
-out "$_cert" \
|
||||
-days 365 \
|
||||
-subj "/CN=ClawTap" \
|
||||
-addext "subjectAltName=IP:$_lan_ip" \
|
||||
2>/dev/null
|
||||
chmod 600 "$_key"
|
||||
log "Certificate generated at ~/.clawtap/{cert,key}.pem"
|
||||
echo ""
|
||||
echo " To trust on Android: send cert.pem to your phone → Settings → Security → Install certificate → CA certificate"
|
||||
echo " To trust on iOS: send cert.pem to your phone → open it → Install Profile → Settings → General → About → Certificate Trust Settings → enable ClawTap"
|
||||
echo ""
|
||||
else
|
||||
log "Skipping certificate generation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Restart ---
|
||||
echo "Restarting $SERVICE_NAME..."
|
||||
log "Restarting $SERVICE_NAME..."
|
||||
systemctl --user restart "$SERVICE_NAME"
|
||||
|
||||
echo "Status:"
|
||||
systemctl --user status "$SERVICE_NAME" --no-pager
|
||||
log "Status:"
|
||||
systemctl --user status "$SERVICE_NAME" --no-pager || true
|
||||
|
||||
Reference in New Issue
Block a user