agent-relay-server 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -201,7 +201,7 @@ export AGENT_RELAY_URL=http://100.x.y.z:4850
201
201
  | `PORT` | `4850` | Server port |
202
202
  | `HOST` | `127.0.0.1` | Bind address |
203
203
  | `DB_PATH` | `agent-relay.db` | SQLite database path |
204
- | `STALE_TTL_MS` | `600000` | Mark agents offline after this heartbeat gap |
204
+ | `STALE_TTL_MS` | `120000` | Mark agents offline after this heartbeat gap |
205
205
  | `OFFLINE_PRUNE_MS` | `86400000` | Delete offline agents older than this |
206
206
  | `REAP_INTERVAL_MS` | `60000` | Reaper cadence for stale/offline cleanup |
207
207
  | `RETENTION_DAYS` | `30` | Auto-prune messages older than this |
@@ -311,6 +311,7 @@ claude/ # Claude Code plugin
311
311
  ├── monitors/monitors.json # Auto-start inbox monitor
312
312
  └── hooks/
313
313
  ├── relay-monitor.sh # Register, inject context, poll
314
+ ├── set-status.sh # Turn hooks: busy/idle status updates
314
315
  ├── session-end.sh # Mark agent offline
315
316
  └── poll-inbox.sh # Inbox poll loop
316
317
 
@@ -14,6 +14,24 @@ type RelayStats = {
14
14
  version?: string;
15
15
  };
16
16
 
17
+ type HookHandshake = {
18
+ status: "ok" | "error";
19
+ code: string;
20
+ message?: string;
21
+ pid?: number;
22
+ threadId?: string;
23
+ timestamp?: string;
24
+ };
25
+
26
+ type HookWaitResult = {
27
+ ok: boolean;
28
+ code: string;
29
+ message?: string;
30
+ pid?: number;
31
+ };
32
+
33
+ const DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS = 5000;
34
+
17
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
36
  const packageRoot = resolve(__dirname, "..");
19
37
  const home = process.env.HOME || process.env.USERPROFILE || homedir();
@@ -76,6 +94,21 @@ function readJsonFile<T>(path: string, fallback: T): T {
76
94
  return JSON.parse(readFileSync(path, "utf8")) as T;
77
95
  }
78
96
 
97
+ function errorMessage(error: unknown): string {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
100
+
101
+ function envPositiveInt(name: string, fallback: number): number {
102
+ const raw = process.env[name];
103
+ if (!raw) return fallback;
104
+ const parsed = Number.parseInt(raw, 10);
105
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
106
+ }
107
+
108
+ function appendLauncherLog(runDir: string, line: string): void {
109
+ appendFileSync(join(runDir, "launcher.log"), `${new Date().toISOString()} ${line}\n`);
110
+ }
111
+
79
112
  function isAgentRelaySessionStartCommand(command: string): boolean {
80
113
  return /agent-relay.*codex\/hooks\/session-start\.ts/.test(command);
81
114
  }
@@ -336,21 +369,35 @@ async function waitForPort(url: string, child: ReturnType<typeof Bun.spawn>): Pr
336
369
  throw new Error(`timed out waiting for ${url}`);
337
370
  }
338
371
 
339
- async function waitForSidecarPid(runDir: string, timeoutMs: number): Promise<boolean> {
340
- const pidsPath = join(runDir, "sidecar-pids.txt");
372
+ async function waitForHookHandshake(runDir: string, timeoutMs: number): Promise<HookWaitResult> {
373
+ const handshakePath = join(runDir, "session-start-handshake.json");
341
374
  const deadline = Date.now() + timeoutMs;
342
375
 
343
376
  while (Date.now() < deadline) {
344
- if (existsSync(pidsPath)) {
345
- for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
346
- const pid = Number(line.trim());
347
- if (Number.isFinite(pid) && pid > 0 && isAlive(pid)) return true;
377
+ if (existsSync(handshakePath)) {
378
+ try {
379
+ const parsed = JSON.parse(readFileSync(handshakePath, "utf8")) as HookHandshake;
380
+ const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : "HOOK_HANDSHAKE_INVALID";
381
+ const message = typeof parsed.message === "string" ? parsed.message : undefined;
382
+ const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) && parsed.pid > 0
383
+ ? parsed.pid
384
+ : undefined;
385
+
386
+ if (parsed.status === "ok") return { ok: true, code, message, pid };
387
+ if (parsed.status === "error") return { ok: false, code, message, pid };
388
+ return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: `unexpected status ${String((parsed as any).status)}` };
389
+ } catch (error) {
390
+ return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: errorMessage(error) };
348
391
  }
349
392
  }
350
393
  await Bun.sleep(100);
351
394
  }
