tuiboard 0.11.0 → 0.13.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/.tuiboard/config.example.yaml +33 -9
- package/CHANGELOG.md +127 -0
- package/README.md +116 -30
- package/bin/tuiboard.ts +9 -0
- package/package.json +3 -2
- package/src/app-exit.ts +37 -0
- package/src/app.tsx +23 -13
- package/src/config/loader.ts +40 -7
- package/src/input/handleKey.ts +115 -52
- package/src/input/herdr-open.ts +132 -0
- package/src/input/open-session.ts +452 -0
- package/src/store/agent-adapters/claude-code.ts +349 -0
- package/src/store/agent-adapters/codex.ts +406 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +196 -0
- package/src/store/agent-adapters/pi.ts +254 -0
- package/src/store/agents.ts +182 -342
- package/src/store/herdr.ts +272 -0
- package/src/store/index.ts +48 -8
- package/src/ui/AgentRow.tsx +188 -50
- package/src/ui/AgentsBar.tsx +9 -3
- package/src/ui/Modal.tsx +63 -6
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +12 -5
package/src/store/agents.ts
CHANGED
|
@@ -1,48 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discovery + reactive store for local
|
|
2
|
+
* Discovery + reactive store for local coding-agent sessions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Watches both with chokidar; re-parses the changed jsonl on update.
|
|
9
|
-
* Eager initial scan (1-2s for ~80 sessions) is acceptable startup cost.
|
|
4
|
+
* Each agent CLI (Claude Code, Codex, OpenCode, Pi) plugs in through an `AgentAdapter`
|
|
5
|
+
* that owns its own on-disk format, status semantics and resume command —
|
|
6
|
+
* see `src/store/agent-adapters/`. This module only holds the shared shape
|
|
7
|
+
* and merges every adapter's sessions into one sorted, watched list.
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
|
-
import { existsSync
|
|
13
|
-
import {
|
|
14
|
-
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { resolve, sep } from "node:path";
|
|
12
|
+
|
|
15
13
|
import chokidar from "chokidar";
|
|
16
14
|
import { createSignal } from "solid-js";
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
import {
|
|
17
|
+
linkHerdrSessions,
|
|
18
|
+
type HerdrLink,
|
|
19
|
+
type HerdrSnapshot,
|
|
20
|
+
type HerdrSource,
|
|
21
|
+
} from "~/store/herdr";
|
|
22
|
+
|
|
23
|
+
/** Threshold: session untouched longer than this is "archived" (won't show in compact list). */
|
|
24
|
+
export const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
|
|
25
|
+
|
|
26
|
+
export type AgentProvider = "claude-code" | "codex" | "opencode" | "pi";
|
|
27
|
+
|
|
28
|
+
/** Two-letter harness badge + display name, per provider. */
|
|
29
|
+
export const HARNESS: Record<AgentProvider, { code: string; name: string }> = {
|
|
30
|
+
"claude-code": { code: "cc", name: "Claude Code" },
|
|
31
|
+
codex: { code: "cx", name: "Codex" },
|
|
32
|
+
opencode: { code: "oc", name: "OpenCode" },
|
|
33
|
+
pi: { code: "pi", name: "Pi" },
|
|
34
|
+
};
|
|
21
35
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
/** Threshold: jsonl untouched longer than this is "archived" (won't show in compact list). */
|
|
25
|
-
const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
|
|
36
|
+
/** Agents-zone filter: every harness, or just one. */
|
|
37
|
+
export type AgentsFilter = "all" | AgentProvider;
|
|
26
38
|
|
|
27
39
|
export type AgentStatus =
|
|
40
|
+
/** Waiting for you (permission/question) — known only via herdr. */
|
|
41
|
+
| "live-blocked"
|
|
28
42
|
| "live-busy"
|
|
43
|
+
/** Finished its turn, not looked at yet — known only via herdr. */
|
|
44
|
+
| "live-done"
|
|
29
45
|
| "live-idle"
|
|
30
|
-
|
|
46
|
+
/** Looks busy, but stopped updating — the process likely crashed. */
|
|
47
|
+
| "stale"
|
|
31
48
|
| "dormant"
|
|
32
49
|
| "archived";
|
|
33
50
|
|
|
34
|
-
export interface LivePidRecord {
|
|
35
|
-
mtimeMs: number;
|
|
36
|
-
/** "busy" | "idle" | undefined */
|
|
37
|
-
status?: string;
|
|
38
|
-
pid?: number;
|
|
39
|
-
version?: string;
|
|
40
|
-
cwd?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
51
|
export interface AgentSession {
|
|
52
|
+
provider: AgentProvider;
|
|
44
53
|
sessionId: string;
|
|
45
|
-
|
|
54
|
+
/** File (or database) the session was read from. */
|
|
55
|
+
sourcePath: string;
|
|
46
56
|
cwd: string;
|
|
47
57
|
cwdShort: string;
|
|
48
58
|
status: AgentStatus;
|
|
@@ -55,58 +65,52 @@ export interface AgentSession {
|
|
|
55
65
|
lastUser?: string;
|
|
56
66
|
lastAssistant?: string;
|
|
57
67
|
gitBranch?: string;
|
|
68
|
+
/** Model id as the agent recorded it (latest turn), e.g. `claude-opus-5`. */
|
|
69
|
+
model?: string;
|
|
70
|
+
/** Shell command that resumes this session when run from `cwd`. */
|
|
71
|
+
resumeCommand: string;
|
|
72
|
+
/** Same, as argv (`[binary, ...args]`) — for launchers that start the agent directly. */
|
|
73
|
+
resumeArgv: string[];
|
|
74
|
+
/** Set when the session is open in a herdr pane. */
|
|
75
|
+
herdr?: HerdrLink;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
* We can recognize a Windows-shaped slug by the `<letter>--` prefix
|
|
69
|
-
* (drive letter followed by colon → two leading dashes). Anything else
|
|
70
|
-
* is assumed POSIX. This works regardless of `process.platform`, so
|
|
71
|
-
* decoding remote-shape paths (sessions originated on a different OS)
|
|
72
|
-
* still produces something sensible.
|
|
73
|
-
*/
|
|
74
|
-
export function cwdFromSlug(slug: string): string {
|
|
75
|
-
// Windows drive letter shape: "C--Users-foo" → "C:\Users\foo".
|
|
76
|
-
if (slug.length >= 3 && /^[A-Za-z]--/.test(slug)) {
|
|
77
|
-
return slug[0] + ":\\" + slug.slice(3).replaceAll("-", "\\");
|
|
78
|
-
}
|
|
79
|
-
// POSIX shape: "-home-foo" → "/home/foo".
|
|
80
|
-
if (slug.startsWith("-")) {
|
|
81
|
-
return "/" + slug.slice(1).replaceAll("-", "/");
|
|
82
|
-
}
|
|
83
|
-
// Fallback: bare directory name. Pick the separator from the host OS so
|
|
84
|
-
// the result at least concatenates correctly when the user copies it.
|
|
85
|
-
const sep = process.platform === "win32" ? "\\" : "/";
|
|
86
|
-
return slug.replaceAll("-", sep);
|
|
78
|
+
/** One agent CLI's session source. */
|
|
79
|
+
export interface AgentAdapter {
|
|
80
|
+
provider: AgentProvider;
|
|
81
|
+
/** Files/directories whose changes should trigger a refresh. */
|
|
82
|
+
watchPaths(): string[];
|
|
83
|
+
/** Full scan. Must not throw — a missing install yields `[]`. */
|
|
84
|
+
discover(now: number): AgentSession[];
|
|
87
85
|
}
|
|
88
86
|
|
|
89
|
-
/** Last 3 path parts with leading ellipsis when path is long. */
|
|
87
|
+
/** Last 3 path parts with leading ellipsis when path is long. Keeps the path's own separator. */
|
|
90
88
|
export function cwdShort(cwd: string): string {
|
|
91
89
|
const parts = cwd.split(/[\\/]/).filter((p) => p.length > 0);
|
|
92
90
|
if (parts.length >= 4) {
|
|
93
|
-
|
|
91
|
+
const sep = cwd.includes("/") ? "/" : "\\";
|
|
92
|
+
return "…" + parts.slice(-3).join(sep);
|
|
94
93
|
}
|
|
95
94
|
return cwd;
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
97
|
+
/**
|
|
98
|
+
* Compact model label for tight rows: drops the provider prefix, the
|
|
99
|
+
* `claude-` family prefix, date stamps and context-window suffixes.
|
|
100
|
+
* `claude-opus-5[1m]` → `opus-5` · `anthropic/claude-sonnet-4-5-20250929` → `sonnet-4-5`
|
|
101
|
+
*/
|
|
102
|
+
export function shortModel(raw: string | undefined): string | undefined {
|
|
103
|
+
if (!raw || raw.startsWith("<")) return undefined; // e.g. Claude's "<synthetic>"
|
|
104
|
+
const m = (raw.split("/").pop() ?? raw)
|
|
105
|
+
.replace(/\[[^\]]*\]$/, "")
|
|
106
|
+
.replace(/^claude-/, "")
|
|
107
|
+
.replace(/-\d{8}$/, "");
|
|
108
|
+
return m || undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Apply the Agents-zone harness filter. Keeps the store's sort order. */
|
|
112
|
+
export function filterSessions(arr: AgentSession[], filter: AgentsFilter): AgentSession[] {
|
|
113
|
+
return filter === "all" ? arr : arr.filter((s) => s.provider === filter);
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
/** Compact human-readable age. Mirrors av.py `_fmt_age`. */
|
|
@@ -119,268 +123,33 @@ export function formatAge(ts: number, now: number): string {
|
|
|
119
123
|
return `${Math.floor(delta / 86_400)}d`;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
lastUser?: string;
|
|
132
|
-
lastAssistant?: string;
|
|
133
|
-
messageCount: number;
|
|
134
|
-
toolCount: number;
|
|
135
|
-
gitBranch?: string;
|
|
136
|
-
}
|
|
126
|
+
const STATUS_RANK: Record<AgentStatus, number> = {
|
|
127
|
+
"live-blocked": 0,
|
|
128
|
+
"live-busy": 1,
|
|
129
|
+
"live-done": 2,
|
|
130
|
+
"live-idle": 3,
|
|
131
|
+
"stale": 4,
|
|
132
|
+
"dormant": 5,
|
|
133
|
+
"archived": 6,
|
|
134
|
+
};
|
|
137
135
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
* a fallback displayName — otherwise N sessions opened by the same skill
|
|
142
|
-
* all look identical in the list.
|
|
143
|
-
*/
|
|
144
|
-
function looksSyntheticUser(text: string): boolean {
|
|
145
|
-
const t = text.trimStart();
|
|
146
|
-
if (!t) return true;
|
|
147
|
-
// Skill bootstrap: "Base directory for this skill: ..."
|
|
148
|
-
if (t.startsWith("Base directory for this skill")) return true;
|
|
149
|
-
// System-injected tags: <command-name>, <task-notification>, <system-reminder>, <local-command-stdout>
|
|
150
|
-
if (/^<[a-z][a-z0-9-]*>/i.test(t)) return true;
|
|
151
|
-
// Bare slash-command invocation (the literal "/morning", "/log", etc.)
|
|
152
|
-
if (/^\/[a-z][a-z0-9-]*\s*$/i.test(t)) return true;
|
|
153
|
-
return false;
|
|
136
|
+
/** Is the agent process running (as far as we can tell)? */
|
|
137
|
+
export function isLive(status: AgentStatus): boolean {
|
|
138
|
+
return status.startsWith("live-");
|
|
154
139
|
}
|
|
155
140
|
|
|
156
141
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
142
|
+
* Most recently active first; status only breaks ties. Archived sessions
|
|
143
|
+
* always go last — the dashboard strip hides them and relies on that to keep
|
|
144
|
+
* cursor indices aligned with the full list.
|
|
160
145
|
*/
|
|
161
|
-
export function
|
|
162
|
-
let customTitle: string | undefined;
|
|
163
|
-
let aiTitle: string | undefined;
|
|
164
|
-
let firstHumanUser: string | undefined;
|
|
165
|
-
let lastUser: string | undefined;
|
|
166
|
-
let lastAssistant: string | undefined;
|
|
167
|
-
let gitBranch: string | undefined;
|
|
168
|
-
let messageCount = 0;
|
|
169
|
-
let toolCount = 0;
|
|
170
|
-
|
|
171
|
-
const recordUserText = (text: string) => {
|
|
172
|
-
lastUser = text;
|
|
173
|
-
if (firstHumanUser === undefined && !looksSyntheticUser(text)) {
|
|
174
|
-
firstHumanUser = text;
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
for (const line of content.split("\n")) {
|
|
179
|
-
if (!line.trim()) continue;
|
|
180
|
-
let obj: any;
|
|
181
|
-
try {
|
|
182
|
-
obj = JSON.parse(line);
|
|
183
|
-
} catch {
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
if (obj.gitBranch) gitBranch = obj.gitBranch;
|
|
187
|
-
const t = obj.type;
|
|
188
|
-
if (t === "custom-title") {
|
|
189
|
-
customTitle = obj.customTitle ?? obj.title ?? customTitle;
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (t === "ai-title") {
|
|
193
|
-
aiTitle = obj.aiTitle ?? obj.title ?? aiTitle;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const msg = obj.message ?? {};
|
|
197
|
-
const role = msg.role;
|
|
198
|
-
if (role === "user") {
|
|
199
|
-
messageCount++;
|
|
200
|
-
const content = msg.content;
|
|
201
|
-
if (typeof content === "string") {
|
|
202
|
-
recordUserText(content);
|
|
203
|
-
} else if (Array.isArray(content)) {
|
|
204
|
-
for (const part of content) {
|
|
205
|
-
if (
|
|
206
|
-
part &&
|
|
207
|
-
typeof part === "object" &&
|
|
208
|
-
part.type === "text" &&
|
|
209
|
-
typeof part.text === "string"
|
|
210
|
-
) {
|
|
211
|
-
recordUserText(part.text);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} else if (role === "assistant") {
|
|
216
|
-
messageCount++;
|
|
217
|
-
const content = msg.content;
|
|
218
|
-
if (Array.isArray(content)) {
|
|
219
|
-
for (const part of content) {
|
|
220
|
-
if (!part || typeof part !== "object") continue;
|
|
221
|
-
if (part.type === "text" && typeof part.text === "string") {
|
|
222
|
-
lastAssistant = part.text;
|
|
223
|
-
} else if (part.type === "tool_use") {
|
|
224
|
-
toolCount++;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
customTitle,
|
|
233
|
-
aiTitle,
|
|
234
|
-
firstHumanUser,
|
|
235
|
-
lastUser,
|
|
236
|
-
lastAssistant,
|
|
237
|
-
messageCount,
|
|
238
|
-
toolCount,
|
|
239
|
-
gitBranch,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ─── Discovery ──────────────────────────────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
function discoverLivePids(): Map<string, LivePidRecord> {
|
|
246
|
-
const out = new Map<string, LivePidRecord>();
|
|
247
|
-
if (!existsSync(SESSIONS_DIR)) return out;
|
|
248
|
-
let entries: string[];
|
|
249
|
-
try {
|
|
250
|
-
entries = readdirSync(SESSIONS_DIR);
|
|
251
|
-
} catch {
|
|
252
|
-
return out;
|
|
253
|
-
}
|
|
254
|
-
for (const f of entries) {
|
|
255
|
-
if (!f.endsWith(".json")) continue;
|
|
256
|
-
const path = join(SESSIONS_DIR, f);
|
|
257
|
-
try {
|
|
258
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
259
|
-
const sid = raw.sessionId;
|
|
260
|
-
if (!sid) continue;
|
|
261
|
-
const stat = statSync(path);
|
|
262
|
-
out.set(sid, {
|
|
263
|
-
mtimeMs: stat.mtimeMs,
|
|
264
|
-
status: raw.status?.toLowerCase(),
|
|
265
|
-
pid: raw.pid,
|
|
266
|
-
version: raw.version,
|
|
267
|
-
cwd: raw.cwd,
|
|
268
|
-
});
|
|
269
|
-
} catch {
|
|
270
|
-
// ignore — malformed PID files happen during writes
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return out;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
interface JsonlEntry {
|
|
277
|
-
slug: string;
|
|
278
|
-
sessionId: string;
|
|
279
|
-
path: string;
|
|
280
|
-
mtimeMs: number;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function discoverJsonlFiles(): JsonlEntry[] {
|
|
284
|
-
const out: JsonlEntry[] = [];
|
|
285
|
-
if (!existsSync(PROJECTS_DIR)) return out;
|
|
286
|
-
let slugs: string[];
|
|
287
|
-
try {
|
|
288
|
-
slugs = readdirSync(PROJECTS_DIR);
|
|
289
|
-
} catch {
|
|
290
|
-
return out;
|
|
291
|
-
}
|
|
292
|
-
for (const slug of slugs) {
|
|
293
|
-
const slugDir = join(PROJECTS_DIR, slug);
|
|
294
|
-
let slugStat;
|
|
295
|
-
try {
|
|
296
|
-
slugStat = statSync(slugDir);
|
|
297
|
-
} catch {
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
if (!slugStat.isDirectory()) continue;
|
|
301
|
-
let inner: string[];
|
|
302
|
-
try {
|
|
303
|
-
inner = readdirSync(slugDir);
|
|
304
|
-
} catch {
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
for (const f of inner) {
|
|
308
|
-
// Skip subagent transcripts — they're addressed by their parent session.
|
|
309
|
-
if (!f.endsWith(".jsonl")) continue;
|
|
310
|
-
const path = join(slugDir, f);
|
|
311
|
-
try {
|
|
312
|
-
const stat = statSync(path);
|
|
313
|
-
if (!stat.isFile()) continue;
|
|
314
|
-
out.push({
|
|
315
|
-
slug,
|
|
316
|
-
sessionId: f.slice(0, -".jsonl".length),
|
|
317
|
-
path,
|
|
318
|
-
mtimeMs: stat.mtimeMs,
|
|
319
|
-
});
|
|
320
|
-
} catch {
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return out;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function buildSession(
|
|
329
|
-
jsonl: JsonlEntry,
|
|
330
|
-
live: LivePidRecord | undefined,
|
|
331
|
-
now: number,
|
|
332
|
-
): AgentSession {
|
|
333
|
-
let parsed: TranscriptParseResult;
|
|
334
|
-
try {
|
|
335
|
-
parsed = parseTranscript(readFileSync(jsonl.path, "utf-8"));
|
|
336
|
-
} catch {
|
|
337
|
-
parsed = { messageCount: 0, toolCount: 0 };
|
|
338
|
-
}
|
|
339
|
-
const cwd = live?.cwd ?? cwdFromSlug(jsonl.slug);
|
|
340
|
-
const hasRealTitle = Boolean(parsed.customTitle || parsed.aiTitle);
|
|
341
|
-
const fallbackText =
|
|
342
|
-
parsed.firstHumanUser?.split("\n").find((l) => l.trim().length > 0)?.slice(0, 60) ??
|
|
343
|
-
parsed.lastUser?.split("\n").find((l) => l.trim().length > 0)?.slice(0, 60);
|
|
344
|
-
const uuid8 = jsonl.sessionId.slice(0, 8);
|
|
345
|
-
// When no human-authored title is available, suffix the uuid8 so visually
|
|
346
|
-
// identical fallback titles (e.g. many sessions started by the same /skill)
|
|
347
|
-
// still produce distinct rows.
|
|
348
|
-
const displayName = hasRealTitle
|
|
349
|
-
? (parsed.customTitle ?? parsed.aiTitle)!.slice(0, 60)
|
|
350
|
-
: fallbackText
|
|
351
|
-
? `${fallbackText} · ${uuid8}`
|
|
352
|
-
: uuid8;
|
|
353
|
-
return {
|
|
354
|
-
sessionId: jsonl.sessionId,
|
|
355
|
-
jsonlPath: jsonl.path,
|
|
356
|
-
cwd,
|
|
357
|
-
cwdShort: cwdShort(cwd),
|
|
358
|
-
status: classifyStatus(now, jsonl.mtimeMs, live),
|
|
359
|
-
lastActivityMs: jsonl.mtimeMs,
|
|
360
|
-
customTitle: parsed.customTitle,
|
|
361
|
-
aiTitle: parsed.aiTitle,
|
|
362
|
-
displayName,
|
|
363
|
-
messageCount: parsed.messageCount,
|
|
364
|
-
toolCount: parsed.toolCount,
|
|
365
|
-
lastUser: parsed.lastUser,
|
|
366
|
-
lastAssistant: parsed.lastAssistant,
|
|
367
|
-
gitBranch: parsed.gitBranch,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const STATUS_RANK: Record<AgentStatus, number> = {
|
|
372
|
-
"live-busy": 0,
|
|
373
|
-
"live-idle": 1,
|
|
374
|
-
"stale-pid": 2,
|
|
375
|
-
"dormant": 3,
|
|
376
|
-
"archived": 4,
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
146
|
+
export function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
380
147
|
return arr.slice().sort((a, b) => {
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
148
|
+
const archived = Number(a.status === "archived") - Number(b.status === "archived");
|
|
149
|
+
if (archived !== 0) return archived;
|
|
150
|
+
const recency = b.lastActivityMs - a.lastActivityMs;
|
|
151
|
+
if (recency !== 0) return recency;
|
|
152
|
+
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
384
153
|
});
|
|
385
154
|
}
|
|
386
155
|
|
|
@@ -388,48 +157,119 @@ function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
|
388
157
|
|
|
389
158
|
export interface AgentsStore {
|
|
390
159
|
sessions: () => AgentSession[];
|
|
160
|
+
/** Latest herdr snapshot (undefined without herdr). */
|
|
161
|
+
herdr: () => HerdrSnapshot | undefined;
|
|
391
162
|
refresh: () => void;
|
|
392
163
|
dispose: () => Promise<void>;
|
|
393
164
|
}
|
|
394
165
|
|
|
166
|
+
const DEBOUNCE_MS = 200;
|
|
167
|
+
/** Cap on debounce deferral, so a session streaming non-stop still refreshes. */
|
|
168
|
+
const DEBOUNCE_MAX_WAIT_MS = 1000;
|
|
169
|
+
/**
|
|
170
|
+
* The watcher ignores paths that don't exist yet (agent installed, or first
|
|
171
|
+
* session started, after tuiboard launched) — poll for them this often.
|
|
172
|
+
*/
|
|
173
|
+
const MISSING_PATH_POLL_MS = 5000;
|
|
174
|
+
|
|
395
175
|
/**
|
|
396
|
-
* Reactive store of local
|
|
397
|
-
*
|
|
398
|
-
*
|
|
176
|
+
* Reactive store of local agent sessions. Watches every adapter's paths and,
|
|
177
|
+
* on a change, re-scans only the adapter that owns the changed path (short
|
|
178
|
+
* debounce). Initial scan is eager.
|
|
399
179
|
*/
|
|
400
|
-
export function createAgentsStore(
|
|
180
|
+
export function createAgentsStore(
|
|
181
|
+
adapters: AgentAdapter[],
|
|
182
|
+
{
|
|
183
|
+
missingPathPollMs = MISSING_PATH_POLL_MS,
|
|
184
|
+
herdr,
|
|
185
|
+
}: { missingPathPollMs?: number; herdr?: HerdrSource } = {},
|
|
186
|
+
): AgentsStore {
|
|
401
187
|
const [sessions, setSessions] = createSignal<AgentSession[]>([]);
|
|
188
|
+
const byAdapter = new Map<AgentAdapter, AgentSession[]>();
|
|
189
|
+
|
|
190
|
+
function scan(adapter: AgentAdapter, now: number): void {
|
|
191
|
+
try {
|
|
192
|
+
byAdapter.set(adapter, adapter.discover(now));
|
|
193
|
+
} catch {
|
|
194
|
+
// One broken adapter must not blank out the others; keep its last list.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function publish(): void {
|
|
199
|
+
const all = [...byAdapter.values()].flat();
|
|
200
|
+
setSessions(sortSessions(linkHerdrSessions(all, herdr?.snapshot())));
|
|
201
|
+
}
|
|
202
|
+
herdr?.onChange(publish);
|
|
402
203
|
|
|
403
204
|
function refresh(): void {
|
|
404
205
|
const now = Date.now();
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
const built = jsonlFiles.map((j) =>
|
|
408
|
-
buildSession(j, live.get(j.sessionId), now),
|
|
409
|
-
);
|
|
410
|
-
setSessions(sortSessions(built));
|
|
206
|
+
for (const adapter of adapters) scan(adapter, now);
|
|
207
|
+
publish();
|
|
411
208
|
}
|
|
412
209
|
|
|
413
210
|
refresh();
|
|
414
211
|
|
|
212
|
+
const watched = adapters.flatMap((adapter) =>
|
|
213
|
+
adapter.watchPaths().map((p) => ({ root: resolve(p), adapter })),
|
|
214
|
+
);
|
|
215
|
+
const owners = (path: string) => {
|
|
216
|
+
const abs = resolve(path);
|
|
217
|
+
return new Set(
|
|
218
|
+
watched
|
|
219
|
+
.filter((w) => abs === w.root || abs.startsWith(w.root + sep))
|
|
220
|
+
.map((w) => w.adapter),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const pending = new Set<AgentAdapter>();
|
|
415
225
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
416
|
-
|
|
226
|
+
let firstPendingAt = 0;
|
|
227
|
+
const flush = () => {
|
|
228
|
+
debounceTimer = undefined;
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
for (const adapter of pending) scan(adapter, now);
|
|
231
|
+
pending.clear();
|
|
232
|
+
publish();
|
|
233
|
+
};
|
|
234
|
+
const onChange = (path: string) => {
|
|
235
|
+
const hit = owners(path);
|
|
236
|
+
if (hit.size === 0) return;
|
|
237
|
+
for (const adapter of hit) pending.add(adapter);
|
|
238
|
+
const now = Date.now();
|
|
417
239
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
418
|
-
|
|
240
|
+
else firstPendingAt = now;
|
|
241
|
+
const wait = Math.min(DEBOUNCE_MS, firstPendingAt + DEBOUNCE_MAX_WAIT_MS - now);
|
|
242
|
+
debounceTimer = setTimeout(flush, Math.max(0, wait));
|
|
419
243
|
};
|
|
420
244
|
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
245
|
+
const roots = [...new Set(watched.map((w) => w.root))];
|
|
246
|
+
const watcher = chokidar.watch(
|
|
247
|
+
roots.filter((r) => existsSync(r)),
|
|
248
|
+
{ ignoreInitial: true, depth: 3 },
|
|
249
|
+
);
|
|
425
250
|
watcher.on("add", onChange);
|
|
251
|
+
watcher.on("addDir", onChange);
|
|
426
252
|
watcher.on("change", onChange);
|
|
427
253
|
watcher.on("unlink", onChange);
|
|
428
254
|
|
|
255
|
+
let missing = roots.filter((r) => !existsSync(r));
|
|
256
|
+
const missingPoll = setInterval(() => {
|
|
257
|
+
const appeared = missing.filter((r) => existsSync(r));
|
|
258
|
+
if (appeared.length === 0) return;
|
|
259
|
+
missing = missing.filter((r) => !appeared.includes(r));
|
|
260
|
+
watcher.add(appeared);
|
|
261
|
+
for (const root of appeared) onChange(root);
|
|
262
|
+
if (missing.length === 0) clearInterval(missingPoll);
|
|
263
|
+
}, missingPathPollMs);
|
|
264
|
+
missingPoll.unref?.();
|
|
265
|
+
if (missing.length === 0) clearInterval(missingPoll);
|
|
266
|
+
|
|
429
267
|
async function dispose() {
|
|
430
268
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
269
|
+
clearInterval(missingPoll);
|
|
270
|
+
herdr?.dispose();
|
|
431
271
|
await watcher.close();
|
|
432
272
|
}
|
|
433
273
|
|
|
434
|
-
return { sessions, refresh, dispose };
|
|
274
|
+
return { sessions, herdr: () => herdr?.snapshot(), refresh, dispose };
|
|
435
275
|
}
|