first commit

This commit is contained in:
iceBear67
2026-08-09 04:00:13 +00:00
commit b3b6bf3f70
46 changed files with 7146 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
{
"name": "grok-glance",
"version": "0.1.0",
"description": "A passkey-guarded web dashboard that lets you glance at what Grok Build is doing from your phone, and approve or deny risky tool calls remotely.",
"author": {
"name": "grok-glance"
},
"license": "MIT",
"keywords": [
"grok-glance",
"glance dashboard",
"webauthn passkey",
"remote approval",
"session monitor"
],
"hooks": "hooks/hooks.json"
}
+293
View File
@@ -0,0 +1,293 @@
# grok-glance
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what
an agent is doing from your phone — and tap **approve** or **deny** when it wants to run something
risky.
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
or drive a session.
```
┌────────────────────────────┐
│ ● grok-glance 2 sessions│
├────────────────────────────┤
│ Waiting on you 62s │
│ Bash · in remote-grok │
│ rm -rf ./dist │
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
│ [ Deny ] [ Approve ] │
├────────────────────────────┤
│ remote-grok working │
│ ~/src/remote-grok │
│ Last asked: fix the flaky │
│ ⟳ Read 4s │
│ server/src/state.ts │
│ 12 tools 0 failed 0 ✗ │
├────────────────────────────┤
│ Activity │
│ ● Read state.ts 14:22 │
│ ● Bash npm test 14:21 │
└────────────────────────────┘
```
## Requirements
- Node.js 20 or newer, and npm.
- Grok Build.
- For phone access: [Tailscale](https://tailscale.com/) on both the machine and the phone. See
[Why Tailscale](#why-tailscale-and-not-just-the-lan-ip) — a LAN IP genuinely cannot work.
## Install
```sh
git clone <this repo> grok-glance
cd grok-glance
npm install && npm run build
```
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required;
the daemon serves the dashboard itself.
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
for a local checkout the shortest path is a one-entry catalog. Create
`.grok-plugin/marketplace.json` in a directory that contains your checkout:
```json
{
"name": "local",
"description": "Local plugins",
"owner": { "name": "me" },
"plugins": [
{
"name": "grok-glance",
"description": "Passkey-guarded phone dashboard for Grok Build.",
"category": "monitoring",
"source": { "type": "local", "path": "./grok-glance" }
}
]
}
```
…then add that marketplace and install `grok-glance` from Grok Build's `/plugin` interface.
Once installed, the daemon starts by itself: the `SessionStart` hook boots it in the background on
the first session after installation.
## Get it onto your phone
The daemon binds to `127.0.0.1` only and never opens a port to your network. Tailscale Serve
publishes it inside your tailnet with real TLS:
```sh
tailscale serve --bg 127.0.0.1:8791
tailscale serve status # note the https://<box>.<tailnet>.ts.net URL
```
Tell grok-glance which origin it is being served on — this is also the WebAuthn relying-party ID,
so it has to be exact:
```sh
node bin/glance set-origin https://<box>.<tailnet>.ts.net
```
## Enrol the phone
```sh
node bin/glance enroll
```
That prints a URL and an 8-character code, good for 10 minutes, single use:
```
Open this on your phone:
https://mybox.tailnet-1234.ts.net/?enroll
Enrollment code: K7QM4RTX
Valid for: 10 minutes (single use)
```
Open the URL on the phone, type the code, tap **Create passkey**, and confirm with Face ID / a
fingerprint / the device PIN. From then on the phone unlocks the dashboard with that passkey and
nothing else gets in.
Repeat for each device you want. `node bin/glance devices` lists them; `node bin/glance revoke
<id-prefix>` removes one (and kills its live session immediately).
## What the dashboard shows
- **Now** — the workspace, its state (working / waiting on you / idle / error / ended), the last
thing you asked, the tool currently running with a live elapsed timer, and running counts of
tools, failures and denials.
- **Sessions** — one row per live session when there is more than one; tap to filter.
- **Activity** — a timeline of prompts, tool calls with durations, failures, permission denials,
notifications, subagents, compactions, session start/end.
- **Pending approvals** — a card per waiting tool call, with the command, a countdown, and two
large buttons.
Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a
phone that slept through twenty events still wakes up showing the truth.
## Remote approve / deny
Off by default. Turn it on from the phone's settings panel, or:
```sh
node bin/glance approval risky # Bash, Write, Edit, MultiEdit, NotebookEdit
node bin/glance approval all # every tool call — noisy
node bin/glance approval off
```
Defaults worth knowing:
| Behaviour | Default | Changeable from the phone | Why |
|---|---|---|---|
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
| On timeout | allow | yes | Flip to *deny* if you would rather fail closed. |
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon
down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls
actually blocked, use Grok Build's own permission settings.
## CLI
`bin/glance` is plain Node with no dependencies. Run it as `node bin/glance <command>`.
| Command | What it does |
|---|---|
| `status` | Running? On which origin, with how many devices? |
| `up` | Start the daemon in the background |
| `serve` | Run it in the foreground (for debugging) |
| `stop` | Stop it |
| `logs` | Last 60 lines of the daemon log |
| `enroll` | Mint a one-time enrolment code and URL |
| `set-origin <url>` | Set the public https origin and RP ID |
| `devices` | List enrolled devices |
| `revoke <id-prefix>` | Revoke a device |
| `approval <off\|risky\|all>` | Set the approval policy |
| `sync-hooks` | Rewrite hook URLs after changing the port |
## Files and configuration
Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set it:
| File | Contents |
|---|---|
| `config.json` | Port, bind host, public origin, RP ID, approval settings |
| `credentials.json` | Enrolled passkeys — credential IDs, public keys, counters. No secrets of yours. |
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
| `secret.key` | 32-byte HMAC key used to sign session cookies |
| `admin.token` | Rotated every daemon start; authenticates the CLI |
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
| `daemon.log` | Daemon stdout/stderr |
Three environment variables override `config.json`, which is mostly useful for testing a second
instance without touching your real one:
| Variable | Effect |
|---|---|
| `GLANCE_HOME` | Where all of the above lives. Read by the daemon, the CLI, and the hook scripts. |
| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) |
| `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted |
To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in
`hooks/hooks.json` match. Restart the daemon afterwards.
## Security notes
**Network surface.** The daemon listens on `127.0.0.1:8791` and makes no outbound connections of
its own. Three classes of caller:
| Path | Caller | Authentication |
|---|---|---|
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only |
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies
remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by
address alone.
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
last 30 days; revoking a device drops its session at once.
**Cross-site defence.** Every POST must carry exactly `content-type: application/json`, which is
not a CORS-safelisted type, so a hostile page cannot post here without a preflight that is never
answered. `/api/*` additionally requires an `x-glance-csrf` header. The pages themselves are served
with a strict CSP (`script-src 'self'`, no framing, no form actions) and `no-store`.
**User verification is required**, for both enrolment and sign-in: the phone asks for a biometric
or PIN every time, so a stolen unlocked phone is not automatically a way in. Passkeys are created
as resident keys, so the phone offers the right one without you typing a username.
**Rate limits.** Authentication attempts are capped (40 per 5 minutes), enrolment attempts more
tightly (12 per 5 minutes, and a code burns itself after 5 wrong guesses). The limiter is keyed
globally on purpose: behind a tunnel every request arrives from `127.0.0.1`, so per-IP buckets
would be one bucket wearing a hat.
**What crosses the wire.** Tool names, truncated arguments, prompt first lines, file paths,
durations, and exit statuses — a summary, not a transcript. Known secret shapes are redacted before
storage: `sk-`/`rk-`/`pk-` keys, `xai-` keys, GitHub and Slack tokens, AWS access key IDs, JWTs,
PEM private-key blocks, `Authorization:`/`Bearer` headers, and `password`/`secret`/`token`/`api_key`
assignments. This is a filter, not a guarantee — a secret in an unusual shape will show up in the
timeline. Treat the dashboard as being as sensitive as your terminal.
**Threat model.** grok-glance assumes the machine it runs on is trusted. It protects against
someone else on your tailnet, or a browser tab you left open, reaching the dashboard. It does not
protect against a local attacker who can read `~/.grok/glance` — with `admin.token` they can enrol
their own device.
### Why Tailscale, and not just the LAN IP?
WebAuthn will not run outside a secure context, and — separately — a bare IP address cannot be a
relying-party ID. So `http://192.168.1.20:8791` can never hold a passkey, no matter what the
browser is willing to render. You need a hostname with valid TLS. Tailscale Serve gives you one for
free, with the tunnel closed to everything outside your tailnet.
Any other route to a real https hostname works too: set `origin` in `config.json` (or use
`set-origin`) to whatever your reverse proxy terminates on. If you put grok-glance behind a proxy
reachable from the public internet, the passkey gate is the only thing standing in front of it.
Changing the origin changes the RP ID, and **passkeys are bound to the RP ID** — existing devices
stop working and must be enrolled again.
## Hook wiring
`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
and quietly do nothing when the daemon is down.
Two exceptions:
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a
`command` entry matching only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs
`bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only
documented way to return a deny decision, so the gate has to be a spawned process; keeping the
match narrow means the cost is paid only for calls that could actually need a tap.
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
deliberately denying.
## Deliberately omitted
Not oversights — decisions:
- **Sending prompts or steering the session.** Hooks cannot inject input, so a phone-to-agent
channel would need a second transport and a much larger threat model.
- **Full transcripts and tool output.** Summaries only. Streaming assistant text through a phone
would mean shipping your codebase through it.
- **Push notifications.** Needs a VAPID key, a service worker, and a subscription store, for a
dashboard you open on purpose.
- **Multi-user accounts and roles.** One user, several devices.
- **Diff views, file browsing, cost/token charts, log search, session resume.** All out of scope
for a glance.
- **Editing the risky-tool pattern from the phone.** Shown but not editable: a typo'd regex there
would silently change what gets gated. Edit `config.json` instead.
## Licence
MIT.
Executable
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* grok-glance CLI.
*
* Privileged operations (minting enrollment codes, revoking devices) are authenticated with
* a local admin token read from $GLANCE_HOME/admin.token, not by "is this request from
* localhost". That distinction matters: `tailscale serve` proxies remote traffic to
* 127.0.0.1, so the daemon cannot tell a local caller from a tunnelled one by address alone.
*/
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import {
PLUGIN_ROOT,
baseUrl,
glanceHome,
isDaemonUp,
readConfig,
sleep,
} from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
const cfg = readConfig();
const cmd = process.argv[2] ?? "status";
const args = process.argv.slice(3);
function adminToken() {
try {
return fs.readFileSync(path.join(glanceHome(), "admin.token"), "utf8").trim();
} catch {
return null;
}
}
async function api(pathname, { method = "GET", body, admin = false } = {}) {
const headers = { "content-type": "application/json" };
if (admin) {
const token = adminToken();
if (!token) {
throw new Error("no admin token found - is the daemon running? try `glance up`");
}
headers["x-glance-admin"] = token;
}
const res = await fetch(`${baseUrl(cfg)}${pathname}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error(data?.error ?? `${res.status} ${res.statusText}`);
return data;
}
function requireBuild() {
if (!fs.existsSync(SERVER_ENTRY)) {
console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`);
process.exit(1);
}
}
async function ensureUp() {
if (await isDaemonUp(cfg)) return true;
requireBuild();
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
});
child.unref();
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`);
return false;
}
switch (cmd) {
case "serve":
case "start": {
requireBuild();
await import(SERVER_ENTRY);
break;
}
case "up": {
if (await ensureUp()) console.log(`grok-glance running on ${baseUrl(cfg)}`);
else process.exit(1);
break;
}
case "stop": {
if (!(await isDaemonUp(cfg))) {
console.log("not running");
break;
}
await api("/local/shutdown", { method: "POST", admin: true });
console.log("stopped");
break;
}
case "status": {
if (!(await isDaemonUp(cfg))) {
console.log(`grok-glance: not running (port ${cfg.port})`);
console.log("start it with: glance up");
break;
}
const s = await api("/local/status", { admin: true });
console.log(`grok-glance ${s.version} on ${baseUrl(cfg)}`);
console.log(` public origin : ${s.origin ?? "(not configured - see README)"}`);
console.log(` rp id : ${s.rpId ?? "(not configured)"}`);
console.log(` devices : ${s.devices}`);
console.log(` approval mode : ${s.approval.mode}`);
console.log(` watchers : ${s.watchers}`);
console.log(` sessions : ${s.sessions}`);
console.log(` events kept : ${s.events}`);
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
break;
}
case "enroll": {
if (!(await ensureUp())) process.exit(1);
const out = await api("/local/enroll", { method: "POST", admin: true });
console.log("\n Open this on your phone:\n");
console.log(` ${out.url}\n`);
console.log(` Enrollment code: ${out.code}`);
console.log(` Valid for: ${Math.round(out.expiresInMs / 60000)} minutes (single use)\n`);
if (!out.originConfigured) {
console.log(" Note: no public origin configured yet, so the URL above is localhost.");
console.log(" Set one up first (see README), e.g.:\n");
console.log(" tailscale serve --bg 127.0.0.1:" + cfg.port);
console.log(" glance set-origin https://<your-box>.<tailnet>.ts.net\n");
}
break;
}
case "set-origin": {
const origin = args[0];
if (!origin) {
console.error("usage: glance set-origin https://your-box.tailnet.ts.net");
process.exit(1);
}
if (!(await ensureUp())) process.exit(1);
const out = await api("/local/origin", { method: "POST", admin: true, body: { origin } });
console.log(`origin : ${out.origin}`);
console.log(`rp id : ${out.rpId}`);
console.log("\nEnrolled devices are bound to the rp id. Changing it invalidates them.");
break;
}
case "devices": {
if (!(await isDaemonUp(cfg))) {
console.error("not running");
process.exit(1);
}
const out = await api("/local/devices", { admin: true });
if (!out.devices.length) {
console.log("no devices enrolled - run: glance enroll");
break;
}
for (const d of out.devices) {
console.log(`${d.id.slice(0, 16)}… ${d.label.padEnd(24)} added ${new Date(d.createdAt).toISOString().slice(0, 10)} last seen ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`);
}
break;
}
case "revoke": {
if (!args[0]) {
console.error("usage: glance revoke <device-id-prefix>");
process.exit(1);
}
const out = await api("/local/devices/revoke", {
method: "POST",
admin: true,
body: { idPrefix: args[0] },
});
console.log(`revoked ${out.revoked} device(s)`);
break;
}
case "approval": {
const mode = args[0];
if (!["off", "risky", "all"].includes(mode)) {
console.error("usage: glance approval <off|risky|all>");
process.exit(1);
}
const out = await api("/local/approval", { method: "POST", admin: true, body: { mode } });
console.log(`approval mode: ${out.mode}`);
break;
}
case "sync-hooks": {
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
let changed = 0;
for (const groups of Object.values(doc.hooks ?? {})) {
for (const group of groups) {
for (const h of group.hooks ?? []) {
if (h.type === "http" && typeof h.url === "string") {
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
if (next !== h.url) changed++;
h.url = next;
}
}
}
}
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
break;
}
case "logs": {
const file = path.join(glanceHome(), "daemon.log");
if (!fs.existsSync(file)) {
console.log("no log yet");
break;
}
process.stdout.write(fs.readFileSync(file, "utf8").split("\n").slice(-60).join("\n") + "\n");
break;
}
default:
console.log(`grok-glance - glance at Grok Build from your phone
glance up start the daemon in the background
glance serve run it in the foreground
glance stop stop it
glance status show what is running
glance enroll mint a one-time code to enrol a phone
glance set-origin <url> set the public https origin (and webauthn rp id)
glance devices list enrolled devices
glance revoke <id-prefix> revoke a device
glance approval <off|risky|all> remote approval policy
glance sync-hooks rewrite hook urls after a port change
glance logs tail the daemon log
`);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
/**
* PreToolUse hook: the remote approval gate.
*
* Asks the daemon what to do with this tool call. The daemon holds the request open while
* your phone decides, then answers allow/deny. Every failure path here is fail-open —
* a daemon that is down, slow, or confused must not be able to block your agent.
*
* Denying is the only outcome that changes behaviour, and approving only lets through a
* call Grok was already about to make. This hook can never introduce a new command.
*/
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
function allow() {
process.exit(0);
}
function deny(reason) {
// Belt and braces: the documented deny signals are a JSON decision on stdout *and*
// exit code 2. We emit both so a change in precedence cannot silently allow.
process.stdout.write(
JSON.stringify({ decision: "deny", reason: reason || "Denied from grok-glance" }),
);
process.exit(2);
}
const payload = envEnvelope(await readStdinJson());
const cfg = readConfig();
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
try {
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
if (data && data.decision === "deny") deny(data.reason);
allow();
} catch {
allow();
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Shared helpers for the grok-glance hook scripts and CLI.
*
* Deliberately dependency-free and stdlib-only: these run on the critical path of
* every Grok Build tool call, so they must start fast and never wedge a session.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
export const DEFAULT_PORT = 8791;
/**
* State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the
* CLI (which does not) always agree on where config, credentials and events are.
*/
export function glanceHome() {
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
return path.join(os.homedir(), ".grok", "glance");
}
export function readConfig() {
const file = path.join(glanceHome(), "config.json");
let raw = {};
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
// No config yet, or unreadable: defaults are always usable.
}
const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT);
return {
...raw,
port: Number.isFinite(port) ? port : DEFAULT_PORT,
host: raw.host ?? "127.0.0.1",
};
}
export function baseUrl(cfg = readConfig()) {
return `http://127.0.0.1:${cfg.port}`;
}
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
export async function readStdinJson() {
if (process.stdin.isTTY) return {};
const chunks = [];
try {
for await (const chunk of process.stdin) chunks.push(chunk);
} catch {
return {};
}
const text = Buffer.concat(chunks).toString("utf8").trim();
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return {};
}
}
/**
* Grok Build also passes the event in the environment. We merge it in so a payload that
* is missing fields (or absent entirely) still produces a usable event.
*/
export function envEnvelope(payload) {
return {
hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown",
sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown",
workspaceRoot:
payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(),
cwd: payload.cwd ?? process.cwd(),
hookName: process.env.GROK_HOOK_NAME ?? undefined,
...payload,
};
}
export async function postJson(url, body, timeoutMs) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
if (!text) return { status: res.status, data: null };
try {
return { status: res.status, data: JSON.parse(text) };
} catch {
return { status: res.status, data: null };
}
}
export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
try {
const res = await fetch(`${baseUrl(cfg)}/healthz`, {
signal: AbortSignal.timeout(timeoutMs),
});
return res.ok;
} catch {
return false;
}
}
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* SessionStart hook: make sure the glance daemon is running, then record the event.
*
* This is the only hook that spawns anything. It always exits 0 — a monitoring
* dashboard must never be the reason a Grok Build session fails to start.
*/
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import {
PLUGIN_ROOT,
baseUrl,
envEnvelope,
glanceHome,
isDaemonUp,
postJson,
readConfig,
readStdinJson,
sleep,
} from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
async function ensureDaemon(cfg) {
if (await isDaemonUp(cfg)) return true;
if (!fs.existsSync(SERVER_ENTRY)) {
// Not built yet. Say so once, on stderr, where it is recorded but harmless.
process.stderr.write(
`[grok-glance] not built yet - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`,
);
return false;
}
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, GLANCE_STARTED_BY: "hook" },
});
child.unref();
// Give it a moment to bind before the first http hook fires.
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
return false;
}
const payload = envEnvelope(await readStdinJson());
const cfg = readConfig();
try {
const up = await ensureDaemon(cfg);
if (up) {
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
}
} catch {
// Fail open, always.
}
process.exit(0);
+27
View File
@@ -0,0 +1,27 @@
---
description: Set up or control the grok-glance phone dashboard (status, enrol a device, approval policy)
argument-hint: [status | up | stop | enroll | set-origin <url> | devices | revoke <id> | approval off|risky|all | logs]
---
Run the grok-glance CLI and report the result plainly.
The CLI lives at `$GROK_PLUGIN_ROOT/bin/glance`. The user asked for: **$ARGUMENTS**
If no argument was given, treat it as `status`.
Then:
1. Run `node "$GROK_PLUGIN_ROOT/bin/glance" $ARGUMENTS`.
2. If it says the plugin is not built, run `npm install && npm run build` in `$GROK_PLUGIN_ROOT`
(this takes a minute or two) and try again.
3. Report what came back. For `enroll`, show the URL and the code verbatim — the user needs to
type them on their phone, so do not paraphrase or reformat them.
4. If the output mentions that no public origin is configured, explain the Tailscale Serve setup:
`tailscale serve --bg 127.0.0.1:8791`, then
`node "$GROK_PLUGIN_ROOT/bin/glance" set-origin https://<box>.<tailnet>.ts.net`.
For anything beyond running the command — troubleshooting a failed passkey prompt, explaining the
approval modes, or a first-time setup — read the `glance` skill and follow it.
Do not change the approval policy unless that is what was asked. Warn before running `set-origin`
on a setup that already has devices enrolled: the RP ID changes and existing passkeys stop working.
+107
View File
@@ -0,0 +1,107 @@
{
"_comment": [
"grok-glance hook wiring.",
"Passive events use type=http: they POST straight into the daemon with no process spawn,",
"so they cost ~nothing per tool call and fail open if the daemon is not running.",
"SessionStart uses type=command because it is what boots the daemon.",
"PreToolUse is wired twice on purpose: an http entry records every tool call for the",
"timeline, and a command entry gates only risky tools, because only a command hook has a",
"documented way to return a deny decision.",
"If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite",
"the URLs below, or edit them by hand."
],
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
"timeout": 20
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "http",
"url": "http://127.0.0.1:8791/hook/record",
"timeout": 3
}
]
},
{
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
"timeout": 125
}
]
}
],
"PostToolUse": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"PostToolUseFailure": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"UserPromptSubmit": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"PermissionDenied": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"Notification": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"Stop": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"StopFailure": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"SubagentStart": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"SubagentStop": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"PreCompact": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"PostCompact": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
],
"SessionEnd": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
}
]
}
}
+2283
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "grok-glance",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Glance at what Grok Build is doing, from your phone, behind a passkey.",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npm run build:server && npm run build:web",
"build:server": "tsc -p tsconfig.server.json",
"build:web": "tsc -p tsconfig.web.json && vite build",
"dev": "vite",
"start": "node dist/server/index.js",
"glance": "node bin/glance"
},
"dependencies": {
"@heroui/react": "3.2.4",
"@heroui/styles": "3.2.4",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.1",
"@types/node": "22.12.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "6.0.2",
"tailwind-variants": "3.3.0",
"tailwindcss": "4.3.1",
"typescript": "5.6.3",
"vite": "8.0.16"
}
}
+143
View File
@@ -0,0 +1,143 @@
import crypto from "node:crypto";
import type { Config } from "./config.js";
import type { GlanceState, HookPayload } from "./state.js";
import { summarizeTool } from "./summarize.js";
import type { PendingApproval } from "./protocol.js";
export interface Decision {
decision: "allow" | "deny";
reason?: string;
}
interface Waiter {
approval: PendingApproval;
settle: (decision: Decision) => void;
timer: NodeJS.Timeout;
}
/**
* Holds a PreToolUse hook open while your phone decides.
*
* Every path that is not an explicit tap is designed to get out of the way: approval off,
* tool not risky, nobody watching, or the request timing out all resolve immediately so a
* dashboard can never become the reason your agent stalls.
*/
export class ApprovalBroker {
private readonly waiters = new Map<string, Waiter>();
constructor(
private readonly cfg: Config,
private readonly state: GlanceState,
/** Is at least one browser currently streaming events? */
private readonly hasWatcher: () => boolean,
) {}
private gates(toolName: string): boolean {
const { mode, riskyPattern } = this.cfg.approval;
if (mode === "off") return false;
if (mode === "all") return true;
try {
return new RegExp(riskyPattern).test(toolName);
} catch {
// A bad pattern should not silently gate everything.
return false;
}
}
pending(): PendingApproval[] {
return [...this.waiters.values()]
.map((w) => w.approval)
.sort((a, b) => a.createdAt - b.createdAt);
}
async request(payload: HookPayload): Promise<Decision> {
const tool = typeof payload.toolName === "string" ? payload.toolName : "tool";
if (!this.gates(tool)) return { decision: "allow" };
if (this.cfg.approval.requireWatcher && !this.hasWatcher()) {
return { decision: "allow" };
}
const sessionId = payload.sessionId ?? "unknown";
const summary = summarizeTool(tool, payload.toolInput);
const now = Date.now();
const approval: PendingApproval = {
id: crypto.randomBytes(9).toString("base64url"),
sessionId,
sessionLabel: this.state.sessionLabel(sessionId),
tool,
title: summary.title,
detail: summary.detail,
createdAt: now,
expiresAt: now + this.cfg.approval.timeoutMs,
};
this.state.record(sessionId, "approval_request", `Waiting on you: ${tool}`, {
tool,
detail: summary.title,
});
return new Promise<Decision>((resolve) => {
const timer = setTimeout(() => {
this.waiters.delete(approval.id);
const onTimeout = this.cfg.approval.onTimeout;
this.state.record(
sessionId,
"approval_expired",
onTimeout === "deny"
? `No answer in time - denied ${tool}`
: `No answer in time - allowed ${tool}`,
{ tool, detail: summary.title },
);
resolve(
onTimeout === "deny"
? { decision: "deny", reason: "grok-glance: no answer from your device in time" }
: { decision: "allow" },
);
}, this.cfg.approval.timeoutMs);
// Do not hold the process open just for a pending approval.
timer.unref?.();
this.waiters.set(approval.id, {
approval,
timer,
settle: (decision) => resolve(decision),
});
});
}
/** Called by the API when you tap Approve or Deny. */
resolve(id: string, decision: "allow" | "deny", by: string): boolean {
const waiter = this.waiters.get(id);
if (!waiter) return false;
clearTimeout(waiter.timer);
this.waiters.delete(id);
const { approval } = waiter;
this.state.record(
approval.sessionId,
decision === "allow" ? "approval_allowed" : "approval_denied",
decision === "allow"
? `Approved ${approval.tool} from ${by}`
: `Denied ${approval.tool} from ${by}`,
{ tool: approval.tool, detail: approval.title },
);
waiter.settle(
decision === "allow"
? { decision: "allow" }
: { decision: "deny", reason: `Denied from grok-glance (${by})` },
);
return true;
}
/** Resolve everything as allow — used on shutdown so no hook is left hanging. */
drain(): void {
for (const [id, waiter] of this.waiters) {
clearTimeout(waiter.timer);
this.waiters.delete(id);
waiter.settle({ decision: "allow" });
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import crypto from "node:crypto";
import type { IncomingMessage } from "node:http";
/* ------------------------------------------------------------------- cookies */
export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf";
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx < 0) continue;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (key) out[key] = decodeURIComponent(value);
}
return out;
}
/** token.hmac(token) — lets us reject forged cookies without touching disk. */
export function signToken(token: string, secret: Buffer): string {
const mac = crypto.createHmac("sha256", secret).update(token).digest("base64url");
return `${token}.${mac}`;
}
export function unsignToken(signed: string | undefined, secret: Buffer): string | null {
if (!signed) return null;
const idx = signed.lastIndexOf(".");
if (idx <= 0) return null;
const token = signed.slice(0, idx);
const mac = signed.slice(idx + 1);
const expected = crypto.createHmac("sha256", secret).update(token).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
return token;
}
/**
* The daemon always listens on plain http (a tunnel terminates TLS), so whether the cookie
* may carry the Secure flag depends on how the *browser* reached us. Setting Secure on a
* genuinely-http localhost connection would make the browser throw the cookie away.
*/
export function requestIsHttps(req: IncomingMessage): boolean {
const proto = header(req, "x-forwarded-proto");
if (proto) return proto.split(",")[0].trim() === "https";
return false;
}
export function buildSessionCookie(
value: string,
opts: { secure: boolean; maxAgeSec: number },
): string {
const parts = [
`${SESSION_COOKIE}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${opts.maxAgeSec}`,
];
if (opts.secure) parts.push("Secure");
return parts.join("; ");
}
export function clearSessionCookie(secure: boolean): string {
const parts = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"];
if (secure) parts.push("Secure");
return parts.join("; ");
}
export function header(req: IncomingMessage, name: string): string | undefined {
const value = req.headers[name];
if (Array.isArray(value)) return value[0];
return value;
}
/* -------------------------------------------------------------- rate limiting */
/**
* Fixed-window counter. Keyed globally rather than per-IP on purpose: behind a tunnel every
* request arrives from 127.0.0.1, so per-IP buckets would be a single bucket wearing a hat.
*/
export class RateLimiter {
private hits = new Map<string, { count: number; resetAt: number }>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
) {}
/** Returns true when the caller is still within budget. */
allow(key: string): boolean {
const now = Date.now();
const entry = this.hits.get(key);
if (!entry || entry.resetAt <= now) {
this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
return true;
}
entry.count += 1;
return entry.count <= this.limit;
}
reset(key: string): void {
this.hits.delete(key);
}
}
/* ---------------------------------------------------------- enrolment codes */
// No 0/O/1/I/L — these get read off a terminal and typed on a phone.
const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const CODE_LENGTH = 8;
const CODE_TTL_MS = 10 * 60_000;
const MAX_CODE_ATTEMPTS = 5;
interface EnrollmentCode {
code: string;
expiresAt: number;
attempts: number;
}
export class EnrollmentCodes {
private current: EnrollmentCode | null = null;
mint(): { code: string; expiresInMs: number } {
const bytes = crypto.randomBytes(CODE_LENGTH);
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
this.current = { code, expiresAt: Date.now() + CODE_TTL_MS, attempts: 0 };
return { code, expiresInMs: CODE_TTL_MS };
}
/** Constant-time compare. Counts the attempt, and burns the code after too many misses. */
check(candidate: string): boolean {
const entry = this.current;
if (!entry) return false;
if (entry.expiresAt <= Date.now()) {
this.current = null;
return false;
}
entry.attempts += 1;
if (entry.attempts > MAX_CODE_ATTEMPTS) {
this.current = null;
return false;
}
const a = Buffer.from(normalize(candidate));
const b = Buffer.from(entry.code);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/**
* Single use. Registration checks the code twice — once to hand out options, once to accept
* the attestation — so only the second call consumes it, otherwise a failed prompt on the
* phone would force you back to the terminal for a fresh code.
*/
consume(candidate: string): boolean {
if (!this.check(candidate)) return false;
this.current = null;
return true;
}
get active(): boolean {
return !!this.current && this.current.expiresAt > Date.now();
}
}
function normalize(code: string): string {
return code.trim().toUpperCase().replace(/[\s-]/g, "");
}
+142
View File
@@ -0,0 +1,142 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ApprovalSettings } from "./protocol.js";
export const VERSION = "0.1.0";
export const DEFAULT_PORT = 8791;
export interface Config {
port: number;
host: string;
/** Public https origin the phone will use, e.g. https://box.tailnet-1234.ts.net */
origin?: string;
/** WebAuthn Relying Party ID. Derived from `origin` unless set explicitly. */
rpId?: string;
rpName: string;
approval: ApprovalSettings;
/** How many events to keep in memory and hand to the UI. */
retainEvents: number;
}
export function glanceHome(): string {
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
return path.join(os.homedir(), ".grok", "glance");
}
export const paths = {
get home() {
return glanceHome();
},
get config() {
return path.join(glanceHome(), "config.json");
},
get credentials() {
return path.join(glanceHome(), "credentials.json");
},
get authSessions() {
return path.join(glanceHome(), "auth-sessions.json");
},
get secret() {
return path.join(glanceHome(), "secret.key");
},
get adminToken() {
return path.join(glanceHome(), "admin.token");
},
get events() {
return path.join(glanceHome(), "events.jsonl");
},
};
const DEFAULTS: Config = {
port: DEFAULT_PORT,
host: "127.0.0.1",
rpName: "grok-glance",
approval: {
// Off by default: installing a dashboard should not silently start gating your tools.
// Flip it on from the phone, or with `glance approval risky`.
mode: "off",
riskyPattern: "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
timeoutMs: 90_000,
// If nobody is actually watching, allow immediately rather than stalling the agent.
requireWatcher: true,
// Grok's own hook contract is fail-open on timeout, so match it by default.
onTimeout: "allow",
},
retainEvents: 400,
};
export function ensureHome(): void {
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
// Tighten it even if the directory already existed with looser bits.
try {
fs.chmodSync(glanceHome(), 0o700);
} catch {
/* best effort */
}
}
export function loadConfig(): Config {
ensureHome();
let stored: Partial<Config> = {};
try {
stored = JSON.parse(fs.readFileSync(paths.config, "utf8")) as Partial<Config>;
} catch {
/* first run */
}
const merged: Config = {
...DEFAULTS,
...stored,
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
};
if (process.env.GLANCE_PORT) {
const p = Number(process.env.GLANCE_PORT);
if (Number.isFinite(p)) merged.port = p;
}
if (process.env.GLANCE_ORIGIN) merged.origin = process.env.GLANCE_ORIGIN;
merged.rpId = merged.rpId ?? deriveRpId(merged.origin);
return merged;
}
export function saveConfig(cfg: Config): void {
ensureHome();
fs.writeFileSync(paths.config, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
}
/**
* The RP ID is the origin's hostname. Note that a bare IP address is not a valid RP ID,
* so a LAN address like 192.168.1.20 can never work — that is a WebAuthn rule, not ours.
*/
export function deriveRpId(origin?: string): string | undefined {
if (!origin) return undefined;
try {
const url = new URL(origin);
const host = url.hostname;
if (isIpAddress(host)) return undefined;
return host;
} catch {
return undefined;
}
}
export function isIpAddress(host: string): boolean {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
if (host.includes(":")) return true; // IPv6
return false;
}
/**
* Origins we will accept assertions from. The configured public origin, plus localhost so
* that you can enrol and test on the machine itself before setting up a tunnel.
*/
export function expectedOrigins(cfg: Config): string[] {
const list = [`http://localhost:${cfg.port}`, `http://127.0.0.1:${cfg.port}`];
if (cfg.origin) list.unshift(cfg.origin.replace(/\/$/, ""));
return list;
}
export function expectedRpIds(cfg: Config): string[] {
const ids = new Set<string>(["localhost"]);
if (cfg.rpId) ids.add(cfg.rpId);
return [...ids];
}
+84
View File
@@ -0,0 +1,84 @@
import type { IncomingMessage, ServerResponse } from "node:http";
const MAX_BODY_BYTES = 256 * 1024;
export interface Res {
json(status: number, body: unknown, headers?: Record<string, string>): void;
text(status: number, body: string, headers?: Record<string, string>): void;
empty(status: number, headers?: Record<string, string>): void;
}
/** Headers applied to every response. The UI is entirely self-hosted, so the CSP can be strict. */
export const SECURITY_HEADERS: Record<string, string> = {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"x-frame-options": "DENY",
"cross-origin-opener-policy": "same-origin",
"content-security-policy": [
"default-src 'self'",
"script-src 'self'",
// HeroUI/Tailwind inject style attributes and inline blocks at runtime.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'none'",
].join("; "),
};
export function responder(res: ServerResponse): Res {
const send = (status: number, body: string | null, headers: Record<string, string> = {}) => {
if (res.headersSent) return;
res.writeHead(status, { ...SECURITY_HEADERS, ...headers });
res.end(body ?? undefined);
};
return {
json(status, body, headers = {}) {
send(status, JSON.stringify(body), {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
...headers,
});
},
text(status, body, headers = {}) {
send(status, body, {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
...headers,
});
},
empty(status, headers = {}) {
send(status, null, headers);
},
};
}
export async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error("body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
export async function readJson<T = unknown>(req: IncomingMessage): Promise<T | null> {
const raw = await readBody(req);
if (!raw.trim()) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
+534
View File
@@ -0,0 +1,534 @@
/**
* grok-glance daemon.
*
* One small http server with three kinds of caller:
*
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
* /api/* the web app, gated by a passkey-backed cookie session.
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
*
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
* gone, Grok Build keeps working.
*/
import http from "node:http";
import crypto from "node:crypto";
import { URL } from "node:url";
import {
DEFAULT_PORT,
VERSION,
deriveRpId,
ensureHome,
expectedOrigins,
isIpAddress,
loadConfig,
paths,
saveConfig,
} from "./config.js";
import {
CSRF_HEADER,
EnrollmentCodes,
RateLimiter,
SESSION_COOKIE,
buildSessionCookie,
clearSessionCookie,
header,
parseCookies,
requestIsHttps,
signToken,
unsignToken,
} from "./auth.js";
import { readJson, responder } from "./http.js";
import { serveStatic, webBuildExists } from "./static.js";
import { SseHub } from "./sse.js";
import { GlanceState, type HookPayload } from "./state.js";
import { ApprovalBroker } from "./approvals.js";
import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
import {
destroyAuthSession,
deviceList,
lookupAuthSession,
revokeCredentials,
rotateAdminToken,
sessionSecret,
} from "./store.js";
import type { ApprovalMode, GateInfo } from "./protocol.js";
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
ensureHome();
const cfg = loadConfig();
const secret = sessionSecret();
const adminToken = rotateAdminToken();
const webauthn = new WebAuthnService(cfg);
const codes = new EnrollmentCodes();
// Generous enough for a fumbled passkey prompt, tight enough that the code is not brute-forceable.
const authLimiter = new RateLimiter(40, 5 * 60_000);
const enrollLimiter = new RateLimiter(12, 5 * 60_000);
const state = new GlanceState(cfg);
let hub: SseHub | null = null;
const broker = new ApprovalBroker(cfg, state, () => hub?.hasWatcher() ?? false);
const sse = new SseHub(() => state.snapshot(broker.pending()));
hub = sse;
state.onChange(() => sse.publish());
/* ------------------------------------------------------------------ request auth */
interface Session {
token: string;
credentialId: string;
label: string;
}
function currentSession(req: http.IncomingMessage): Session | null {
const cookies = parseCookies(req.headers.cookie);
const token = unsignToken(cookies[SESSION_COOKIE], secret);
if (!token) return null;
const record = lookupAuthSession(token);
if (!record) return null;
return { token, credentialId: record.credentialId, label: record.label };
}
function isAdmin(req: http.IncomingMessage): boolean {
const provided = header(req, "x-glance-admin");
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(adminToken);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/**
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
* hostile page cannot post here without a preflight we never answer. The custom header on
* /api/* is a second, independent barrier on top of the SameSite=Strict cookie.
*/
function isJsonPost(req: http.IncomingMessage): boolean {
const ct = (header(req, "content-type") ?? "").split(";")[0].trim().toLowerCase();
return ct === "application/json";
}
function hasCsrfHeader(req: http.IncomingMessage): boolean {
return !!header(req, CSRF_HEADER);
}
/* ---------------------------------------------------------------------- routing */
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
console.error("[glance] unhandled", err);
try {
responder(res).json(500, { error: "internal error" });
} catch {
/* response already gone */
}
});
});
async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const out = responder(res);
const url = new URL(req.url ?? "/", `http://localhost:${cfg.port}`);
const p = url.pathname;
const method = req.method ?? "GET";
if (method === "OPTIONS") {
// No CORS. Cross-origin callers get nothing, which is the point.
out.empty(405, { allow: "GET, POST" });
return;
}
/* ---------------------------------------------------------------- health */
if (p === "/healthz") {
out.json(200, { ok: true, version: VERSION });
return;
}
/* ----------------------------------------------------------------- hooks */
if (p.startsWith("/hook/")) {
if (method !== "POST" || !isJsonPost(req)) {
out.json(405, { error: "post json" });
return;
}
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
if (p === "/hook/record") {
const event = state.ingest(payload);
out.json(200, { ok: true, id: event?.id ?? null });
return;
}
if (p === "/hook/approve") {
// Note: this deliberately does not ingest an event. The PreToolUse http hook already
// recorded the tool call; recording it here too would double every entry.
const decision = await broker.request(payload);
out.json(200, decision);
return;
}
out.json(404, { error: "unknown hook" });
return;
}
/* -------------------------------------------------------- local admin API */
if (p.startsWith("/local/")) {
if (!isAdmin(req)) {
out.json(403, { error: "admin token required" });
return;
}
if (p === "/local/status" && method === "GET") {
out.json(200, {
version: VERSION,
origin: cfg.origin,
rpId: cfg.rpId,
devices: deviceList().length,
approval: cfg.approval,
watchers: sse.count,
sessions: state.sessionCount,
events: state.eventCount,
webBuilt: webBuildExists(),
home: paths.home,
});
return;
}
if (p === "/local/enroll" && method === "POST") {
const minted = codes.mint();
const base = cfg.origin?.replace(/\/$/, "") ?? `http://localhost:${cfg.port}`;
out.json(200, {
code: minted.code,
expiresInMs: minted.expiresInMs,
url: `${base}/?enroll=1`,
originConfigured: !!cfg.origin,
});
return;
}
if (p === "/local/origin" && method === "POST") {
const body = await readJson<{ origin?: string }>(req);
const raw = (body?.origin ?? "").trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
out.json(400, { error: "not a url" });
return;
}
if (isIpAddress(parsed.hostname)) {
out.json(400, {
error:
"a bare IP address cannot be a WebAuthn relying party id - use a hostname with TLS",
});
return;
}
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
out.json(400, { error: "passkeys need https (or localhost for local testing)" });
return;
}
cfg.origin = `${parsed.protocol}//${parsed.host}`;
cfg.rpId = deriveRpId(cfg.origin);
saveConfig(cfg);
out.json(200, { origin: cfg.origin, rpId: cfg.rpId });
return;
}
if (p === "/local/devices" && method === "GET") {
out.json(200, { devices: deviceList() });
return;
}
if (p === "/local/devices/revoke" && method === "POST") {
const body = await readJson<{ idPrefix?: string }>(req);
const prefix = (body?.idPrefix ?? "").trim();
if (prefix.length < 4) {
out.json(400, { error: "give at least 4 characters of the device id" });
return;
}
out.json(200, { revoked: revokeCredentials(prefix) });
sse.publish();
return;
}
if (p === "/local/approval" && method === "POST") {
const body = await readJson<{ mode?: ApprovalMode }>(req);
if (!applyApprovalMode(body?.mode)) {
out.json(400, { error: "mode must be off, risky or all" });
return;
}
out.json(200, cfg.approval);
return;
}
if (p === "/local/shutdown" && method === "POST") {
out.json(200, { ok: true });
shutdown("cli");
return;
}
out.json(404, { error: "unknown local endpoint" });
return;
}
/* -------------------------------------------------------------- web API */
if (p.startsWith("/api/")) {
if (method === "POST" && (!isJsonPost(req) || !hasCsrfHeader(req))) {
out.json(400, { error: "bad request" });
return;
}
const session = currentSession(req);
const secure = requestIsHttps(req);
if (p === "/api/gate" && method === "GET") {
const gate: GateInfo = {
authenticated: !!session,
enrolled: webauthn.enrolled,
enrollmentOpen: codes.active,
version: VERSION,
deviceLabel: session?.label,
rpId: cfg.rpId,
};
out.json(200, gate);
return;
}
/* --- enrolment: a one-time code from the terminal, or an already-trusted device --- */
if (p === "/api/auth/register/options" && method === "POST") {
if (!enrollLimiter.allow("enroll")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{ code?: string }>(req);
if (!session && !codes.check(body?.code ?? "")) {
out.json(403, { error: "that enrolment code is not valid" });
return;
}
out.json(200, await webauthn.registrationOptions());
return;
}
if (p === "/api/auth/register/verify" && method === "POST") {
if (!enrollLimiter.allow("enroll")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{
code?: string;
label?: string;
response?: RegistrationResponseJSON;
}>(req);
if (!body?.response) {
out.json(400, { error: "missing response" });
return;
}
if (!session && !codes.consume(body.code ?? "")) {
out.json(403, { error: "that enrolment code is not valid" });
return;
}
const label = body.label?.trim() || "phone";
const result = await webauthn.verifyRegistration(body.response, label);
if (!result.ok || !result.token) {
out.json(400, { error: result.error ?? "registration failed" });
return;
}
enrollLimiter.reset("enroll");
out.json(
200,
{ ok: true, label },
{
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
secure,
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
}),
},
);
console.log(`[glance] enrolled device "${label}"`);
return;
}
/* --------------------------------- sign in ---------------------------------- */
if (p === "/api/auth/login/options" && method === "POST") {
if (!authLimiter.allow("login")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
if (!webauthn.enrolled) {
out.json(409, { error: "no device enrolled yet - run `glance enroll`" });
return;
}
out.json(200, await webauthn.authenticationOptions());
return;
}
if (p === "/api/auth/login/verify" && method === "POST") {
if (!authLimiter.allow("login")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{ response?: AuthenticationResponseJSON }>(req);
if (!body?.response) {
out.json(400, { error: "missing response" });
return;
}
const result = await webauthn.verifyAuthentication(body.response);
if (!result.ok || !result.token) {
out.json(403, { error: result.error ?? "sign in failed" });
return;
}
authLimiter.reset("login");
out.json(
200,
{ ok: true, label: result.label },
{
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
secure,
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
}),
},
);
return;
}
if (p === "/api/auth/logout" && method === "POST") {
if (session) destroyAuthSession(session.token);
out.json(200, { ok: true }, { "set-cookie": clearSessionCookie(secure) });
return;
}
/* ------------------------- everything below needs a passkey ------------------ */
if (!session) {
out.json(401, { error: "not signed in" });
return;
}
if (p === "/api/snapshot" && method === "GET") {
out.json(200, state.snapshot(broker.pending()));
return;
}
if (p === "/api/approvals/resolve" && method === "POST") {
const body = await readJson<{ id?: string; decision?: "allow" | "deny" }>(req);
const id = body?.id ?? "";
const decision = body?.decision;
if (!id || (decision !== "allow" && decision !== "deny")) {
out.json(400, { error: "need id and decision" });
return;
}
const settled = broker.resolve(id, decision, session.label);
// A miss is normal: the prompt may have timed out, or another device answered first.
out.json(settled ? 200 : 409, settled ? { ok: true } : { error: "no longer pending" });
sse.publish();
return;
}
if (p === "/api/approval" && method === "POST") {
const body = await readJson<{
mode?: ApprovalMode;
requireWatcher?: boolean;
onTimeout?: "allow" | "deny";
}>(req);
if (body?.mode !== undefined && !applyApprovalMode(body.mode)) {
out.json(400, { error: "mode must be off, risky or all" });
return;
}
if (typeof body?.requireWatcher === "boolean") {
cfg.approval.requireWatcher = body.requireWatcher;
}
if (body?.onTimeout === "allow" || body?.onTimeout === "deny") {
cfg.approval.onTimeout = body.onTimeout;
}
saveConfig(cfg);
out.json(200, cfg.approval);
sse.publish();
return;
}
if (p === "/api/devices" && method === "GET") {
out.json(200, { devices: deviceList(), current: session.credentialId });
return;
}
out.json(404, { error: "unknown endpoint" });
return;
}
/* ---------------------------------------------------------------- SSE stream */
if (p === "/events") {
if (method !== "GET") {
out.json(405, { error: "get only" });
return;
}
if (!currentSession(req)) {
out.json(401, { error: "not signed in" });
return;
}
sse.add(res);
return;
}
/* ------------------------------------------------------------- static files */
if (method !== "GET") {
out.json(405, { error: "get only" });
return;
}
serveStatic(p, res);
}
/** Shared by the CLI and the web UI so both paths validate the same way. */
function applyApprovalMode(mode: unknown): boolean {
if (mode !== "off" && mode !== "risky" && mode !== "all") return false;
cfg.approval.mode = mode;
saveConfig(cfg);
sse.publish();
return true;
}
/* --------------------------------------------------------------------- lifecycle */
let shuttingDown = false;
function shutdown(why: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[glance] shutting down (${why})`);
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
broker.drain();
sse.closeAll();
server.close(() => process.exit(0));
// Don't let a lingering keep-alive socket hold the process forever.
setTimeout(() => process.exit(0), 1500).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("uncaughtException", (err) => {
console.error("[glance] uncaught", err);
});
process.on("unhandledRejection", (err) => {
console.error("[glance] unhandled rejection", err);
});
server.listen(cfg.port, cfg.host, () => {
console.log(`[glance] ${VERSION} listening on http://${cfg.host}:${cfg.port}`);
console.log(`[glance] state: ${paths.home}`);
console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`);
console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`);
if (!webBuildExists()) console.log("[glance] web app not built yet: npm install && npm run build");
if (cfg.port !== DEFAULT_PORT) console.log(`[glance] note: non-default port, run \`glance sync-hooks\``);
});
server.on("error", (err) => {
console.error(`[glance] listen failed: ${(err as Error).message}`);
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Wire protocol shared between the daemon and the web app.
*
* NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are
* duplicated rather than shared because the server compiles under NodeNext while the web
* app compiles under a bundler resolution, and a single rootDir cannot span both.
*/
export type EventKind =
| "session_start"
| "session_end"
| "prompt"
| "tool_start"
| "tool_end"
| "tool_fail"
| "permission_denied"
| "turn_end"
| "turn_error"
| "notification"
| "subagent_start"
| "subagent_end"
| "compact"
| "approval_request"
| "approval_allowed"
| "approval_denied"
| "approval_expired";
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
export interface GlanceEvent {
id: number;
ts: number;
sessionId: string;
kind: EventKind;
/** Tool name, for tool-shaped events. */
tool?: string;
/** One-line human summary, already truncated and redacted. */
title: string;
/** Optional second line, e.g. a file path or an error message. */
detail?: string;
durationMs?: number;
}
export interface SessionView {
id: string;
/** Basename of the workspace root — what you actually recognise on a phone. */
label: string;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
currentTool?: { name: string; title: string; startedAt: number };
counts: { tools: number; failures: number; denials: number };
}
export interface PendingApproval {
id: string;
sessionId: string;
sessionLabel: string;
tool: string;
title: string;
detail?: string;
createdAt: number;
expiresAt: number;
}
export type ApprovalMode = "off" | "risky" | "all";
export interface ApprovalSettings {
mode: ApprovalMode;
riskyPattern: string;
timeoutMs: number;
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
requireWatcher: boolean;
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
onTimeout: "allow" | "deny";
}
export interface Snapshot {
now: number;
version: string;
sessions: SessionView[];
events: GlanceEvent[];
pending: PendingApproval[];
approval: ApprovalSettings;
}
export interface DeviceInfo {
id: string;
label: string;
createdAt: number;
lastUsedAt?: number;
}
/** Everything the app needs before it knows whether you are signed in. */
export interface GateInfo {
authenticated: boolean;
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
enrolled: boolean;
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
enrollmentOpen: boolean;
version: string;
deviceLabel?: string;
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
rpId?: string;
}
+120
View File
@@ -0,0 +1,120 @@
import type { ServerResponse } from "node:http";
import { SECURITY_HEADERS } from "./http.js";
import type { Snapshot } from "./protocol.js";
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
const THROTTLE_MS = 250;
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
const HEARTBEAT_MS = 25_000;
interface Client {
id: number;
res: ServerResponse;
}
export class SseHub {
private clients = new Map<number, Client>();
private nextId = 1;
private pending = false;
private lastSentAt = 0;
private timer: NodeJS.Timeout | null = null;
private heartbeat: NodeJS.Timeout | null = null;
constructor(private readonly snapshot: () => Snapshot) {}
/** True when at least one browser is listening — the approval broker asks before gating. */
hasWatcher(): boolean {
return this.clients.size > 0;
}
get count(): number {
return this.clients.size;
}
add(res: ServerResponse): void {
res.writeHead(200, {
...SECURITY_HEADERS,
"content-type": "text/event-stream",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
// Belt and braces for any buffering proxy in front of us.
"x-accel-buffering": "no",
});
res.write(": connected\n\n");
const client: Client = { id: this.nextId++, res };
this.clients.set(client.id, client);
const drop = () => {
this.clients.delete(client.id);
if (this.clients.size === 0) this.stopHeartbeat();
};
res.on("close", drop);
res.on("error", drop);
this.send(client, "snapshot", this.snapshot());
this.startHeartbeat();
}
private startHeartbeat(): void {
if (this.heartbeat) return;
this.heartbeat = setInterval(() => {
for (const client of this.clients.values()) {
try {
client.res.write(": ping\n\n");
} catch {
this.clients.delete(client.id);
}
}
}, HEARTBEAT_MS);
this.heartbeat.unref?.();
}
private stopHeartbeat(): void {
if (!this.heartbeat) return;
clearInterval(this.heartbeat);
this.heartbeat = null;
}
private send(client: Client, event: string, data: unknown): void {
try {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.clients.delete(client.id);
}
}
/**
* Push a fresh full snapshot, throttled. Sending the whole state rather than deltas keeps
* the client dumb: a phone that slept through twenty events still lands on the truth.
*/
publish(): void {
if (this.clients.size === 0) return;
if (this.pending) return;
const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt));
this.pending = true;
this.timer = setTimeout(() => {
this.pending = false;
this.lastSentAt = Date.now();
const snap = this.snapshot();
for (const client of [...this.clients.values()]) {
this.send(client, "snapshot", snap);
}
}, wait);
this.timer.unref?.();
}
closeAll(): void {
if (this.timer) clearTimeout(this.timer);
this.stopHeartbeat();
for (const client of this.clients.values()) {
try {
client.res.write("event: bye\ndata: {}\n\n");
client.res.end();
} catch {
/* going away anyway */
}
}
this.clients.clear();
}
}
+297
View File
@@ -0,0 +1,297 @@
import { VERSION, type Config } from "./config.js";
import { appendEventLog, readRecentEvents } from "./store.js";
import {
labelForWorkspace,
summarizeNotification,
summarizePrompt,
summarizeTool,
truncateDetail,
truncateTitle,
} from "./summarize.js";
import type {
EventKind,
GlanceEvent,
PendingApproval,
SessionState,
SessionView,
Snapshot,
} from "./protocol.js";
/** A session that has said nothing for this long is treated as idle, not working. */
const STALE_WORKING_MS = 10 * 60_000;
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
SessionStart: "session_start",
SessionEnd: "session_end",
UserPromptSubmit: "prompt",
PreToolUse: "tool_start",
PostToolUse: "tool_end",
PostToolUseFailure: "tool_fail",
PermissionDenied: "permission_denied",
Stop: "turn_end",
StopFailure: "turn_error",
Notification: "notification",
SubagentStart: "subagent_start",
SubagentStop: "subagent_end",
PreCompact: "compact",
PostCompact: "compact",
};
export interface HookPayload {
hookEventName?: string;
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
toolName?: string;
toolInput?: unknown;
[key: string]: unknown;
}
export class GlanceState {
private events: GlanceEvent[] = [];
private sessions = new Map<string, SessionView>();
/** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */
private toolStarts = new Map<string, number>();
private nextId = 1;
private readonly listeners = new Set<() => void>();
constructor(private readonly cfg: Config) {
// Warm start: keep recent history across daemon restarts.
const recent = readRecentEvents(cfg.retainEvents);
this.events = recent;
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
}
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
try {
listener();
} catch {
/* a broken listener must not break ingestion */
}
}
}
private session(id: string, payload: HookPayload): SessionView {
let existing = this.sessions.get(id);
if (!existing) {
existing = {
id,
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(),
counts: { tools: 0, failures: 0, denials: 0 },
};
this.sessions.set(id, existing);
} else if (payload.workspaceRoot || payload.cwd) {
// Keep the label fresh if the session moved.
existing.label = labelForWorkspace(payload.workspaceRoot, payload.cwd ?? existing.cwd);
existing.cwd = payload.workspaceRoot ?? payload.cwd ?? existing.cwd;
}
return existing;
}
private push(event: GlanceEvent): void {
this.events.push(event);
if (this.events.length > this.cfg.retainEvents) {
this.events.splice(0, this.events.length - this.cfg.retainEvents);
}
appendEventLog(event);
}
/** Record a raw hook payload. Returns the event it produced, if any. */
ingest(payload: HookPayload): GlanceEvent | null {
const hookName = payload.hookEventName ?? "";
const kind = EVENT_KIND_BY_HOOK[hookName];
if (!kind) return null;
const sessionId = payload.sessionId ?? "unknown";
const session = this.session(sessionId, payload);
const now = Date.now();
session.lastActivity = now;
const tool = typeof payload.toolName === "string" ? payload.toolName : undefined;
let title = hookName;
let detail: string | undefined;
let durationMs: number | undefined;
switch (kind) {
case "session_start":
session.state = "idle";
title = `Session started in ${session.label}`;
detail = session.cwd || undefined;
break;
case "session_end":
session.state = "ended";
session.currentTool = undefined;
title = "Session ended";
break;
case "prompt":
session.state = "working";
session.lastPrompt = summarizePrompt(payload);
title = session.lastPrompt;
break;
case "tool_start": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
session.state = "working";
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
title = summary.title;
detail = summary.detail;
break;
}
case "tool_end":
case "tool_fail": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
const key = `${sessionId}|${tool ?? "tool"}`;
const startedAt = this.toolStarts.get(key);
if (startedAt) {
durationMs = now - startedAt;
this.toolStarts.delete(key);
}
if (session.currentTool?.name === tool) session.currentTool = undefined;
session.state = "working";
title = summary.title;
detail = summary.detail;
if (kind === "tool_end") {
session.counts.tools += 1;
} else {
session.counts.failures += 1;
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || detail;
}
break;
}
case "permission_denied":
session.counts.denials += 1;
title = tool ? `Permission denied: ${tool}` : "Permission denied";
detail = summarizeTool(tool ?? "tool", payload.toolInput).title;
break;
case "turn_end":
session.state = "idle";
session.currentTool = undefined;
title = "Turn finished";
break;
case "turn_error":
session.state = "error";
session.currentTool = undefined;
title = "Turn failed";
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
break;
case "notification":
title = summarizeNotification(payload);
break;
case "subagent_start":
title = "Subagent started";
detail = truncateDetail(String(payload["description"] ?? payload["subagentType"] ?? "")) || undefined;
break;
case "subagent_end":
title = "Subagent finished";
break;
case "compact":
title = hookName === "PreCompact" ? "Compacting conversation" : "Compaction done";
break;
default:
break;
}
const event: GlanceEvent = {
id: this.nextId++,
ts: now,
sessionId,
kind,
tool,
title: truncateTitle(title),
detail,
durationMs,
};
this.push(event);
this.notify();
return event;
}
/** Record something the daemon itself decided, e.g. an approval outcome. */
record(
sessionId: string,
kind: EventKind,
title: string,
opts: { tool?: string; detail?: string } = {},
): GlanceEvent {
const now = Date.now();
const session = this.sessions.get(sessionId);
if (session) {
session.lastActivity = now;
if (kind === "approval_request") session.state = "waiting";
else if (kind === "approval_allowed" || kind === "approval_denied") session.state = "working";
}
const event: GlanceEvent = {
id: this.nextId++,
ts: now,
sessionId,
kind,
tool: opts.tool,
title: truncateTitle(title),
detail: opts.detail,
};
this.push(event);
this.notify();
return event;
}
private effectiveState(session: SessionView, now: number): SessionState {
if (session.state === "working" && now - session.lastActivity > STALE_WORKING_MS) {
return "idle";
}
return session.state;
}
snapshot(pending: PendingApproval[]): Snapshot {
const now = Date.now();
const waiting = new Set(pending.map((p) => p.sessionId));
const sessions = [...this.sessions.values()]
.map((s) => ({
...s,
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
}))
.sort((a, b) => b.lastActivity - a.lastActivity);
return {
now,
version: VERSION,
sessions,
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
pending,
approval: this.cfg.approval,
};
}
sessionLabel(sessionId: string): string {
return this.sessions.get(sessionId)?.label ?? "workspace";
}
get sessionCount(): number {
return this.sessions.size;
}
get eventCount(): number {
return this.events.length;
}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http";
import { SECURITY_HEADERS } from "./http.js";
const here = path.dirname(fileURLToPath(import.meta.url));
/** dist/server/* and dist/web/* are siblings after a build. */
export const WEB_ROOT = path.resolve(here, "..", "web");
const TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
};
export function webBuildExists(): boolean {
return fs.existsSync(path.join(WEB_ROOT, "index.html"));
}
/**
* Serve the built app. Vite fingerprints everything under /assets, so those can be cached hard
* while index.html must not be — otherwise a phone keeps a stale shell after an upgrade.
*/
export function serveStatic(urlPath: string, res: ServerResponse): void {
if (!webBuildExists()) {
res.writeHead(503, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
res.end("grok-glance: web app not built yet. Run `npm install && npm run build`.\n");
return;
}
const clean = decodeURIComponent(urlPath.split("?")[0]);
const candidate = path.resolve(WEB_ROOT, "." + path.posix.normalize(clean));
// Anything that escapes the build directory falls back to the shell rather than leaking.
const inside = candidate === WEB_ROOT || candidate.startsWith(WEB_ROOT + path.sep);
let file = inside && isFile(candidate) ? candidate : "";
if (!file) file = path.join(WEB_ROOT, "index.html");
const ext = path.extname(file).toLowerCase();
const isHashed = file.includes(`${path.sep}assets${path.sep}`);
try {
const body = fs.readFileSync(file);
res.writeHead(200, {
...SECURITY_HEADERS,
"content-type": TYPES[ext] ?? "application/octet-stream",
"cache-control": isHashed ? "public, max-age=31536000, immutable" : "no-cache",
});
res.end(body);
} catch {
res.writeHead(404, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
res.end("not found\n");
}
}
function isFile(p: string): boolean {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
+211
View File
@@ -0,0 +1,211 @@
import fs from "node:fs";
import crypto from "node:crypto";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { ensureHome, paths } from "./config.js";
import type { DeviceInfo, GlanceEvent } from "./protocol.js";
export interface StoredCredential {
/** Base64URL credential ID. */
id: string;
/** Base64 (standard) encoded COSE public key. */
publicKey: string;
counter: number;
transports?: AuthenticatorTransportFuture[];
label: string;
createdAt: number;
lastUsedAt?: number;
deviceType?: string;
backedUp?: boolean;
}
interface AuthSessionRecord {
/** SHA-256 of the session token. The token itself is never written to disk. */
tokenHash: string;
credentialId: string;
label: string;
createdAt: number;
expiresAt: number;
}
function readJsonFile<T>(file: string, fallback: T): T {
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
} catch {
return fallback;
}
}
function writeJsonFile(file: string, value: unknown): void {
ensureHome();
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
fs.renameSync(tmp, file);
}
/* ------------------------------------------------------------------ secrets */
/** HMAC key used to sign session cookies. Created once, 0600. */
export function sessionSecret(): Buffer {
ensureHome();
try {
const existing = fs.readFileSync(paths.secret);
if (existing.length >= 32) return existing;
} catch {
/* create below */
}
const key = crypto.randomBytes(32);
fs.writeFileSync(paths.secret, key, { mode: 0o600 });
return key;
}
/**
* Token that authorises privileged local operations (enrol, revoke, shutdown).
* Rotated on every daemon start so a leaked token dies with the process.
*/
export function rotateAdminToken(): string {
ensureHome();
const token = crypto.randomBytes(24).toString("base64url");
fs.writeFileSync(paths.adminToken, token + "\n", { mode: 0o600 });
return token;
}
/* -------------------------------------------------------------- credentials */
export function listCredentials(): StoredCredential[] {
return readJsonFile<StoredCredential[]>(paths.credentials, []);
}
export function saveCredentials(creds: StoredCredential[]): void {
writeJsonFile(paths.credentials, creds);
}
export function addCredential(cred: StoredCredential): void {
const all = listCredentials().filter((c) => c.id !== cred.id);
all.push(cred);
saveCredentials(all);
}
export function findCredential(id: string): StoredCredential | undefined {
return listCredentials().find((c) => c.id === id);
}
export function touchCredential(id: string, counter: number): void {
const all = listCredentials();
const cred = all.find((c) => c.id === id);
if (!cred) return;
cred.counter = counter;
cred.lastUsedAt = Date.now();
saveCredentials(all);
}
export function revokeCredentials(idPrefix: string): number {
const all = listCredentials();
const keep = all.filter((c) => !c.id.startsWith(idPrefix));
saveCredentials(keep);
const removed = all.length - keep.length;
if (removed > 0) {
// A revoked device must lose any live session too, or it keeps its cookie access.
const sessions = listAuthSessions().filter((s) => !s.credentialId.startsWith(idPrefix));
writeJsonFile(paths.authSessions, sessions);
}
return removed;
}
export function deviceList(): DeviceInfo[] {
return listCredentials().map((c) => ({
id: c.id,
label: c.label,
createdAt: c.createdAt,
lastUsedAt: c.lastUsedAt,
}));
}
/* ------------------------------------------------------------ auth sessions */
function listAuthSessions(): AuthSessionRecord[] {
const now = Date.now();
return readJsonFile<AuthSessionRecord[]>(paths.authSessions, []).filter(
(s) => s.expiresAt > now,
);
}
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function createAuthSession(
credentialId: string,
label: string,
ttlMs: number,
): { token: string; expiresAt: number } {
const token = crypto.randomBytes(32).toString("base64url");
const expiresAt = Date.now() + ttlMs;
const sessions = listAuthSessions();
sessions.push({
tokenHash: hashToken(token),
credentialId,
label,
createdAt: Date.now(),
expiresAt,
});
writeJsonFile(paths.authSessions, sessions);
return { token, expiresAt };
}
export function lookupAuthSession(token: string): AuthSessionRecord | undefined {
const wanted = hashToken(token);
return listAuthSessions().find((s) => {
const a = Buffer.from(s.tokenHash, "hex");
const b = Buffer.from(wanted, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
export function destroyAuthSession(token: string): void {
const wanted = hashToken(token);
writeJsonFile(
paths.authSessions,
listAuthSessions().filter((s) => s.tokenHash !== wanted),
);
}
/* -------------------------------------------------------------- event log */
const MAX_LOG_BYTES = 5 * 1024 * 1024;
export function appendEventLog(event: GlanceEvent): void {
try {
ensureHome();
let size = 0;
try {
size = fs.statSync(paths.events).size;
} catch {
/* no log yet */
}
if (size > MAX_LOG_BYTES) {
fs.renameSync(paths.events, `${paths.events}.1`);
}
fs.appendFileSync(paths.events, JSON.stringify(event) + "\n", { mode: 0o600 });
} catch {
// The dashboard is not worth crashing over.
}
}
/** Read back the tail of the log so a restarted daemon still has recent history. */
export function readRecentEvents(limit: number): GlanceEvent[] {
try {
const text = fs.readFileSync(paths.events, "utf8");
const lines = text.split("\n").filter(Boolean).slice(-limit);
const out: GlanceEvent[] = [];
for (const line of lines) {
try {
out.push(JSON.parse(line) as GlanceEvent);
} catch {
/* skip malformed line */
}
}
return out;
} catch {
return [];
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Turns a raw hook payload into something you can read on a phone screen.
*
* Two jobs: pick the one field that actually says what the tool is doing, and strip anything
* that looks like a credential before it leaves the machine.
*/
const TITLE_MAX = 180;
const DETAIL_MAX = 400;
/**
* Conservative redaction: only well-known credential shapes and explicit key=value
* assignments. Deliberately not "redact any long string" — that would mangle ordinary
* paths and hashes and make the timeline useless.
*/
const REDACTIONS: Array<[RegExp, string]> = [
[/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, "$1-[redacted]"],
[/\bxai-[A-Za-z0-9_-]{16,}/g, "xai-[redacted]"],
[/\bgh[pousr]_[A-Za-z0-9_]{16,}/g, "gh?_[redacted]"],
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox?-[redacted]"],
[/\bAKIA[0-9A-Z]{16}\b/g, "AKIA[redacted]"],
[/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g, "[jwt redacted]"],
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[private key redacted]"],
[/\b(authorization|bearer)\s*[:=]?\s*[A-Za-z0-9._~+/-]{16,}=*/gi, "$1 [redacted]"],
[
/\b([A-Za-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\s*[:=]\s*("[^"]*"|'[^']*'|\S+)/gi,
"$1=[redacted]",
],
];
export function redact(text: string): string {
let out = text;
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
return out;
}
function clean(value: unknown, max: number): string {
if (value === undefined || value === null) return "";
const raw = typeof value === "string" ? value : JSON.stringify(value);
const collapsed = redact(raw).replace(/\s+/g, " ").trim();
return collapsed.length > max ? collapsed.slice(0, max - 1) + "…" : collapsed;
}
function basename(p: string): string {
const parts = p.replace(/\/+$/, "").split("/");
return parts[parts.length - 1] || p;
}
/** Shorten a path to something that fits a phone: keep the last two segments. */
export function shortPath(p: string): string {
if (!p) return "";
const parts = p.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length <= 2) return p;
return `…/${parts.slice(-2).join("/")}`;
}
export interface ToolSummary {
title: string;
detail?: string;
}
/**
* Grok maps Claude Code tool names onto its own, so the input keys follow the Claude
* shapes. Anything unrecognised falls back to a JSON preview.
*/
export function summarizeTool(toolName: string, input: unknown): ToolSummary {
const obj = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
const pick = (...keys: string[]): string | undefined => {
for (const key of keys) {
const value = obj[key];
if (typeof value === "string" && value.trim()) return value;
}
return undefined;
};
switch (toolName) {
case "Bash":
case "BashOutput": {
const cmd = pick("command");
return {
title: clean(cmd ?? toolName, TITLE_MAX),
detail: clean(pick("description"), DETAIL_MAX) || undefined,
};
}
case "Read":
case "Write":
case "Edit":
case "MultiEdit":
case "NotebookEdit": {
const file = pick("file_path", "notebook_path", "path");
return {
title: file ? shortPath(clean(file, TITLE_MAX)) : toolName,
detail: file ? clean(file, DETAIL_MAX) : undefined,
};
}
case "Glob":
case "Grep": {
const pattern = pick("pattern", "query");
const where = pick("path", "glob");
return {
title: clean(pattern ?? toolName, TITLE_MAX),
detail: where ? clean(where, DETAIL_MAX) : undefined,
};
}
case "WebFetch":
case "WebSearch": {
const target = pick("url", "query");
return { title: clean(target ?? toolName, TITLE_MAX) };
}
case "Task":
case "Agent": {
return {
title: clean(pick("description", "prompt") ?? toolName, TITLE_MAX),
detail: clean(pick("subagent_type"), DETAIL_MAX) || undefined,
};
}
default: {
const first = pick("command", "file_path", "path", "url", "query", "pattern", "description");
if (first) return { title: clean(first, TITLE_MAX) };
const keys = Object.keys(obj);
if (!keys.length) return { title: toolName };
return { title: clean(obj[keys[0]], TITLE_MAX) || toolName };
}
}
}
export function summarizePrompt(payload: Record<string, unknown>): string {
const prompt =
payload["prompt"] ?? payload["userPrompt"] ?? payload["message"] ?? payload["text"];
return clean(prompt, TITLE_MAX) || "(prompt)";
}
export function summarizeNotification(payload: Record<string, unknown>): string {
const message = payload["message"] ?? payload["notification"] ?? payload["text"];
return clean(message, TITLE_MAX) || "Notification";
}
export function labelForWorkspace(workspaceRoot: string | undefined, cwd: string): string {
const source = workspaceRoot || cwd || "";
return basename(source) || "workspace";
}
export function truncateTitle(text: string): string {
return clean(text, TITLE_MAX);
}
export function truncateDetail(text: string): string {
return clean(text, DETAIL_MAX);
}
+192
View File
@@ -0,0 +1,192 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { expectedOrigins, expectedRpIds, type Config } from "./config.js";
import {
addCredential,
createAuthSession,
findCredential,
listCredentials,
touchCredential,
} from "./store.js";
/** Passkeys live for a month before the phone has to prove itself again. */
export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
const CHALLENGE_TTL_MS = 5 * 60_000;
const MAX_CHALLENGES = 32;
/**
* There is exactly one logical user here — you. A stable handle means a second passkey
* enrolled later joins the same account instead of creating a parallel one.
*/
const USER_HANDLE = new TextEncoder().encode("grok-glance");
const USER_NAME = "grok-glance";
type Purpose = "register" | "authenticate";
/**
* Challenges are held server-side and consumed exactly once. SimpleWebAuthn lets us pass a
* predicate for `expectedChallenge`, so we never need to trust the client to tell us which
* challenge it was answering.
*/
class ChallengeStore {
private items = new Map<string, { purpose: Purpose; expiresAt: number }>();
issue(challenge: string, purpose: Purpose): void {
this.prune();
if (this.items.size >= MAX_CHALLENGES) {
// Drop the oldest rather than growing without bound.
const oldest = this.items.keys().next();
if (!oldest.done) this.items.delete(oldest.value);
}
this.items.set(challenge, { purpose, expiresAt: Date.now() + CHALLENGE_TTL_MS });
}
consume(challenge: string, purpose: Purpose): boolean {
this.prune();
const entry = this.items.get(challenge);
if (!entry || entry.purpose !== purpose) return false;
this.items.delete(challenge);
return true;
}
private prune(): void {
const now = Date.now();
for (const [key, value] of this.items) {
if (value.expiresAt <= now) this.items.delete(key);
}
}
}
export interface AuthOutcome {
ok: boolean;
error?: string;
token?: string;
label?: string;
}
export class WebAuthnService {
private challenges = new ChallengeStore();
constructor(private readonly cfg: Config) {}
private get rpId(): string {
return this.cfg.rpId ?? "localhost";
}
get enrolled(): boolean {
return listCredentials().length > 0;
}
async registrationOptions() {
const existing = listCredentials();
const options = await generateRegistrationOptions({
rpName: this.cfg.rpName,
rpID: this.rpId,
userID: USER_HANDLE,
userName: USER_NAME,
userDisplayName: this.cfg.rpName,
attestationType: "none",
// Don't let the same device enrol twice — it just confuses the device list.
excludeCredentials: existing.map((c) => ({
id: c.id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required",
// "Strictly guarded" means a biometric or PIN every time, not merely possession.
userVerification: "required",
},
timeout: 120_000,
});
this.challenges.issue(options.challenge, "register");
return options;
}
async verifyRegistration(
response: RegistrationResponseJSON,
label: string,
): Promise<AuthOutcome> {
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: (challenge) => this.challenges.consume(challenge, "register"),
expectedOrigin: expectedOrigins(this.cfg),
expectedRPID: expectedRpIds(this.cfg),
requireUserVerification: true,
});
} catch (err) {
return { ok: false, error: (err as Error).message };
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: "registration could not be verified" };
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
addCredential({
id: credential.id,
publicKey: Buffer.from(credential.publicKey).toString("base64"),
counter: credential.counter,
transports: credential.transports,
label: label.trim().slice(0, 40) || "device",
createdAt: Date.now(),
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
const session = createAuthSession(credential.id, label, SESSION_TTL_MS);
return { ok: true, token: session.token, label };
}
async authenticationOptions() {
const options = await generateAuthenticationOptions({
rpID: this.rpId,
allowCredentials: listCredentials().map((c) => ({
id: c.id,
transports: c.transports,
})),
userVerification: "required",
timeout: 120_000,
});
this.challenges.issue(options.challenge, "authenticate");
return options;
}
async verifyAuthentication(response: AuthenticationResponseJSON): Promise<AuthOutcome> {
const stored = findCredential(response.id);
if (!stored) return { ok: false, error: "unknown device" };
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: (challenge) => this.challenges.consume(challenge, "authenticate"),
expectedOrigin: expectedOrigins(this.cfg),
expectedRPID: expectedRpIds(this.cfg),
credential: {
id: stored.id,
publicKey: new Uint8Array(Buffer.from(stored.publicKey, "base64")),
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: true,
});
} catch (err) {
return { ok: false, error: (err as Error).message };
}
if (!verification.verified) return { ok: false, error: "assertion rejected" };
touchCredential(stored.id, verification.authenticationInfo.newCounter);
const session = createAuthSession(stored.id, stored.label, SESSION_TTL_MS);
return { ok: true, token: session.token, label: stored.label };
}
}
+83
View File
@@ -0,0 +1,83 @@
---
name: glance
description: Set up, inspect, or control grok-glance — the passkey-guarded phone dashboard for this Grok Build session. Use when the user asks to watch a session from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off.
---
# grok-glance
A local daemon plus web dashboard that shows what Grok Build is doing, readable from a phone
behind a WebAuthn passkey. It can also pause risky tool calls until someone taps approve.
The daemon is started automatically by the `SessionStart` hook. Everything below is done through
the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
## First check whether it is even built
The plugin ships as TypeScript and must be built once:
```sh
cd "$GROK_PLUGIN_ROOT" && npm install && npm run build
```
`glance status` prints a "not built" error with this same instruction if it is missing. Do not
attempt to skip the build — the daemon entry point is `dist/server/index.js`.
## The commands
```sh
glance status # is it running, which origin, how many devices
glance up # start the daemon in the background
glance stop # stop it
glance logs # last 60 lines of the daemon log
glance enroll # mint a one-time code + URL for a new phone
glance set-origin <https-url> # set the public origin and WebAuthn RP ID
glance devices # list enrolled devices
glance revoke <id-prefix> # revoke one
glance approval <off|risky|all> # remote approve/deny policy
glance sync-hooks # rewrite hook URLs after changing the port
```
## Getting it onto a phone
The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare
IP can never be a WebAuthn RP ID — so the supported path is Tailscale Serve:
```sh
tailscale serve --bg 127.0.0.1:8791
tailscale serve status # read the https://<box>.<tailnet>.ts.net URL
glance set-origin https://<box>.<tailnet>.ts.net
glance enroll
```
Then open the printed URL on the phone, type the code, and create the passkey. The phone must be
on the same tailnet.
Changing the origin changes the RP ID, which invalidates existing passkeys. Say so before running
`set-origin` on a working setup.
## Remote approve/deny
`glance approval risky` makes `Bash`, `Write`, `Edit`, `MultiEdit` and `NotebookEdit` calls pause
and wait for a tap on the phone. Defaults that matter:
- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`).
- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's
settings panel if you want the opposite.
- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This
is a convenience gate, not a security boundary.
`glance approval off` (the default) means Grok Build never blocks on the phone.
## When something does not work
- **"not running"** → `glance up`, then `glance logs`.
- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not
cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar.
- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in
`hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it.
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it.
## What it deliberately does not do
Read-only plus approve/deny. It cannot send prompts, edit files, run tools, or resume a session.
Do not tell the user otherwise.
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"outDir": "dist/server",
"rootDir": "server/src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["server/src"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"paths": {
"@/*": ["./web/src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["web/src", "web/vite-env.d.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath } from "node:url";
// The web app lives in web/ and is emitted into dist/web, which the daemon serves.
export default defineConfig({
root: "web",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./web/src", import.meta.url)),
},
},
build: {
outDir: "../dist/web",
emptyOutDir: true,
},
server: {
// `npm run dev` serves the UI on 5173 and proxies the API to the daemon.
proxy: {
"/api": "http://127.0.0.1:8791",
"/events": {
target: "http://127.0.0.1:8791",
changeOrigin: false,
},
},
},
});
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover so the sticky header sits under the notch rather than beside it. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<title>grok-glance</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="glance" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#09090b" />
<circle cx="32" cy="32" r="15" fill="none" stroke="#fafafa" stroke-width="3.5" />
<circle cx="32" cy="32" r="5.5" fill="#fafafa" />
<path d="M32 9v5M32 50v5M9 32h5M50 32h5" stroke="#71717a" stroke-width="3.5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+19
View File
@@ -0,0 +1,19 @@
{
"name": "grok-glance",
"short_name": "glance",
"description": "Glance at what Grok Build is doing.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Alert, Button, Card, Spinner, useTheme } from "@heroui/react";
import { api } from "@/lib/api";
import { useGlance, useNow } from "@/lib/useGlance";
import { Gate } from "@/components/Gate";
import { NowCard } from "@/components/NowCard";
import { PendingCard } from "@/components/PendingCard";
import { SessionsCard } from "@/components/SessionsCard";
import { SettingsPanel } from "@/components/SettingsPanel";
import { Timeline } from "@/components/Timeline";
import { GearIcon } from "@/components/icons";
import type { GateInfo } from "@/protocol";
export default function App() {
const [gate, setGate] = useState<GateInfo | null>(null);
const [fatal, setFatal] = useState<string | null>(null);
// HeroUI's own hook: it writes the `dark` class and `data-theme` in a layout effect (no
// flash), follows the OS while the intent is "system", and persists an explicit choice.
const { resolvedTheme, setTheme } = useTheme();
const [showSettings, setShowSettings] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [busyIds, setBusyIds] = useState<string[]>([]);
const refreshGate = useCallback(async () => {
try {
setGate(await api.gate());
setFatal(null);
} catch (err) {
setFatal((err as Error).message);
}
}, []);
useEffect(() => {
void refreshGate();
}, [refreshGate]);
const authenticated = !!gate?.authenticated;
const { snapshot, connection } = useGlance(authenticated);
const now = useNow(1000);
// A dropped stream is usually the network, but it is also how an expired session shows up.
// Re-checking the gate turns the second case back into the unlock screen instead of a spinner.
useEffect(() => {
if (!authenticated || connection !== "offline") return;
const id = window.setTimeout(() => void refreshGate(), 3000);
return () => window.clearTimeout(id);
}, [authenticated, connection, refreshGate]);
async function resolve(id: string, decision: "allow" | "deny") {
setBusyIds((ids) => [...ids, id]);
try {
await api.resolveApproval(id, decision);
} catch {
// Losing the race is normal: it may have timed out, or another device answered first.
} finally {
setBusyIds((ids) => ids.filter((x) => x !== id));
}
}
if (fatal && !gate) {
return (
<Centered>
<Alert status="danger">
<Alert.Content>
<Alert.Title>Cannot reach the daemon</Alert.Title>
<Alert.Description>{fatal}</Alert.Description>
</Alert.Content>
</Alert>
<Button variant="outline" size="md" fullWidth onPress={() => void refreshGate()}>
Try again
</Button>
</Centered>
);
}
if (!gate) {
return (
<Centered>
<Spinner size="lg" color="current" />
</Centered>
);
}
if (!authenticated) {
return <Gate gate={gate} onSignedIn={() => void refreshGate()} />;
}
const sessions = snapshot?.sessions ?? [];
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
const pending = snapshot?.pending ?? [];
return (
<div className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-10 border-b border-separator bg-background/85 backdrop-blur-md">
<div className="mx-auto flex max-w-md items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3">
<span
className={`h-2 w-2 shrink-0 rounded-full ${
connection === "live"
? "bg-success"
: connection === "connecting"
? "bg-warning"
: "bg-danger"
}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-tight">grok-glance</h1>
<p className="text-[11px] text-muted">
{connection === "live"
? `${sessions.length} session${sessions.length === 1 ? "" : "s"}`
: connection === "connecting"
? "connecting…"
: "offline — retrying"}
</p>
</div>
<Button
size="sm"
variant={showSettings ? "primary" : "ghost"}
isIconOnly
aria-label="Settings"
onPress={() => setShowSettings((value) => !value)}
>
<GearIcon />
</Button>
</div>
</header>
<main className="mx-auto flex max-w-md flex-col gap-3 px-4 py-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
{pending.map((approval) => (
<PendingCard
key={approval.id}
approval={approval}
now={now}
busy={busyIds.includes(approval.id)}
onResolve={resolve}
/>
))}
{showSettings && snapshot && (
<SettingsPanel
approval={snapshot.approval}
deviceLabel={gate.deviceLabel}
version={snapshot.version}
theme={resolvedTheme === "dark" ? "dark" : "light"}
onToggleTheme={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
onSignedOut={() => void refreshGate()}
/>
)}
{!snapshot ? (
<Centered inline>
<Spinner size="lg" color="current" />
</Centered>
) : sessions.length === 0 ? (
<Card>
<Card.Header>
<Card.Title className="text-base">Nothing to show yet</Card.Title>
<Card.Description>
Start Grok Build in a workspace and this page will fill in as it works.
</Card.Description>
</Card.Header>
</Card>
) : (
<>
{focus && <NowCard session={focus} now={now} />}
{sessions.length > 1 && (
<SessionsCard
sessions={sessions}
selectedId={selected}
onSelect={setSelected}
now={now}
/>
)}
<Timeline events={snapshot.events} sessionId={selected} />
</>
)}
</main>
</div>
);
}
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
return (
<div
className={`mx-auto flex w-full max-w-sm flex-col items-center justify-center gap-4 px-5 ${
inline ? "py-16" : "min-h-dvh"
}`}
>
{children}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from "react";
import { Alert, Button, Card, Input, Spinner } from "@heroui/react";
import { browserSupportsWebAuthn } from "@simplewebauthn/browser";
import { api } from "@/lib/api";
import { FingerprintIcon, LockIcon } from "@/components/icons";
import type { GateInfo } from "@/protocol";
/** A friendly default so most people never touch the label field. */
function guessDeviceName(): string {
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) return "Android phone";
if (/Macintosh/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows PC";
return "device";
}
function messageFor(err: unknown): string {
const e = err as { name?: string; message?: string };
if (e?.name === "NotAllowedError") return "Cancelled, or the prompt timed out. Try again.";
if (e?.name === "InvalidStateError") return "This device is already enrolled — just sign in.";
if (e?.name === "SecurityError") {
return "The browser refused this origin. Passkeys need the exact https hostname the daemon was configured with.";
}
return e?.message ?? "Something went wrong.";
}
export function Gate({ gate, onSignedIn }: { gate: GateInfo; onSignedIn: () => void }) {
const wantsEnroll = new URLSearchParams(location.search).has("enroll");
const [enrolling, setEnrolling] = useState(!gate.enrolled || wantsEnroll);
const [code, setCode] = useState("");
const [label, setLabel] = useState(guessDeviceName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const supported = browserSupportsWebAuthn();
// The commonest setup failure by far: the page was opened on a hostname the RP ID does not
// cover, e.g. the LAN IP instead of the tailnet name. Say so before the prompt fails.
const hostMismatch =
!!gate.rpId &&
location.hostname !== gate.rpId &&
!location.hostname.endsWith(`.${gate.rpId}`) &&
location.hostname !== "localhost";
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
onSignedIn();
} catch (err) {
setError(messageFor(err));
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto flex min-h-dvh w-full max-w-sm flex-col justify-center gap-5 px-5 py-10">
<header className="flex flex-col items-center gap-3 text-center">
<span className="rounded-2xl border border-border p-3 text-foreground">
<LockIcon className="h-6 w-6" />
</span>
<div>
<h1 className="text-xl font-semibold tracking-tight">grok-glance</h1>
<p className="mt-1 text-sm text-muted">
Only enrolled devices get past this screen.
</p>
</div>
</header>
{!supported && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>This browser cannot do passkeys</Alert.Title>
<Alert.Description>
Open the dashboard in Safari or Chrome over https.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{hostMismatch && (
<Alert status="warning">
<Alert.Content>
<Alert.Title>Wrong hostname for this passkey</Alert.Title>
<Alert.Description>
You are on {location.hostname}, but the daemon expects {gate.rpId}. Open that
hostname instead, or run{" "}
<code className="font-mono text-xs">glance set-origin</code>.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{error && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>{error}</Alert.Title>
</Alert.Content>
</Alert>
)}
{enrolling ? (
<Card>
<Card.Header>
<Card.Title>Enrol this device</Card.Title>
<Card.Description>
Run <code className="font-mono text-xs">glance enroll</code> on the machine running
Grok Build, then type the code it prints.
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Enrolment code</span>
<Input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
placeholder="ABCD2345"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
inputMode="text"
maxLength={12}
aria-label="Enrolment code"
className="font-mono tracking-[0.25em]"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Name this device</span>
<Input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="iPhone"
maxLength={40}
aria-label="Device name"
/>
</label>
</Card.Content>
<Card.Footer className="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
fullWidth
isDisabled={busy || !supported || code.trim().length < 4}
onPress={() => run(() => api.enroll(code, label))}
>
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
Create passkey
</Button>
{gate.enrolled && (
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(false)}>
I already have a passkey
</Button>
)}
</Card.Footer>
</Card>
) : (
<Card>
<Card.Header>
<Card.Title>Unlock</Card.Title>
<Card.Description>Use the passkey on this device.</Card.Description>
</Card.Header>
<Card.Footer className="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
fullWidth
isDisabled={busy || !supported}
onPress={() => run(() => api.signIn())}
>
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
Unlock with passkey
</Button>
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(true)}>
Enrol a new device
</Button>
</Card.Footer>
</Card>
)}
<p className="text-center text-xs text-muted">
grok-glance {gate.version}
{gate.rpId ? ` · ${gate.rpId}` : ""}
</p>
</main>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { Card, Spinner } from "@heroui/react";
import { StateChip, ToolChip } from "@/components/StatusChip";
import { duration, relTime } from "@/lib/format";
import type { SessionView } from "@/protocol";
export function NowCard({ session, now }: { session: SessionView; now: number }) {
const tool = session.currentTool;
return (
<Card>
<Card.Header>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Card.Title className="truncate text-base">{session.label}</Card.Title>
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
</div>
<StateChip state={session.state} />
</div>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
{session.lastPrompt && (
<div>
<p className="text-[11px] font-medium tracking-wide text-muted uppercase">
Last asked
</p>
<p className="mt-0.5 text-sm leading-snug break-words">{session.lastPrompt}</p>
</div>
)}
{tool ? (
<div className="flex items-start gap-2.5 rounded-xl bg-surface-secondary p-3">
<Spinner size="sm" color="current" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<ToolChip tool={tool.name} />
<span className="text-xs tabular-nums text-muted">
{duration(Math.max(0, now - tool.startedAt))}
</span>
</div>
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
</div>
</div>
) : (
<p className="text-sm text-muted">
Nothing running · last activity {relTime(session.lastActivity, now)}
</p>
)}
</Card.Content>
<Card.Footer>
<dl className="grid w-full grid-cols-3 gap-2 text-center">
<Stat label="tools" value={session.counts.tools} />
<Stat label="failed" value={session.counts.failures} tone={session.counts.failures > 0} />
<Stat label="denied" value={session.counts.denials} tone={session.counts.denials > 0} />
</dl>
</Card.Footer>
</Card>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: boolean }) {
return (
<div className="rounded-lg bg-surface-secondary py-2">
<dd className={`text-lg leading-none font-semibold tabular-nums ${tone ? "text-danger" : ""}`}>
{value}
</dd>
<dt className="mt-1 text-[11px] tracking-wide text-muted uppercase">{label}</dt>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Button, Card } from "@heroui/react";
import { BanIcon, CheckIcon } from "@/components/icons";
import { ToolChip } from "@/components/StatusChip";
import { secondsLeft } from "@/lib/format";
import type { PendingApproval } from "@/protocol";
export function PendingCard({
approval,
now,
busy,
onResolve,
}: {
approval: PendingApproval;
now: number;
busy: boolean;
onResolve: (id: string, decision: "allow" | "deny") => void;
}) {
const left = secondsLeft(approval.expiresAt, now);
const total = Math.max(1, approval.expiresAt - approval.createdAt);
const remaining = Math.max(0, Math.min(1, (approval.expiresAt - now) / total));
return (
<Card className="border-warning/60">
<Card.Header>
<div className="flex items-center justify-between gap-2">
<Card.Title className="text-base">Waiting on you</Card.Title>
<span className="text-xs tabular-nums text-muted">{left}s</span>
</div>
<Card.Description className="flex flex-wrap items-center gap-1.5">
<ToolChip tool={approval.tool} />
<span className="text-xs text-muted">in {approval.sessionLabel}</span>
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-2">
<p className="text-sm leading-snug break-words">{approval.title}</p>
{approval.detail && (
<pre className="max-h-32 overflow-auto rounded-lg bg-surface-secondary p-2.5 font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-surface-secondary-foreground">
{approval.detail}
</pre>
)}
{/* A bar rather than only a number: you can see at a glance how much time is left. */}
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-secondary">
<div
className="h-full rounded-full bg-warning transition-[width] duration-1000 ease-linear"
style={{ width: `${remaining * 100}%` }}
/>
</div>
</Card.Content>
<Card.Footer className="grid grid-cols-2 gap-2">
<Button
variant="danger-soft"
size="lg"
isDisabled={busy}
onPress={() => onResolve(approval.id, "deny")}
>
<BanIcon />
Deny
</Button>
<Button
variant="primary"
size="lg"
isDisabled={busy}
onPress={() => onResolve(approval.id, "allow")}
>
<CheckIcon />
Approve
</Button>
</Card.Footer>
</Card>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
import { Card } from "@heroui/react";
import { StateChip } from "@/components/StatusChip";
import { relTime } from "@/lib/format";
import type { SessionView } from "@/protocol";
/**
* Only rendered when more than one session is live. With a single workspace the Now card
* already says everything, and an extra list is just noise on a small screen.
*/
export function SessionsCard({
sessions,
selectedId,
onSelect,
now,
}: {
sessions: SessionView[];
selectedId: string | null;
onSelect: (id: string | null) => void;
now: number;
}) {
return (
<Card>
<Card.Header>
<Card.Title className="text-base">Sessions</Card.Title>
<Card.Description className="text-xs">
Tap one to filter the activity list.
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
<ul className="flex flex-col">
<li className="border-b border-separator">
<Row active={selectedId === null} onPress={() => onSelect(null)}>
<span className="text-sm">All sessions</span>
<span className="text-xs text-muted">{sessions.length}</span>
</Row>
</li>
{sessions.map((session) => (
<li key={session.id} className="border-b border-separator last:border-b-0">
<Row
active={selectedId === session.id}
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
>
<span className="min-w-0 flex-1 truncate text-sm">{session.label}</span>
<span className="shrink-0 text-[11px] text-muted">
{relTime(session.lastActivity, now)}
</span>
<StateChip state={session.state} />
</Row>
</li>
))}
</ul>
</Card.Content>
</Card>
);
}
function Row({
active,
onPress,
children,
}: {
active: boolean;
onPress: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onPress}
aria-pressed={active}
className={`flex w-full items-center gap-2 px-4 py-3 text-left transition-colors ${
active ? "bg-surface-secondary" : "hover:bg-surface-hover"
}`}
>
{children}
</button>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useState } from "react";
import { Alert, Button, Card } from "@heroui/react";
import { api } from "@/lib/api";
import { MoonIcon, SunIcon } from "@/components/icons";
import type { ApprovalMode, ApprovalSettings, DeviceInfo } from "@/protocol";
type Patch = Parameters<typeof api.setApproval>[0];
const MODES: { value: ApprovalMode; label: string; hint: string }[] = [
{ value: "off", label: "Off", hint: "Grok Build never waits for you." },
{ value: "risky", label: "Risky", hint: "Shell commands and file writes need a tap." },
{ value: "all", label: "All", hint: "Every tool call needs a tap. Noisy." },
];
export function SettingsPanel({
approval,
deviceLabel,
version,
theme,
onToggleTheme,
onSignedOut,
}: {
approval: ApprovalSettings;
deviceLabel?: string;
version: string;
theme: "light" | "dark";
onToggleTheme: () => void;
onSignedOut: () => void;
}) {
const [local, setLocal] = useState(approval);
const [error, setError] = useState<string | null>(null);
const [devices, setDevices] = useState<DeviceInfo[] | null>(null);
const [currentId, setCurrentId] = useState<string>("");
// Keep in step with the stream: another device may have changed the policy.
useEffect(() => setLocal(approval), [approval]);
useEffect(() => {
api.devices().then(
(out) => {
setDevices(out.devices);
setCurrentId(out.current);
},
() => setDevices([]),
);
}, []);
async function apply(patch: Patch) {
setError(null);
const previous = local;
setLocal({ ...local, ...patch });
try {
setLocal(await api.setApproval(patch));
} catch (err) {
setLocal(previous);
setError((err as Error).message);
}
}
const hint = MODES.find((m) => m.value === local.mode)?.hint;
return (
<div className="flex flex-col gap-3">
{error && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>{error}</Alert.Title>
</Alert.Content>
</Alert>
)}
<Card>
<Card.Header>
<Card.Title className="text-base">Remote approval</Card.Title>
<Card.Description className="text-xs">
Which tool calls should pause and wait for a tap on this phone.
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2">
{MODES.map((mode) => (
<Button
key={mode.value}
size="md"
variant={local.mode === mode.value ? "primary" : "outline"}
onPress={() => apply({ mode: mode.value })}
>
{mode.label}
</Button>
))}
</div>
{hint && <p className="text-xs text-muted">{hint}</p>}
<Toggle
label="Only when a phone is watching"
hint="Off means a tool call can wait even with nobody looking at this page."
value={local.requireWatcher}
onChange={(value) => apply({ requireWatcher: value })}
/>
<Toggle
label="Deny if nobody answers"
hint={`Otherwise it is allowed after ${Math.round(local.timeoutMs / 1000)}s.`}
value={local.onTimeout === "deny"}
onChange={(value) => apply({ onTimeout: value ? "deny" : "allow" })}
/>
{local.mode !== "off" && (
<p className="font-mono text-[11px] break-all text-muted">
risky pattern: {local.riskyPattern}
</p>
)}
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title className="text-base">Devices</Card.Title>
<Card.Description className="text-xs">
Revoke from the terminal with <code className="font-mono">glance revoke &lt;id&gt;</code>
.
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
{devices === null ? (
<p className="px-4 text-sm text-muted">Loading</p>
) : (
<ul className="flex flex-col">
{devices.map((device) => (
<li
key={device.id}
className="flex items-center justify-between gap-2 border-t border-separator px-4 py-2.5 first:border-t-0"
>
<div className="min-w-0">
<p className="truncate text-sm">
{device.label}
{device.id === currentId && (
<span className="ml-1.5 text-[11px] text-muted">(this one)</span>
)}
</p>
<p className="font-mono text-[11px] text-muted">{device.id.slice(0, 16)}</p>
</div>
<span className="shrink-0 text-[11px] text-muted">
{new Date(device.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</Card.Content>
</Card>
<Card>
<Card.Content className="flex flex-col gap-2">
<Button variant="outline" size="md" fullWidth onPress={onToggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="danger-soft"
size="md"
fullWidth
onPress={() => api.logout().then(onSignedOut, onSignedOut)}
>
Sign out {deviceLabel ? `(${deviceLabel})` : ""}
</Button>
<p className="pt-1 text-center text-xs text-muted">grok-glance {version}</p>
</Card.Content>
</Card>
</div>
);
}
/**
* A two-state row built from a Button rather than a switch: the whole row is a large tap
* target, which matters more on a phone than the affordance of a sliding thumb.
*/
function Toggle({
label,
hint,
value,
onChange,
}: {
label: string;
hint: string;
value: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm">{label}</p>
<p className="text-xs text-muted">{hint}</p>
</div>
<Button
size="sm"
variant={value ? "primary" : "outline"}
onPress={() => onChange(!value)}
aria-pressed={value}
>
{value ? "On" : "Off"}
</Button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Chip } from "@heroui/react";
import type { SessionState } from "@/protocol";
type ChipColor = "accent" | "danger" | "default" | "success" | "warning";
const STATE: Record<SessionState, { color: ChipColor; label: string }> = {
working: { color: "accent", label: "working" },
waiting: { color: "warning", label: "waiting on you" },
idle: { color: "success", label: "idle" },
error: { color: "danger", label: "error" },
ended: { color: "default", label: "ended" },
};
export function StateChip({ state, size = "sm" }: { state: SessionState; size?: "sm" | "md" }) {
const { color, label } = STATE[state];
return (
<Chip color={color} size={size} variant="soft">
<Chip.Label>{label}</Chip.Label>
</Chip>
);
}
export function ToolChip({ tool }: { tool: string }) {
return (
<Chip color="default" size="sm" variant="tertiary">
<Chip.Label>{tool}</Chip.Label>
</Chip>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { Button, Card } from "@heroui/react";
import { clockTime, duration } from "@/lib/format";
import type { EventKind, GlanceEvent } from "@/protocol";
const DOT: Record<EventKind, string> = {
session_start: "bg-muted",
session_end: "bg-muted",
prompt: "bg-accent",
tool_start: "bg-muted",
tool_end: "bg-success",
tool_fail: "bg-danger",
permission_denied: "bg-danger",
turn_end: "bg-accent",
turn_error: "bg-danger",
notification: "bg-warning",
subagent_start: "bg-muted",
subagent_end: "bg-muted",
compact: "bg-muted",
approval_request: "bg-warning",
approval_allowed: "bg-success",
approval_denied: "bg-danger",
approval_expired: "bg-warning",
};
const PAGE = 40;
/**
* PreToolUse rows are hidden: PostToolUse reports the same call with a duration, and the
* running one is already the headline of the Now card. Showing both doubles every line.
*/
function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[] {
return events.filter(
(event) =>
event.kind !== "tool_start" && (sessionId === null || event.sessionId === sessionId),
);
}
export function Timeline({
events,
sessionId,
}: {
events: GlanceEvent[];
sessionId: string | null;
}) {
const [limit, setLimit] = useState(PAGE);
const rows = visible(events, sessionId);
const shown = rows.slice(0, limit);
return (
<Card>
<Card.Header>
<Card.Title className="text-base">Activity</Card.Title>
<Card.Description className="text-xs">
{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
<ol className="flex flex-col">
{shown.map((event) => (
<li
key={event.id}
className="flex gap-2.5 border-t border-separator px-4 py-2.5 first:border-t-0"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT[event.kind] ?? "bg-muted"}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 text-sm leading-snug break-words">{event.title}</p>
<span className="shrink-0 text-[11px] tabular-nums text-muted">
{clockTime(event.ts)}
</span>
</div>
{event.detail && (
<p className="mt-0.5 font-mono text-[11px] leading-relaxed break-all text-muted">
{event.detail}
</p>
)}
{event.durationMs !== undefined && (
<p className="mt-0.5 text-[11px] tabular-nums text-muted">
took {duration(event.durationMs)}
</p>
)}
</div>
</li>
))}
</ol>
</Card.Content>
{rows.length > shown.length && (
<Card.Footer>
<Button variant="ghost" size="sm" fullWidth onPress={() => setLimit(limit + PAGE)}>
Show {Math.min(PAGE, rows.length - shown.length)} older
</Button>
</Card.Footer>
)}
</Card>
);
}
+130
View File
@@ -0,0 +1,130 @@
/** Hand-rolled icons: no icon package, so the bundle stays small and offline-safe. */
interface IconProps {
className?: string;
}
const base = "h-4 w-4 shrink-0";
export function LockIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<rect x="4" y="10.5" width="16" height="10.5" rx="2.5" />
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</svg>
);
}
export function FingerprintIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 3a9 9 0 0 0-9 9" />
<path d="M21 12a9 9 0 0 0-9-9" />
<path d="M12 7a5 5 0 0 0-5 5v3" />
<path d="M17 12a5 5 0 0 0-5-5" />
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V19" />
<path d="M13.5 12.5A1.5 1.5 0 0 0 12 11" />
<path d="M16.5 15.5V12" />
<path d="M7 19.5v-1" />
</svg>
);
}
export function GearIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 3v2.2M12 18.8V21M4.2 7.5l1.9 1.1M17.9 15.4l1.9 1.1M4.2 16.5l1.9-1.1M17.9 8.6l1.9-1.1" />
</svg>
);
}
export function CheckIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
}
export function BanIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" />
<path d="M6.2 17.8 17.8 6.2" />
</svg>
);
}
export function SunIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
export function MoonIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a7 7 0 1 0 10.5 10.5Z" />
</svg>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type {
ApprovalMode,
ApprovalSettings,
DeviceInfo,
GateInfo,
Snapshot,
} from "@/protocol";
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/browser";
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
/**
* The daemon rejects any POST without this header. A cross-origin page cannot set it without
* a CORS preflight that we never answer, so it is a second barrier behind the SameSite cookie.
*/
const POST_HEADERS = {
"content-type": "application/json",
"x-glance-csrf": "1",
};
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: "same-origin", ...init });
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
if (!res.ok) {
const message =
(body as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
throw new ApiError(message, res.status);
}
return body as T;
}
function post<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: "POST",
headers: POST_HEADERS,
body: JSON.stringify(body ?? {}),
});
}
export const api = {
gate: () => request<GateInfo>("/api/gate"),
snapshot: () => request<Snapshot>("/api/snapshot"),
devices: () => request<{ devices: DeviceInfo[]; current: string }>("/api/devices"),
logout: () => post<{ ok: true }>("/api/auth/logout"),
resolveApproval: (id: string, decision: "allow" | "deny") =>
post<{ ok: true }>("/api/approvals/resolve", { id, decision }),
setApproval: (patch: {
mode?: ApprovalMode;
requireWatcher?: boolean;
onTimeout?: "allow" | "deny";
}) => post<ApprovalSettings>("/api/approval", patch),
/**
* Sign in with an already-enrolled passkey. The device proves itself with a biometric or
* PIN; we never see or store anything the phone could not re-derive.
*/
async signIn(): Promise<string | undefined> {
const optionsJSON = await post<PublicKeyCredentialRequestOptionsJSON>(
"/api/auth/login/options",
);
const response = await startAuthentication({ optionsJSON });
const out = await post<{ ok: true; label?: string }>("/api/auth/login/verify", { response });
return out.label;
},
/**
* Enrol this device using a one-time code from `glance enroll`. The code is checked twice —
* once to get options, once to accept the attestation — and only consumed on success.
*/
async enroll(code: string, label: string): Promise<void> {
const optionsJSON = await post<PublicKeyCredentialCreationOptionsJSON>(
"/api/auth/register/options",
{ code },
);
const response = await startRegistration({ optionsJSON });
await post<{ ok: true }>("/api/auth/register/verify", { code, label, response });
},
};
+30
View File
@@ -0,0 +1,30 @@
/** Time formatting for a screen you look at for three seconds. */
export function relTime(ts: number, now: number): string {
const delta = Math.max(0, now - ts);
const s = Math.round(delta / 1000);
if (s < 5) return "now";
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
export function clockTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function duration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
/** Seconds left, floored at zero, for an approval countdown. */
export function secondsLeft(expiresAt: number, now: number): number {
return Math.max(0, Math.ceil((expiresAt - now) / 1000));
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type { Snapshot } from "@/protocol";
export type Connection = "connecting" | "live" | "offline";
/** Backoff caps out quickly: a phone coming out of sleep should reconnect, not sulk. */
const RETRY_MS = [500, 1000, 2000, 4000, 8000, 15_000];
/**
* Subscribes to the daemon's event stream and keeps the latest full snapshot.
*
* The server sends whole snapshots rather than deltas, so a phone that slept through twenty
* events still lands on the truth with no reconciliation logic here.
*/
export function useGlance(enabled: boolean) {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [connection, setConnection] = useState<Connection>("connecting");
const attempt = useRef(0);
useEffect(() => {
if (!enabled) {
setSnapshot(null);
setConnection("connecting");
return;
}
let stopped = false;
let source: EventSource | null = null;
let timer: number | undefined;
const schedule = () => {
if (stopped) return;
const wait = RETRY_MS[Math.min(attempt.current, RETRY_MS.length - 1)];
attempt.current += 1;
timer = window.setTimeout(open, wait);
};
const open = () => {
if (stopped) return;
// Fetch once alongside the stream so the first paint does not wait on the SSE handshake.
api.snapshot().then(
(snap) => {
if (!stopped) setSnapshot(snap);
},
() => {
/* the stream will report the real problem */
},
);
source = new EventSource("/events", { withCredentials: true });
source.addEventListener("open", () => {
if (stopped) return;
attempt.current = 0;
setConnection("live");
});
source.addEventListener("snapshot", (event) => {
if (stopped) return;
try {
setSnapshot(JSON.parse((event as MessageEvent<string>).data) as Snapshot);
setConnection("live");
} catch {
/* ignore a malformed frame rather than tearing down the stream */
}
});
// The daemon says goodbye on shutdown; reconnecting will pick it up when it returns.
source.addEventListener("bye", () => {
source?.close();
setConnection("offline");
schedule();
});
source.addEventListener("error", () => {
source?.close();
source = null;
if (stopped) return;
setConnection("offline");
schedule();
});
};
open();
// iOS suspends the stream in the background; nudge it the moment the app is looked at.
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (source && source.readyState === EventSource.OPEN) {
api.snapshot().then(setSnapshot, () => {});
return;
}
source?.close();
source = null;
attempt.current = 0;
if (timer) window.clearTimeout(timer);
open();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
stopped = true;
document.removeEventListener("visibilitychange", onVisible);
if (timer) window.clearTimeout(timer);
source?.close();
};
}, [enabled]);
return { snapshot, connection };
}
/** A ticking clock, for countdowns and "3s ago" labels. */
export function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);
return now;
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "@/App";
import "@/styles/globals.css";
// HeroUI v3 needs no provider — its components carry their own state. Theme handling lives in
// the library's own `useTheme` hook, which App calls.
const host = document.getElementById("root");
if (!host) throw new Error("missing #root");
createRoot(host).render(
<StrictMode>
<App />
</StrictMode>,
);
+107
View File
@@ -0,0 +1,107 @@
/**
* Wire protocol shared between the daemon and the web app.
*
* NOTE: this is a copy of server/src/protocol.ts. Keep the two in sync — they are duplicated
* rather than shared because the server compiles under NodeNext while the web app compiles
* under a bundler resolution, and a single rootDir cannot span both.
*/
export type EventKind =
| "session_start"
| "session_end"
| "prompt"
| "tool_start"
| "tool_end"
| "tool_fail"
| "permission_denied"
| "turn_end"
| "turn_error"
| "notification"
| "subagent_start"
| "subagent_end"
| "compact"
| "approval_request"
| "approval_allowed"
| "approval_denied"
| "approval_expired";
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
export interface GlanceEvent {
id: number;
ts: number;
sessionId: string;
kind: EventKind;
/** Tool name, for tool-shaped events. */
tool?: string;
/** One-line human summary, already truncated and redacted. */
title: string;
/** Optional second line, e.g. a file path or an error message. */
detail?: string;
durationMs?: number;
}
export interface SessionView {
id: string;
/** Basename of the workspace root — what you actually recognise on a phone. */
label: string;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
currentTool?: { name: string; title: string; startedAt: number };
counts: { tools: number; failures: number; denials: number };
}
export interface PendingApproval {
id: string;
sessionId: string;
sessionLabel: string;
tool: string;
title: string;
detail?: string;
createdAt: number;
expiresAt: number;
}
export type ApprovalMode = "off" | "risky" | "all";
export interface ApprovalSettings {
mode: ApprovalMode;
riskyPattern: string;
timeoutMs: number;
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
requireWatcher: boolean;
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
onTimeout: "allow" | "deny";
}
export interface Snapshot {
now: number;
version: string;
sessions: SessionView[];
events: GlanceEvent[];
pending: PendingApproval[];
approval: ApprovalSettings;
}
export interface DeviceInfo {
id: string;
label: string;
createdAt: number;
lastUsedAt?: number;
}
/** Everything the app needs before it knows whether you are signed in. */
export interface GateInfo {
authenticated: boolean;
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
enrolled: boolean;
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
enrollmentOpen: boolean;
version: string;
deviceLabel?: string;
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
rpId?: string;
}
+44
View File
@@ -0,0 +1,44 @@
@import "tailwindcss";
@import "@heroui/styles";
@custom-variant dark (&:is(.dark *));
/* A dashboard you read one-handed: no rubber-band scroll surprises, no text inflation. */
html {
-webkit-text-size-adjust: 100%;
/* useTheme sets the class and data-theme but not color-scheme, and without it the phone
paints native scrollbars and form controls light on a dark page. */
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
min-height: 100dvh;
overscroll-behavior-y: none;
}
/* Keep the timeline scrollable without a visible scrollbar eating width on mobile. */
.glance-scroll {
scrollbar-width: thin;
}
.glance-scroll::-webkit-scrollbar {
width: 6px;
}
.glance-scroll::-webkit-scrollbar-thumb {
border-radius: 3px;
background: color-mix(in oklab, currentColor 20%, transparent);
}
/* Respect a user who has asked the OS to calm things down. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />