agent-andon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agent Andon contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # 🚦 Agent Andon
2
+
3
+ **A traffic-light status board for your AI coding agents.**
4
+
5
+ Stand an old iPad on your desk. Submit a task to Claude Code or Codex, then go do
6
+ something else. One glance at the iPad tells you whether your agent is **working,
7
+ needs you, done, or stuck** — no babysitting the terminal, no forgetting to come back.
8
+
9
+ ![Agent Andon board: three tiles — NEEDS YOU, DONE, WORKING — with the screen edge glowing the most-urgent colour](docs/board.png)
10
+
11
+ > *Andon* (行灯) is the lean-manufacturing signal board: a light that tells the
12
+ > whole floor, at a glance, whether a line is running or needs a human. Same idea,
13
+ > for your agents.
14
+
15
+ - **Zero runtime dependencies** — pure Node.js standard library.
16
+ - **One command to wire up** — `andon install claude` edits your hooks for you (with a backup).
17
+ - **Multi-agent native** — every session is its own tile; the screen edge glows the most-urgent state.
18
+ - **Just an iPad + Safari** — no app, no hardware, no account.
19
+
20
+ <sub>中文用户:把闲置 iPad 立在桌边,变成 Claude Code / Codex 的"安灯"状态看板。提交任务后放心去干别的,一瞥就知道 agent 在跑 / 该你了 / 完成了 / 卡住了。</sub>
21
+
22
+ ---
23
+
24
+ ## How it works
25
+
26
+ ```
27
+ Claude Code / Codex ──(native hook)──▶ andon server (your Mac) ◀──(polls 1×/s)── iPad Safari
28
+ ```
29
+
30
+ 1. **Detect** — each tool's native hook mechanism reports state changes. No change to your workflow.
31
+ 2. **Relay** — a tiny HTTP server on your Mac receives the events.
32
+ 3. **Display** — the iPad opens the board and polls once a second. The whole border becomes
33
+ the "tower light," readable from across the room; *needs-you* / *stuck* pulse and chime.
34
+
35
+ State priority (the border takes the most urgent one):
36
+ `stuck (red) > needs-you (amber) > done (green) > working (blue) > idle`.
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ npm install -g agent-andon # or: npx agent-andon serve --demo
44
+ ```
45
+
46
+ From source:
47
+
48
+ ```bash
49
+ git clone <your-repo> agent-andon && cd agent-andon
50
+ npm install && npm run build
51
+ node dist/cli.js serve --demo
52
+ ```
53
+
54
+ > Requires Node.js ≥ 18.
55
+
56
+ ---
57
+
58
+ ## Quickstart (60 seconds)
59
+
60
+ **1. Verify the board with fake data:**
61
+
62
+ ```bash
63
+ andon serve --demo
64
+ ```
65
+
66
+ It prints a `http://<your-mac-ip>:8787` URL. Open it on the iPad — you should see two
67
+ tiles cycling colors. Once it looks right, `Ctrl-C` and run for real:
68
+
69
+ ```bash
70
+ andon serve
71
+ ```
72
+
73
+ **2. Set up the iPad** (same Wi-Fi as the Mac):
74
+
75
+ - Safari → open the printed URL. **It's `http://`, not `https://`.**
76
+ - Tap **"Enable alerts"** to unlock the chime (Safari needs one tap for audio).
77
+ - Share → **Add to Home Screen** → launch from the icon for a full-screen, address-bar-free board.
78
+ - Belt-and-suspenders against sleep: **Settings → Display & Brightness → Auto-Lock → Never.**
79
+ (The page also requests a Wake Lock.)
80
+
81
+ **3. Wire up your agents:**
82
+
83
+ ```bash
84
+ andon install claude # edits ~/.claude/settings.json (keeps a .andon-backup)
85
+ andon install codex # edits ~/.codex/config.toml (keeps a .andon-backup)
86
+ andon doctor # confirm everything's connected; reprints the iPad URL
87
+ ```
88
+
89
+ Restart your Claude Code session and it lights up the board automatically. That's it.
90
+
91
+ ---
92
+
93
+ ## Commands
94
+
95
+ | Command | What it does |
96
+ |---|---|
97
+ | `andon serve [--demo] [--port N] [--token T]` | Run the board server |
98
+ | `andon install claude` | Auto-wire Claude Code hooks (with backup) |
99
+ | `andon install codex` | Auto-wire the Codex notify hook (with backup) |
100
+ | `andon doctor` | Health check + what's wired + iPad URL |
101
+ | `andon post <state> <agent> [title] [msg]` | Push a status by hand |
102
+ | `andon hook` / `andon notify` | *(internal — invoked by the hooks)* |
103
+
104
+ `andon install --dry-run claude` prints the change without writing.
105
+
106
+ ### Event → state mapping (Claude Code)
107
+
108
+ | Claude Code event | Board state | When |
109
+ |---|---|---|
110
+ | `UserPromptSubmit` | working (blue) | you just submitted a prompt |
111
+ | `Notification` | needs-you (amber, pulses) | waiting on permission / your input |
112
+ | `Stop` | done (green) | the turn finished |
113
+ | `StopFailure` | stuck (red, pulses) | the turn failed (newer Claude Code only) |
114
+ | `SessionEnd` | *removed* | session ended; tile disappears |
115
+
116
+ Multiple sessions each get their own tile (keyed by `session_id`).
117
+
118
+ ### Codex
119
+
120
+ `andon install codex` adds the `notify` hook → you get the green **done** signal each turn.
121
+ For the blue **working** signal too, source the shipped wrapper from your `~/.zshrc`:
122
+
123
+ ```bash
124
+ source /path/to/agent-andon/examples/codex-wrapper.sh
125
+ ```
126
+
127
+ Now `codex` turns blue on launch, clears on exit, and goes green each turn.
128
+
129
+ > **Known Codex limits:** Codex doesn't push approval requests to `notify`, so it can't
130
+ > show amber "needs-you" — that prompt stays in the Codex terminal. The red "stuck"
131
+ > signal is the least reliable across both tools; don't read "not red" as "no error."
132
+
133
+ ---
134
+
135
+ ## Naming a tile
136
+
137
+ The default title is the project folder name. Override per-terminal:
138
+
139
+ ```bash
140
+ ANDON_LABEL="backend refactor" claude
141
+ ANDON_LABEL="landing copy" codex
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Run it in the background
147
+
148
+ ```bash
149
+ # tmux
150
+ tmux new -s andon 'andon serve'
151
+
152
+ # or nohup
153
+ nohup andon serve >/tmp/agent-andon.log 2>&1 &
154
+
155
+ # or at login: see examples/com.agentandon.server.plist
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Security
161
+
162
+ By default the server binds `0.0.0.0` with **no authentication** — anyone on the LAN can
163
+ read and post status. Fine on a trusted home Wi-Fi; **don't run it on a public/untrusted
164
+ network.** For a shared network, set a token (export it everywhere the hooks run too):
165
+
166
+ ```bash
167
+ ANDON_TOKEN=somesecret andon serve
168
+ ```
169
+
170
+ With a token set, `/state` and `/event` require it. The hooks and CLI send it as an
171
+ `x-andon-token` header automatically (as long as `ANDON_TOKEN` is in their environment);
172
+ on the iPad, open the board with `?token=somesecret` and it carries the token through.
173
+ `/healthz` stays open so `andon doctor` always works.
174
+
175
+ The board only ever exposes high-level status (state, project name, a one-line message) —
176
+ never code or full logs. Event bodies are capped at 64 KB.
177
+
178
+ ---
179
+
180
+ ## Configuration
181
+
182
+ | Env var | Default | Meaning |
183
+ |---|---|---|
184
+ | `AGENT_STATUS_URL` | `http://127.0.0.1:8787` | server base URL the hooks post to |
185
+ | `ANDON_TOKEN` | *(none)* | shared token required by `/state` and `/event` when set |
186
+ | `ANDON_PORT` / `ANDON_HOST` | `8787` / `0.0.0.0` | server bind |
187
+ | `ANDON_LABEL` | folder name | tile title (per terminal) |
188
+ | `ANDON_SESSION` | — | per-launch session id (set by the codex wrapper) |
189
+
190
+ ---
191
+
192
+ ## Develop
193
+
194
+ ```bash
195
+ npm run build # tsc -> dist/
196
+ npm test # node:test unit tests for the store (Node 22.6+)
197
+ npm run dev # tsc --watch
198
+ ```
199
+
200
+ Architecture: `src/store.ts` is the pure, tested state model; `src/server.ts` is the
201
+ HTTP layer; `src/commands/*` are the CLI verbs; `assets/dashboard.html` is the
202
+ self-contained board.
203
+
204
+ ---
205
+
206
+ ## Troubleshooting
207
+
208
+ - **iPad can't open the page** — same Wi-Fi? `http` not `https`? Mac firewall allowing
209
+ incoming connections (System Settings → Network → Firewall)? IP copied correctly
210
+ (it's printed at startup, and `andon doctor` reprints it)?
211
+ - **Claude hook does nothing** — run `claude --debug` once and watch for hook errors;
212
+ re-run `andon install claude`; `andon doctor` to confirm.
213
+ - **Codex stays green, never blue** — that's expected without the wrapper (see Codex above).
214
+ - **A "working" tile is stuck** — a process likely died before sending its end event.
215
+ It auto-clears after 6h; for Codex, `andon post gone codex` from that project dir clears it now.
216
+
217
+ ---
218
+
219
+ ## License
220
+
221
+ MIT
@@ -0,0 +1,362 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="apple-mobile-web-app-title" content="Andon">
10
+ <meta name="theme-color" content="#0a0c10">
11
+ <link rel="manifest" href="/manifest.webmanifest">
12
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
13
+ <link rel="apple-touch-icon" href="/favicon.svg">
14
+ <title>Agent Andon</title>
15
+ <style>
16
+ /* ───────── tokens ─────────
17
+ signal palette = the content. green/amber/red/blue ARE the message.
18
+ deliberately NOT the "dark bg + one acid accent" default: every state
19
+ owns a colour, and the whole iPad edge glows the dominant one. */
20
+ :root{
21
+ --bg:#0a0c10;
22
+ --surface:#12151c;
23
+ --hairline:#222732;
24
+ --text:#e8edf4;
25
+ --muted:#7b8696;
26
+ --faint:#4a5260;
27
+
28
+ --c-working:#5b8cff; /* periwinkle — calm, "leave it alone" */
29
+ --c-waiting:#f6a623; /* marigold — "your move" */
30
+ --c-done:#2fd47a; /* emerald — finished cleanly */
31
+ --c-error:#ff5d5d; /* signal red — blocked / stuck */
32
+ --c-idle:#48515f; /* slate — nothing running */
33
+ --c-offline:#7a4a4a; /* board lost the Mac */
34
+ }
35
+ *{box-sizing:border-box;margin:0;padding:0}
36
+ html,body{height:100%}
37
+ body{
38
+ background:var(--bg);color:var(--text);
39
+ font-family:-apple-system,"SF Pro Display",system-ui,"PingFang SC",sans-serif;
40
+ -webkit-font-smoothing:antialiased;overflow:hidden;
41
+ transition:background-color .6s ease;
42
+ /* dominant colour resolver — JS sets data-dominant on <body> */
43
+ --dom:var(--c-idle); --domsoft:rgba(72,81,95,.35);
44
+ }
45
+ body[data-dominant="working"]{--dom:var(--c-working);--domsoft:rgba(91,140,255,.42);background:#0a0e16}
46
+ body[data-dominant="waiting"]{--dom:var(--c-waiting);--domsoft:rgba(246,166,35,.46);background:#13100a}
47
+ body[data-dominant="done"] {--dom:var(--c-done); --domsoft:rgba(47,212,122,.42);background:#0a130e}
48
+ body[data-dominant="error"] {--dom:var(--c-error); --domsoft:rgba(255,93,93,.48); background:#150b0c}
49
+ body[data-dominant="idle"] {--dom:var(--c-idle); --domsoft:rgba(72,81,95,.30)}
50
+ body[data-dominant="offline"]{--dom:var(--c-offline);--domsoft:rgba(122,74,74,.38)}
51
+
52
+ /* ambient wash from the top edge, tinted to the dominant state */
53
+ .ambient{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.8;
54
+ background:radial-gradient(130% 80% at 50% -10%,var(--domsoft),transparent 62%);
55
+ transition:background .6s ease}
56
+
57
+ /* SIGNATURE: the perimeter glow = the tower light.
58
+ thick luminous border + inward bleed. readable from across the room. */
59
+ .signal-frame{position:fixed;inset:9px;z-index:5;pointer-events:none;
60
+ border:14px solid var(--dom);border-radius:26px;opacity:.85;
61
+ box-shadow:inset 0 0 90px -22px var(--dom),0 0 46px -8px var(--domsoft);
62
+ transition:border-color .6s ease,opacity .6s ease,box-shadow .6s ease}
63
+ body[data-dominant="working"] .signal-frame{opacity:.46} /* calm when fine */
64
+ body[data-dominant="idle"] .signal-frame{opacity:.24} /* nearly dark */
65
+ body[data-dominant="offline"] .signal-frame{opacity:.40}
66
+ body[data-dominant="waiting"] .signal-frame{animation:framePulse 1.7s ease-in-out infinite}
67
+ body[data-dominant="error"] .signal-frame{animation:framePulse .9s ease-in-out infinite}
68
+ @keyframes framePulse{0%,100%{opacity:.4}50%{opacity:1}}
69
+
70
+ /* ───────── layout ───────── */
71
+ .app{position:relative;z-index:2;height:100%;display:flex;flex-direction:column;
72
+ padding:34px 44px 32px}
73
+ .head{display:flex;align-items:center;gap:16px;
74
+ font-family:ui-monospace,"SF Mono",Menlo,monospace;
75
+ font-size:13px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted)}
76
+ .brand{color:var(--text);font-weight:600;letter-spacing:.34em}
77
+ .head .spacer{flex:1}
78
+ .conn{display:flex;align-items:center;gap:8px}
79
+ .conn .led{width:8px;height:8px;border-radius:50%;background:var(--c-done);
80
+ box-shadow:0 0 11px var(--c-done);transition:background .3s,box-shadow .3s}
81
+ .conn.off .led{background:var(--c-error);box-shadow:0 0 11px var(--c-error)}
82
+ .clock{font-variant-numeric:tabular-nums;color:var(--text)}
83
+ .btn{font:inherit;color:var(--muted);background:transparent;border:1px solid var(--hairline);
84
+ border-radius:999px;padding:7px 15px;letter-spacing:.12em;cursor:pointer;
85
+ -webkit-tap-highlight-color:transparent;transition:color .3s,border-color .3s}
86
+ .btn.on{color:var(--c-done);border-color:rgba(47,212,122,.4)}
87
+
88
+ .board{flex:1;min-height:0;display:grid;gap:22px;align-content:center;overflow:auto;
89
+ margin-top:28px;grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr))}
90
+ .board[data-count="1"]{grid-template-columns:1fr}
91
+
92
+ /* ───────── tile ───────── */
93
+ .tile{position:relative;background:var(--surface);border:1px solid var(--hairline);
94
+ border-radius:18px;padding:30px 34px;display:flex;flex-direction:column;overflow:hidden;
95
+ min-height:min(46vh,360px);transition:border-color .45s ease}
96
+ .tile::before{content:"";position:absolute;left:0;top:0;bottom:0;width:6px;background:var(--tc);
97
+ box-shadow:0 0 28px -2px var(--tc)} /* state bar down the left edge */
98
+ .tile[data-state="working"]{--tc:var(--c-working);border-color:rgba(91,140,255,.24)}
99
+ .tile[data-state="waiting"]{--tc:var(--c-waiting);border-color:rgba(246,166,35,.30)}
100
+ .tile[data-state="done"] {--tc:var(--c-done); border-color:rgba(47,212,122,.30)}
101
+ .tile[data-state="error"] {--tc:var(--c-error); border-color:rgba(255,93,93,.34)}
102
+
103
+ .tile-top{display:flex;align-items:center;gap:12px;min-width:0;
104
+ font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:15px;letter-spacing:.1em}
105
+ .dot{width:11px;height:11px;border-radius:50%;background:var(--tc);
106
+ box-shadow:0 0 15px var(--tc);flex:none}
107
+ .agent{color:var(--text);font-weight:600;flex:none}
108
+ .proj{color:var(--faint);text-transform:none;letter-spacing:.01em;
109
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
110
+ .clear-hint{margin-left:auto;font-size:11px;letter-spacing:.14em;color:var(--faint);
111
+ opacity:0;transition:opacity .3s;flex:none}
112
+ .tile.clearable{cursor:pointer}
113
+ .tile.clearable .clear-hint{opacity:.55}
114
+
115
+ .tile-mid{flex:1;display:flex;flex-direction:column;justify-content:center;gap:7px;padding:16px 0}
116
+ .word{font-weight:800;line-height:.94;letter-spacing:-.015em;text-transform:uppercase;
117
+ color:var(--tc);font-size:clamp(2.4rem,7vw,6rem);text-shadow:0 0 44px var(--domsoft)}
118
+ .gloss{font-size:clamp(1rem,2.3vw,1.7rem);color:var(--muted);font-weight:500;letter-spacing:.05em}
119
+
120
+ .tile-bot{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;
121
+ font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13px;color:var(--muted)}
122
+ .msg{color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
123
+ .ago{flex:none;font-variant-numeric:tabular-nums}
124
+
125
+ /* the live "dot" breathes so a tile reads as alive vs settled */
126
+ .tile[data-state="working"] .dot{animation:breathe 2.4s ease-in-out infinite}
127
+ .tile[data-state="waiting"] .dot{animation:breathe 1.5s ease-in-out infinite}
128
+ .tile[data-state="error"] .dot{animation:breathe .9s ease-in-out infinite}
129
+ @keyframes breathe{0%,100%{opacity:.5}50%{opacity:1}}
130
+
131
+ /* word sizing by how many tiles are on screen */
132
+ .board[data-count="1"] .word{font-size:clamp(3.6rem,13vw,11rem)}
133
+ .board[data-count="2"] .word{font-size:clamp(2.7rem,8vw,7.5rem)}
134
+
135
+ /* ───────── idle / empty ───────── */
136
+ .idle-card{grid-column:1/-1;justify-self:center;text-align:center;
137
+ display:flex;flex-direction:column;align-items:center;gap:12px}
138
+ .idle-card .word{color:var(--c-idle);text-shadow:none;font-size:clamp(3rem,11vw,8rem)}
139
+ .idle-card .gloss{color:var(--muted)}
140
+ .idle-sub{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13px;
141
+ letter-spacing:.06em;color:var(--faint)}
142
+
143
+ @media (prefers-reduced-motion:reduce){
144
+ .signal-frame,.dot{animation:none!important}
145
+ }
146
+ </style>
147
+ </head>
148
+ <body data-dominant="idle">
149
+ <div class="ambient"></div>
150
+ <div class="signal-frame"></div>
151
+
152
+ <div class="app">
153
+ <div class="head">
154
+ <span class="brand">ANDON</span>
155
+ <span>agent status</span>
156
+ <span class="spacer"></span>
157
+ <span class="conn" id="conn"><span class="led"></span><span class="conn-txt">synced</span></span>
158
+ <span class="clock" id="clock">--:--</span>
159
+ <button class="btn" id="soundBtn">Enable alerts</button>
160
+ </div>
161
+ <div class="board" id="board" data-count="1"></div>
162
+ </div>
163
+
164
+ <script>
165
+ "use strict";
166
+ const POLL_MS = 1000;
167
+ const PRI = {error:0, waiting:1, done:2, working:3, idle:4};
168
+ const WORDS = {
169
+ working:["WORKING","工作中"],
170
+ waiting:["NEEDS YOU","该你了"],
171
+ done :["DONE","完成"],
172
+ error :["STUCK","卡住了"],
173
+ };
174
+ const AGENTS = {claude:"CLAUDE CODE", codex:"CODEX"};
175
+
176
+ const board = document.getElementById("board");
177
+ const conn = document.getElementById("conn");
178
+ const connTxt= conn.querySelector(".conn-txt");
179
+ const clock = document.getElementById("clock");
180
+ const soundBtn = document.getElementById("soundBtn");
181
+
182
+ const tiles = new Map(); // id -> tile element
183
+ let prevState = {}; // id -> last state, for transition chimes
184
+ let primed = false; // suppress chimes on the very first render (page load)
185
+
186
+ const agentLabel = a => AGENTS[a] || (a || "AGENT").toUpperCase();
187
+ function ago(sec){
188
+ sec = Math.max(0, Math.round(sec));
189
+ if (sec < 60) return sec + "s ago";
190
+ const m = Math.floor(sec/60);
191
+ if (m < 60) return m + "m ago";
192
+ const h = Math.floor(m/60);
193
+ return h + "h " + (m%60) + "m ago";
194
+ }
195
+
196
+ /* ---------- tiles ---------- */
197
+ function makeTile(){
198
+ const el = document.createElement("div");
199
+ el.className = "tile";
200
+ el.innerHTML =
201
+ '<div class="tile-top"><span class="dot"></span>' +
202
+ '<span class="agent"></span><span class="proj"></span>' +
203
+ '<span class="clear-hint">TAP TO CLEAR</span></div>' +
204
+ '<div class="tile-mid"><div class="word"></div><div class="gloss"></div></div>' +
205
+ '<div class="tile-bot"><span class="msg"></span><span class="ago"></span></div>';
206
+ el.addEventListener("click", () => {
207
+ if (el.dataset.state === "done" || el.dataset.state === "error"){
208
+ post({id: el.dataset.id, agent: el.dataset.agent, state: "gone"});
209
+ el.remove(); tiles.delete(el.dataset.id);
210
+ }
211
+ });
212
+ return el;
213
+ }
214
+ function updateTile(el, s, serverTime){
215
+ el.dataset.id = s.id; el.dataset.agent = s.agent; el.dataset.state = s.state;
216
+ el.classList.toggle("clearable", s.state === "done" || s.state === "error");
217
+ el.querySelector(".agent").textContent = agentLabel(s.agent);
218
+ el.querySelector(".proj").textContent = s.title || "";
219
+ const w = WORDS[s.state] || [s.state.toUpperCase(), ""];
220
+ el.querySelector(".word").textContent = w[0];
221
+ el.querySelector(".gloss").textContent = w[1];
222
+ const msg = el.querySelector(".msg");
223
+ msg.textContent = s.message || "";
224
+ msg.style.display = s.message ? "" : "none";
225
+ el.querySelector(".ago").textContent = ago(serverTime - s.updated_at);
226
+ }
227
+
228
+ function ensureIdle(){
229
+ if (board.querySelector(".idle-card")) return;
230
+ board.dataset.count = "1";
231
+ const d = document.createElement("div");
232
+ d.className = "idle-card";
233
+ d.innerHTML = '<div class="word">IDLE</div><div class="gloss">没有 agent 在跑</div>' +
234
+ '<div class="idle-sub">start a run in Claude Code or Codex — it lights up here</div>';
235
+ board.appendChild(d);
236
+ }
237
+ function removeIdle(){ const d = board.querySelector(".idle-card"); if (d) d.remove(); }
238
+
239
+ /* ---------- render ---------- */
240
+ function render(data){
241
+ const list = data.sessions || [];
242
+ const serverTime = data.server_time || (Date.now()/1000);
243
+
244
+ // dominant state -> perimeter glow colour
245
+ let dom = "idle";
246
+ for (const s of list) if (PRI[s.state] < PRI[dom]) dom = s.state;
247
+ document.body.dataset.dominant = dom;
248
+ board.dataset.count = String(list.length === 0 ? 1 : Math.min(list.length, 3));
249
+
250
+ // detect transitions into an attention state -> queue one chime (most urgent wins).
251
+ // skip on the first render after load, so reopening the page doesn't re-chime
252
+ // tiles that were already in that state.
253
+ let chime = null;
254
+ if (primed){
255
+ for (const s of list){
256
+ if (prevState[s.id] !== s.state &&
257
+ (s.state === "waiting" || s.state === "error" || s.state === "done")){
258
+ if (chime === null || PRI[s.state] < PRI[chime]) chime = s.state;
259
+ }
260
+ }
261
+ }
262
+ prevState = {};
263
+ for (const s of list) prevState[s.id] = s.state;
264
+ primed = true;
265
+
266
+ if (list.length === 0){
267
+ for (const [,el] of tiles) el.remove();
268
+ tiles.clear();
269
+ ensureIdle();
270
+ } else {
271
+ removeIdle();
272
+ const ids = new Set(list.map(s => s.id));
273
+ for (const [id, el] of [...tiles]) if (!ids.has(id)){ el.remove(); tiles.delete(id); }
274
+ for (const s of list){
275
+ let el = tiles.get(s.id);
276
+ if (!el){ el = makeTile(); tiles.set(s.id, el); }
277
+ updateTile(el, s, serverTime);
278
+ board.appendChild(el); // reorder by priority (keeps animations)
279
+ }
280
+ }
281
+ if (chime) playChime(chime);
282
+ }
283
+
284
+ /* ---------- polling ---------- */
285
+ // If the board is opened with ?token=… (token-protected server), carry it
286
+ // through to /state and /event — otherwise the server answers 401 and the
287
+ // board can never sync.
288
+ const TOKEN = new URLSearchParams(location.search).get("token");
289
+ const withToken = (path) => TOKEN ? path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : path;
290
+
291
+ async function poll(){
292
+ try{
293
+ const r = await fetch(withToken("/state"), {cache:"no-store"});
294
+ if (!r.ok) throw new Error("bad status");
295
+ render(await r.json());
296
+ conn.classList.remove("off");
297
+ connTxt.textContent = "synced";
298
+ } catch (e){
299
+ conn.classList.add("off");
300
+ connTxt.textContent = "reconnecting…";
301
+ document.body.dataset.dominant = "offline"; // edge dims to red-grey
302
+ } finally {
303
+ setTimeout(poll, POLL_MS);
304
+ }
305
+ }
306
+ async function post(body){
307
+ try{
308
+ await fetch(withToken("/event"), {method:"POST",
309
+ headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
310
+ } catch (e){ /* ignore */ }
311
+ }
312
+
313
+ /* ---------- sound (WebAudio, no files) ---------- */
314
+ let actx = null, soundOn = false;
315
+ function tone(freq, start, dur, peak){
316
+ if (!actx) return;
317
+ const o = actx.createOscillator(), g = actx.createGain();
318
+ o.type = "sine"; o.frequency.value = freq;
319
+ o.connect(g); g.connect(actx.destination);
320
+ const t = actx.currentTime + start;
321
+ g.gain.setValueAtTime(0.0001, t);
322
+ g.gain.exponentialRampToValueAtTime(peak, t + 0.02);
323
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
324
+ o.start(t); o.stop(t + dur + 0.03);
325
+ }
326
+ function playChime(kind){
327
+ if (!soundOn || !actx) return;
328
+ if (actx.state === "suspended") actx.resume();
329
+ if (kind === "done"){ tone(660,0,.18,.18); tone(990,.16,.30,.15); }
330
+ else if (kind === "waiting"){ tone(560,0,.24,.16); }
331
+ else if (kind === "error"){ tone(320,0,.18,.20); tone(250,.20,.34,.20); }
332
+ }
333
+ soundBtn.addEventListener("click", () => {
334
+ if (!actx){
335
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e){}
336
+ }
337
+ if (actx && actx.state === "suspended") actx.resume();
338
+ soundOn = !soundOn;
339
+ soundBtn.classList.toggle("on", soundOn);
340
+ soundBtn.textContent = soundOn ? "Alerts on" : "Enable alerts";
341
+ if (soundOn) tone(880, 0, .12, .10); // tiny confirm beep
342
+ });
343
+
344
+ /* ---------- keep the iPad awake (iPadOS 16.4+) ---------- */
345
+ let wakeLock = null;
346
+ async function reqWake(){
347
+ try { if ("wakeLock" in navigator) wakeLock = await navigator.wakeLock.request("screen"); }
348
+ catch (e){ /* if unsupported: set Auto-Lock → Never in iPad settings */ }
349
+ }
350
+ document.addEventListener("visibilitychange", () => {
351
+ if (document.visibilityState === "visible") reqWake();
352
+ });
353
+
354
+ /* ---------- boot ---------- */
355
+ function tick(){ clock.textContent = new Date().toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"}); }
356
+ ensureIdle();
357
+ tick(); setInterval(tick, 1000);
358
+ reqWake();
359
+ poll();
360
+ </script>
361
+ </body>
362
+ </html>
@@ -0,0 +1,20 @@
1
+ /** Static, dependency-free assets served by the board (PWA polish). */
2
+ /** Web app manifest so "Add to Home Screen" gives a real app icon + name. */
3
+ export declare const MANIFEST: {
4
+ name: string;
5
+ short_name: string;
6
+ description: string;
7
+ start_url: string;
8
+ display: string;
9
+ orientation: string;
10
+ background_color: string;
11
+ theme_color: string;
12
+ icons: {
13
+ src: string;
14
+ sizes: string;
15
+ type: string;
16
+ purpose: string;
17
+ }[];
18
+ };
19
+ /** A tiny self-contained "andon lamp" SVG icon — three stacked signal dots. */
20
+ export declare const FAVICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\">\n <rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#0a0c10\"/>\n <circle cx=\"32\" cy=\"17\" r=\"7\" fill=\"#ff5d5d\"/>\n <circle cx=\"32\" cy=\"32\" r=\"7\" fill=\"#f6a623\"/>\n <circle cx=\"32\" cy=\"47\" r=\"7\" fill=\"#2fd47a\"/>\n</svg>";
package/dist/assets.js ADDED
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /** Static, dependency-free assets served by the board (PWA polish). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.FAVICON_SVG = exports.MANIFEST = void 0;
5
+ /** Web app manifest so "Add to Home Screen" gives a real app icon + name. */
6
+ exports.MANIFEST = {
7
+ name: "Agent Andon",
8
+ short_name: "Andon",
9
+ description: "Traffic-light status board for your AI coding agents",
10
+ start_url: "/",
11
+ display: "fullscreen",
12
+ orientation: "any",
13
+ background_color: "#0a0c10",
14
+ theme_color: "#0a0c10",
15
+ icons: [
16
+ {
17
+ src: "/favicon.svg",
18
+ sizes: "any",
19
+ type: "image/svg+xml",
20
+ purpose: "any maskable",
21
+ },
22
+ ],
23
+ };
24
+ /** A tiny self-contained "andon lamp" SVG icon — three stacked signal dots. */
25
+ exports.FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
26
+ <rect width="64" height="64" rx="14" fill="#0a0c10"/>
27
+ <circle cx="32" cy="17" r="7" fill="#ff5d5d"/>
28
+ <circle cx="32" cy="32" r="7" fill="#f6a623"/>
29
+ <circle cx="32" cy="47" r="7" fill="#2fd47a"/>
30
+ </svg>`;
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};