352
395
 
353
- return false;
396
+ return {
397
+ ok: false,
398
+ code: "HOOK_HANDSHAKE_TIMEOUT",
399
+ message: `no hook handshake observed within ${timeoutMs}ms`,
400
+ };
354
401
  }
355
402
 
356
403
  function spawnFallbackSidecar(runDir: string, env: Record<string, string | undefined>): number {
@@ -656,10 +703,39 @@ async function start(args: string[]): Promise<void> {
656
703
  stderr: "inherit",
657
704
  });
658
705
 
659
- const sidecarDetected = await waitForSidecarPid(runDir, 2500);
660
- if (!sidecarDetected && codex.exitCode === null) {
706
+ const hookTimeoutMs = envPositiveInt("AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS", DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS);
707
+ const handshake = await waitForHookHandshake(runDir, hookTimeoutMs);
708
+ let fallbackReason: HookWaitResult | null = null;
709
+
710
+ if (!handshake.ok) {
711
+ fallbackReason = handshake;
712
+ } else if (handshake.pid === undefined) {
713
+ fallbackReason = {
714
+ ok: false,
715
+ code: "HOOK_HANDSHAKE_NO_PID",
716
+ message: "hook reported success without a sidecar pid",
717
+ };
718
+ } else if (!isAlive(handshake.pid)) {
719
+ fallbackReason = {
720
+ ok: false,
721
+ code: "HOOK_HANDSHAKE_PID_NOT_ALIVE",
722
+ message: `hook reported pid ${handshake.pid} but it is not running`,
723
+ };
724
+ } else {
725
+ appendLauncherLog(runDir, `HOOK_HANDSHAKE_OK code=${handshake.code} pid=${handshake.pid}`);
726
+ }
727
+
728
+ if (fallbackReason && codex.exitCode === null) {
661
729
  const pid = spawnFallbackSidecar(runDir, env);
662
- console.error(`Agent Relay: SessionStart hook did not start sidecar; started fallback sidecar pid ${pid}`);
730
+ appendFileSync(
731
+ join(runDir, "launcher.log"),
732
+ `${new Date().toISOString()} HOOK_FALLBACK_STARTED reason=${fallbackReason.code} fallbackPid=${pid}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}\n`,
733
+ );
734
+ } else if (fallbackReason) {
735
+ appendLauncherLog(
736
+ runDir,
737
+ `HOOK_FALLBACK_SKIPPED reason=${fallbackReason.code} codexExit=${codex.exitCode}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}`,
738
+ );
663
739
  }
664
740
 
665
741
  const exitCode = await codex.exited;
package/codex/README.md CHANGED
@@ -97,6 +97,7 @@ Useful environment variables:
97
97
 
98
98
  - `AGENT_RELAY_URL`
99
99
  - `AGENT_RELAY_CAPS`
100
+ - `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` (launcher wait for SessionStart handshake, default `5000`)
100
101
  - `CODEX_APP_SERVER_URL`
101
102
  - `CODEX_THREAD_ID`
102
103
  - `CODEX_THREAD_MODE=auto|resume|start`
@@ -107,6 +108,10 @@ Useful environment variables:
107
108
  - `CODEX_LIVE_RIG`
108
109
  - `CODEX_MODEL`
109
110
 
111
+ Fallback reason codes are written to `runtime/<run>/launcher.log` when the
112
+ launcher has to start a sidecar because the SessionStart hook did not confirm
113
+ startup in time.
114
+
110
115
  ## Notes
111
116
 
112
117
  Current sidecar behavior is stable for live delivery. Remaining gaps are advanced policies such as batching by sender, message prioritization queues, and more nuanced retry/backoff behavior.
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { pickThreadId } from "./session-start-lib.ts";
3
+
4
+ describe("pickThreadId", () => {
5
+ it("prefers explicit thread ids over session ids", () => {
6
+ const threadId = pickThreadId({
7
+ session_id: "session-123",
8
+ thread_id: "thread-456",
9
+ });
10
+
11
+ expect(threadId).toBe("thread-456");
12
+ });
13
+
14
+ it("uses nested thread id when present", () => {
15
+ const threadId = pickThreadId({
16
+ session: { id: "session-123" },
17
+ thread: { id: "thread-789" },
18
+ });
19
+
20
+ expect(threadId).toBe("thread-789");
21
+ });
22
+
23
+ it("falls back to session id when thread id is missing", () => {
24
+ const threadId = pickThreadId({
25
+ sessionId: "session-abc",
26
+ });
27
+
28
+ expect(threadId).toBe("session-abc");
29
+ });
30
+
31
+ it("ignores blank candidates", () => {
32
+ const threadId = pickThreadId({
33
+ threadId: " ",
34
+ session_id: " session-trimmed ",
35
+ });
36
+
37
+ expect(threadId).toBe("session-trimmed");
38
+ });
39
+ });
@@ -0,0 +1,25 @@
1
+ export type HookInput = {
2
+ session_id?: string;
3
+ sessionId?: string;
4
+ thread_id?: string;
5
+ threadId?: string;
6
+ session?: { id?: string };
7
+ thread?: { id?: string };
8
+ cwd?: string;
9
+ model?: string;
10
+ };
11
+
12
+ export function pickThreadId(input: HookInput): string {
13
+ const candidates = [
14
+ input.thread_id,
15
+ input.threadId,
16
+ input.thread?.id,
17
+ input.session_id,
18
+ input.sessionId,
19
+ input.session?.id,
20
+ ];
21
+ for (const value of candidates) {
22
+ if (typeof value === "string" && value.trim()) return value.trim();
23
+ }
24
+ return "";
25
+ }
@@ -3,16 +3,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } fr
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { buildAgentIdentity } from "../relay.ts";
6
+ import { pickThreadId, type HookInput } from "./session-start-lib.ts";
6
7
 
