Compare commits
6
Commits
b3b6bf3f70
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966133eeda | ||
|
|
b586323fdb | ||
|
|
3cfa011d0c | ||
|
|
d20eb9255c | ||
|
|
6d34a17d9d | ||
|
|
5eec1940be |
@@ -1,4 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "grok-glance",
|
||||
"description": "One-entry marketplace: this repo is both the catalog and the plugin, so `grok plugin marketplace add <git-url>` is enough to install it.",
|
||||
"owner": {
|
||||
"name": "grok-glance"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "grok-glance",
|
||||
"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.",
|
||||
"category": "monitoring",
|
||||
"keywords": [
|
||||
"grok-glance",
|
||||
"glance dashboard",
|
||||
"webauthn passkey",
|
||||
"remote approval",
|
||||
"session monitor"
|
||||
],
|
||||
"source": { "type": "local", "path": "./" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,77 +1,106 @@
|
||||
# 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.
|
||||
your agents are doing from your phone — and tap **approve** or **deny** when one wants to run
|
||||
something risky. Several agents at once is the normal case, not an edge case.
|
||||
|
||||
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
|
||||
or drive a session.
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ ● grok-glance 2 sessions│
|
||||
│ ● grok-glance 2 working ·│
|
||||
│ 1 waiting │
|
||||
├────────────────────────────┤
|
||||
│ Waiting on you 62s │
|
||||
│ Bash · in remote-grok │
|
||||
│ Bash · in ●2 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 ✗ │
|
||||
│ Agents 3 live │
|
||||
│ All agents 3 │
|
||||
│ ●1 remote-grok working │
|
||||
│ Read 4s state.ts +2 │
|
||||
│ 31 tools now │
|
||||
│ ●2 remote-grok waiting │
|
||||
│ Bash 1m rm -rf ./dist │
|
||||
│ 12 tools 1 failed 4s │
|
||||
│ ●3 docs-site error │
|
||||
│ build failed 2m │
|
||||
├────────────────────────────┤
|
||||
│ Activity │
|
||||
│ ● Read state.ts 14:22 │
|
||||
│ ● Bash npm test 14:21 │
|
||||
│ ● Read state.ts ●1 14:22 │
|
||||
│ ● Bash npm test ●3 14:21 │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20 or newer, and npm.
|
||||
- Node.js 20 or newer. **npm is only needed to develop it** — `dist/` is committed, and the
|
||||
daemon bundle carries its one runtime dependency inside it, so an installed copy never runs
|
||||
a build or an install step.
|
||||
- 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
|
||||
|
||||
Grok Build loads plugins straight out of `~/.grok/plugins/`, so the shortest install is a clone:
|
||||
|
||||
```sh
|
||||
git clone <this repo> grok-glance
|
||||
cd grok-glance
|
||||
npm install && npm run build
|
||||
git clone <this repo> ~/.grok/plugins/grok-glance
|
||||
```
|
||||
|
||||
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required;
|
||||
the daemon serves the dashboard itself.
|
||||
That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable
|
||||
**grok-glance**. The first hook to fire after that — your next prompt, or the first tool call —
|
||||
starts the daemon in the background, and the dashboard is on `http://127.0.0.1:8791`. (Not the
|
||||
`SessionStart` hook, which for a plugin never runs; see [Hook wiring](#hook-wiring).)
|
||||
|
||||
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:
|
||||
### …or from a marketplace, by URL
|
||||
|
||||
```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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
If you would rather install it the way marketplace plugins are installed — or point several
|
||||
machines at one URL — this repo is also its own one-entry marketplace:
|
||||
`.grok-plugin/marketplace.json` lists exactly one plugin, sourced from `./`, which is the repo
|
||||
root the catalog itself lives in. So the repo's git URL is a complete marketplace, with no commit
|
||||
SHA to pin (a self-referencing remote source would have to pin the SHA of the commit that contains
|
||||
the pin).
|
||||
|
||||
Add it as a marketplace source from the TUI — `/plugins`, Marketplace tab — or from the CLI:
|
||||
|
||||
```sh
|
||||
grok plugin marketplace add https://your-git-host/you/grok-glance.git
|
||||
grok plugin install grok-glance --trust
|
||||
```
|
||||
|
||||
…then add that marketplace and install `grok-glance` from Grok Build's `/plugin` interface.
|
||||
Configured sources are recorded in `~/.grok/config.toml` under `[[marketplace.sources]]` and in
|
||||
`~/.grok/plugins/known_marketplaces.json`; the TUI and `grok plugin marketplace list` read the same
|
||||
list. Check `grok plugin marketplace --help` if the subcommand names have moved — the clone above
|
||||
does not depend on any of this.
|
||||
|
||||
Once installed, the daemon starts by itself: the `SessionStart` hook boots it in the background on
|
||||
the first session after installation.
|
||||
### Why there is no build step
|
||||
|
||||
`dist/` is checked in:
|
||||
|
||||
- `dist/server/index.js` — the daemon, bundled to a single dependency-free ESM file. Its only
|
||||
runtime dependency, `@simplewebauthn/server`, is inlined; everything else it uses is the Node
|
||||
standard library. It is *not* minified, so what ships is what you can read.
|
||||
- `dist/web/` — the dashboard, already a static bundle, which the daemon serves itself.
|
||||
|
||||
The hook scripts under `bin/` were stdlib-only from the start. So a clone has nothing to resolve
|
||||
and nothing to compile, which is what makes a bare git URL enough.
|
||||
|
||||
Working on it instead? Then you do need the toolchain:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build # tsc typechecks, rolldown bundles the server, vite builds the web app
|
||||
npm run check:dist # rebuilds and fails if the committed dist/ is stale
|
||||
```
|
||||
|
||||
`hooks/hooks.json` is checked in as-is — nothing about it is generated or machine-specific. The
|
||||
shared secret the hook scripts authenticate with lives in `~/.grok/glance/hook.secret` (mode 0600)
|
||||
and is created by the daemon on first start; it never appears in `hooks.json`.
|
||||
|
||||
## Get it onto your phone
|
||||
|
||||
@@ -116,18 +145,50 @@ Repeat for each device you want. `node bin/glance devices` lists them; `node bin
|
||||
|
||||
## 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.
|
||||
- **Agents** — one row per agent whenever there is more than one: badge, workspace, state, what it
|
||||
is running right now, and how long ago it last did anything. Tap one to focus it; tap **All
|
||||
agents** to come back. Ended sessions are folded away behind a toggle.
|
||||
- **Now** — the focused agent: its workspace, state (working / waiting on you / idle / error /
|
||||
ended), the last thing you asked, **every** tool it currently has in flight with a live elapsed
|
||||
timer each, and running counts of tools, failures and denials.
|
||||
- **Activity** — a merged timeline of prompts, tool calls with durations, failures, permission
|
||||
denials, notifications, subagents, compactions, session start/end. Each row is stamped with the
|
||||
badge of the agent it came from; focusing an agent filters it down to that one.
|
||||
- **Pending approvals** — a card per waiting tool call, with the command, which agent is asking, 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.
|
||||
|
||||
## Several agents at once
|
||||
|
||||
Watching four agents on a phone is a different problem from watching one, so a few things are not
|
||||
what you might assume:
|
||||
|
||||
- **Badges, not names.** Labels are workspace basenames, so two agents in the same repo are both
|
||||
"remote-grok". The daemon hands each session a small ordinal in arrival order — `●1`, `●2` — and
|
||||
the dashboard colours everything belonging to that agent with it: its row, its timeline lines, its
|
||||
approval cards. The ordinal survives a daemon restart.
|
||||
- **Sorted by who needs you, then fixed.** Rows are ordered *waiting → error → working → idle →
|
||||
ended*, and ties break on badge. Within a state an agent never changes position, because a list
|
||||
that re-sorts on every event moves the row out from under a thumb already heading for it.
|
||||
- **A chatty agent cannot bury the others.** The event ring is global, but eviction always takes
|
||||
from whichever session currently holds the most of it. One agent in a tight loop trims itself
|
||||
rather than blanking everyone else's history.
|
||||
- **Parallel tool calls are all shown.** An agent runs several tools at once; the focused card lists
|
||||
them and the overview row shows the first with a `+2`. Durations are matched oldest-first per tool
|
||||
name, since hook payloads carry no call id.
|
||||
- **A restart does not lose the roster.** Events carry no workspace root, so the session map is
|
||||
persisted separately (`sessions.json`) and reloaded on boot — otherwise every agent would come
|
||||
back nameless until it happened to speak again. In-flight tools are deliberately *not* restored:
|
||||
they belonged to a process that no longer exists.
|
||||
|
||||
`glance status` shows the same breakdown from a terminal:
|
||||
|
||||
```
|
||||
sessions : 4 (1 waiting on you, 1 error, 2 working)
|
||||
```
|
||||
|
||||
## Remote approve / deny
|
||||
|
||||
Off by default. Turn it on from the phone's settings panel, or:
|
||||
@@ -144,12 +205,13 @@ Defaults worth knowing:
|
||||
|---|---|---|---|
|
||||
| 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. |
|
||||
| Timeout | 90s | no — edit `config.json` | Also the ceiling: the approval hook gets 125s in `hooks.json`, and the daemon clamps a larger `timeoutMs` down to 90s so the script always outlives its own wait. |
|
||||
| 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.
|
||||
down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer
|
||||
recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own
|
||||
permission settings.
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -167,7 +229,6 @@ actually blocked, use Grok Build's own permission settings.
|
||||
| `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
|
||||
|
||||
@@ -180,8 +241,11 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
|
||||
| `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 |
|
||||
| `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. |
|
||||
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
|
||||
| `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. |
|
||||
| `daemon.log` | Daemon stdout/stderr |
|
||||
| `daemon.lock` | Held while a hook script is starting the daemon, so a burst of events starts one and not five. Created with `O_EXCL`, deleted on the way out, and ignored by anyone else once 15s stale. |
|
||||
|
||||
Three environment variables override `config.json`, which is mostly useful for testing a second
|
||||
instance without touching your real one:
|
||||
@@ -192,8 +256,8 @@ instance without touching your real one:
|
||||
| `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.
|
||||
Nothing in `hooks/hooks.json` is machine-specific: the hook scripts read `config.json` themselves,
|
||||
so changing the port or `approval.timeoutMs` needs nothing but a daemon restart.
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -202,13 +266,20 @@ its own. Three classes of caller:
|
||||
|
||||
| Path | Caller | Authentication |
|
||||
|---|---|---|
|
||||
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only |
|
||||
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request |
|
||||
| `/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.
|
||||
None of the three trusts the source address, 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. Without
|
||||
the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval
|
||||
prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and
|
||||
additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local
|
||||
hook process never sends and a tunnelled caller always does.
|
||||
|
||||
That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are
|
||||
local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives
|
||||
through `tailscale serve` with those headers on every request and is unaffected.
|
||||
|
||||
**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
|
||||
@@ -256,21 +327,59 @@ 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.
|
||||
`hooks/hooks.json` subscribes to all 14 lifecycle events and is a plain checked-in file — edit it
|
||||
directly.
|
||||
|
||||
Two exceptions:
|
||||
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon
|
||||
by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host**
|
||||
and refuses the resolved address if it is private, link-local or CGNAT
|
||||
(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback
|
||||
fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into
|
||||
`100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no
|
||||
request header but `Content-Type`, with no configuration surface for one, so such a hook could not
|
||||
authenticate itself even if it could connect. This plugin shipped `http` hooks for a while, and the
|
||||
result was 13 passive hooks failing validation silently on every event.
|
||||
|
||||
- `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.
|
||||
A command hook has none of those problems. It is a local process, so no URL is validated, nothing
|
||||
traverses the tunnel, and it can present the shared secret — hook traffic goes straight to
|
||||
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs
|
||||
`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ:
|
||||
|
||||
- `SessionStart` runs `bin/glance-up.mjs`. It is the obvious hook to boot the daemon from, and it
|
||||
never runs — see below.
|
||||
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second 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; keeping the match narrow means the gate's cost is paid only for calls that could
|
||||
actually need a tap. Its `timeout` is a fixed 125s, and the daemon clamps its own wait to 90s
|
||||
against it (`APPROVAL_MAX_WAIT_MS`) so the script is never killed before it can fail open.
|
||||
|
||||
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
||||
deliberately denying.
|
||||
deliberately denying — including when the daemon rejects their token.
|
||||
|
||||
### Why every recorder boots the daemon
|
||||
|
||||
A plugin gets no usable boot event, so `bin/glance-record.mjs` starts the daemon itself when it
|
||||
finds it missing, and whichever event fires first wins.
|
||||
|
||||
`SessionStart` looks like the right answer and cannot work. Grok Build dispatches it from inside
|
||||
session creation (`xai-grok-shell`, `agent_ops.rs` → `SessionCommand::DispatchSessionStartHook`),
|
||||
and the dispatch resolves against the session's hook registry **as it stands at that moment**. That
|
||||
registry comes from `discover_hooks()`, whose sources are the config layers and the global/project
|
||||
settings files — `~/.grok/settings.json`, `<git_root>/.grok/hooks`, the vendor-compat paths. Plugin
|
||||
directories are not among them. Plugin hooks are appended separately, under a `plugin/` prefix, by
|
||||
`reload_hooks_impl` and `reload_plugins_impl` — both of which run later, in response to a plugin
|
||||
action, a `/hooks reload`, or a folder-trust grant. So a plugin's `SessionStart` entry is always
|
||||
registered after `SessionStart` has already been dispatched, and is never called. Every other event
|
||||
this plugin subscribes to happens later in the session, once the plugin registry has landed, which
|
||||
is why they all work.
|
||||
|
||||
The symptom, if you hit this from the other end: no `daemon.log` at all, nothing on `:8791`, and a
|
||||
manual `glance up` working perfectly.
|
||||
|
||||
Asking costs one loopback request to `/healthz` per event, which is the steady state once the
|
||||
daemon is up. The spawn path is taken once. A `daemon.lock` (`O_EXCL`, 15s staleness) keeps a burst
|
||||
of concurrent events from starting five daemons and leaving four of them to die on `EADDRINUSE`.
|
||||
|
||||
## Deliberately omitted
|
||||
|
||||
|
||||
+33
-40
@@ -10,17 +10,16 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
PLUGIN_ROOT,
|
||||
SERVER_ENTRY,
|
||||
baseUrl,
|
||||
ensureDaemon,
|
||||
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);
|
||||
@@ -59,29 +58,38 @@ async function api(pathname, { method = "GET", body, admin = false } = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** "3 (2 working, 1 waiting on you)" — a raw count says nothing when you watch several agents. */
|
||||
function sessionBreakdown(states) {
|
||||
if (!states || typeof states !== "object") return "";
|
||||
const order = [
|
||||
["waiting", "waiting on you"],
|
||||
["error", "error"],
|
||||
["working", "working"],
|
||||
["idle", "idle"],
|
||||
["ended", "ended"],
|
||||
];
|
||||
const parts = order
|
||||
.filter(([key]) => Number(states[key]) > 0)
|
||||
.map(([key, label]) => `${states[key]} ${label}`);
|
||||
return parts.length ? ` (${parts.join(", ")})` : "";
|
||||
}
|
||||
|
||||
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`);
|
||||
console.error(
|
||||
`grok-glance: ${SERVER_ENTRY} is missing.\n\n` +
|
||||
`dist/ ships with the plugin, so this checkout is incomplete. Rebuild it:\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")}`);
|
||||
// No hook timeout to fit inside here, so wait long enough that a slow cold start still counts.
|
||||
if (await ensureDaemon(cfg, { waitMs: 8000, startedBy: "cli" })) return true;
|
||||
console.error(`daemon did not come up; see ${path.join(glanceHome(), "daemon.log")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -122,8 +130,14 @@ switch (cmd) {
|
||||
console.log(` devices : ${s.devices}`);
|
||||
console.log(` approval mode : ${s.approval.mode}`);
|
||||
console.log(` watchers : ${s.watchers}`);
|
||||
console.log(` sessions : ${s.sessions}`);
|
||||
console.log(` sessions : ${s.sessions}${sessionBreakdown(s.sessionStates)}`);
|
||||
console.log(` events kept : ${s.events}`);
|
||||
if (s.hookAuthOk === false) {
|
||||
console.log(
|
||||
"\n ! hook auth mismatch: $GLANCE_HOME/hook.secret no longer matches what the daemon" +
|
||||
"\n loaded, so events are being dropped. Restart it: glance stop && glance up",
|
||||
);
|
||||
}
|
||||
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
||||
break;
|
||||
}
|
||||
@@ -199,26 +213,6 @@ switch (cmd) {
|
||||
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)) {
|
||||
@@ -241,7 +235,6 @@ switch (cmd) {
|
||||
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
|
||||
`);
|
||||
}
|
||||
|
||||
+21
-4
@@ -10,7 +10,15 @@
|
||||
* 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";
|
||||
import {
|
||||
APPROVAL_HOOK_TIMEOUT_SECS,
|
||||
baseUrl,
|
||||
envEnvelope,
|
||||
hookHeaders,
|
||||
postJson,
|
||||
readConfig,
|
||||
readStdinJson,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
@@ -28,11 +36,20 @@ function deny(reason) {
|
||||
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);
|
||||
// Finish inside the hook's own timeout from hooks.json, with room to spare: if Grok Build
|
||||
// kills us first, the fail-open path below never gets to run.
|
||||
const waitMs = Math.min(
|
||||
APPROVAL_HOOK_TIMEOUT_SECS * 1000 - 10_000,
|
||||
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
|
||||
);
|
||||
|
||||
try {
|
||||
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
|
||||
const { data } = await postJson(
|
||||
`${baseUrl(cfg)}/hook/approve`,
|
||||
payload,
|
||||
waitMs,
|
||||
hookHeaders(),
|
||||
);
|
||||
if (data && data.decision === "deny") deny(data.reason);
|
||||
allow();
|
||||
} catch {
|
||||
|
||||
+144
-2
@@ -8,10 +8,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
|
||||
|
||||
export const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
||||
|
||||
export const DEFAULT_PORT = 8791;
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,41 @@ export function baseUrl(cfg = readConfig()) {
|
||||
return `http://127.0.0.1:${cfg.port}`;
|
||||
}
|
||||
|
||||
/** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */
|
||||
export const HOOK_HEADER = "x-glance-hook";
|
||||
|
||||
/**
|
||||
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
|
||||
* regenerated secret is picked up without restarting anything.
|
||||
*
|
||||
* Only the daemon ever creates it. A hook must never be the thing that creates state, and a
|
||||
* missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null
|
||||
* and the callers carry on.
|
||||
*/
|
||||
export function hookSecret() {
|
||||
try {
|
||||
return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hookHeaders() {
|
||||
const token = hookSecret();
|
||||
return token ? { [HOOK_HEADER]: token } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the PreToolUse approval hook is allowed to run, in seconds — the `timeout` written
|
||||
* next to glance-approve.mjs in hooks/hooks.json. Change one, change the other.
|
||||
*
|
||||
* It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills
|
||||
* it and the fail-open path never runs. So the daemon caps its own wait well inside it (see
|
||||
* APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of
|
||||
* that to answer.
|
||||
*/
|
||||
export const APPROVAL_HOOK_TIMEOUT_SECS = 125;
|
||||
|
||||
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
|
||||
export async function readStdinJson() {
|
||||
if (process.stdin.isTTY) return {};
|
||||
@@ -77,10 +115,10 @@ export function envEnvelope(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function postJson(url, body, timeoutMs) {
|
||||
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { "content-type": "application/json", ...extraHeaders },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
@@ -105,3 +143,107 @@ export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
|
||||
}
|
||||
|
||||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/* ------------------------------------------------------------------ daemon start */
|
||||
|
||||
/**
|
||||
* How long a spawn lock is believed before another hook takes it over. Long enough to cover a
|
||||
* cold Node start and a bind, short enough that a script killed mid-spawn cannot wedge startup
|
||||
* for the rest of the session.
|
||||
*/
|
||||
const SPAWN_LOCK_STALE_MS = 15_000;
|
||||
|
||||
/** Never hold the lock for less than this, so a fire-and-forget caller still covers the bind. */
|
||||
const MIN_SPAWN_WAIT_MS = 600;
|
||||
|
||||
/**
|
||||
* Claim the right to spawn the daemon, so a burst of hooks does not start a race in which every
|
||||
* loser dies on EADDRINUSE and litters daemon.log.
|
||||
*
|
||||
* `wx` is the whole mechanism: an atomic create-or-fail. A lock that is already there and still
|
||||
* fresh means another script is mid-spawn, and we wait for its daemon rather than starting a
|
||||
* second one. Taking over a *stale* lock is deliberately not atomic — two scripts could both
|
||||
* decide it is stale and both spawn — because the consequence is only the EADDRINUSE we had
|
||||
* before, and it takes a 15s-dead lock to get there at all.
|
||||
*/
|
||||
function acquireSpawnLock(home) {
|
||||
const file = path.join(home, "daemon.lock");
|
||||
try {
|
||||
fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
|
||||
return file;
|
||||
} catch {
|
||||
try {
|
||||
if (Date.now() - fs.statSync(file).mtimeMs < SPAWN_LOCK_STALE_MS) return null;
|
||||
fs.writeFileSync(file, String(process.pid), { mode: 0o600 });
|
||||
return file;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilUp(cfg, budgetMs) {
|
||||
const deadline = Date.now() + budgetMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(100);
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) break;
|
||||
if (await isDaemonUp(cfg, Math.min(300, Math.max(50, left)))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the daemon is listening, starting it if it is not. Never throws.
|
||||
*
|
||||
* Every recording hook calls this, not just one designated boot hook, because Grok Build gives
|
||||
* a plugin no usable boot event. `SessionStart` is dispatched from inside session creation
|
||||
* (xai-grok-shell agent_ops.rs, `DispatchSessionStartHook`) and dispatch reads the session's
|
||||
* hook registry as it stands at that moment. That registry is built by `discover_hooks()`,
|
||||
* whose sources are the config layers and the global/project settings files — plugin hooks are
|
||||
* not among them. They are appended later, with a `plugin/` prefix, only by `reload_hooks_impl`
|
||||
* and `reload_plugins_impl`. So a plugin's `SessionStart` entry is registered strictly after
|
||||
* `SessionStart` has already fired, and never runs. Every other event we subscribe to happens
|
||||
* later in the session, once the plugin registry has landed — so whichever of them fires first
|
||||
* is the one that has to boot us.
|
||||
*
|
||||
* The cost of asking is one loopback request to /healthz once the daemon is up, which is the
|
||||
* steady state; the spawn path is taken once per machine boot.
|
||||
*/
|
||||
export async function ensureDaemon(cfg = readConfig(), options = {}) {
|
||||
const { waitMs = 8000, startedBy = "hook", onMissingBuild } = options;
|
||||
try {
|
||||
if (await isDaemonUp(cfg)) return true;
|
||||
if (!fs.existsSync(SERVER_ENTRY)) {
|
||||
onMissingBuild?.(SERVER_ENTRY);
|
||||
return false;
|
||||
}
|
||||
const home = glanceHome();
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
|
||||
const budget = Math.max(waitMs, MIN_SPAWN_WAIT_MS);
|
||||
const lock = acquireSpawnLock(home);
|
||||
// Someone else is already starting it: wait on theirs instead of racing it.
|
||||
if (!lock) return await waitUntilUp(cfg, budget);
|
||||
|
||||
try {
|
||||
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: startedBy },
|
||||
});
|
||||
child.unref();
|
||||
return await waitUntilUp(cfg, budget);
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(lock);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A dashboard that cannot start must still not be the reason a tool call fails.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Passive recorder hook: POST one lifecycle event into the daemon and get out of the way.
|
||||
*
|
||||
* Wired to every observed event. This used to be a `type: "http"` hook — no process spawn at
|
||||
* all — until it turned out that Grok Build's http runner validates the URL against its SSRF
|
||||
* rules and rejects any scheme that is not https (crates/codegen/xai-grok-hooks/src/runner/
|
||||
* http.rs, `validate_hook_url`). The daemon speaks plain http on loopback, so an http hook
|
||||
* could never reach it: those hooks were failing validation, silently, on every event.
|
||||
*
|
||||
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an
|
||||
* authentication header, which the http runner has no config surface for.
|
||||
*
|
||||
* It also boots the daemon if nothing else has. That is not this hook being greedy: a plugin's
|
||||
* `SessionStart` entry provably never runs (see `ensureDaemon` in glance-lib.mjs), so there is
|
||||
* no single boot event to delegate to and whichever recorder fires first has to do it.
|
||||
*
|
||||
* Always exits 0. A dashboard must never be the reason a tool call fails.
|
||||
*/
|
||||
|
||||
import {
|
||||
baseUrl,
|
||||
ensureDaemon,
|
||||
envEnvelope,
|
||||
hookHeaders,
|
||||
postJson,
|
||||
readConfig,
|
||||
readStdinJson,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
/**
|
||||
* hooks/hooks.json gives this hook 5s. Everything below has to finish inside that with room to
|
||||
* spare, because a killed script is a lost event either way — and losing one is fine, the next
|
||||
* event is 40ms behind it.
|
||||
*/
|
||||
const START_BUDGET_MS = 1200;
|
||||
const POST_BUDGET_MS = 1500;
|
||||
|
||||
try {
|
||||
const payload = envEnvelope(await readStdinJson());
|
||||
const cfg = readConfig();
|
||||
// Cheap when the daemon is already up, which is every call but the first of the session.
|
||||
if (await ensureDaemon(cfg, { waitMs: START_BUDGET_MS, startedBy: "record-hook" })) {
|
||||
// hookHeaders() is read after the daemon is up: on a first-ever run it is the daemon we
|
||||
// just started that created hook.secret.
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, POST_BUDGET_MS, hookHeaders());
|
||||
}
|
||||
} catch {
|
||||
// Daemon down, no secret yet, malformed payload — all the same answer: carry on.
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
+27
-43
@@ -2,64 +2,48 @@
|
||||
/**
|
||||
* 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.
|
||||
* Note that as of Grok Build's current hook wiring this never actually runs: a plugin's
|
||||
* `SessionStart` entry is registered after `SessionStart` has already been dispatched, so the
|
||||
* event finds no plugin hooks to call. `ensureDaemon` in glance-lib.mjs has the details, and
|
||||
* bin/glance-record.mjs is what really boots the daemon.
|
||||
*
|
||||
* It stays wired anyway. It costs nothing when it does not fire, it is the correct hook for the
|
||||
* job on the day that ordering is fixed, and it keeps the answer to "what starts this thing"
|
||||
* in the obvious place.
|
||||
*
|
||||
* It always exits 0 — a monitoring dashboard must never be the reason a session fails to start.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
PLUGIN_ROOT,
|
||||
baseUrl,
|
||||
ensureDaemon,
|
||||
envEnvelope,
|
||||
glanceHome,
|
||||
isDaemonUp,
|
||||
hookHeaders,
|
||||
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);
|
||||
// hooks/hooks.json allows this hook 20s; leave most of it as headroom.
|
||||
const up = await ensureDaemon(cfg, {
|
||||
waitMs: 8000,
|
||||
startedBy: "session-start-hook",
|
||||
onMissingBuild: (entry) => {
|
||||
// dist/ ships with the plugin, so this means an incomplete checkout. Say so once, on
|
||||
// stderr, where it is recorded but harmless — a hook must never fail a session.
|
||||
process.stderr.write(
|
||||
`[grok-glance] ${entry} is missing - run \`npm install && npm run build\` in the plugin root\n`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (up) {
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
|
||||
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
|
||||
// spawned is what created hook.secret.
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500, hookHeaders());
|
||||
}
|
||||
} catch {
|
||||
// Fail open, always.
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ 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.
|
||||
2. The plugin ships prebuilt, so this should just work. If it does say the plugin is not built,
|
||||
`dist/` is missing from the checkout: run `npm install && npm run build` in
|
||||
`$GROK_PLUGIN_ROOT` (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:
|
||||
|
||||
Vendored
+23758
File diff suppressed because it is too large
Load Diff
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+15
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -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 |
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
<!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" />
|
||||
<script type="module" crossorigin src="/assets/index-D-dJ5pn0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZSiLyex.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+19
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+71
-24
@@ -1,14 +1,37 @@
|
||||
{
|
||||
"_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."
|
||||
"Every entry is a `command` hook, including the 13 passive recorders. That is not a style",
|
||||
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
|
||||
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
|
||||
"then resolves the host and refuses the resolved address if it is private, link-local or",
|
||||
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
|
||||
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
|
||||
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
|
||||
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
|
||||
"could connect.",
|
||||
"",
|
||||
"A command hook has none of those problems: it is a local process, so there is no URL to",
|
||||
"validate, no proxy in the path, and it reads the shared secret out of $GLANCE_HOME itself.",
|
||||
"It costs one Node start (~40ms) per event. Nothing here is secret, and nothing here is",
|
||||
"derived from config.json - the scripts read that themselves - so this file is plain,",
|
||||
"committed, and edited by hand.",
|
||||
"",
|
||||
"Every recorder boots the daemon if it is not already up, rather than one designated boot",
|
||||
"hook doing it. Grok Build gives a plugin no usable boot event: SessionStart is dispatched",
|
||||
"from inside session creation, against the hook registry as it stands at that moment, and",
|
||||
"that registry holds only config-layer and settings-file hooks. Plugin hooks are appended",
|
||||
"afterwards, under a 'plugin/' prefix, by reload_hooks_impl and reload_plugins_impl - so the",
|
||||
"SessionStart entry below is registered strictly after SessionStart has already fired and",
|
||||
"never runs. It is kept because it costs nothing and is the right hook once that is fixed.",
|
||||
"",
|
||||
"PreToolUse is wired twice on purpose: one entry records every call for the timeline, and a",
|
||||
"second, narrowly matched entry runs the approval gate, because PreToolUse is the only",
|
||||
"blocking event and only a command hook can return a deny.",
|
||||
"",
|
||||
"The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps",
|
||||
"approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets",
|
||||
"to fail open. Changing the number here means changing APPROVAL_HOOK_TIMEOUT_SECS in",
|
||||
"bin/glance-lib.mjs and APPROVAL_MAX_WAIT_MS in server/src/config.ts to match."
|
||||
],
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
@@ -26,9 +49,9 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8791/hook/record",
|
||||
"timeout": 3
|
||||
"type": "command",
|
||||
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -45,62 +68,86 @@
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PermissionDenied": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"StopFailure": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStart": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+1
@@ -21,6 +21,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"rolldown": "1.0.3",
|
||||
"tailwind-variants": "3.3.0",
|
||||
"tailwindcss": "4.3.1",
|
||||
"typescript": "5.6.3",
|
||||
|
||||
+4
-2
@@ -9,11 +9,12 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:server && npm run build:web",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"build:server": "tsc -p tsconfig.server.json && rolldown server/src/index.ts -o dist/server/index.js -f esm -p node",
|
||||
"build:web": "tsc -p tsconfig.web.json && vite build",
|
||||
"dev": "vite",
|
||||
"start": "node dist/server/index.js",
|
||||
"glance": "node bin/glance"
|
||||
"glance": "node bin/glance",
|
||||
"check:dist": "npm run build && test -z \"$(git status --porcelain dist)\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "3.2.4",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"rolldown": "1.0.3",
|
||||
"tailwind-variants": "3.3.0",
|
||||
"tailwindcss": "4.3.1",
|
||||
"typescript": "5.6.3",
|
||||
|
||||
@@ -44,10 +44,15 @@ export class ApprovalBroker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soonest to expire first. With one agent that is the same as oldest-first; with four it is
|
||||
* the difference between answering the call that is about to time out and answering the one
|
||||
* that happened to ask first.
|
||||
*/
|
||||
pending(): PendingApproval[] {
|
||||
return [...this.waiters.values()]
|
||||
.map((w) => w.approval)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
.sort((a, b) => a.expiresAt - b.expiresAt || a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
async request(payload: HookPayload): Promise<Decision> {
|
||||
@@ -59,12 +64,14 @@ export class ApprovalBroker {
|
||||
}
|
||||
|
||||
const sessionId = payload.sessionId ?? "unknown";
|
||||
const session = this.state.ensureSession(sessionId, payload);
|
||||
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),
|
||||
sessionLabel: session.label,
|
||||
sessionBadge: session.badge,
|
||||
tool,
|
||||
title: summary.title,
|
||||
detail: summary.detail,
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { IncomingMessage } from "node:http";
|
||||
|
||||
export const SESSION_COOKIE = "glance_session";
|
||||
export const CSRF_HEADER = "x-glance-csrf";
|
||||
/** Shared-secret header presented by the hook scripts on /hook/*. */
|
||||
export const HOOK_HEADER = "x-glance-hook";
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
@@ -43,9 +43,15 @@ export const paths = {
|
||||
get adminToken() {
|
||||
return path.join(glanceHome(), "admin.token");
|
||||
},
|
||||
get hookSecret() {
|
||||
return path.join(glanceHome(), "hook.secret");
|
||||
},
|
||||
get events() {
|
||||
return path.join(glanceHome(), "events.jsonl");
|
||||
},
|
||||
get sessions() {
|
||||
return path.join(glanceHome(), "sessions.json");
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULTS: Config = {
|
||||
@@ -76,6 +82,21 @@ export function ensureHome(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest the daemon may hold a tool call waiting for a tap.
|
||||
*
|
||||
* hooks/hooks.json gives the approval hook a fixed 125s timeout, and glance-approve.mjs keeps
|
||||
* 10s of that for itself. Waiting longer than this would get the script killed mid-wait, and a
|
||||
* killed script never runs its fail-open path — so a hand-edited config.json is clamped rather
|
||||
* than believed.
|
||||
*/
|
||||
export const APPROVAL_MAX_WAIT_MS = 90_000;
|
||||
|
||||
function clampApprovalWait(ms: number): number {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return DEFAULTS.approval.timeoutMs;
|
||||
return Math.min(ms, APPROVAL_MAX_WAIT_MS);
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
ensureHome();
|
||||
let stored: Partial<Config> = {};
|
||||
@@ -89,6 +110,7 @@ export function loadConfig(): Config {
|
||||
...stored,
|
||||
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
|
||||
};
|
||||
merged.approval.timeoutMs = clampApprovalWait(merged.approval.timeoutMs);
|
||||
if (process.env.GLANCE_PORT) {
|
||||
const p = Number(process.env.GLANCE_PORT);
|
||||
if (Number.isFinite(p)) merged.port = p;
|
||||
|
||||
+49
-9
@@ -3,10 +3,13 @@
|
||||
*
|
||||
* One small http server with three kinds of caller:
|
||||
*
|
||||
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
|
||||
* /hook/* the plugin's hook scripts, gated by the shared secret in hook.secret.
|
||||
* /api/* the web app, gated by a passkey-backed cookie session.
|
||||
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
|
||||
*
|
||||
* None of the three trusts the source address: `tailscale serve` proxies tailnet traffic to
|
||||
* 127.0.0.1, so every caller looks local.
|
||||
*
|
||||
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
|
||||
* gone, Grok Build keeps working.
|
||||
*/
|
||||
@@ -15,7 +18,6 @@ import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { URL } from "node:url";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
VERSION,
|
||||
deriveRpId,
|
||||
ensureHome,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
import {
|
||||
CSRF_HEADER,
|
||||
EnrollmentCodes,
|
||||
HOOK_HEADER,
|
||||
RateLimiter,
|
||||
SESSION_COOKIE,
|
||||
buildSessionCookie,
|
||||
@@ -47,7 +50,9 @@ import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
|
||||
import {
|
||||
destroyAuthSession,
|
||||
deviceList,
|
||||
hookSecret,
|
||||
lookupAuthSession,
|
||||
readHookSecretFromDisk,
|
||||
revokeCredentials,
|
||||
rotateAdminToken,
|
||||
sessionSecret,
|
||||
@@ -60,6 +65,7 @@ ensureHome();
|
||||
const cfg = loadConfig();
|
||||
const secret = sessionSecret();
|
||||
const adminToken = rotateAdminToken();
|
||||
const hookToken = hookSecret();
|
||||
const webauthn = new WebAuthnService(cfg);
|
||||
const codes = new EnrollmentCodes();
|
||||
|
||||
@@ -93,14 +99,36 @@ function currentSession(req: http.IncomingMessage): Session | null {
|
||||
return { token, credentialId: record.credentialId, label: record.label };
|
||||
}
|
||||
|
||||
function isAdmin(req: http.IncomingMessage): boolean {
|
||||
const provided = header(req, "x-glance-admin");
|
||||
function sameSecret(provided: string | undefined | null, expected: string): boolean {
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(adminToken);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function isAdmin(req: http.IncomingMessage): boolean {
|
||||
return sameSecret(header(req, "x-glance-admin"), adminToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this really one of our hook scripts?
|
||||
*
|
||||
* "It came from 127.0.0.1" proves nothing: `tailscale serve` proxies tailnet traffic to
|
||||
* loopback, so without a check anyone on the tailnet could POST forged events into the
|
||||
* timeline and answer /hook/approve on your behalf. Two independent barriers:
|
||||
*
|
||||
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), sent as a header and
|
||||
* compared in constant time. Header only: a secret in a query string ends up in logs
|
||||
* and shell history, and every caller here is a local process that can set one.
|
||||
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
|
||||
* it tunnels, so their presence means the caller is not a local process — which no
|
||||
* real hook ever is. This keeps a leaked secret from being usable off-box.
|
||||
*/
|
||||
function isHookCaller(req: http.IncomingMessage): boolean {
|
||||
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
|
||||
return sameSecret(header(req, HOOK_HEADER), hookToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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
|
||||
@@ -154,6 +182,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
||||
out.json(405, { error: "post json" });
|
||||
return;
|
||||
}
|
||||
// Before reading a body: an unauthenticated caller gets to spend nothing here.
|
||||
if (!isHookCaller(req)) {
|
||||
out.json(403, { error: "hook token required" });
|
||||
return;
|
||||
}
|
||||
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
|
||||
|
||||
if (p === "/hook/record") {
|
||||
@@ -163,8 +196,8 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
||||
}
|
||||
|
||||
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.
|
||||
// Note: this deliberately does not ingest an event. The PreToolUse recording hook
|
||||
// already logged the tool call; recording it here too would double every entry.
|
||||
const decision = await broker.request(payload);
|
||||
out.json(200, decision);
|
||||
return;
|
||||
@@ -191,9 +224,13 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
||||
approval: cfg.approval,
|
||||
watchers: sse.count,
|
||||
sessions: state.sessionCount,
|
||||
sessionStates: state.stateSummary(broker.pending()),
|
||||
events: state.eventCount,
|
||||
webBuilt: webBuildExists(),
|
||||
home: paths.home,
|
||||
// Would a hook script authenticate right now? The daemon holds the token it read at
|
||||
// startup; if the file has since changed or gone, recording is silently dropping.
|
||||
hookAuthOk: sameSecret(readHookSecretFromDisk(), hookToken),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -504,6 +541,8 @@ function shutdown(why: string): void {
|
||||
console.log(`[glance] shutting down (${why})`);
|
||||
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
|
||||
broker.drain();
|
||||
// Keep the agents on screen across the restart instead of blanking every one of them.
|
||||
state.flush();
|
||||
sse.closeAll();
|
||||
server.close(() => process.exit(0));
|
||||
// Don't let a lingering keep-alive socket hold the process forever.
|
||||
@@ -524,8 +563,9 @@ server.listen(cfg.port, cfg.host, () => {
|
||||
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\``);
|
||||
// dist/ is committed, so this only fires for a developer who deleted it. Nothing warns about a
|
||||
// non-default port any more: the hook scripts read config.json themselves.
|
||||
if (!webBuildExists()) console.log("[glance] web app missing from dist/: npm install && npm run build");
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
|
||||
+16
-3
@@ -41,16 +41,28 @@ export interface GlanceEvent {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface RunningTool {
|
||||
name: string;
|
||||
title: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
id: string;
|
||||
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||
label: string;
|
||||
/**
|
||||
* Small ordinal handed out in arrival order and kept across daemon restarts. Labels are
|
||||
* basenames, so two agents in the same repo look identical; this is what tells them apart,
|
||||
* and the dashboard colours each agent by it.
|
||||
*/
|
||||
badge: number;
|
||||
cwd: string;
|
||||
state: SessionState;
|
||||
startedAt: number;
|
||||
lastActivity: number;
|
||||
lastPrompt?: string;
|
||||
currentTool?: { name: string; title: string; startedAt: number };
|
||||
/** Tool calls in flight, oldest first — an agent can run several at once. */
|
||||
running: RunningTool[];
|
||||
counts: { tools: number; failures: number; denials: number };
|
||||
}
|
||||
|
||||
@@ -58,6 +70,8 @@ export interface PendingApproval {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sessionLabel: string;
|
||||
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
|
||||
sessionBadge: number;
|
||||
tool: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
@@ -78,7 +92,6 @@ export interface ApprovalSettings {
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
now: number;
|
||||
version: string;
|
||||
sessions: SessionView[];
|
||||
events: GlanceEvent[];
|
||||
|
||||
+20
-4
@@ -4,6 +4,13 @@ import type { Snapshot } from "./protocol.js";
|
||||
|
||||
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
|
||||
const THROTTLE_MS = 250;
|
||||
/**
|
||||
* Snapshots are whole state, so they grow with the number of agents being watched, while the
|
||||
* push rate grows with it too. Past this size, slow down rather than push a phone the same
|
||||
* 60 KB four times a second: nobody reads a dashboard at 4 Hz.
|
||||
*/
|
||||
const LARGE_SNAPSHOT_BYTES = 24 * 1024;
|
||||
const SLOW_THROTTLE_MS = 1_000;
|
||||
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
|
||||
const HEARTBEAT_MS = 25_000;
|
||||
|
||||
@@ -17,6 +24,7 @@ export class SseHub {
|
||||
private nextId = 1;
|
||||
private pending = false;
|
||||
private lastSentAt = 0;
|
||||
private throttleMs = THROTTLE_MS;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private heartbeat: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -77,8 +85,13 @@ export class SseHub {
|
||||
}
|
||||
|
||||
private send(client: Client, event: string, data: unknown): void {
|
||||
this.write(client, event, JSON.stringify(data));
|
||||
}
|
||||
|
||||
/** Serialise once, write to every client — the payload is identical for all of them. */
|
||||
private write(client: Client, event: string, json: string): void {
|
||||
try {
|
||||
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
client.res.write(`event: ${event}\ndata: ${json}\n\n`);
|
||||
} catch {
|
||||
this.clients.delete(client.id);
|
||||
}
|
||||
@@ -91,14 +104,17 @@ export class SseHub {
|
||||
publish(): void {
|
||||
if (this.clients.size === 0) return;
|
||||
if (this.pending) return;
|
||||
const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt));
|
||||
const wait = Math.max(0, this.throttleMs - (Date.now() - this.lastSentAt));
|
||||
this.pending = true;
|
||||
this.timer = setTimeout(() => {
|
||||
this.pending = false;
|
||||
this.lastSentAt = Date.now();
|
||||
const snap = this.snapshot();
|
||||
const json = JSON.stringify(this.snapshot());
|
||||
// Judge the cadence on what was actually just sent, so a quiet single-agent dashboard
|
||||
// stays at 250ms and only a crowded one backs off.
|
||||
this.throttleMs = json.length > LARGE_SNAPSHOT_BYTES ? SLOW_THROTTLE_MS : THROTTLE_MS;
|
||||
for (const client of [...this.clients.values()]) {
|
||||
this.send(client, "snapshot", snap);
|
||||
this.write(client, "snapshot", json);
|
||||
}
|
||||
}, wait);
|
||||
this.timer.unref?.();
|
||||
|
||||
+195
-22
@@ -1,5 +1,5 @@
|
||||
import { VERSION, type Config } from "./config.js";
|
||||
import { appendEventLog, readRecentEvents } from "./store.js";
|
||||
import { appendEventLog, readRecentEvents, readSessions, writeSessions } from "./store.js";
|
||||
import {
|
||||
labelForWorkspace,
|
||||
summarizeNotification,
|
||||
@@ -20,6 +20,30 @@ import type {
|
||||
/** A session that has said nothing for this long is treated as idle, not working. */
|
||||
const STALE_WORKING_MS = 10 * 60_000;
|
||||
|
||||
/** Sessions quieter than this are not restored on start — they are last week's agents. */
|
||||
const RESTORE_MAX_AGE_MS = 12 * 60 * 60_000;
|
||||
|
||||
/**
|
||||
* A PreToolUse whose PostToolUse never arrives (crash, kill, timeout) would otherwise sit in
|
||||
* the running list forever, so the list is bounded and the oldest entry falls off.
|
||||
*/
|
||||
const MAX_RUNNING_PER_SESSION = 8;
|
||||
|
||||
/** Persisting the session map on every hook would mean a file write per tool call. */
|
||||
const PERSIST_DEBOUNCE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Which agent you want to look at first. Sorting purely by recency — the obvious choice with
|
||||
* one session — makes every row jump under your thumb once four agents are working at once.
|
||||
*/
|
||||
const ATTENTION_RANK: Record<SessionState, number> = {
|
||||
waiting: 0,
|
||||
error: 1,
|
||||
working: 2,
|
||||
idle: 3,
|
||||
ended: 4,
|
||||
};
|
||||
|
||||
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
|
||||
SessionStart: "session_start",
|
||||
SessionEnd: "session_end",
|
||||
@@ -50,9 +74,16 @@ export interface HookPayload {
|
||||
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>();
|
||||
/**
|
||||
* sessionId|toolName -> start timestamps, oldest first, so PostToolUse can report a
|
||||
* duration. An array rather than a single stamp because an agent runs tools in parallel
|
||||
* and the payload carries no call id: matching FIFO within a tool name is the closest
|
||||
* thing to one we have.
|
||||
*/
|
||||
private toolStarts = new Map<string, number[]>();
|
||||
private nextId = 1;
|
||||
private nextBadge = 1;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly cfg: Config) {
|
||||
@@ -60,6 +91,15 @@ export class GlanceState {
|
||||
const recent = readRecentEvents(cfg.retainEvents);
|
||||
this.events = recent;
|
||||
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
|
||||
|
||||
// …and the agents themselves, so a restart mid-supervision does not blank the overview.
|
||||
const cutoff = Date.now() - RESTORE_MAX_AGE_MS;
|
||||
for (const stored of readSessions()) {
|
||||
const session = restoreSession(stored);
|
||||
if (!session || session.lastActivity < cutoff) continue;
|
||||
this.sessions.set(session.id, session);
|
||||
this.nextBadge = Math.max(this.nextBadge, session.badge + 1);
|
||||
}
|
||||
}
|
||||
|
||||
onChange(listener: () => void): () => void {
|
||||
@@ -83,10 +123,11 @@ export class GlanceState {
|
||||
existing = {
|
||||
id,
|
||||
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
|
||||
badge: this.nextBadge++,
|
||||
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
||||
state: "idle",
|
||||
startedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
running: [],
|
||||
counts: { tools: 0, failures: 0, denials: 0 },
|
||||
};
|
||||
this.sessions.set(id, existing);
|
||||
@@ -100,12 +141,81 @@ export class GlanceState {
|
||||
|
||||
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);
|
||||
}
|
||||
this.trim();
|
||||
appendEventLog(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict from whichever session is using most of the ring rather than simply dropping the
|
||||
* oldest event. A single agent grinding through a build would otherwise push every other
|
||||
* agent's history out, and the timeline would silently become a one-agent timeline.
|
||||
*/
|
||||
private trim(): void {
|
||||
while (this.events.length > this.cfg.retainEvents) {
|
||||
const perSession = new Map<string, number>();
|
||||
for (const event of this.events) {
|
||||
perSession.set(event.sessionId, (perSession.get(event.sessionId) ?? 0) + 1);
|
||||
}
|
||||
let greediest = this.events[0].sessionId;
|
||||
let most = 0;
|
||||
for (const [sessionId, count] of perSession) {
|
||||
if (count > most) {
|
||||
most = count;
|
||||
greediest = sessionId;
|
||||
}
|
||||
}
|
||||
const oldest = this.events.findIndex((e) => e.sessionId === greediest);
|
||||
this.events.splice(oldest < 0 ? 0 : oldest, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget what a session had in flight — nothing survives a turn ending or a crash. */
|
||||
private clearRunning(sessionId: string, session: SessionView): void {
|
||||
session.running = [];
|
||||
for (const key of this.toolStarts.keys()) {
|
||||
if (key.startsWith(`${sessionId}|`)) this.toolStarts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the session map out. Debounced, because the alternative is a file write per hook —
|
||||
* and with several agents running that is several writes a second.
|
||||
*/
|
||||
private schedulePersist(): void {
|
||||
if (this.persistTimer) return;
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
this.persist();
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
this.persistTimer.unref?.();
|
||||
}
|
||||
|
||||
/** Flush that write now — called on shutdown so the last few seconds are not lost. */
|
||||
flush(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
// `running` is dropped on the way out: those tool calls belong to a process that is about
|
||||
// to stop existing, and a restored session claiming three live tools would be a lie the
|
||||
// dashboard has no way to disprove.
|
||||
writeSessions([...this.sessions.values()].map((s) => ({ ...s, running: [] })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure a session is known without recording anything for it. The approval gate and the
|
||||
* recorder are two separate hooks on the same PreToolUse, so the gate can easily be the
|
||||
* first to hear about an agent — and an approval card that cannot say which agent is asking
|
||||
* is worthless when four of them are running.
|
||||
*/
|
||||
ensureSession(sessionId: string, payload: HookPayload = {}): SessionView {
|
||||
return this.session(sessionId, payload);
|
||||
}
|
||||
|
||||
/** Record a raw hook payload. Returns the event it produced, if any. */
|
||||
ingest(payload: HookPayload): GlanceEvent | null {
|
||||
const hookName = payload.hookEventName ?? "";
|
||||
@@ -131,7 +241,7 @@ export class GlanceState {
|
||||
|
||||
case "session_end":
|
||||
session.state = "ended";
|
||||
session.currentTool = undefined;
|
||||
this.clearRunning(sessionId, session);
|
||||
title = "Session ended";
|
||||
break;
|
||||
|
||||
@@ -142,10 +252,15 @@ export class GlanceState {
|
||||
break;
|
||||
|
||||
case "tool_start": {
|
||||
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
||||
const name = tool ?? "tool";
|
||||
const summary = summarizeTool(name, payload.toolInput);
|
||||
session.state = "working";
|
||||
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
|
||||
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
|
||||
session.running.push({ name, title: summary.title, startedAt: now });
|
||||
if (session.running.length > MAX_RUNNING_PER_SESSION) session.running.shift();
|
||||
const starts = this.toolStarts.get(`${sessionId}|${name}`) ?? [];
|
||||
starts.push(now);
|
||||
if (starts.length > MAX_RUNNING_PER_SESSION) starts.shift();
|
||||
this.toolStarts.set(`${sessionId}|${name}`, starts);
|
||||
title = summary.title;
|
||||
detail = summary.detail;
|
||||
break;
|
||||
@@ -153,14 +268,16 @@ export class GlanceState {
|
||||
|
||||
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);
|
||||
const name = tool ?? "tool";
|
||||
const summary = summarizeTool(name, payload.toolInput);
|
||||
const key = `${sessionId}|${name}`;
|
||||
const starts = this.toolStarts.get(key);
|
||||
if (starts?.length) {
|
||||
durationMs = now - starts.shift()!;
|
||||
if (!starts.length) this.toolStarts.delete(key);
|
||||
}
|
||||
if (session.currentTool?.name === tool) session.currentTool = undefined;
|
||||
const running = session.running.findIndex((t) => t.name === name);
|
||||
if (running >= 0) session.running.splice(running, 1);
|
||||
session.state = "working";
|
||||
title = summary.title;
|
||||
detail = summary.detail;
|
||||
@@ -181,13 +298,13 @@ export class GlanceState {
|
||||
|
||||
case "turn_end":
|
||||
session.state = "idle";
|
||||
session.currentTool = undefined;
|
||||
this.clearRunning(sessionId, session);
|
||||
title = "Turn finished";
|
||||
break;
|
||||
|
||||
case "turn_error":
|
||||
session.state = "error";
|
||||
session.currentTool = undefined;
|
||||
this.clearRunning(sessionId, session);
|
||||
title = "Turn failed";
|
||||
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
|
||||
break;
|
||||
@@ -224,6 +341,7 @@ export class GlanceState {
|
||||
durationMs,
|
||||
};
|
||||
this.push(event);
|
||||
this.schedulePersist();
|
||||
this.notify();
|
||||
return event;
|
||||
}
|
||||
@@ -252,6 +370,7 @@ export class GlanceState {
|
||||
detail: opts.detail,
|
||||
};
|
||||
this.push(event);
|
||||
this.schedulePersist();
|
||||
this.notify();
|
||||
return event;
|
||||
}
|
||||
@@ -270,11 +389,13 @@ export class GlanceState {
|
||||
.map((s) => ({
|
||||
...s,
|
||||
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
|
||||
// A tool that has been "running" for ten minutes lost its PostToolUse somewhere.
|
||||
running: s.running.filter((t) => now - t.startedAt < STALE_WORKING_MS),
|
||||
}))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
// Whatever needs you first, then a fixed slot per agent so rows stay where you left them.
|
||||
.sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
|
||||
|
||||
return {
|
||||
now,
|
||||
version: VERSION,
|
||||
sessions,
|
||||
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
|
||||
@@ -287,6 +408,28 @@ export class GlanceState {
|
||||
return this.sessions.get(sessionId)?.label ?? "workspace";
|
||||
}
|
||||
|
||||
sessionBadge(sessionId: string): number {
|
||||
return this.sessions.get(sessionId)?.badge ?? 0;
|
||||
}
|
||||
|
||||
/** How many agents are in each state — what `glance status` prints from the terminal. */
|
||||
stateSummary(pending: PendingApproval[] = []): Record<SessionState, number> {
|
||||
const now = Date.now();
|
||||
const waiting = new Set(pending.map((p) => p.sessionId));
|
||||
const counts: Record<SessionState, number> = {
|
||||
working: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
error: 0,
|
||||
ended: 0,
|
||||
};
|
||||
for (const session of this.sessions.values()) {
|
||||
const state = waiting.has(session.id) ? "waiting" : this.effectiveState(session, now);
|
||||
counts[state] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
get sessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
@@ -295,3 +438,33 @@ export class GlanceState {
|
||||
return this.events.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a session read back from disk, or reject it. Written by a previous version, edited
|
||||
* by hand, truncated by a full disk — none of that may take the daemon down, and a session
|
||||
* with a broken shape is better dropped than rendered as `undefined` on a phone.
|
||||
*/
|
||||
function restoreSession(raw: unknown): SessionView | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const s = raw as Partial<SessionView>;
|
||||
if (typeof s.id !== "string" || !s.id) return null;
|
||||
if (typeof s.badge !== "number" || !Number.isFinite(s.badge)) return null;
|
||||
const counts = s.counts ?? { tools: 0, failures: 0, denials: 0 };
|
||||
return {
|
||||
id: s.id,
|
||||
label: typeof s.label === "string" && s.label ? s.label : "workspace",
|
||||
badge: Math.max(1, Math.floor(s.badge)),
|
||||
cwd: typeof s.cwd === "string" ? s.cwd : "",
|
||||
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle",
|
||||
lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
|
||||
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
|
||||
// Nothing survives the restart: whatever reports the end of a tool call was talking to
|
||||
// the process that just died.
|
||||
running: [],
|
||||
counts: {
|
||||
tools: Number(counts.tools) || 0,
|
||||
failures: Number(counts.failures) || 0,
|
||||
denials: Number(counts.denials) || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function webBuildExists(): boolean {
|
||||
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");
|
||||
res.end("grok-glance: dist/web is missing. Run `npm install && npm run build`.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+62
-1
@@ -2,7 +2,7 @@ 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";
|
||||
import type { DeviceInfo, GlanceEvent, SessionView } from "./protocol.js";
|
||||
|
||||
export interface StoredCredential {
|
||||
/** Base64URL credential ID. */
|
||||
@@ -69,6 +69,47 @@ export function rotateAdminToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token the hook scripts present on /hook/*. Unlike `admin.token` this is *not* rotated on
|
||||
* every start: hook scripts are separate short-lived processes that read the file per
|
||||
* invocation, and a rotation mid-session would 403 whatever was already in flight.
|
||||
*
|
||||
* It exists because `tailscale serve` proxies tailnet traffic to 127.0.0.1, so "the request
|
||||
* came from loopback" says nothing about who sent it. Without this, anyone on the tailnet
|
||||
* could forge timeline events and answer approval prompts.
|
||||
*
|
||||
* Created exclusively (`wx`) so two hooks racing on a fresh home cannot end up with
|
||||
* different values — the loser re-reads the winner's file.
|
||||
*/
|
||||
export function hookSecret(): string {
|
||||
ensureHome();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const existing = fs.readFileSync(paths.hookSecret, "utf8").trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
/* create below */
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
try {
|
||||
fs.writeFileSync(paths.hookSecret, token + "\n", { mode: 0o600, flag: "wx" });
|
||||
return token;
|
||||
} catch {
|
||||
// Lost the race (or the file appeared between the read and the write): read it back.
|
||||
}
|
||||
}
|
||||
return fs.readFileSync(paths.hookSecret, "utf8").trim();
|
||||
}
|
||||
|
||||
/** Whatever is on disk right now, for diagnostics. Never creates the file. */
|
||||
export function readHookSecretFromDisk(): string | null {
|
||||
try {
|
||||
return fs.readFileSync(paths.hookSecret, "utf8").trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- credentials */
|
||||
|
||||
export function listCredentials(): StoredCredential[] {
|
||||
@@ -191,6 +232,26 @@ export function appendEventLog(event: GlanceEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- session map */
|
||||
|
||||
/**
|
||||
* The overview itself, so restarting the daemon does not blank every agent you were
|
||||
* watching until each one happens to fire its next hook. Replaying the event log is not
|
||||
* enough: events carry no workspace root, and a truncated ring would under-count tools.
|
||||
*/
|
||||
export function readSessions(): SessionView[] {
|
||||
const raw = readJsonFile<unknown>(paths.sessions, []);
|
||||
return Array.isArray(raw) ? (raw as SessionView[]) : [];
|
||||
}
|
||||
|
||||
export function writeSessions(sessions: SessionView[]): void {
|
||||
try {
|
||||
writeJsonFile(paths.sessions, sessions);
|
||||
} catch {
|
||||
// Same rule as the event log: 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 {
|
||||
|
||||
+44
-16
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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 one or several sessions from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off.
|
||||
---
|
||||
|
||||
# grok-glance
|
||||
@@ -8,19 +8,22 @@ description: Set up, inspect, or control grok-glance — the passkey-guarded pho
|
||||
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`.
|
||||
One daemon covers every session on the machine, so several agents running at once all appear on the
|
||||
same dashboard — no per-session setup.
|
||||
|
||||
## First check whether it is even built
|
||||
The daemon starts itself: every recording hook boots it if it is not already up, so the first
|
||||
prompt or tool call of a session brings it back. (Not the `SessionStart` hook — a plugin's
|
||||
`SessionStart` entry is registered after the event has already fired, so it never runs. If someone
|
||||
reports an empty `:8791` and no `daemon.log`, that is the reason, and any hook firing will fix it.)
|
||||
|
||||
The plugin ships as TypeScript and must be built once:
|
||||
Everything below is done through the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
|
||||
|
||||
```sh
|
||||
cd "$GROK_PLUGIN_ROOT" && npm install && npm run build
|
||||
```
|
||||
## No build step
|
||||
|
||||
`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 plugin ships prebuilt: `dist/` is committed, and the daemon is a single dependency-free
|
||||
bundle. A clone is ready to run. Only reach for `npm install && npm run build` in
|
||||
`$GROK_PLUGIN_ROOT` if `glance status` actually says it is not built, which means `dist/` was
|
||||
deleted from the checkout.
|
||||
|
||||
## The commands
|
||||
|
||||
@@ -34,9 +37,28 @@ 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
|
||||
```
|
||||
|
||||
## Watching several agents
|
||||
|
||||
Nothing to configure — every session that runs the hooks shows up. `glance status` reports the
|
||||
roster and what each agent is doing:
|
||||
|
||||
```
|
||||
sessions : 4 (1 waiting on you, 1 error, 2 working)
|
||||
```
|
||||
|
||||
Points worth passing on to the user:
|
||||
|
||||
- Agents are identified by a coloured badge (`●1`, `●2`) as well as the workspace name, because two
|
||||
agents in the same repo carry the same label. The badge is stable across daemon restarts.
|
||||
- The list is ordered by who needs attention (waiting → error → working → idle → ended) and never
|
||||
re-sorts underneath a tap.
|
||||
- Approval cards say which agent is asking; with `approval risky` on and several agents, expect
|
||||
several cards.
|
||||
- One noisy agent will not push the others out of the timeline — the event ring is trimmed from
|
||||
whichever session is using the most of it.
|
||||
|
||||
## Getting it onto a phone
|
||||
|
||||
The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare
|
||||
@@ -63,8 +85,8 @@ 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.
|
||||
- Every failure path is fail-open: daemon down, timeout, bad JSON, a rejected hook secret — 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.
|
||||
|
||||
@@ -73,9 +95,15 @@ and wait for a tap on the phone. Defaults that matter:
|
||||
- **"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.
|
||||
- **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook
|
||||
secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being
|
||||
dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that
|
||||
`hooks/hooks.json` exists and that the plugin is registered with Grok Build.
|
||||
- **Page says "run npm install && npm run build"** → `dist/web` is missing from the checkout,
|
||||
which should not happen in a clone. Re-clone, or build it.
|
||||
- **Only one agent shows up** → the others were started before the plugin was installed, or in an
|
||||
environment where the hooks are not registered. A session appears on its next hook event; nothing
|
||||
can be back-filled for one that already ran.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"outDir": "dist/server",
|
||||
"rootDir": "server/src",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
@@ -15,8 +14,7 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["server/src"]
|
||||
}
|
||||
|
||||
+44
-6
@@ -10,7 +10,7 @@ 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";
|
||||
import type { GateInfo, SessionState, SessionView } from "@/protocol";
|
||||
|
||||
export default function App() {
|
||||
const [gate, setGate] = useState<GateInfo | null>(null);
|
||||
@@ -87,8 +87,12 @@ export default function App() {
|
||||
}
|
||||
|
||||
const sessions = snapshot?.sessions ?? [];
|
||||
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
|
||||
const pending = snapshot?.pending ?? [];
|
||||
// With one agent the detail card *is* the dashboard, so focus it and skip the list. With
|
||||
// several, the overview leads and the detail appears only for the one you tapped.
|
||||
const focus =
|
||||
sessions.find((s) => s.id === selected) ?? (sessions.length === 1 ? sessions[0] : undefined);
|
||||
const many = sessions.length > 1;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background text-foreground">
|
||||
@@ -108,7 +112,7 @@ export default function App() {
|
||||
<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"}`
|
||||
? stateSummary(sessions)
|
||||
: connection === "connecting"
|
||||
? "connecting…"
|
||||
: "offline — retrying"}
|
||||
@@ -163,8 +167,14 @@ export default function App() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{focus && <NowCard session={focus} now={now} />}
|
||||
{sessions.length > 1 && (
|
||||
{focus && (
|
||||
<NowCard
|
||||
session={focus}
|
||||
now={now}
|
||||
onBack={many ? () => setSelected(null) : undefined}
|
||||
/>
|
||||
)}
|
||||
{many && (
|
||||
<SessionsCard
|
||||
sessions={sessions}
|
||||
selectedId={selected}
|
||||
@@ -172,7 +182,12 @@ export default function App() {
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
<Timeline events={snapshot.events} sessionId={selected} />
|
||||
<Timeline
|
||||
events={snapshot.events}
|
||||
sessionId={selected}
|
||||
sessions={sessions}
|
||||
onClearFilter={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
@@ -180,6 +195,29 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_ORDER: Array<[SessionState, string]> = [
|
||||
["waiting", "waiting"],
|
||||
["error", "error"],
|
||||
["working", "working"],
|
||||
["idle", "idle"],
|
||||
["ended", "ended"],
|
||||
];
|
||||
|
||||
/**
|
||||
* "2 working · 1 waiting" rather than "3 sessions". When you are supervising several agents,
|
||||
* the count you actually want from the top of the screen is how many of them need you.
|
||||
*/
|
||||
function stateSummary(sessions: SessionView[]): string {
|
||||
if (sessions.length === 0) return "no sessions yet";
|
||||
const counts = new Map<SessionState, number>();
|
||||
for (const session of sessions) {
|
||||
counts.set(session.state, (counts.get(session.state) ?? 0) + 1);
|
||||
}
|
||||
return SUMMARY_ORDER.filter(([state]) => counts.get(state))
|
||||
.map(([state, label]) => `${counts.get(state)} ${label}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { Card, Spinner } from "@heroui/react";
|
||||
import { Button, Card, Spinner } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
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;
|
||||
export function NowCard({
|
||||
session,
|
||||
now,
|
||||
onBack,
|
||||
}: {
|
||||
session: SessionView;
|
||||
now: number;
|
||||
/** Only passed when there is more than one agent — otherwise there is nothing to go back to. */
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const running = session.running;
|
||||
|
||||
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.Title className="flex min-w-0 items-center gap-2 text-base">
|
||||
<SessionBadge badge={session.badge} label={session.label} />
|
||||
</Card.Title>
|
||||
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
|
||||
</div>
|
||||
<StateChip state={session.state} />
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<StateChip state={session.state} />
|
||||
{onBack && (
|
||||
<Button size="sm" variant="ghost" onPress={onBack}>
|
||||
All agents
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -28,18 +47,24 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
|
||||
</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>
|
||||
{running.length > 0 ? (
|
||||
/* An agent runs tools in parallel, so this is a list — showing only the newest one
|
||||
would keep redrawing the same card with a different tool in it. */
|
||||
<div className="flex flex-col gap-2.5 rounded-xl bg-surface-secondary p-3">
|
||||
{running.map((tool) => (
|
||||
<div key={`${tool.name}-${tool.startedAt}`} className="flex items-start gap-2.5">
|
||||
<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="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Card } from "@heroui/react";
|
||||
import { BanIcon, CheckIcon } from "@/components/icons";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { ToolChip } from "@/components/StatusChip";
|
||||
import { secondsLeft } from "@/lib/format";
|
||||
import type { PendingApproval } from "@/protocol";
|
||||
@@ -28,7 +29,14 @@ export function PendingCard({
|
||||
</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>
|
||||
{/* Which agent is asking. Two of them in one repo would otherwise both read
|
||||
"in remote-grok", and you would be approving a command blind. */}
|
||||
<span className="text-xs text-muted">in</span>
|
||||
<SessionBadge
|
||||
badge={approval.sessionBadge}
|
||||
label={approval.sessionLabel}
|
||||
className="text-xs text-muted"
|
||||
/>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { sessionColor } from "@/lib/sessionColor";
|
||||
|
||||
/**
|
||||
* Colour dot plus #N: the only thing on screen guaranteed to be unique per agent, which
|
||||
* matters the moment two of them are running in the same repo.
|
||||
*/
|
||||
export function SessionBadge({
|
||||
badge,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
badge: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = sessionColor(badge);
|
||||
return (
|
||||
<span className={`inline-flex min-w-0 items-center gap-1.5 ${className}`}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${color.dot}`} aria-hidden="true" />
|
||||
<span className={`shrink-0 text-[11px] font-semibold tabular-nums ${color.text}`}>
|
||||
#{badge}
|
||||
</span>
|
||||
{label !== undefined && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { StateChip } from "@/components/StatusChip";
|
||||
import { relTime } from "@/lib/format";
|
||||
import { duration, 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.
|
||||
* Every agent at once: what each one is doing right now, not just the one you last tapped.
|
||||
* Rendered whenever more than one session is live — with a single workspace the Now card
|
||||
* already says all of this and a list is just noise on a small screen.
|
||||
*
|
||||
* The server sorts these: whatever needs you first, then a fixed slot per agent. Rows must
|
||||
* not reorder themselves under a thumb that is already moving toward one.
|
||||
*/
|
||||
export function SessionsCard({
|
||||
sessions,
|
||||
@@ -19,42 +25,97 @@ export function SessionsCard({
|
||||
onSelect: (id: string | null) => void;
|
||||
now: number;
|
||||
}) {
|
||||
const [showEnded, setShowEnded] = useState(false);
|
||||
const live = sessions.filter((s) => s.state !== "ended");
|
||||
const ended = sessions.filter((s) => s.state === "ended");
|
||||
const rows = showEnded ? [...live, ...ended] : live;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">Sessions</Card.Title>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Card.Title className="text-base">Agents</Card.Title>
|
||||
<span className="text-xs text-muted">{live.length} live</span>
|
||||
</div>
|
||||
<Card.Description className="text-xs">
|
||||
Tap one to filter the activity list.
|
||||
Tap one for its detail and its own activity.
|
||||
</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="flex-1 text-sm">All agents</span>
|
||||
<span className="text-xs text-muted">{sessions.length}</span>
|
||||
</Row>
|
||||
</li>
|
||||
{sessions.map((session) => (
|
||||
{rows.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} />
|
||||
<AgentRow session={session} now={now} />
|
||||
</Row>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
|
||||
{ended.length > 0 && (
|
||||
<Card.Footer>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-muted"
|
||||
onClick={() => setShowEnded((value) => !value)}
|
||||
>
|
||||
{showEnded
|
||||
? "Hide ended"
|
||||
: `Show ${ended.length} ended session${ended.length === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRow({ session, now }: { session: SessionView; now: number }) {
|
||||
const [head, ...rest] = session.running;
|
||||
const { tools, failures, denials } = session.counts;
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<SessionBadge badge={session.badge} label={session.label} className="flex-1 text-sm" />
|
||||
<StateChip state={session.state} />
|
||||
</span>
|
||||
|
||||
{head ? (
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 text-xs">
|
||||
<span className="shrink-0 font-medium">{head.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted">
|
||||
{duration(Math.max(0, now - head.startedAt))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">{head.title}</span>
|
||||
{rest.length > 0 && <span className="shrink-0 text-muted">+{rest.length}</span>}
|
||||
</span>
|
||||
) : (
|
||||
session.lastPrompt && (
|
||||
<span className="min-w-0 truncate text-xs text-muted">{session.lastPrompt}</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<span className="flex items-baseline gap-2 text-[11px] text-muted">
|
||||
<span className="tabular-nums">{tools} tools</span>
|
||||
{failures > 0 && <span className="tabular-nums text-danger">{failures} failed</span>}
|
||||
{denials > 0 && <span className="tabular-nums text-danger">{denials} denied</span>}
|
||||
<span className="ml-auto tabular-nums">{relTime(session.lastActivity, now)}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
active,
|
||||
onPress,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { clockTime, duration } from "@/lib/format";
|
||||
import type { EventKind, GlanceEvent } from "@/protocol";
|
||||
import type { EventKind, GlanceEvent, SessionView } from "@/protocol";
|
||||
|
||||
const DOT: Record<EventKind, string> = {
|
||||
session_start: "bg-muted",
|
||||
@@ -39,20 +40,35 @@ function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[]
|
||||
export function Timeline({
|
||||
events,
|
||||
sessionId,
|
||||
sessions,
|
||||
onClearFilter,
|
||||
}: {
|
||||
events: GlanceEvent[];
|
||||
sessionId: string | null;
|
||||
sessions: SessionView[];
|
||||
onClearFilter?: () => void;
|
||||
}) {
|
||||
const [limit, setLimit] = useState(PAGE);
|
||||
const rows = visible(events, sessionId);
|
||||
const shown = rows.slice(0, limit);
|
||||
const focused = sessionId ? sessions.find((s) => s.id === sessionId) : undefined;
|
||||
// Interleaved lines from four agents are unreadable without saying whose each one is.
|
||||
const badges = sessions.length > 1 && !sessionId ? new Map(sessions.map((s) => [s.id, s])) : null;
|
||||
|
||||
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`}
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Card.Title className="text-base">Activity</Card.Title>
|
||||
{sessionId && onClearFilter && (
|
||||
<button type="button" className="text-xs text-accent" onClick={onClearFilter}>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Card.Description className="flex items-center gap-1.5 text-xs">
|
||||
{focused && <SessionBadge badge={focused.badge} label={focused.label} />}
|
||||
<span>{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}</span>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
@@ -70,7 +86,10 @@ export function Timeline({
|
||||
<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">
|
||||
<span className="flex shrink-0 items-baseline gap-1.5 text-[11px] tabular-nums text-muted">
|
||||
{badges?.get(event.sessionId) && (
|
||||
<SessionBadge badge={badges.get(event.sessionId)!.badge} />
|
||||
)}
|
||||
{clockTime(event.ts)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A fixed colour per agent, keyed on the badge the daemon hands out.
|
||||
*
|
||||
* Labels are workspace basenames, so two agents working in the same repo read identically.
|
||||
* Colour plus #N is what makes a row in the overview, a line in the timeline and an approval
|
||||
* card recognisably the same agent without reading anything.
|
||||
*
|
||||
* Written as whole class names on purpose: Tailwind scans the source text, so a class
|
||||
* assembled from a template string at runtime would not survive the build.
|
||||
*/
|
||||
|
||||
export interface SessionColor {
|
||||
dot: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const PALETTE: SessionColor[] = [
|
||||
{ dot: "bg-sky-500", text: "text-sky-600 dark:text-sky-400" },
|
||||
{ dot: "bg-violet-500", text: "text-violet-600 dark:text-violet-400" },
|
||||
{ dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" },
|
||||
{ dot: "bg-rose-500", text: "text-rose-600 dark:text-rose-400" },
|
||||
{ dot: "bg-cyan-500", text: "text-cyan-600 dark:text-cyan-400" },
|
||||
{ dot: "bg-fuchsia-500", text: "text-fuchsia-600 dark:text-fuchsia-400" },
|
||||
{ dot: "bg-lime-500", text: "text-lime-600 dark:text-lime-400" },
|
||||
];
|
||||
|
||||
export function sessionColor(badge: number): SessionColor {
|
||||
const index = Math.max(0, Math.floor(badge) - 1) % PALETTE.length;
|
||||
return PALETTE[index];
|
||||
}
|
||||
+19
-6
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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 =
|
||||
@@ -41,16 +41,28 @@ export interface GlanceEvent {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface RunningTool {
|
||||
name: string;
|
||||
title: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
id: string;
|
||||
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||
label: string;
|
||||
/**
|
||||
* Small ordinal handed out in arrival order and kept across daemon restarts. Labels are
|
||||
* basenames, so two agents in the same repo look identical; this is what tells them apart,
|
||||
* and the dashboard colours each agent by it.
|
||||
*/
|
||||
badge: number;
|
||||
cwd: string;
|
||||
state: SessionState;
|
||||
startedAt: number;
|
||||
lastActivity: number;
|
||||
lastPrompt?: string;
|
||||
currentTool?: { name: string; title: string; startedAt: number };
|
||||
/** Tool calls in flight, oldest first — an agent can run several at once. */
|
||||
running: RunningTool[];
|
||||
counts: { tools: number; failures: number; denials: number };
|
||||
}
|
||||
|
||||
@@ -58,6 +70,8 @@ export interface PendingApproval {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sessionLabel: string;
|
||||
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
|
||||
sessionBadge: number;
|
||||
tool: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
@@ -78,7 +92,6 @@ export interface ApprovalSettings {
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
now: number;
|
||||
version: string;
|
||||
sessions: SessionView[];
|
||||
events: GlanceEvent[];
|
||||
|
||||
Reference in New Issue
Block a user