tuiboard 0.10.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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Pi (pi.dev) adapter for the agents store.
3
+ *
4
+ * Reads (read-only), per Pi's `docs/session-format.md`:
5
+ * <sessions dir>/--<cwd>--/<timestamp>_<uuid>.jsonl
6
+ * line 1 {"type":"session","id":<uuid>,"cwd":…}
7
+ * then tree entries — `message` (user / assistant / toolResult / …),
8
+ * `session_info` (name), `model_change`, `compaction`, …
9
+ *
10
+ * Sessions dir: $PI_CODING_AGENT_SESSION_DIR, else `sessionDir` in
11
+ * <agent dir>/settings.json, else <agent dir>/sessions; agent dir is
12
+ * $PI_CODING_AGENT_DIR or ~/.pi/agent.
13
+ *
14
+ * Pi keeps no PID registry, so liveness is inferred like Codex/OpenCode: a
15
+ * turn is in progress while the last message still awaits the model (user,
16
+ * tool result, or an assistant that stopped to call tools); "stale" once the
17
+ * file is untouched for PI_STALE_AFTER_MS. Idle TUIs aren't detectable.
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { isAbsolute, join, resolve } from "node:path";
23
+
24
+ import {
25
+ DORMANT_AFTER_MS,
26
+ cwdShort,
27
+ type AgentAdapter,
28
+ type AgentSession,
29
+ type AgentStatus,
30
+ } from "~/store/agents";
31
+
32
+ /** Open turn untouched longer than this → "stale". */
33
+ export const PI_STALE_AFTER_MS = 30 * 60 * 1000;
34
+
35
+ function expandHome(p: string): string {
36
+ return p === "~" ? homedir() : p.startsWith("~/") || p.startsWith("~\\") ? join(homedir(), p.slice(2)) : p;
37
+ }
38
+
39
+ export function defaultPiSessionsDir(env: Record<string, string | undefined> = process.env): string {
40
+ if (env.PI_CODING_AGENT_SESSION_DIR) return expandHome(env.PI_CODING_AGENT_SESSION_DIR);
41
+ const agentDir = expandHome(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"));
42
+ try {
43
+ const settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
44
+ if (typeof settings?.sessionDir === "string" && settings.sessionDir) {
45
+ const dir = expandHome(settings.sessionDir);
46
+ return isAbsolute(dir) ? dir : resolve(agentDir, dir);
47
+ }
48
+ } catch {
49
+ // no settings / not JSON: default location
50
+ }
51
+ return join(agentDir, "sessions");
52
+ }
53
+
54
+ export interface PiParseResult {
55
+ sessionId?: string;
56
+ cwd?: string;
57
+ name?: string;
58
+ firstUser?: string;
59
+ lastUser?: string;
60
+ lastAssistant?: string;
61
+ model?: string;
62
+ messageCount: number;
63
+ toolCount: number;
64
+ /** The last message still awaits the model. */
65
+ turnOpen: boolean;
66
+ }
67
+
68
+ function textOf(content: unknown): string | undefined {
69
+ if (typeof content === "string") return content;
70
+ if (!Array.isArray(content)) return undefined;
71
+ const texts = content
72
+ .filter((c) => c && c.type === "text" && typeof c.text === "string")
73
+ .map((c) => c.text as string);
74
+ return texts.length > 0 ? texts.join("\n") : undefined;
75
+ }
76
+
77
+ /**
78
+ * Single pass in file order (entries are appended as they happen, so the
79
+ * last message line is the current position). Defensive: malformed lines
80
+ * are skipped.
81
+ */
82
+ export function parsePiSession(content: string): PiParseResult {
83
+ const out: PiParseResult = { messageCount: 0, toolCount: 0, turnOpen: false };
84
+ for (const line of content.split("\n")) {
85
+ if (!line.trim()) continue;
86
+ let e: any;
87
+ try {
88
+ e = JSON.parse(line);
89
+ } catch {
90
+ continue;
91
+ }
92
+ switch (e?.type) {
93
+ case "session":
94
+ if (typeof e.id === "string") out.sessionId = e.id;
95
+ if (typeof e.cwd === "string") out.cwd = e.cwd;
96
+ break;
97
+ case "session_info":
98
+ // `name` undefined clears it.
99
+ out.name = typeof e.name === "string" && e.name ? e.name : undefined;
100
+ break;
101
+ case "model_change":
102
+ if (typeof e.modelId === "string") out.model = e.modelId;
103
+ break;
104
+ case "message": {
105
+ const m = e.message;
106
+ switch (m?.role) {
107
+ case "user": {
108
+ out.messageCount++;
109
+ out.turnOpen = true;
110
+ const text = textOf(m.content);
111
+ if (text) {
112
+ out.lastUser = text;
113
+ out.firstUser ??= text;
114
+ }
115
+ break;
116
+ }
117
+ case "assistant": {
118
+ out.messageCount++;
119
+ if (typeof m.model === "string") out.model = m.model;
120
+ if (Array.isArray(m.content)) {
121
+ out.toolCount += m.content.filter((c: any) => c?.type === "toolCall").length;
122
+ }
123
+ const text = textOf(m.content);
124
+ if (text) out.lastAssistant = text;
125
+ // toolUse: tools run next and the model is called again.
126
+ out.turnOpen = m.stopReason === "toolUse";
127
+ break;
128
+ }
129
+ case "toolResult":
130
+ case "bashExecution":
131
+ out.turnOpen = true;
132
+ break;
133
+ }
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ export function classifyPiStatus(
142
+ now: number,
143
+ lastActivityMs: number,
144
+ turnOpen: boolean,
145
+ ): AgentStatus {
146
+ const age = now - lastActivityMs;
147
+ if (age > DORMANT_AFTER_MS) return "archived";
148
+ if (turnOpen) return age > PI_STALE_AFTER_MS ? "stale" : "live-busy";
149
+ return "dormant";
150
+ }
151
+
152
+ interface SessionFile {
153
+ path: string;
154
+ mtimeMs: number;
155
+ size: number;
156
+ }
157
+
158
+ function listSessionFiles(root: string): SessionFile[] {
159
+ const out: SessionFile[] = [];
160
+ let dirs;
161
+ try {
162
+ dirs = readdirSync(root, { withFileTypes: true });
163
+ } catch {
164
+ return out;
165
+ }
166
+ for (const d of dirs) {
167
+ if (!d.isDirectory()) continue;
168
+ const dir = join(root, d.name);
169
+ let files: string[];
170
+ try {
171
+ files = readdirSync(dir);
172
+ } catch {
173
+ continue;
174
+ }
175
+ for (const f of files) {
176
+ if (!f.endsWith(".jsonl")) continue;
177
+ const path = join(dir, f);
178
+ try {
179
+ const st = statSync(path);
180
+ if (st.isFile()) out.push({ path, mtimeMs: st.mtimeMs, size: st.size });
181
+ } catch {
182
+ // vanished mid-scan
183
+ }
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** `<timestamp>_<uuid>.jsonl` → uuid, for files whose header can't be read. */
190
+ function idFromFileName(path: string): string {
191
+ const base = path.split(/[\\/]/).pop() ?? path;
192
+ return base.replace(/\.jsonl$/, "").split("_").pop() ?? base;
193
+ }
194
+
195
+ function firstLine(text: string | undefined): string | undefined {
196
+ return text?.split("\n").find((l) => l.trim().length > 0)?.trim().slice(0, 60);
197
+ }
198
+
199
+ export function createPiAdapter(sessionsDir = defaultPiSessionsDir()): AgentAdapter {
200
+ // Sessions grow while a turn runs: re-parse a file only when it changed.
201
+ const cache = new Map<string, { mtimeMs: number; size: number; parsed: PiParseResult }>();
202
+
203
+ function parseFile(f: SessionFile): PiParseResult {
204
+ const hit = cache.get(f.path);
205
+ if (hit && hit.mtimeMs === f.mtimeMs && hit.size === f.size) return hit.parsed;
206
+ let parsed: PiParseResult;
207
+ try {
208
+ parsed = parsePiSession(readFileSync(f.path, "utf-8"));
209
+ } catch {
210
+ parsed = { messageCount: 0, toolCount: 0, turnOpen: false };
211
+ }
212
+ cache.set(f.path, { mtimeMs: f.mtimeMs, size: f.size, parsed });
213
+ return parsed;
214
+ }
215
+
216
+ function discover(now: number): AgentSession[] {
217
+ if (!existsSync(sessionsDir)) return [];
218
+ const files = listSessionFiles(sessionsDir);
219
+ const sessions = files.map((f): AgentSession => {
220
+ const p = parseFile(f);
221
+ const sessionId = p.sessionId ?? idFromFileName(f.path);
222
+ const cwd = p.cwd ?? "";
223
+ const fallback = firstLine(p.firstUser);
224
+ const id8 = sessionId.slice(0, 8);
225
+ return {
226
+ provider: "pi",
227
+ sessionId,
228
+ sourcePath: f.path,
229
+ cwd,
230
+ cwdShort: cwdShort(cwd),
231
+ status: classifyPiStatus(now, f.mtimeMs, p.turnOpen),
232
+ lastActivityMs: f.mtimeMs,
233
+ customTitle: p.name,
234
+ displayName: p.name ? p.name.slice(0, 60) : fallback ? `${fallback} · ${id8}` : id8,
235
+ messageCount: p.messageCount,
236
+ toolCount: p.toolCount,
237
+ lastUser: p.lastUser,
238
+ lastAssistant: p.lastAssistant,
239
+ model: p.model,
240
+ resumeCommand: `pi --session ${sessionId}`,
241
+ };
242
+ });
243
+ const live = new Set(files.map((f) => f.path));
244
+ for (const path of cache.keys()) if (!live.has(path)) cache.delete(path);
245
+ return sessions;
246
+ }
247
+
248
+ return {
249
+ provider: "pi",
250
+ watchPaths: () => [sessionsDir],
251
+ discover,
252
+ };
253
+ }