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

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