tuiboard 0.11.0 → 0.12.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 +29 -9
- package/CHANGELOG.md +71 -0
- package/README.md +63 -30
- package/package.json +3 -2
- package/src/config/loader.ts +32 -7
- package/src/input/handleKey.ts +65 -51
- package/src/input/open-session.ts +403 -0
- package/src/store/agent-adapters/claude-code.ts +348 -0
- package/src/store/agent-adapters/codex.ts +405 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +195 -0
- package/src/store/agent-adapters/pi.ts +253 -0
- package/src/store/agents.ts +139 -336
- package/src/store/index.ts +45 -7
- package/src/ui/AgentRow.tsx +147 -41
- package/src/ui/AgentsBar.tsx +8 -3
- package/src/ui/Modal.tsx +40 -6
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +11 -5
package/src/store/agents.ts
CHANGED
|
@@ -1,48 +1,47 @@
|
|
|
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
|
-
const
|
|
20
|
-
|
|
16
|
+
/** Threshold: session untouched longer than this is "archived" (won't show in compact list). */
|
|
17
|
+
export const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
|
|
18
|
+
|
|
19
|
+
export type AgentProvider = "claude-code" | "codex" | "opencode" | "pi";
|
|
21
20
|
|
|
22
|
-
/**
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/** Two-letter harness badge + display name, per provider. */
|
|
22
|
+
export const HARNESS: Record<AgentProvider, { code: string; name: string }> = {
|
|
23
|
+
"claude-code": { code: "cc", name: "Claude Code" },
|
|
24
|
+
codex: { code: "cx", name: "Codex" },
|
|
25
|
+
opencode: { code: "oc", name: "OpenCode" },
|
|
26
|
+
pi: { code: "pi", name: "Pi" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Agents-zone filter: every harness, or just one. */
|
|
30
|
+
export type AgentsFilter = "all" | AgentProvider;
|
|
26
31
|
|
|
27
32
|
export type AgentStatus =
|
|
28
33
|
| "live-busy"
|
|
29
34
|
| "live-idle"
|
|
30
|
-
|
|
35
|
+
/** Looks busy, but stopped updating — the process likely crashed. */
|
|
36
|
+
| "stale"
|
|
31
37
|
| "dormant"
|
|
32
38
|
| "archived";
|
|
33
39
|
|
|
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
40
|
export interface AgentSession {
|
|
41
|
+
provider: AgentProvider;
|
|
44
42
|
sessionId: string;
|
|
45
|
-
|
|
43
|
+
/** File (or database) the session was read from. */
|
|
44
|
+
sourcePath: string;
|
|
46
45
|
cwd: string;
|
|
47
46
|
cwdShort: string;
|
|
48
47
|
status: AgentStatus;
|
|
@@ -55,58 +54,48 @@ export interface AgentSession {
|
|
|
55
54
|
lastUser?: string;
|
|
56
55
|
lastAssistant?: string;
|
|
57
56
|
gitBranch?: string;
|
|
57
|
+
/** Model id as the agent recorded it (latest turn), e.g. `claude-opus-5`. */
|
|
58
|
+
model?: string;
|
|
59
|
+
/** Shell command that resumes this session when run from `cwd`. */
|
|
60
|
+
resumeCommand: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
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);
|
|
63
|
+
/** One agent CLI's session source. */
|
|
64
|
+
export interface AgentAdapter {
|
|
65
|
+
provider: AgentProvider;
|
|
66
|
+
/** Files/directories whose changes should trigger a refresh. */
|
|
67
|
+
watchPaths(): string[];
|
|
68
|
+
/** Full scan. Must not throw — a missing install yields `[]`. */
|
|
69
|
+
discover(now: number): AgentSession[];
|
|
87
70
|
}
|
|
88
71
|
|
|
89
|
-
/** Last 3 path parts with leading ellipsis when path is long. */
|
|
72
|
+
/** Last 3 path parts with leading ellipsis when path is long. Keeps the path's own separator. */
|
|
90
73
|
export function cwdShort(cwd: string): string {
|
|
91
74
|
const parts = cwd.split(/[\\/]/).filter((p) => p.length > 0);
|
|
92
75
|
if (parts.length >= 4) {
|
|
93
|
-
|
|
76
|
+
const sep = cwd.includes("/") ? "/" : "\\";
|
|
77
|
+
return "…" + parts.slice(-3).join(sep);
|
|
94
78
|
}
|
|
95
79
|
return cwd;
|
|
96
80
|
}
|
|
97
81
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
82
|
+
/**
|
|
83
|
+
* Compact model label for tight rows: drops the provider prefix, the
|
|
84
|
+
* `claude-` family prefix, date stamps and context-window suffixes.
|
|
85
|
+
* `claude-opus-5[1m]` → `opus-5` · `anthropic/claude-sonnet-4-5-20250929` → `sonnet-4-5`
|
|
86
|
+
*/
|
|
87
|
+
export function shortModel(raw: string | undefined): string | undefined {
|
|
88
|
+
if (!raw || raw.startsWith("<")) return undefined; // e.g. Claude's "<synthetic>"
|
|
89
|
+
const m = (raw.split("/").pop() ?? raw)
|
|
90
|
+
.replace(/\[[^\]]*\]$/, "")
|
|
91
|
+
.replace(/^claude-/, "")
|
|
92
|
+
.replace(/-\d{8}$/, "");
|
|
93
|
+
return m || undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Apply the Agents-zone harness filter. Keeps the store's sort order. */
|
|
97
|
+
export function filterSessions(arr: AgentSession[], filter: AgentsFilter): AgentSession[] {
|
|
98
|
+
return filter === "all" ? arr : arr.filter((s) => s.provider === filter);
|
|
110
99
|
}
|
|
111
100
|
|
|
112
101
|
/** Compact human-readable age. Mirrors av.py `_fmt_age`. */
|
|
@@ -119,264 +108,15 @@ export function formatAge(ts: number, now: number): string {
|
|
|
119
108
|
return `${Math.floor(delta / 86_400)}d`;
|
|
120
109
|
}
|
|
121
110
|
|
|
122
|
-
export interface TranscriptParseResult {
|
|
123
|
-
customTitle?: string;
|
|
124
|
-
aiTitle?: string;
|
|
125
|
-
/**
|
|
126
|
-
* First user message that looks like a real human prompt — skill loaders,
|
|
127
|
-
* system tags, and bare slash-command invocations are filtered. Used as
|
|
128
|
-
* the displayName fallback when there is no custom/ai title.
|
|
129
|
-
*/
|
|
130
|
-
firstHumanUser?: string;
|
|
131
|
-
lastUser?: string;
|
|
132
|
-
lastAssistant?: string;
|
|
133
|
-
messageCount: number;
|
|
134
|
-
toolCount: number;
|
|
135
|
-
gitBranch?: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Heuristic: is this user "message" actually a skill/system bootstrap that
|
|
140
|
-
* Claude Code injected on session start? Used to skip these when picking
|
|
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;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Lightweight pass over a jsonl transcript. Defensive: malformed lines
|
|
158
|
-
* are skipped silently because the format is internal to Claude Code
|
|
159
|
-
* and may drift between versions.
|
|
160
|
-
*/
|
|
161
|
-
export function parseTranscript(content: string): TranscriptParseResult {
|
|
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
111
|
const STATUS_RANK: Record<AgentStatus, number> = {
|
|
372
112
|
"live-busy": 0,
|
|
373
113
|
"live-idle": 1,
|
|
374
|
-
"stale
|
|
114
|
+
"stale": 2,
|
|
375
115
|
"dormant": 3,
|
|
376
116
|
"archived": 4,
|
|
377
117
|
};
|
|
378
118
|
|
|
379
|
-
function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
119
|
+
export function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
380
120
|
return arr.slice().sort((a, b) => {
|
|
381
121
|
const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
382
122
|
if (r !== 0) return r;
|
|
@@ -392,42 +132,105 @@ export interface AgentsStore {
|
|
|
392
132
|
dispose: () => Promise<void>;
|
|
393
133
|
}
|
|
394
134
|
|
|
135
|
+
const DEBOUNCE_MS = 200;
|
|
136
|
+
/** Cap on debounce deferral, so a session streaming non-stop still refreshes. */
|
|
137
|
+
const DEBOUNCE_MAX_WAIT_MS = 1000;
|
|
138
|
+
/**
|
|
139
|
+
* The watcher ignores paths that don't exist yet (agent installed, or first
|
|
140
|
+
* session started, after tuiboard launched) — poll for them this often.
|
|
141
|
+
*/
|
|
142
|
+
const MISSING_PATH_POLL_MS = 5000;
|
|
143
|
+
|
|
395
144
|
/**
|
|
396
|
-
* Reactive store of local
|
|
397
|
-
*
|
|
398
|
-
*
|
|
145
|
+
* Reactive store of local agent sessions. Watches every adapter's paths and,
|
|
146
|
+
* on a change, re-scans only the adapter that owns the changed path (short
|
|
147
|
+
* debounce). Initial scan is eager.
|
|
399
148
|
*/
|
|
400
|
-
export function createAgentsStore(
|
|
149
|
+
export function createAgentsStore(
|
|
150
|
+
adapters: AgentAdapter[],
|
|
151
|
+
{ missingPathPollMs = MISSING_PATH_POLL_MS } = {},
|
|
152
|
+
): AgentsStore {
|
|
401
153
|
const [sessions, setSessions] = createSignal<AgentSession[]>([]);
|
|
154
|
+
const byAdapter = new Map<AgentAdapter, AgentSession[]>();
|
|
155
|
+
|
|
156
|
+
function scan(adapter: AgentAdapter, now: number): void {
|
|
157
|
+
try {
|
|
158
|
+
byAdapter.set(adapter, adapter.discover(now));
|
|
159
|
+
} catch {
|
|
160
|
+
// One broken adapter must not blank out the others; keep its last list.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function publish(): void {
|
|
165
|
+
setSessions(sortSessions([...byAdapter.values()].flat()));
|
|
166
|
+
}
|
|
402
167
|
|
|
403
168
|
function refresh(): void {
|
|
404
169
|
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));
|
|
170
|
+
for (const adapter of adapters) scan(adapter, now);
|
|
171
|
+
publish();
|
|
411
172
|
}
|
|
412
173
|
|
|
413
174
|
refresh();
|
|
414
175
|
|
|
176
|
+
const watched = adapters.flatMap((adapter) =>
|
|
177
|
+
adapter.watchPaths().map((p) => ({ root: resolve(p), adapter })),
|
|
178
|
+
);
|
|
179
|
+
const owners = (path: string) => {
|
|
180
|
+
const abs = resolve(path);
|
|
181
|
+
return new Set(
|
|
182
|
+
watched
|
|
183
|
+
.filter((w) => abs === w.root || abs.startsWith(w.root + sep))
|
|
184
|
+
.map((w) => w.adapter),
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const pending = new Set<AgentAdapter>();
|
|
415
189
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
416
|
-
|
|
190
|
+
let firstPendingAt = 0;
|
|
191
|
+
const flush = () => {
|
|
192
|
+
debounceTimer = undefined;
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
for (const adapter of pending) scan(adapter, now);
|
|
195
|
+
pending.clear();
|
|
196
|
+
publish();
|
|
197
|
+
};
|
|
198
|
+
const onChange = (path: string) => {
|
|
199
|
+
const hit = owners(path);
|
|
200
|
+
if (hit.size === 0) return;
|
|
201
|
+
for (const adapter of hit) pending.add(adapter);
|
|
202
|
+
const now = Date.now();
|
|
417
203
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
418
|
-
|
|
204
|
+
else firstPendingAt = now;
|
|
205
|
+
const wait = Math.min(DEBOUNCE_MS, firstPendingAt + DEBOUNCE_MAX_WAIT_MS - now);
|
|
206
|
+
debounceTimer = setTimeout(flush, Math.max(0, wait));
|
|
419
207
|
};
|
|
420
208
|
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
209
|
+
const roots = [...new Set(watched.map((w) => w.root))];
|
|
210
|
+
const watcher = chokidar.watch(
|
|
211
|
+
roots.filter((r) => existsSync(r)),
|
|
212
|
+
{ ignoreInitial: true, depth: 3 },
|
|
213
|
+
);
|
|
425
214
|
watcher.on("add", onChange);
|
|
215
|
+
watcher.on("addDir", onChange);
|
|
426
216
|
watcher.on("change", onChange);
|
|
427
217
|
watcher.on("unlink", onChange);
|
|
428
218
|
|
|
219
|
+
let missing = roots.filter((r) => !existsSync(r));
|
|
220
|
+
const missingPoll = setInterval(() => {
|
|
221
|
+
const appeared = missing.filter((r) => existsSync(r));
|
|
222
|
+
if (appeared.length === 0) return;
|
|
223
|
+
missing = missing.filter((r) => !appeared.includes(r));
|
|
224
|
+
watcher.add(appeared);
|
|
225
|
+
for (const root of appeared) onChange(root);
|
|
226
|
+
if (missing.length === 0) clearInterval(missingPoll);
|
|
227
|
+
}, missingPathPollMs);
|
|
228
|
+
missingPoll.unref?.();
|
|
229
|
+
if (missing.length === 0) clearInterval(missingPoll);
|
|
230
|
+
|
|
429
231
|
async function dispose() {
|
|
430
232
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
233
|
+
clearInterval(missingPoll);
|
|
431
234
|
await watcher.close();
|
|
432
235
|
}
|
|
433
236
|
|
package/src/store/index.ts
CHANGED
|
@@ -29,7 +29,15 @@ import {
|
|
|
29
29
|
createBoardWatcher,
|
|
30
30
|
type BoardWatcher,
|
|
31
31
|
} from "~/io/watcher";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
HARNESS,
|
|
34
|
+
createAgentsStore,
|
|
35
|
+
filterSessions,
|
|
36
|
+
type AgentSession,
|
|
37
|
+
type AgentsFilter,
|
|
38
|
+
type AgentsStore,
|
|
39
|
+
} from "./agents";
|
|
40
|
+
import { AGENT_ADAPTERS } from "./agent-adapters";
|
|
33
41
|
import {
|
|
34
42
|
createCalendarStore,
|
|
35
43
|
createGoogleEvent,
|
|
@@ -259,6 +267,10 @@ export interface UIState {
|
|
|
259
267
|
marked: Record<string, true>;
|
|
260
268
|
/** Active filter. */
|
|
261
269
|
filter: "all" | "today" | "overdue" | "tomorrow" | "followup";
|
|
270
|
+
/** Agents-zone harness filter (`f` while the Agents zone is active). */
|
|
271
|
+
agentsFilter: AgentsFilter;
|
|
272
|
+
/** Outcome of the last Enter (open session), shown in that session's detail. */
|
|
273
|
+
lastLaunch?: { sessionId: string; ok: boolean; text: string; ts: number };
|
|
262
274
|
/** Banner messages (errors, conflicts, undo notifications). */
|
|
263
275
|
banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
|
|
264
276
|
/** Open modal, if any. Keyboard handler routes input to the modal when set. */
|
|
@@ -349,6 +361,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
349
361
|
view: "kanban",
|
|
350
362
|
marked: {},
|
|
351
363
|
filter: "all",
|
|
364
|
+
agentsFilter: "all",
|
|
352
365
|
},
|
|
353
366
|
undo: [],
|
|
354
367
|
rev: 0,
|
|
@@ -362,12 +375,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
362
375
|
initialBoards.map((b) => b.board.filepath),
|
|
363
376
|
);
|
|
364
377
|
|
|
365
|
-
// Agents store has its own lifecycle (chokidar watcher on
|
|
366
|
-
// Shared dispose() boundary below so SIGINT cleans both. When
|
|
367
|
-
// zone is disabled we skip the watcher entirely (no
|
|
368
|
-
// and hand back an inert stub.
|
|
378
|
+
// Agents store has its own lifecycle (chokidar watcher on each agent CLI's
|
|
379
|
+
// session dirs). Shared dispose() boundary below so SIGINT cleans both. When
|
|
380
|
+
// the agents zone is disabled we skip the watcher entirely (no session reads
|
|
381
|
+
// at all) and hand back an inert stub.
|
|
369
382
|
const agentsStore: AgentsStore = enabledZones.agents
|
|
370
|
-
? createAgentsStore()
|
|
383
|
+
? createAgentsStore(AGENT_ADAPTERS)
|
|
371
384
|
: noopAgentsStore();
|
|
372
385
|
// Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
|
|
373
386
|
// (no network) when the agenda zone is disabled.
|
|
@@ -1230,6 +1243,28 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1230
1243
|
setState("ui", "zoomed", (z: boolean) => !z);
|
|
1231
1244
|
}
|
|
1232
1245
|
|
|
1246
|
+
/**
|
|
1247
|
+
* Sessions the Agents zone lists, after the harness filter. The dashboard
|
|
1248
|
+
* strip additionally hides archived ones — they sort last, so row indices
|
|
1249
|
+
* into this list stay valid for both views.
|
|
1250
|
+
*/
|
|
1251
|
+
function agentSessions(): AgentSession[] {
|
|
1252
|
+
return filterSessions(agentsStore.sessions(), state.ui.agentsFilter);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function setLastLaunch(sessionId: string, ok: boolean, text: string): void {
|
|
1256
|
+
setState("ui", "lastLaunch", { sessionId, ok, text, ts: Date.now() });
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Cycle the Agents-zone filter: all → cc → cx → oc → pi → all. */
|
|
1260
|
+
function cycleAgentsFilter(): AgentsFilter {
|
|
1261
|
+
const cycle: AgentsFilter[] = ["all", ...(Object.keys(HARNESS) as AgentsFilter[])];
|
|
1262
|
+
const next = cycle[(cycle.indexOf(state.ui.agentsFilter) + 1) % cycle.length]!;
|
|
1263
|
+
setState("ui", "agentsFilter", next);
|
|
1264
|
+
setState("ui", "row", 0);
|
|
1265
|
+
return next;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1233
1268
|
function setFilter(f: UIState["filter"]): void {
|
|
1234
1269
|
setState("ui", "filter", f);
|
|
1235
1270
|
// The cursor's row was an index into the unfiltered list — reset to top
|
|
@@ -1308,7 +1343,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1308
1343
|
|
|
1309
1344
|
/**
|
|
1310
1345
|
* Manual full refresh (the `r` key). Re-reads every board from disk, rescans
|
|
1311
|
-
*
|
|
1346
|
+
* agent sessions, and force-refetches the Agenda's calendar (bypassing
|
|
1312
1347
|
* the 30-min cache). Lets the user pull in external changes — a calendar
|
|
1313
1348
|
* event edited in the browser, a board touched elsewhere — without leaving
|
|
1314
1349
|
* tuiboard. Boards normally auto-reload via the file watcher; this also
|
|
@@ -1696,6 +1731,9 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1696
1731
|
config,
|
|
1697
1732
|
activeBoard,
|
|
1698
1733
|
agents: agentsStore,
|
|
1734
|
+
agentSessions,
|
|
1735
|
+
cycleAgentsFilter,
|
|
1736
|
+
setLastLaunch,
|
|
1699
1737
|
calendar: calendarStore,
|
|
1700
1738
|
// queries
|
|
1701
1739
|
getBoardByPath,
|