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.
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseServeArgs = parseServeArgs;
4
+ exports.serve = serve;
5
+ /** `andon serve [--demo] [--port N] [--host H] [--token T]` */
6
+ const server_1 = require("../server");
7
+ const demo_1 = require("../demo");
8
+ const net_1 = require("../net");
9
+ function parseServeArgs(argv) {
10
+ const args = {
11
+ port: Number(process.env.ANDON_PORT) || 8787,
12
+ host: process.env.ANDON_HOST || "0.0.0.0",
13
+ demo: false,
14
+ token: process.env.ANDON_TOKEN || undefined,
15
+ };
16
+ // Consume the value after a flag, erroring if it's missing or is itself a flag.
17
+ const takeValue = (argv, i, flag) => {
18
+ const v = argv[i + 1];
19
+ if (v === undefined || v.startsWith("-")) {
20
+ throw new Error(`${flag} needs a value`);
21
+ }
22
+ return v;
23
+ };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === "--demo")
27
+ args.demo = true;
28
+ else if (a === "--port")
29
+ args.port = Number(takeValue(argv, i++, "--port"));
30
+ else if (a === "--host")
31
+ args.host = takeValue(argv, i++, "--host");
32
+ else if (a === "--token")
33
+ args.token = takeValue(argv, i++, "--token");
34
+ else if (a?.startsWith("--port="))
35
+ args.port = Number(a.split("=")[1]);
36
+ else if (a?.startsWith("--token="))
37
+ args.token = a.split("=")[1];
38
+ else if (a?.startsWith("--host="))
39
+ args.host = a.split("=")[1];
40
+ }
41
+ return args;
42
+ }
43
+ function serve(argv) {
44
+ let args;
45
+ try {
46
+ args = parseServeArgs(argv);
47
+ }
48
+ catch (e) {
49
+ console.error(`✗ ${e.message}`);
50
+ process.exit(2);
51
+ return;
52
+ }
53
+ if (!Number.isFinite(args.port) || args.port <= 0) {
54
+ console.error(`✗ invalid port: ${args.port}`);
55
+ process.exit(1);
56
+ }
57
+ const { server, store } = (0, server_1.createServer)({
58
+ port: args.port,
59
+ host: args.host,
60
+ token: args.token,
61
+ });
62
+ if (args.demo)
63
+ (0, demo_1.startDemo)(store);
64
+ server.on("error", (err) => {
65
+ if (err.code === "EADDRINUSE") {
66
+ console.error(`\n ✗ Port ${args.port} is already in use.\n` +
67
+ ` Another Andon server may be running, or pick a free port:\n` +
68
+ ` andon serve --port 8788\n`);
69
+ }
70
+ else {
71
+ console.error(`\n ✗ Server error: ${err.message}\n`);
72
+ }
73
+ process.exit(1);
74
+ });
75
+ server.listen(args.port, args.host, () => {
76
+ const url = `http://${(0, net_1.lanIp)()}:${args.port}`;
77
+ const tokenSuffix = args.token ? `?token=${args.token}` : "";
78
+ console.log("\n 🚦 Agent Andon is live");
79
+ console.log(" ──────────────────────────────────────────");
80
+ console.log(` This Mac: http://127.0.0.1:${args.port}${tokenSuffix}`);
81
+ console.log(` iPad: ${url}${tokenSuffix}`);
82
+ console.log(" (iPad must be on the same Wi-Fi)");
83
+ if (args.token)
84
+ console.log(" 🔒 token auth enabled");
85
+ if (args.demo)
86
+ console.log(" [demo] injecting fake agents, cycling every 3s");
87
+ console.log(" Ctrl-C to stop\n");
88
+ });
89
+ const shutdown = () => {
90
+ console.log("\n stopped.");
91
+ server.close(() => process.exit(0));
92
+ setTimeout(() => process.exit(0), 500).unref();
93
+ };
94
+ process.on("SIGINT", shutdown);
95
+ process.on("SIGTERM", shutdown);
96
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The tile title. `ANDON_LABEL` (set per-terminal) wins so you can name a run
3
+ * "backend refactor" instead of leaning on the directory basename.
4
+ */
5
+ export declare function labelFor(cwd: string, fallback: string): string;
6
+ /**
7
+ * Session id rules, shared by `post` and `notify` so the same project shows as
8
+ * ONE tile across "working/done/gone":
9
+ * ANDON_SESSION (injected by the codex wrapper, unique per launch) ->
10
+ * else codex falls back to cwd, other agents to the agent name.
11
+ */
12
+ export declare function sessionId(agent: string, cwd: string): string;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.labelFor = labelFor;
37
+ exports.sessionId = sessionId;
38
+ /** Tiny helpers shared by the client-side commands. */
39
+ const path = __importStar(require("path"));
40
+ /**
41
+ * The tile title. `ANDON_LABEL` (set per-terminal) wins so you can name a run
42
+ * "backend refactor" instead of leaning on the directory basename.
43
+ */
44
+ function labelFor(cwd, fallback) {
45
+ const env = process.env.ANDON_LABEL;
46
+ if (env && env.trim())
47
+ return env.trim();
48
+ const base = path.basename(cwd.replace(/\/+$/, ""));
49
+ return base || fallback;
50
+ }
51
+ /**
52
+ * Session id rules, shared by `post` and `notify` so the same project shows as
53
+ * ONE tile across "working/done/gone":
54
+ * ANDON_SESSION (injected by the codex wrapper, unique per launch) ->
55
+ * else codex falls back to cwd, other agents to the agent name.
56
+ */
57
+ function sessionId(agent, cwd) {
58
+ return (process.env.ANDON_SESSION ||
59
+ (agent === "codex" ? cwd : agent));
60
+ }
package/dist/demo.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Injects two fake agents that cycle states, for verifying the board first. */
2
+ import type { SessionStore } from "./store";
3
+ export declare function startDemo(store: SessionStore): NodeJS.Timeout;
package/dist/demo.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startDemo = startDemo;
4
+ function startDemo(store) {
5
+ const seq = [
6
+ ["claude", "/Users/you/dev/checkout-api",
7
+ ["working", "working", "waiting", "working", "done", "done"]],
8
+ ["codex", "/Users/you/dev/landing-page",
9
+ ["working", "working", "working", "error", "working", "done"]],
10
+ ];
11
+ const msgs = {
12
+ waiting: "needs permission: Bash(git push origin main)",
13
+ error: "command failed: exit 1 — tsc: 3 errors",
14
+ };
15
+ let i = 0;
16
+ const tick = () => {
17
+ for (const [agent, cwd, states] of seq) {
18
+ const st = states[i % states.length];
19
+ store.apply({
20
+ agent,
21
+ id: cwd,
22
+ state: st,
23
+ title: cwd.split("/").pop() || agent,
24
+ message: msgs[st] ?? "",
25
+ });
26
+ }
27
+ i++;
28
+ };
29
+ tick(); // seed immediately
30
+ const t = setInterval(tick, 3000);
31
+ t.unref?.();
32
+ return t;
33
+ }
package/dist/net.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** Default base URL hooks post to; override with AGENT_STATUS_URL. */
2
+ export declare function serverBase(): string;
3
+ /**
4
+ * Best-effort LAN IP for the "open this on your iPad" URL. Prefers a private
5
+ * (RFC1918) IPv4, skips loopback / link-local / virtual where possible.
6
+ */
7
+ export declare function lanIp(): string;
package/dist/net.js ADDED
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.serverBase = serverBase;
37
+ exports.lanIp = lanIp;
38
+ /** Small networking helpers shared by the server and CLI. */
39
+ const os = __importStar(require("os"));
40
+ /** Default base URL hooks post to; override with AGENT_STATUS_URL. */
41
+ function serverBase() {
42
+ return (process.env.AGENT_STATUS_URL || "http://127.0.0.1:8787").replace(/\/+$/, "");
43
+ }
44
+ /**
45
+ * Best-effort LAN IP for the "open this on your iPad" URL. Prefers a private
46
+ * (RFC1918) IPv4, skips loopback / link-local / virtual where possible.
47
+ */
48
+ function lanIp() {
49
+ const nis = os.networkInterfaces();
50
+ const candidates = [];
51
+ for (const name of Object.keys(nis)) {
52
+ for (const ni of nis[name] ?? []) {
53
+ if (ni.family !== "IPv4" || ni.internal)
54
+ continue;
55
+ if (ni.address.startsWith("169.254."))
56
+ continue; // link-local
57
+ candidates.push(ni.address);
58
+ }
59
+ }
60
+ const isPrivate = (a) => a.startsWith("192.168.") ||
61
+ a.startsWith("10.") ||
62
+ /^172\.(1[6-9]|2\d|3[01])\./.test(a);
63
+ return candidates.find(isPrivate) ?? candidates[0] ?? "127.0.0.1";
64
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Agent Andon board server.
3
+ *
4
+ * GET / full-screen dashboard (served from assets/)
5
+ * GET /state JSON snapshot the iPad polls every second
6
+ * GET /healthz liveness + session count
7
+ * GET /manifest.webmanifest, /favicon.svg PWA polish
8
+ * POST /event a hook / the CLI pushes one status event
9
+ *
10
+ * Hardening over the prototype: request-body size cap, guarded body read,
11
+ * optional shared-token auth (ANDON_TOKEN), no CORS (the board is same-origin),
12
+ * and a same-origin guard that blocks cross-origin POST /event (CSRF).
13
+ */
14
+ import * as http from "http";
15
+ import { SessionStore } from "./store";
16
+ export interface ServerOptions {
17
+ port: number;
18
+ host: string;
19
+ /** When set, /state and /event require ?token=… or an x-andon-token header. */
20
+ token?: string;
21
+ /** Inject a store (tests); a fresh one is created otherwise. */
22
+ store?: SessionStore;
23
+ }
24
+ export interface AndonServer {
25
+ server: http.Server;
26
+ store: SessionStore;
27
+ }
28
+ export declare function createServer(opts: ServerOptions): AndonServer;
package/dist/server.js ADDED
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createServer = createServer;
37
+ /**
38
+ * The Agent Andon board server.
39
+ *
40
+ * GET / full-screen dashboard (served from assets/)
41
+ * GET /state JSON snapshot the iPad polls every second
42
+ * GET /healthz liveness + session count
43
+ * GET /manifest.webmanifest, /favicon.svg PWA polish
44
+ * POST /event a hook / the CLI pushes one status event
45
+ *
46
+ * Hardening over the prototype: request-body size cap, guarded body read,
47
+ * optional shared-token auth (ANDON_TOKEN), no CORS (the board is same-origin),
48
+ * and a same-origin guard that blocks cross-origin POST /event (CSRF).
49
+ */
50
+ const http = __importStar(require("http"));
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const store_1 = require("./store");
54
+ const assets_1 = require("./assets");
55
+ /** Reject event bodies larger than this (plenty for a status line). */
56
+ const MAX_BODY = 64 * 1024;
57
+ /** dist/server.js -> ../assets/dashboard.html (also correct once installed). */
58
+ const DASHBOARD_PATH = path.join(__dirname, "..", "assets", "dashboard.html");
59
+ function createServer(opts) {
60
+ const store = opts.store ?? new store_1.SessionStore();
61
+ const sweeper = setInterval(() => store.sweep(), 30_000);
62
+ sweeper.unref?.();
63
+ let dashboard = null;
64
+ try {
65
+ dashboard = fs.readFileSync(DASHBOARD_PATH);
66
+ }
67
+ catch {
68
+ dashboard = null;
69
+ }
70
+ // Accept the token either as ?token=… (the dashboard reads it from its own
71
+ // URL) or as an x-andon-token header (hooks/CLI use this, keeping the secret
72
+ // out of URLs and access logs).
73
+ const authorized = (url, req) => {
74
+ if (!opts.token)
75
+ return true;
76
+ return (url.searchParams.get("token") === opts.token ||
77
+ req.headers["x-andon-token"] === opts.token);
78
+ };
79
+ // CSRF guard: reject cross-origin browser writes. The board is same-origin
80
+ // with the server, and CLI/hooks (curl, Node http) send no Origin header —
81
+ // both pass. Only a request from a *different* web origin is blocked.
82
+ const sameOriginOrNone = (req) => {
83
+ const origin = req.headers.origin;
84
+ if (!origin)
85
+ return true;
86
+ try {
87
+ return new URL(origin).host === req.headers.host;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ };
93
+ const server = http.createServer((req, res) => {
94
+ const send = (code, body, ctype = "application/json") => {
95
+ const buf = typeof body === "string" ? Buffer.from(body, "utf8") : body;
96
+ // No CORS headers: the board is same-origin with the server, so it needs
97
+ // none, and withholding them stops any other website from reading /state.
98
+ res.writeHead(code, {
99
+ "Content-Type": ctype,
100
+ "Content-Length": buf.length,
101
+ "Cache-Control": "no-store",
102
+ });
103
+ res.end(buf);
104
+ };
105
+ const url = new URL(req.url || "/", "http://localhost");
106
+ const p = url.pathname;
107
+ if (req.method === "OPTIONS") {
108
+ // No CORS is offered (same-origin board only); answer preflights plainly.
109
+ res.writeHead(204, { Allow: "GET, POST, OPTIONS" });
110
+ res.end();
111
+ return;
112
+ }
113
+ if (req.method === "GET") {
114
+ if (p === "/" || p === "/index.html") {
115
+ if (dashboard)
116
+ send(200, dashboard, "text/html; charset=utf-8");
117
+ else
118
+ send(500, "dashboard.html is missing from the package assets/.", "text/plain; charset=utf-8");
119
+ }
120
+ else if (p === "/state") {
121
+ if (!authorized(url, req))
122
+ return send(401, JSON.stringify({ error: "unauthorized" }));
123
+ send(200, JSON.stringify(store.snapshot()));
124
+ }
125
+ else if (p === "/healthz") {
126
+ send(200, JSON.stringify({ ok: true, sessions: store.size }));
127
+ }
128
+ else if (p === "/manifest.webmanifest") {
129
+ send(200, JSON.stringify(assets_1.MANIFEST), "application/manifest+json");
130
+ }
131
+ else if (p === "/favicon.svg") {
132
+ send(200, assets_1.FAVICON_SVG, "image/svg+xml");
133
+ }
134
+ else {
135
+ send(404, JSON.stringify({ error: "not found" }));
136
+ }
137
+ return;
138
+ }
139
+ if (req.method === "POST" && p === "/event") {
140
+ if (!sameOriginOrNone(req))
141
+ return send(403, JSON.stringify({ error: "cross-origin forbidden" }));
142
+ if (!authorized(url, req))
143
+ return send(401, JSON.stringify({ error: "unauthorized" }));
144
+ const chunks = [];
145
+ let size = 0;
146
+ let aborted = false;
147
+ req.on("data", (c) => {
148
+ size += c.length;
149
+ if (size > MAX_BODY && !aborted) {
150
+ aborted = true;
151
+ send(413, JSON.stringify({ error: "payload too large" }));
152
+ req.destroy();
153
+ }
154
+ else if (!aborted) {
155
+ chunks.push(c);
156
+ }
157
+ });
158
+ req.on("end", () => {
159
+ if (aborted || res.writableEnded)
160
+ return;
161
+ let ev;
162
+ try {
163
+ ev = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
164
+ }
165
+ catch {
166
+ return send(400, JSON.stringify({ error: "bad json" }));
167
+ }
168
+ const r = store.apply(ev);
169
+ send(r.ok ? 200 : 400, JSON.stringify(r));
170
+ });
171
+ req.on("error", () => {
172
+ if (!res.writableEnded)
173
+ send(400, JSON.stringify({ error: "read error" }));
174
+ });
175
+ return;
176
+ }
177
+ send(404, JSON.stringify({ error: "not found" }));
178
+ });
179
+ server.on("close", () => clearInterval(sweeper));
180
+ return { server, store };
181
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * In-memory session store. Pure logic, no I/O — so it is trivially testable.
3
+ *
4
+ * Concurrency note: Node runs this single-threaded, so unlike the Python
5
+ * prototype no lock is needed. Each HTTP callback runs to completion.
6
+ */
7
+ import { type AndonEvent, type Snapshot } from "./types";
8
+ /** Any session untouched for this long is swept (a process died without cleanup). */
9
+ export declare const HARD_TTL_SEC: number;
10
+ /** Hard cap so a misbehaving/abusive client can't grow the board unbounded. */
11
+ export declare const MAX_SESSIONS = 200;
12
+ export interface ApplyResult {
13
+ ok: boolean;
14
+ error?: string;
15
+ removed?: boolean;
16
+ }
17
+ export declare class SessionStore {
18
+ private readonly now;
19
+ private readonly maxSessions;
20
+ private readonly ttlSec;
21
+ private sessions;
22
+ constructor(now?: () => number, maxSessions?: number, ttlSec?: number);
23
+ /** Create / update / delete one session from a single event. */
24
+ apply(ev: AndonEvent): ApplyResult;
25
+ /** Snapshot, sorted by priority then most-recent — exactly what the board renders. */
26
+ snapshot(): Snapshot;
27
+ /** Drop sessions older than the TTL. Returns how many were removed. */
28
+ sweep(): number;
29
+ get size(): number;
30
+ }
package/dist/store.js ADDED
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionStore = exports.MAX_SESSIONS = exports.HARD_TTL_SEC = void 0;
4
+ /**
5
+ * In-memory session store. Pure logic, no I/O — so it is trivially testable.
6
+ *
7
+ * Concurrency note: Node runs this single-threaded, so unlike the Python
8
+ * prototype no lock is needed. Each HTTP callback runs to completion.
9
+ */
10
+ const types_1 = require("./types");
11
+ /** Any session untouched for this long is swept (a process died without cleanup). */
12
+ exports.HARD_TTL_SEC = 6 * 3600;
13
+ /** Hard cap so a misbehaving/abusive client can't grow the board unbounded. */
14
+ exports.MAX_SESSIONS = 200;
15
+ class SessionStore {
16
+ now;
17
+ maxSessions;
18
+ ttlSec;
19
+ sessions = new Map();
20
+ constructor(now = () => Date.now() / 1000, maxSessions = exports.MAX_SESSIONS, ttlSec = exports.HARD_TTL_SEC) {
21
+ this.now = now;
22
+ this.maxSessions = maxSessions;
23
+ this.ttlSec = ttlSec;
24
+ }
25
+ /** Create / update / delete one session from a single event. */
26
+ apply(ev) {
27
+ const sid = String(ev.id ?? ev.agent ?? "agent").trim();
28
+ if (!sid)
29
+ return { ok: false, error: "missing id" };
30
+ const state = (ev.state ?? "").trim();
31
+ if (state === "gone") {
32
+ const existed = this.sessions.delete(sid);
33
+ return { ok: true, removed: existed };
34
+ }
35
+ if (!types_1.VALID_STATES.has(state)) {
36
+ return { ok: false, error: `invalid state: ${JSON.stringify(ev.state)}` };
37
+ }
38
+ if (!this.sessions.has(sid) && this.sessions.size >= this.maxSessions) {
39
+ return { ok: false, error: "session limit reached" };
40
+ }
41
+ const prev = this.sessions.get(sid);
42
+ const agent = ev.agent || prev?.agent || "agent";
43
+ this.sessions.set(sid, {
44
+ id: sid,
45
+ agent,
46
+ state: state,
47
+ title: ev.title || prev?.title || agent,
48
+ message: ev.message != null ? String(ev.message) : prev?.message ?? "",
49
+ updated_at: this.now(),
50
+ });
51
+ return { ok: true };
52
+ }
53
+ /** Snapshot, sorted by priority then most-recent — exactly what the board renders. */
54
+ snapshot() {
55
+ const items = [...this.sessions.values()].map((s) => ({ ...s }));
56
+ items.sort((a, b) => (types_1.PRIORITY[a.state] ?? 9) - (types_1.PRIORITY[b.state] ?? 9) ||
57
+ b.updated_at - a.updated_at);
58
+ return { server_time: this.now(), sessions: items };
59
+ }
60
+ /** Drop sessions older than the TTL. Returns how many were removed. */
61
+ sweep() {
62
+ const cutoff = this.now() - this.ttlSec;
63
+ let removed = 0;
64
+ for (const [id, s] of this.sessions) {
65
+ if (s.updated_at < cutoff) {
66
+ this.sessions.delete(id);
67
+ removed++;
68
+ }
69
+ }
70
+ return removed;
71
+ }
72
+ get size() {
73
+ return this.sessions.size;
74
+ }
75
+ }
76
+ exports.SessionStore = SessionStore;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared types and the single source of truth for state priority.
3
+ *
4
+ * The status palette IS the message: green/amber/red/blue each own a meaning,
5
+ * and the iPad edge glows the most-urgent ("dominant") one on the board.
6
+ */
7
+ /** A live agent status. */
8
+ export type State = "working" | "waiting" | "done" | "error" | "idle";
9
+ /** What a client may send. `gone` is a command: remove this session's tile. */
10
+ export type EventState = State | "gone";
11
+ /** Raw event posted to POST /event by a hook or the CLI. Intentionally loose. */
12
+ export interface AndonEvent {
13
+ agent?: string;
14
+ id?: string;
15
+ state?: string;
16
+ title?: string;
17
+ message?: string;
18
+ }
19
+ /** A normalized, stored session — one tile on the board. */
20
+ export interface Session {
21
+ id: string;
22
+ agent: string;
23
+ state: State;
24
+ title: string;
25
+ message: string;
26
+ /** epoch seconds */
27
+ updated_at: number;
28
+ }
29
+ /** The payload the dashboard polls from GET /state. */
30
+ export interface Snapshot {
31
+ server_time: number;
32
+ sessions: Session[];
33
+ }
34
+ /**
35
+ * Lower number = "more in need of your attention". The board border takes the
36
+ * lowest (most urgent) state present. Mirrored verbatim in the dashboard JS.
37
+ */
38
+ export declare const PRIORITY: Record<string, number>;
39
+ /** States a tile can hold (excludes the `gone` delete-command). */
40
+ export declare const VALID_STATES: ReadonlySet<string>;
package/dist/types.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /**
3
+ * Shared types and the single source of truth for state priority.
4
+ *
5
+ * The status palette IS the message: green/amber/red/blue each own a meaning,
6
+ * and the iPad edge glows the most-urgent ("dominant") one on the board.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.VALID_STATES = exports.PRIORITY = void 0;
10
+ /**
11
+ * Lower number = "more in need of your attention". The board border takes the
12
+ * lowest (most urgent) state present. Mirrored verbatim in the dashboard JS.
13
+ */
14
+ exports.PRIORITY = {
15
+ error: 0,
16
+ waiting: 1,
17
+ done: 2,
18
+ working: 3,
19
+ idle: 4,
20
+ };
21
+ /** States a tile can hold (excludes the `gone` delete-command). */
22
+ exports.VALID_STATES = new Set([
23
+ "error",
24
+ "waiting",
25
+ "done",
26
+ "working",
27
+ "idle",
28
+ ]);
package/docs/board.png ADDED
Binary file