7
- type HookInput = {
8
- session_id?: string;
9
- sessionId?: string;
10
- thread_id?: string;
8
+ type HookHandshake = {
9
+ status: "ok" | "error";
10
+ code: string;
11
+ message: string;
12
+ pid?: number;
11
13
  threadId?: string;
12
- session?: { id?: string };
13
- thread?: { id?: string };
14
- cwd?: string;
15
- model?: string;
14
+ timestamp: string;
16
15
  };
17
16
 
18
17
  function readStdin(): string {
@@ -45,19 +44,12 @@ function outputContext(context: string): never {
45
44
  process.exit(0);
46
45
  }
47
46
 
48
- function pickThreadId(input: HookInput): string {
49
- const candidates = [
50
- input.session_id,
51
- input.sessionId,
52
- input.thread_id,
53
- input.threadId,
54
- input.session?.id,
55
- input.thread?.id,
56
- ];
57
- for (const value of candidates) {
58
- if (typeof value === "string" && value.trim()) return value.trim();
59
- }
60
- return "";
47
+ function handshakePath(runtimeDir: string): string {
48
+ return join(runtimeDir, "session-start-handshake.json");
49
+ }
50
+
51
+ function writeHandshake(runtimeDir: string, payload: HookHandshake): void {
52
+ writeFileSync(handshakePath(runtimeDir), `${JSON.stringify(payload, null, 2)}\n`);
61
53
  }
62
54
 
63
55
  function existingAlivePid(pidPath: string): number | null {
@@ -90,10 +82,19 @@ const pidPath = join(sessionDir, "sidecar.pid");
90
82
  const statePath = join(sessionDir, "live-state.json");
91
83
  const logPath = join(sessionDir, "sidecar.log");
92
84
  mkdirSync(sessionDir, { recursive: true });
85
+ mkdirSync(runtimeDir, { recursive: true });
93
86
 
94
87
  const autoPidPath = join(runtimeDir, "auto", "sidecar.pid");
95
88
  const activePid = existingAlivePid(pidPath) ?? (threadId ? existingAlivePid(autoPidPath) : null);
96
89
  if (activePid !== null) {
90
+ writeHandshake(runtimeDir, {
91
+ status: "ok",
92
+ code: "HOOK_SIDECAR_REUSED",
93
+ message: `using existing sidecar pid ${activePid}`,
94
+ pid: activePid,
95
+ threadId: threadId || undefined,
96
+ timestamp: new Date().toISOString(),
97
+ });
97
98
  if (threadId) {
98
99
  const identity = buildAgentIdentity({
99
100
  relayUrl,
@@ -126,15 +127,36 @@ if (threadId) {
126
127
  }
127
128
 
128
129
  const logFile = Bun.file(logPath);
129
- const sidecar = Bun.spawn(["bun", "run", join(packageRoot, "codex", "live-sidecar.ts")], {
130
- env: spawnEnv,
131
- stdout: logFile,
132
- stderr: logFile,
133
- });
134
- sidecar.unref();
135
-
136
- writeFileSync(pidPath, String(sidecar.pid));
137
- appendFileSync(join(runtimeDir, "sidecar-pids.txt"), `${sidecar.pid}\n`);
130
+ let sidecarPid = 0;
131
+ try {
132
+ const sidecar = Bun.spawn(["bun", "run", join(packageRoot, "codex", "live-sidecar.ts")], {
133
+ env: spawnEnv,
134
+ stdout: logFile,
135
+ stderr: logFile,
136
+ });
137
+ sidecar.unref();
138
+ sidecarPid = sidecar.pid;
139
+
140
+ writeFileSync(pidPath, String(sidecarPid));
141
+ appendFileSync(join(runtimeDir, "sidecar-pids.txt"), `${sidecarPid}\n`);
142
+ writeHandshake(runtimeDir, {
143
+ status: "ok",
144
+ code: "HOOK_SIDECAR_STARTED",
145
+ message: `spawned sidecar pid ${sidecarPid}`,
146
+ pid: sidecarPid,
147
+ threadId: threadId || undefined,
148
+ timestamp: new Date().toISOString(),
149
+ });
150
+ } catch (error) {
151
+ writeHandshake(runtimeDir, {
152
+ status: "error",
153
+ code: "HOOK_SIDECAR_SPAWN_FAILED",
154
+ message: error instanceof Error ? error.message : String(error),
155
+ threadId: threadId || undefined,
156
+ timestamp: new Date().toISOString(),
157
+ });
158
+ throw error;
159
+ }
138
160
 
139
161
  if (!threadId) {
140
162
  outputContext(`Agent Relay sidecar started in auto-thread mode. Relay URL: ${relayUrl}. Incoming messages will arrive as live user turns once the sidecar resolves the active thread.`);
@@ -90,13 +90,10 @@ async function main(): Promise<void> {
90
90
  throw new Error(`missing runtime directory in launcher output\nstdout:\n${stdout}\nstderr:\n${stderr}`);
91
91
  }
92
92
 
93
- if (!stderr.includes("SessionStart hook did not start sidecar; started fallback sidecar pid")) {
94
- throw new Error(`fallback start message not found\nstderr:\n${stderr}`);
95
- }
96
-
97
93
  const fallbackPidPath = join(runtimeDir, "auto", "sidecar.pid");
98
94
  const fallbackLogPath = join(runtimeDir, "auto", "sidecar.log");
99
95
  const sidecarPidsPath = join(runtimeDir, "sidecar-pids.txt");
96
+ const launcherLogPath = join(runtimeDir, "launcher.log");
100
97
 
101
98
  if (!existsSync(fallbackPidPath)) {
102
99
  throw new Error(`fallback pid file missing: ${fallbackPidPath}`);
@@ -104,12 +101,19 @@ async function main(): Promise<void> {
104
101
  if (!existsSync(sidecarPidsPath)) {
105
102
  throw new Error(`sidecar-pids missing: ${sidecarPidsPath}`);
106
103
  }
104
+ if (!existsSync(launcherLogPath)) {
105
+ throw new Error(`launcher log missing: ${launcherLogPath}`);
106
+ }
107
107
 
108
108
  const pid = readFileSync(fallbackPidPath, "utf8").trim();
109
109
  const pidsFile = readFileSync(sidecarPidsPath, "utf8");
110
+ const launcherLog = readFileSync(launcherLogPath, "utf8");
110
111
  if (!pid || !pidsFile.includes(pid)) {
111
112
  throw new Error(`fallback pid ${pid || "<empty>"} not recorded in sidecar-pids.txt`);
112
113
  }
114
+ if (!launcherLog.includes("HOOK_FALLBACK_STARTED reason=HOOK_HANDSHAKE_TIMEOUT")) {
115
+ throw new Error(`fallback reason code missing in launcher log\nlog:\n${launcherLog}`);
116
+ }
113
117
 
114
118
  log(`fallback sidecar created pid ${pid}`);
115
119
  if (existsSync(fallbackLogPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/public/index.html CHANGED
@@ -30,8 +30,8 @@
30
30
  .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
31
31
  .status-dot.online { background: var(--tblr-success); box-shadow: 0 0 6px var(--tblr-success); }
32
32
  .status-dot.online.not-ready { animation: pulse-dot 1.5s ease-in-out infinite; }
33
- .status-dot.idle { background: var(--tblr-warning); }
34
- .status-dot.busy { background: var(--tblr-danger); }
33
+ .status-dot.idle { background: var(--tblr-success); }
34
+ .status-dot.busy { background: var(--tblr-warning); }
35
35
  .status-dot.offline { background: var(--tblr-secondary); opacity: 0.5; }
36
36
 
37
37
  @keyframes pulse-dot {
@@ -242,6 +242,20 @@
242
242
  <div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
243
243
  <h2 class="page-title mb-0">Agents</h2>
244
244
  <div class="ms-auto d-flex gap-2 align-items-center">
245
+ <select class="form-select form-select-sm" style="width:auto; min-width: 140px" x-model="agentStatusFilter">
246
+ <option value="">Status: All</option>
247
+ <option value="starting">Status: Starting</option>
248
+ <option value="online">Status: Online</option>
249
+ <option value="idle">Status: Idle</option>
250
+ <option value="busy">Status: Busy</option>
251
+ <option value="offline">Status: Offline</option>
252
+ </select>
253
+ <select class="form-select form-select-sm" style="width:auto; min-width: 140px" x-model="agentTagFilter">
254
+ <option value="">Tag: All</option>
255
+ <template x-for="tag in uniqueTags" :key="tag">
256
+ <option :value="tag" x-text="'Tag: ' + tag"></option>
257
+ </template>
258
+ </select>
245
259
  <select class="form-select form-select-sm" style="width:auto" x-model="agentSort">
246
260
  <option value="status">Sort: Status</option>
247
261
  <option value="name">Sort: Name</option>
@@ -251,6 +265,14 @@
251
265
  <button class="btn btn-sm btn-ghost-secondary" @click="agentSortDir = agentSortDir === 'asc' ? 'desc' : 'asc'">
252
266
  <i class="ti" :class="agentSortDir === 'asc' ? 'ti-sort-ascending' : 'ti-sort-descending'"></i>
253
267
  </button>
268
+ <button
269
+ class="btn btn-sm btn-ghost-secondary"
270
+ x-show="agentStatusFilter || agentTagFilter"
271
+ @click="agentStatusFilter = ''; agentTagFilter = ''"
272
+ title="Clear filters"
273
+ >
274
+ <i class="ti ti-x me-1"></i>Clear
275
+ </button>
254
276
  </div>
255
277
  </div>
256
278
 
@@ -260,7 +282,7 @@
260
282
  <div class="card agent-card" :class="{ selected: selectedAgent === a.id }">
261
283
  <div class="card-body">
262
284
  <div class="d-flex align-items-start gap-2">
263
- <span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="a.status !== 'offline' && !a.ready ? 'Starting up…' : a.status"></span>
285
+ <span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="agentStatusTitle(a)"></span>
264
286
  <div class="flex-grow-1 min-width-0">
265
287
  <div class="d-flex align-items-center gap-2">
266
288
  <template x-if="a.label">
@@ -319,7 +341,10 @@
319
341
  <div class="card">
320
342
  <div class="card-body text-center text-secondary py-5">
321
343
  <i class="ti ti-robot-off" style="font-size:48px; opacity:0.3"></i>
322
- <p class="mt-2" x-text="showOffline ? 'No agents registered' : 'No active agents — enable Show Offline'"></p>
344
+ <p
345
+ class="mt-2"
346
+ x-text="(agentStatusFilter || agentTagFilter) ? 'No agents match the current filters' : (showOffline ? 'No agents registered' : 'No active agents — enable Show Offline')"
347
+ ></p>
323
348
  </div>
324
349
  </div>
325
350
  </template>
@@ -699,6 +724,8 @@ function relay() {
699
724
  // Filters
700
725
  channelFilter: '',
701
726
  tagFilter: '',
727
+ agentStatusFilter: load('agentStatusFilter', ''),
728
+ agentTagFilter: load('agentTagFilter', ''),
702
729
 
703
730
  // Charts
704
731
  chartInstances: {},
@@ -716,6 +743,8 @@ function relay() {
716
743
  this.$watch('showOffline', v => this.save('showOffline', v));
717
744
  this.$watch('agentSort', v => this.save('agentSort', v));
718
745
  this.$watch('agentSortDir', v => this.save('agentSortDir', v));
746
+ this.$watch('agentStatusFilter', v => this.save('agentStatusFilter', v));
747
+ this.$watch('agentTagFilter', v => this.save('agentTagFilter', v));
719
748
  this.$watch('view', v => {
720
749
  this.save('view', v);
721
750
  if (v === 'analytics') this.$nextTick(() => this.renderCharts());
@@ -827,13 +856,23 @@ function relay() {
827
856
 
828
857
  get sortedAgents() {
829
858
  let list = this.showOffline ? [...this.agents] : this.agents.filter(a => a.status !== 'offline');
859
+ if (this.agentStatusFilter) {
860
+ if (this.agentStatusFilter === 'starting') {
861
+ list = list.filter(a => a.status !== 'offline' && !a.ready);
862
+ } else {
863
+ list = list.filter(a => a.status === this.agentStatusFilter);
864
+ }
865
+ }
866
+ if (this.agentTagFilter) {
867
+ list = list.filter(a => (a.tags || []).includes(this.agentTagFilter));
868
+ }
830
869
  const dir = this.agentSortDir === 'desc' ? -1 : 1;
831
870
  return list.sort((a, b) => {
832
871
  let cmp = 0;
833
872
  switch (this.agentSort) {
834
873
  case 'name': cmp = this.displayName(a).localeCompare(this.displayName(b)); break;
835
874
  case 'status': {
836
- const order = { online: 0, busy: 1, idle: 2, offline: 3 };
875
+ const order = { online: 0, idle: 1, busy: 2, offline: 3 };
837
876
  cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
838
877
  break;
839
878
  }
@@ -907,9 +946,21 @@ function relay() {
907
946
  return agent ? this.displayName(agent) : target.slice(-8);
908
947
  },
909
948
 
949
+ agentStatusTitle(agent) {
950
+ if (!agent) return '';
951
+ if (agent.status === 'offline') return 'offline';
952
+ if (agent.ready) return agent.status;
953
+ const lastSeenMs = new Date(agent.lastSeen).getTime();
954
+ if (!Number.isFinite(lastSeenMs)) return 'Trying to reconnect…';
955
+ const ageSec = Math.max(0, (Date.now() - lastSeenMs) / 1000);
956
+ return ageSec <= 45 ? 'Starting up…' : 'Trying to reconnect…';
957
+ },
958
+
910
959
  timeAgo(iso) {
911
960
  if (!iso) return '';
912
- const diff = (Date.now() - new Date(iso).getTime()) / 1000;
961
+ const ts = new Date(iso).getTime();
962
+ if (!Number.isFinite(ts)) return '';
963
+ const diff = Math.max(0, (Date.now() - ts) / 1000);
913
964
  if (diff < 60) return Math.floor(diff) + 's ago';
914
965
  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
915
966
  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
@@ -1084,7 +1135,7 @@ function relay() {
1084
1135
  for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
1085
1136
  const labels = Object.keys(counts).filter(k => counts[k] > 0);
1086
1137
  const series = labels.map(k => counts[k]);
1087
- const colorMap = { online: '#48bb78', idle: '#ecc94b', busy: '#f56565', offline: '#718096' };
1138
+ const colorMap = { online: '#48bb78', idle: '#48bb78', busy: '#ecc94b', offline: '#718096' };
1088
1139
 
1089
1140
  this.chartInstances.status = new ApexCharts(el, {
1090
1141
  chart: { type: 'donut', height: 280, background: 'transparent' },
package/src/config.ts CHANGED
@@ -17,7 +17,7 @@ function envPositiveInt(name: string, fallback: number): number {
17
17
  return Math.floor(parsed);
18
18
  }
19
19
 
20
- export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 600_000); // 10min without heartbeat → offline
20
+ export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
21
21
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
22
22
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
23
23