bertrand 0.14.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/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/bertrand.js +3222 -0
- package/dist/migrations/0000_last_nitro.sql +104 -0
- package/dist/migrations/0001_melted_lady_ursula.sql +23 -0
- package/dist/migrations/0002_omniscient_speedball.sql +3 -0
- package/dist/migrations/meta/0000_snapshot.json +753 -0
- package/dist/migrations/meta/0001_snapshot.json +753 -0
- package/dist/migrations/meta/0002_snapshot.json +777 -0
- package/dist/migrations/meta/_journal.json +27 -0
- package/dist/run-screen.js +1021 -0
- package/package.json +61 -0
package/dist/bertrand.js
ADDED
|
@@ -0,0 +1,3222 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/lib/paths.ts
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
var BERTRAND_DIR = ".bertrand", paths;
|
|
23
|
+
var init_paths = __esm(() => {
|
|
24
|
+
paths = {
|
|
25
|
+
root: join(homedir(), BERTRAND_DIR),
|
|
26
|
+
db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
|
|
27
|
+
hooks: join(homedir(), BERTRAND_DIR, "hooks"),
|
|
28
|
+
sessions: join(homedir(), BERTRAND_DIR, "sessions")
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// src/cli/router.ts
|
|
33
|
+
import { existsSync } from "fs";
|
|
34
|
+
function register(name, handler) {
|
|
35
|
+
commands.set(name, handler);
|
|
36
|
+
}
|
|
37
|
+
function alias(from, to) {
|
|
38
|
+
aliases.set(from, to);
|
|
39
|
+
}
|
|
40
|
+
async function autoInitIfFirstRun() {
|
|
41
|
+
if (existsSync(paths.db))
|
|
42
|
+
return;
|
|
43
|
+
const init = commands.get("init");
|
|
44
|
+
if (!init)
|
|
45
|
+
return;
|
|
46
|
+
console.log("Setting up bertrand for first use\u2026");
|
|
47
|
+
const origLog = console.log;
|
|
48
|
+
console.log = () => {};
|
|
49
|
+
try {
|
|
50
|
+
await init([]);
|
|
51
|
+
} finally {
|
|
52
|
+
console.log = origLog;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function route(argv) {
|
|
56
|
+
const args = argv.slice(2);
|
|
57
|
+
const command = args[0];
|
|
58
|
+
if (!command) {
|
|
59
|
+
await autoInitIfFirstRun();
|
|
60
|
+
const handler2 = commands.get("launch");
|
|
61
|
+
if (!handler2)
|
|
62
|
+
throw new Error("No launch command registered");
|
|
63
|
+
return handler2(args);
|
|
64
|
+
}
|
|
65
|
+
if (!resolveCommand(command)) {
|
|
66
|
+
console.error(`Unknown command: ${command}`);
|
|
67
|
+
printUsage();
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const resolved = resolveCommand(command);
|
|
71
|
+
const handler = commands.get(resolved);
|
|
72
|
+
return handler(args.slice(1));
|
|
73
|
+
}
|
|
74
|
+
function resolveCommand(name) {
|
|
75
|
+
if (commands.has(name))
|
|
76
|
+
return name;
|
|
77
|
+
const aliased = aliases.get(name);
|
|
78
|
+
if (aliased && commands.has(aliased))
|
|
79
|
+
return aliased;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
function printUsage() {
|
|
83
|
+
console.log(`
|
|
84
|
+
bertrand \u2014 multi-session workflow manager for Claude Code
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
bertrand Launch TUI (or resume named session)
|
|
88
|
+
bertrand init Setup wizard
|
|
89
|
+
bertrand list Session picker
|
|
90
|
+
bertrand log <session> View session log
|
|
91
|
+
bertrand stats <session> Session statistics
|
|
92
|
+
bertrand archive <name> Archive/unarchive a session
|
|
93
|
+
bertrand update Hook-facing state writer (internal)
|
|
94
|
+
bertrand serve Start dashboard HTTP server
|
|
95
|
+
`.trim());
|
|
96
|
+
}
|
|
97
|
+
var commands, aliases;
|
|
98
|
+
var init_router = __esm(() => {
|
|
99
|
+
init_paths();
|
|
100
|
+
commands = new Map;
|
|
101
|
+
aliases = new Map;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// src/db/schema.ts
|
|
105
|
+
var exports_schema = {};
|
|
106
|
+
__export(exports_schema, {
|
|
107
|
+
worktreeAssociations: () => worktreeAssociations,
|
|
108
|
+
sessions: () => sessions,
|
|
109
|
+
sessionStats: () => sessionStats,
|
|
110
|
+
sessionLabels: () => sessionLabels,
|
|
111
|
+
labels: () => labels,
|
|
112
|
+
groups: () => groups,
|
|
113
|
+
events: () => events,
|
|
114
|
+
conversations: () => conversations
|
|
115
|
+
});
|
|
116
|
+
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
117
|
+
import { sql } from "drizzle-orm";
|
|
118
|
+
var groups, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
|
|
119
|
+
var init_schema = __esm(() => {
|
|
120
|
+
groups = sqliteTable("groups", {
|
|
121
|
+
id: text("id").primaryKey(),
|
|
122
|
+
parentId: text("parent_id").references(() => groups.id, {
|
|
123
|
+
onDelete: "cascade"
|
|
124
|
+
}),
|
|
125
|
+
slug: text("slug").notNull(),
|
|
126
|
+
name: text("name").notNull(),
|
|
127
|
+
path: text("path").notNull(),
|
|
128
|
+
depth: integer("depth").notNull().default(0),
|
|
129
|
+
color: text("color"),
|
|
130
|
+
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
|
|
131
|
+
}, (t) => [
|
|
132
|
+
uniqueIndex("groups_parent_slug").on(t.parentId, t.slug),
|
|
133
|
+
index("groups_path").on(t.path)
|
|
134
|
+
]);
|
|
135
|
+
labels = sqliteTable("labels", {
|
|
136
|
+
id: text("id").primaryKey(),
|
|
137
|
+
name: text("name").notNull().unique(),
|
|
138
|
+
color: text("color"),
|
|
139
|
+
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
|
|
140
|
+
});
|
|
141
|
+
sessions = sqliteTable("sessions", {
|
|
142
|
+
id: text("id").primaryKey(),
|
|
143
|
+
groupId: text("group_id").notNull().references(() => groups.id, { onDelete: "cascade" }),
|
|
144
|
+
slug: text("slug").notNull(),
|
|
145
|
+
name: text("name").notNull(),
|
|
146
|
+
status: text("status", {
|
|
147
|
+
enum: ["active", "waiting", "paused", "archived"]
|
|
148
|
+
}).notNull().default("paused"),
|
|
149
|
+
summary: text("summary"),
|
|
150
|
+
pid: integer("pid"),
|
|
151
|
+
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
|
152
|
+
endedAt: text("ended_at"),
|
|
153
|
+
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
|
154
|
+
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
|
|
155
|
+
}, (t) => [
|
|
156
|
+
uniqueIndex("sessions_group_slug").on(t.groupId, t.slug),
|
|
157
|
+
index("sessions_status").on(t.status),
|
|
158
|
+
index("sessions_started").on(t.startedAt)
|
|
159
|
+
]);
|
|
160
|
+
sessionLabels = sqliteTable("session_labels", {
|
|
161
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
162
|
+
labelId: text("label_id").notNull().references(() => labels.id, { onDelete: "cascade" })
|
|
163
|
+
}, (t) => [
|
|
164
|
+
uniqueIndex("sl_pk").on(t.sessionId, t.labelId),
|
|
165
|
+
index("sl_label").on(t.labelId)
|
|
166
|
+
]);
|
|
167
|
+
conversations = sqliteTable("conversations", {
|
|
168
|
+
id: text("id").primaryKey(),
|
|
169
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
170
|
+
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
|
171
|
+
endedAt: text("ended_at"),
|
|
172
|
+
discarded: integer("discarded", { mode: "boolean" }).notNull().default(false),
|
|
173
|
+
lastQuestion: text("last_question"),
|
|
174
|
+
eventCount: integer("event_count").notNull().default(0)
|
|
175
|
+
}, (t) => [index("conv_session").on(t.sessionId)]);
|
|
176
|
+
events = sqliteTable("events", {
|
|
177
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
178
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
179
|
+
conversationId: text("conversation_id").references(() => conversations.id),
|
|
180
|
+
event: text("event").notNull(),
|
|
181
|
+
summary: text("summary"),
|
|
182
|
+
meta: text("meta", { mode: "json" }),
|
|
183
|
+
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
|
|
184
|
+
}, (t) => [
|
|
185
|
+
index("ev_session").on(t.sessionId),
|
|
186
|
+
index("ev_session_event").on(t.sessionId, t.event),
|
|
187
|
+
index("ev_event_created").on(t.event, t.createdAt),
|
|
188
|
+
index("ev_conversation").on(t.conversationId)
|
|
189
|
+
]);
|
|
190
|
+
worktreeAssociations = sqliteTable("worktree_associations", {
|
|
191
|
+
id: text("id").primaryKey(),
|
|
192
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
193
|
+
branch: text("branch").notNull(),
|
|
194
|
+
worktreePath: text("worktree_path"),
|
|
195
|
+
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
|
196
|
+
enteredAt: text("entered_at").notNull().default(sql`(datetime('now'))`),
|
|
197
|
+
exitedAt: text("exited_at")
|
|
198
|
+
}, (t) => [
|
|
199
|
+
index("wt_session").on(t.sessionId),
|
|
200
|
+
index("wt_active").on(t.active)
|
|
201
|
+
]);
|
|
202
|
+
sessionStats = sqliteTable("session_stats", {
|
|
203
|
+
sessionId: text("session_id").primaryKey().references(() => sessions.id, { onDelete: "cascade" }),
|
|
204
|
+
eventCount: integer("event_count").notNull().default(0),
|
|
205
|
+
conversationCount: integer("conversation_count").notNull().default(0),
|
|
206
|
+
interactionCount: integer("interaction_count").notNull().default(0),
|
|
207
|
+
prCount: integer("pr_count").notNull().default(0),
|
|
208
|
+
claudeWorkS: integer("claude_work_s").notNull().default(0),
|
|
209
|
+
userWaitS: integer("user_wait_s").notNull().default(0),
|
|
210
|
+
activePct: integer("active_pct").notNull().default(0),
|
|
211
|
+
durationS: integer("duration_s").notNull().default(0),
|
|
212
|
+
linesAdded: integer("lines_added").notNull().default(0),
|
|
213
|
+
linesRemoved: integer("lines_removed").notNull().default(0),
|
|
214
|
+
filesTouched: integer("files_touched").notNull().default(0),
|
|
215
|
+
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// src/db/client.ts
|
|
220
|
+
import { Database } from "bun:sqlite";
|
|
221
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
222
|
+
import { mkdirSync } from "fs";
|
|
223
|
+
import { dirname } from "path";
|
|
224
|
+
function getDb() {
|
|
225
|
+
if (_db)
|
|
226
|
+
return _db;
|
|
227
|
+
mkdirSync(dirname(paths.db), { recursive: true });
|
|
228
|
+
const sqlite = new Database(paths.db);
|
|
229
|
+
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
230
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
231
|
+
sqlite.exec("PRAGMA synchronous = NORMAL");
|
|
232
|
+
sqlite.exec("PRAGMA cache_size = -8000");
|
|
233
|
+
sqlite.exec("PRAGMA temp_store = MEMORY");
|
|
234
|
+
_db = drizzle(sqlite, { schema: exports_schema });
|
|
235
|
+
return _db;
|
|
236
|
+
}
|
|
237
|
+
var _db = null;
|
|
238
|
+
var init_client = __esm(() => {
|
|
239
|
+
init_paths();
|
|
240
|
+
init_schema();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// src/lib/id.ts
|
|
244
|
+
import { nanoid } from "nanoid";
|
|
245
|
+
function createId(size = 12) {
|
|
246
|
+
return nanoid(size);
|
|
247
|
+
}
|
|
248
|
+
var init_id = () => {};
|
|
249
|
+
|
|
250
|
+
// src/db/queries/sessions.ts
|
|
251
|
+
import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
252
|
+
function createSession(opts) {
|
|
253
|
+
const db = getDb();
|
|
254
|
+
const id = createId();
|
|
255
|
+
return db.insert(sessions).values({ id, ...opts }).returning().get();
|
|
256
|
+
}
|
|
257
|
+
function getSession(id) {
|
|
258
|
+
return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
|
|
259
|
+
}
|
|
260
|
+
function getSessionByGroupSlug(groupId, slug) {
|
|
261
|
+
return getDb().select().from(sessions).where(and(eq(sessions.groupId, groupId), eq(sessions.slug, slug))).get();
|
|
262
|
+
}
|
|
263
|
+
function getSessionsByGroup(groupId) {
|
|
264
|
+
return getDb().select().from(sessions).where(eq(sessions.groupId, groupId)).all();
|
|
265
|
+
}
|
|
266
|
+
function getActiveSessions() {
|
|
267
|
+
return getDb().select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
|
|
268
|
+
}
|
|
269
|
+
function getAllSessions(opts) {
|
|
270
|
+
const db = getDb();
|
|
271
|
+
const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id));
|
|
272
|
+
if (opts?.excludeArchived) {
|
|
273
|
+
return query.where(inArray(sessions.status, [
|
|
274
|
+
"active",
|
|
275
|
+
"waiting",
|
|
276
|
+
"paused"
|
|
277
|
+
])).all();
|
|
278
|
+
}
|
|
279
|
+
return query.all();
|
|
280
|
+
}
|
|
281
|
+
function updateSessionStatus(id, status) {
|
|
282
|
+
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
|
|
283
|
+
}
|
|
284
|
+
function updateSession(id, data) {
|
|
285
|
+
return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
|
|
286
|
+
}
|
|
287
|
+
function deleteSession(id) {
|
|
288
|
+
return getDb().delete(sessions).where(eq(sessions.id, id)).run();
|
|
289
|
+
}
|
|
290
|
+
var init_sessions = __esm(() => {
|
|
291
|
+
init_client();
|
|
292
|
+
init_schema();
|
|
293
|
+
init_id();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// src/lib/markdown.ts
|
|
297
|
+
function normalizeMarkdown(input) {
|
|
298
|
+
return input.replace(/\r\n?/g, `
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
function normalizeEventMeta(eventName, meta) {
|
|
302
|
+
if (!meta)
|
|
303
|
+
return meta;
|
|
304
|
+
switch (eventName) {
|
|
305
|
+
case "session.recap":
|
|
306
|
+
return mapStringField(meta, "recap", normalizeMarkdown);
|
|
307
|
+
case "assistant.message":
|
|
308
|
+
return mapStringField(meta, "text", normalizeMarkdown);
|
|
309
|
+
case "session.answered":
|
|
310
|
+
return normalizeAnsweredMeta(meta);
|
|
311
|
+
default:
|
|
312
|
+
return meta;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function mapStringField(meta, key, fn) {
|
|
316
|
+
const value = meta[key];
|
|
317
|
+
if (typeof value !== "string")
|
|
318
|
+
return meta;
|
|
319
|
+
return { ...meta, [key]: fn(value) };
|
|
320
|
+
}
|
|
321
|
+
function normalizeAnsweredMeta(meta) {
|
|
322
|
+
const out = { ...meta };
|
|
323
|
+
const answers = meta.answers;
|
|
324
|
+
if (answers && typeof answers === "object" && !Array.isArray(answers)) {
|
|
325
|
+
out.answers = Object.fromEntries(Object.entries(answers).map(([k, v]) => [
|
|
326
|
+
k,
|
|
327
|
+
typeof v === "string" ? normalizeMarkdown(v) : v
|
|
328
|
+
]));
|
|
329
|
+
}
|
|
330
|
+
const annotations = meta.annotations;
|
|
331
|
+
if (annotations && typeof annotations === "object" && !Array.isArray(annotations)) {
|
|
332
|
+
out.annotations = Object.fromEntries(Object.entries(annotations).map(([k, v]) => {
|
|
333
|
+
if (!v || typeof v !== "object")
|
|
334
|
+
return [k, v];
|
|
335
|
+
const a = v;
|
|
336
|
+
if (typeof a.notes !== "string")
|
|
337
|
+
return [k, v];
|
|
338
|
+
return [k, { ...a, notes: normalizeMarkdown(a.notes) }];
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/db/queries/events.ts
|
|
345
|
+
import { eq as eq2, and as and2, desc } from "drizzle-orm";
|
|
346
|
+
function insertEvent(opts) {
|
|
347
|
+
return getDb().insert(events).values({
|
|
348
|
+
sessionId: opts.sessionId,
|
|
349
|
+
conversationId: opts.conversationId,
|
|
350
|
+
event: opts.event,
|
|
351
|
+
summary: opts.summary,
|
|
352
|
+
meta: normalizeEventMeta(opts.event, opts.meta)
|
|
353
|
+
}).returning().get();
|
|
354
|
+
}
|
|
355
|
+
function getEventsBySession(sessionId) {
|
|
356
|
+
return getDb().select().from(events).where(eq2(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
|
|
357
|
+
}
|
|
358
|
+
function getEventsByType(sessionId, eventType) {
|
|
359
|
+
return getDb().select().from(events).where(and2(eq2(events.sessionId, sessionId), eq2(events.event, eventType))).orderBy(events.createdAt).all();
|
|
360
|
+
}
|
|
361
|
+
function getLatestRecaps() {
|
|
362
|
+
const rows = getDb().select({
|
|
363
|
+
sessionId: events.sessionId,
|
|
364
|
+
meta: events.meta,
|
|
365
|
+
createdAt: events.createdAt
|
|
366
|
+
}).from(events).where(eq2(events.event, "session.recap")).orderBy(desc(events.createdAt)).all();
|
|
367
|
+
const result = {};
|
|
368
|
+
for (const row of rows) {
|
|
369
|
+
if (result[row.sessionId])
|
|
370
|
+
continue;
|
|
371
|
+
const meta = row.meta;
|
|
372
|
+
const recap = typeof meta?.recap === "string" ? meta.recap : null;
|
|
373
|
+
if (!recap)
|
|
374
|
+
continue;
|
|
375
|
+
result[row.sessionId] = { recap, createdAt: row.createdAt };
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
var init_events = __esm(() => {
|
|
380
|
+
init_client();
|
|
381
|
+
init_schema();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// src/db/queries/conversations.ts
|
|
385
|
+
import { eq as eq3, and as and3, desc as desc2, sql as sql3 } from "drizzle-orm";
|
|
386
|
+
function createConversation(opts) {
|
|
387
|
+
return getDb().insert(conversations).values(opts).returning().get();
|
|
388
|
+
}
|
|
389
|
+
function getConversation(id) {
|
|
390
|
+
return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
|
|
391
|
+
}
|
|
392
|
+
function getConversationsBySession(sessionId) {
|
|
393
|
+
return getDb().select().from(conversations).where(and3(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc2(conversations.startedAt)).all();
|
|
394
|
+
}
|
|
395
|
+
function endConversation(id) {
|
|
396
|
+
return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
|
|
397
|
+
}
|
|
398
|
+
function updateLastQuestion(id, question) {
|
|
399
|
+
return getDb().update(conversations).set({ lastQuestion: question }).where(eq3(conversations.id, id)).returning().get();
|
|
400
|
+
}
|
|
401
|
+
var init_conversations = __esm(() => {
|
|
402
|
+
init_client();
|
|
403
|
+
init_schema();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// src/cli/commands/update.ts
|
|
407
|
+
var exports_update = {};
|
|
408
|
+
var EVENT_STATUS_MAP;
|
|
409
|
+
var init_update = __esm(() => {
|
|
410
|
+
init_router();
|
|
411
|
+
init_sessions();
|
|
412
|
+
init_events();
|
|
413
|
+
init_conversations();
|
|
414
|
+
EVENT_STATUS_MAP = {
|
|
415
|
+
"session.waiting": "waiting",
|
|
416
|
+
"session.answered": "active",
|
|
417
|
+
"session.active": "active",
|
|
418
|
+
"session.paused": "paused",
|
|
419
|
+
"session.started": "active",
|
|
420
|
+
"session.end": "paused"
|
|
421
|
+
};
|
|
422
|
+
register("update", async (args) => {
|
|
423
|
+
let sessionId = "";
|
|
424
|
+
let event = "";
|
|
425
|
+
let metaJson = "";
|
|
426
|
+
let summaryArg;
|
|
427
|
+
for (let i = 0;i < args.length; i++) {
|
|
428
|
+
const arg = args[i];
|
|
429
|
+
const next = args[i + 1];
|
|
430
|
+
if (arg === "--session-id" && next) {
|
|
431
|
+
sessionId = next;
|
|
432
|
+
i++;
|
|
433
|
+
} else if (arg === "--event" && next) {
|
|
434
|
+
event = next;
|
|
435
|
+
i++;
|
|
436
|
+
} else if (arg === "--summary" && next) {
|
|
437
|
+
summaryArg = next;
|
|
438
|
+
i++;
|
|
439
|
+
} else if (arg === "--meta" && next) {
|
|
440
|
+
metaJson = next;
|
|
441
|
+
i++;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!sessionId || !event) {
|
|
445
|
+
console.error("Usage: bertrand update --session-id <id> --event <type> [--meta <json>]");
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
const session = getSession(sessionId);
|
|
449
|
+
if (!session) {
|
|
450
|
+
console.error(`Session not found: ${sessionId}`);
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
const newStatus = EVENT_STATUS_MAP[event];
|
|
454
|
+
if (newStatus && newStatus === session.status) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
let meta;
|
|
458
|
+
if (metaJson) {
|
|
459
|
+
try {
|
|
460
|
+
meta = JSON.parse(metaJson);
|
|
461
|
+
} catch {
|
|
462
|
+
console.error(`Invalid JSON meta: ${metaJson}`);
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const rawConvoId = meta?.claude_id || process.env.BERTRAND_CLAUDE_ID || undefined;
|
|
467
|
+
const conversationId = rawConvoId && getConversation(rawConvoId) ? rawConvoId : undefined;
|
|
468
|
+
const answersObj = meta?.answers;
|
|
469
|
+
const joinedAnswers = answersObj ? Object.values(answersObj).join(", ") || undefined : undefined;
|
|
470
|
+
insertEvent({
|
|
471
|
+
sessionId,
|
|
472
|
+
conversationId,
|
|
473
|
+
event,
|
|
474
|
+
summary: summaryArg || meta?.question || joinedAnswers || undefined,
|
|
475
|
+
meta
|
|
476
|
+
});
|
|
477
|
+
if (newStatus) {
|
|
478
|
+
updateSessionStatus(sessionId, newStatus);
|
|
479
|
+
}
|
|
480
|
+
if (event === "session.waiting" && conversationId && meta?.question) {
|
|
481
|
+
updateLastQuestion(conversationId, meta.question);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// src/lib/transcript.ts
|
|
487
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
488
|
+
function getContextWindowSize(model) {
|
|
489
|
+
for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
|
|
490
|
+
if (model.startsWith(prefix))
|
|
491
|
+
return size;
|
|
492
|
+
}
|
|
493
|
+
return 200000;
|
|
494
|
+
}
|
|
495
|
+
function getLatestAssistantTurn(filePath) {
|
|
496
|
+
if (!existsSync2(filePath))
|
|
497
|
+
return null;
|
|
498
|
+
const text2 = readFileSync(filePath, "utf-8");
|
|
499
|
+
const lines = text2.split(`
|
|
500
|
+
`);
|
|
501
|
+
const assistantEntries = [];
|
|
502
|
+
for (let i = lines.length - 1;i >= 0; i--) {
|
|
503
|
+
const line = lines[i];
|
|
504
|
+
if (!line)
|
|
505
|
+
continue;
|
|
506
|
+
let entry;
|
|
507
|
+
try {
|
|
508
|
+
entry = JSON.parse(line);
|
|
509
|
+
} catch {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (entry.type === "user")
|
|
513
|
+
break;
|
|
514
|
+
if (entry.type === "assistant")
|
|
515
|
+
assistantEntries.push(entry);
|
|
516
|
+
}
|
|
517
|
+
if (assistantEntries.length === 0)
|
|
518
|
+
return null;
|
|
519
|
+
assistantEntries.reverse();
|
|
520
|
+
let model = "";
|
|
521
|
+
const textParts = [];
|
|
522
|
+
let thinkingBlocks = 0;
|
|
523
|
+
let thinkingBytes = 0;
|
|
524
|
+
for (const entry of assistantEntries) {
|
|
525
|
+
const message = entry.message;
|
|
526
|
+
if (!message)
|
|
527
|
+
continue;
|
|
528
|
+
if (message.model)
|
|
529
|
+
model = message.model;
|
|
530
|
+
const content = message.content;
|
|
531
|
+
if (!content)
|
|
532
|
+
continue;
|
|
533
|
+
for (const block of content) {
|
|
534
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
535
|
+
textParts.push(block.text);
|
|
536
|
+
} else if (block.type === "thinking") {
|
|
537
|
+
thinkingBlocks++;
|
|
538
|
+
const sig = block.signature;
|
|
539
|
+
if (typeof sig === "string")
|
|
540
|
+
thinkingBytes += sig.length;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (textParts.length === 0 && thinkingBlocks === 0)
|
|
545
|
+
return null;
|
|
546
|
+
return {
|
|
547
|
+
model,
|
|
548
|
+
text: textParts.join(`
|
|
549
|
+
|
|
550
|
+
`),
|
|
551
|
+
thinkingBlocks,
|
|
552
|
+
thinkingBytes
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function getContextSnapshot(filePath) {
|
|
556
|
+
if (!existsSync2(filePath))
|
|
557
|
+
return null;
|
|
558
|
+
const text2 = readFileSync(filePath, "utf-8");
|
|
559
|
+
const lines = text2.split(`
|
|
560
|
+
`);
|
|
561
|
+
for (let i = lines.length - 1;i >= 0; i--) {
|
|
562
|
+
const line = lines[i];
|
|
563
|
+
if (!line)
|
|
564
|
+
continue;
|
|
565
|
+
let entry;
|
|
566
|
+
try {
|
|
567
|
+
entry = JSON.parse(line);
|
|
568
|
+
} catch {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (entry.type !== "assistant")
|
|
572
|
+
continue;
|
|
573
|
+
const message = entry.message;
|
|
574
|
+
const usage = message?.usage;
|
|
575
|
+
if (!usage)
|
|
576
|
+
continue;
|
|
577
|
+
const model = message?.model ?? "";
|
|
578
|
+
const inputTokens = usage.input_tokens ?? 0;
|
|
579
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
580
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
|
|
581
|
+
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
582
|
+
const totalContextTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
583
|
+
const windowSize = getContextWindowSize(model);
|
|
584
|
+
const remainingPct = Math.max(0, Math.min(100, Math.round(100 - totalContextTokens * 100 / windowSize)));
|
|
585
|
+
return {
|
|
586
|
+
model,
|
|
587
|
+
inputTokens,
|
|
588
|
+
outputTokens,
|
|
589
|
+
cacheCreationTokens,
|
|
590
|
+
cacheReadTokens,
|
|
591
|
+
totalContextTokens,
|
|
592
|
+
remainingPct
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
var CONTEXT_WINDOW_SIZES;
|
|
598
|
+
var init_transcript = __esm(() => {
|
|
599
|
+
CONTEXT_WINDOW_SIZES = {
|
|
600
|
+
"claude-opus-4": 1e6,
|
|
601
|
+
"claude-sonnet-4": 200000,
|
|
602
|
+
"claude-haiku-4": 200000
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// src/cli/commands/snapshot.ts
|
|
607
|
+
var exports_snapshot = {};
|
|
608
|
+
var init_snapshot = __esm(() => {
|
|
609
|
+
init_router();
|
|
610
|
+
init_sessions();
|
|
611
|
+
init_conversations();
|
|
612
|
+
init_events();
|
|
613
|
+
init_transcript();
|
|
614
|
+
register("snapshot", async (args) => {
|
|
615
|
+
let sessionId = "";
|
|
616
|
+
let transcriptPath = "";
|
|
617
|
+
let conversationId = "";
|
|
618
|
+
for (let i = 0;i < args.length; i++) {
|
|
619
|
+
const arg = args[i];
|
|
620
|
+
const next = args[i + 1];
|
|
621
|
+
if (arg === "--session-id" && next) {
|
|
622
|
+
sessionId = next;
|
|
623
|
+
i++;
|
|
624
|
+
} else if (arg === "--transcript-path" && next) {
|
|
625
|
+
transcriptPath = next;
|
|
626
|
+
i++;
|
|
627
|
+
} else if (arg === "--conversation-id" && next) {
|
|
628
|
+
conversationId = next;
|
|
629
|
+
i++;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (!sessionId || !transcriptPath) {
|
|
633
|
+
console.error("Usage: bertrand snapshot --session-id <id> --transcript-path <path> [--conversation-id <id>]");
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
const session = getSession(sessionId);
|
|
637
|
+
if (!session) {
|
|
638
|
+
console.error(`Session not found: ${sessionId}`);
|
|
639
|
+
process.exit(1);
|
|
640
|
+
}
|
|
641
|
+
const snapshot = getContextSnapshot(transcriptPath);
|
|
642
|
+
if (!snapshot)
|
|
643
|
+
return;
|
|
644
|
+
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
645
|
+
insertEvent({
|
|
646
|
+
sessionId,
|
|
647
|
+
conversationId: convoId,
|
|
648
|
+
event: "context.snapshot",
|
|
649
|
+
summary: `${snapshot.remainingPct}% remaining`,
|
|
650
|
+
meta: {
|
|
651
|
+
model: snapshot.model,
|
|
652
|
+
input_tokens: String(snapshot.inputTokens),
|
|
653
|
+
cache_creation_tokens: String(snapshot.cacheCreationTokens),
|
|
654
|
+
cache_read_tokens: String(snapshot.cacheReadTokens),
|
|
655
|
+
context_window_tokens: String(snapshot.totalContextTokens),
|
|
656
|
+
remaining_pct: String(snapshot.remainingPct),
|
|
657
|
+
claude_id: convoId
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// src/cli/commands/assistant-message.ts
|
|
664
|
+
var exports_assistant_message = {};
|
|
665
|
+
function summarize(text2) {
|
|
666
|
+
const firstLine = text2.split(`
|
|
667
|
+
`).find((l) => l.trim()) ?? "";
|
|
668
|
+
const trimmed = firstLine.trim();
|
|
669
|
+
return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
670
|
+
}
|
|
671
|
+
var RECAP_TAG_RE;
|
|
672
|
+
var init_assistant_message = __esm(() => {
|
|
673
|
+
init_router();
|
|
674
|
+
init_sessions();
|
|
675
|
+
init_conversations();
|
|
676
|
+
init_events();
|
|
677
|
+
init_transcript();
|
|
678
|
+
RECAP_TAG_RE = /<recap>[\s\S]*?<\/recap>/gi;
|
|
679
|
+
register("assistant-message", async (args) => {
|
|
680
|
+
let sessionId = "";
|
|
681
|
+
let transcriptPath = "";
|
|
682
|
+
let conversationId = "";
|
|
683
|
+
for (let i = 0;i < args.length; i++) {
|
|
684
|
+
const arg = args[i];
|
|
685
|
+
const next = args[i + 1];
|
|
686
|
+
if (arg === "--session-id" && next) {
|
|
687
|
+
sessionId = next;
|
|
688
|
+
i++;
|
|
689
|
+
} else if (arg === "--transcript-path" && next) {
|
|
690
|
+
transcriptPath = next;
|
|
691
|
+
i++;
|
|
692
|
+
} else if (arg === "--conversation-id" && next) {
|
|
693
|
+
conversationId = next;
|
|
694
|
+
i++;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (!sessionId || !transcriptPath) {
|
|
698
|
+
console.error("Usage: bertrand assistant-message --session-id <id> --transcript-path <path> [--conversation-id <id>]");
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
const session = getSession(sessionId);
|
|
702
|
+
if (!session) {
|
|
703
|
+
console.error(`Session not found: ${sessionId}`);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
const turn = getLatestAssistantTurn(transcriptPath);
|
|
707
|
+
if (!turn)
|
|
708
|
+
return;
|
|
709
|
+
const text2 = turn.text.replace(RECAP_TAG_RE, "").trim();
|
|
710
|
+
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
711
|
+
insertEvent({
|
|
712
|
+
sessionId,
|
|
713
|
+
conversationId: convoId,
|
|
714
|
+
event: "assistant.message",
|
|
715
|
+
summary: text2 ? summarize(text2) : "thinking only",
|
|
716
|
+
meta: {
|
|
717
|
+
model: turn.model,
|
|
718
|
+
text: text2,
|
|
719
|
+
thinkingBlocks: turn.thinkingBlocks,
|
|
720
|
+
thinkingBytes: turn.thinkingBytes,
|
|
721
|
+
claude_id: convoId
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// src/cli/commands/recap-thinking.ts
|
|
728
|
+
var exports_recap_thinking = {};
|
|
729
|
+
var RECAP_RE;
|
|
730
|
+
var init_recap_thinking = __esm(() => {
|
|
731
|
+
init_router();
|
|
732
|
+
init_sessions();
|
|
733
|
+
init_conversations();
|
|
734
|
+
init_events();
|
|
735
|
+
init_transcript();
|
|
736
|
+
RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
|
|
737
|
+
register("recap-thinking", async (args) => {
|
|
738
|
+
let sessionId = "";
|
|
739
|
+
let transcriptPath = "";
|
|
740
|
+
let conversationId = "";
|
|
741
|
+
for (let i = 0;i < args.length; i++) {
|
|
742
|
+
const arg = args[i];
|
|
743
|
+
const next = args[i + 1];
|
|
744
|
+
if (arg === "--session-id" && next) {
|
|
745
|
+
sessionId = next;
|
|
746
|
+
i++;
|
|
747
|
+
} else if (arg === "--transcript-path" && next) {
|
|
748
|
+
transcriptPath = next;
|
|
749
|
+
i++;
|
|
750
|
+
} else if (arg === "--conversation-id" && next) {
|
|
751
|
+
conversationId = next;
|
|
752
|
+
i++;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (!sessionId || !transcriptPath) {
|
|
756
|
+
console.error("Usage: bertrand recap-thinking --session-id <id> --transcript-path <path> [--conversation-id <id>]");
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
if (!getSession(sessionId))
|
|
760
|
+
return;
|
|
761
|
+
const turn = getLatestAssistantTurn(transcriptPath);
|
|
762
|
+
if (!turn?.text)
|
|
763
|
+
return;
|
|
764
|
+
const match = turn.text.match(RECAP_RE);
|
|
765
|
+
const recap = match?.[1]?.trim();
|
|
766
|
+
if (!recap)
|
|
767
|
+
return;
|
|
768
|
+
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
769
|
+
insertEvent({
|
|
770
|
+
sessionId,
|
|
771
|
+
conversationId: convoId,
|
|
772
|
+
event: "assistant.recap",
|
|
773
|
+
summary: recap.length > 80 ? `${recap.slice(0, 77)}...` : recap,
|
|
774
|
+
meta: { recap, claude_id: convoId }
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// src/terminal/wave.ts
|
|
780
|
+
import { execSync } from "child_process";
|
|
781
|
+
|
|
782
|
+
class WaveAdapter {
|
|
783
|
+
type = "wave";
|
|
784
|
+
detect() {
|
|
785
|
+
try {
|
|
786
|
+
execSync("which wsh", { stdio: "ignore" });
|
|
787
|
+
return true;
|
|
788
|
+
} catch {
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
badge(icon, color, priority = 10, beep = false) {
|
|
793
|
+
try {
|
|
794
|
+
const args = ["badge", icon, "--color", color, "--priority", String(priority)];
|
|
795
|
+
if (beep)
|
|
796
|
+
args.push("--beep");
|
|
797
|
+
execSync(`wsh ${args.join(" ")}`, { stdio: "ignore" });
|
|
798
|
+
} catch {}
|
|
799
|
+
}
|
|
800
|
+
clearBadge() {
|
|
801
|
+
try {
|
|
802
|
+
execSync("wsh badge --clear", { stdio: "ignore" });
|
|
803
|
+
} catch {}
|
|
804
|
+
}
|
|
805
|
+
notify(title, body) {
|
|
806
|
+
try {
|
|
807
|
+
execSync(`wsh notify -t ${JSON.stringify(title)} ${JSON.stringify(body)}`, { stdio: "ignore" });
|
|
808
|
+
} catch {}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
var init_wave = () => {};
|
|
812
|
+
|
|
813
|
+
// src/terminal/noop.ts
|
|
814
|
+
class NoopAdapter {
|
|
815
|
+
type = "noop";
|
|
816
|
+
detect() {
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
badge() {}
|
|
820
|
+
clearBadge() {}
|
|
821
|
+
notify() {}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/terminal/index.ts
|
|
825
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
826
|
+
import { join as join2 } from "path";
|
|
827
|
+
function getTerminalAdapter() {
|
|
828
|
+
if (cachedAdapter)
|
|
829
|
+
return cachedAdapter;
|
|
830
|
+
const configType = readConfigTerminal();
|
|
831
|
+
if (configType === "wave") {
|
|
832
|
+
cachedAdapter = new WaveAdapter;
|
|
833
|
+
return cachedAdapter;
|
|
834
|
+
}
|
|
835
|
+
const wave = new WaveAdapter;
|
|
836
|
+
if (wave.detect()) {
|
|
837
|
+
cachedAdapter = wave;
|
|
838
|
+
return cachedAdapter;
|
|
839
|
+
}
|
|
840
|
+
cachedAdapter = new NoopAdapter;
|
|
841
|
+
return cachedAdapter;
|
|
842
|
+
}
|
|
843
|
+
function readConfigTerminal() {
|
|
844
|
+
try {
|
|
845
|
+
const config = JSON.parse(readFileSync2(join2(paths.root, "config.json"), "utf-8"));
|
|
846
|
+
return config.terminal ?? null;
|
|
847
|
+
} catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
var cachedAdapter = null;
|
|
852
|
+
var init_terminal = __esm(() => {
|
|
853
|
+
init_wave();
|
|
854
|
+
init_paths();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// src/cli/commands/badge.ts
|
|
858
|
+
var exports_badge = {};
|
|
859
|
+
var init_badge = __esm(() => {
|
|
860
|
+
init_router();
|
|
861
|
+
init_terminal();
|
|
862
|
+
register("badge", async (args) => {
|
|
863
|
+
const adapter = getTerminalAdapter();
|
|
864
|
+
if (args.includes("--clear")) {
|
|
865
|
+
adapter.clearBadge();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const icon = args[0];
|
|
869
|
+
if (!icon)
|
|
870
|
+
return;
|
|
871
|
+
const colorIdx = args.indexOf("--color");
|
|
872
|
+
const color = colorIdx >= 0 ? args[colorIdx + 1] ?? "#ffffff" : "#ffffff";
|
|
873
|
+
const priorityIdx = args.indexOf("--priority");
|
|
874
|
+
const priority = priorityIdx >= 0 ? parseInt(args[priorityIdx + 1] ?? "10") : 10;
|
|
875
|
+
const beep = args.includes("--beep");
|
|
876
|
+
adapter.badge(icon, color, priority, beep);
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// src/cli/commands/notify.ts
|
|
881
|
+
var exports_notify = {};
|
|
882
|
+
var init_notify = __esm(() => {
|
|
883
|
+
init_router();
|
|
884
|
+
init_terminal();
|
|
885
|
+
register("notify", async (args) => {
|
|
886
|
+
const adapter = getTerminalAdapter();
|
|
887
|
+
const title = args[0] ?? "bertrand";
|
|
888
|
+
const body = args.slice(1).join(" ");
|
|
889
|
+
if (body) {
|
|
890
|
+
adapter.notify(title, body);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// src/db/queries/stats.ts
|
|
896
|
+
import { eq as eq4, sql as sql4 } from "drizzle-orm";
|
|
897
|
+
function getSessionStats(sessionId) {
|
|
898
|
+
return getDb().select().from(sessionStats).where(eq4(sessionStats.sessionId, sessionId)).get();
|
|
899
|
+
}
|
|
900
|
+
function upsertSessionStats(sessionId, data) {
|
|
901
|
+
return getDb().insert(sessionStats).values({
|
|
902
|
+
sessionId,
|
|
903
|
+
...data,
|
|
904
|
+
updatedAt: sql4`(datetime('now'))`
|
|
905
|
+
}).onConflictDoUpdate({
|
|
906
|
+
target: sessionStats.sessionId,
|
|
907
|
+
set: { ...data, updatedAt: sql4`(datetime('now'))` }
|
|
908
|
+
}).returning().get();
|
|
909
|
+
}
|
|
910
|
+
var init_stats = __esm(() => {
|
|
911
|
+
init_client();
|
|
912
|
+
init_schema();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// src/lib/diff_stats.ts
|
|
916
|
+
function lineCount(s) {
|
|
917
|
+
if (!s)
|
|
918
|
+
return 0;
|
|
919
|
+
return s.split(`
|
|
920
|
+
`).length;
|
|
921
|
+
}
|
|
922
|
+
function computeDiffStats(sessionId) {
|
|
923
|
+
const applied = getEventsByType(sessionId, "tool.applied");
|
|
924
|
+
const files = new Set;
|
|
925
|
+
let linesAdded = 0;
|
|
926
|
+
let linesRemoved = 0;
|
|
927
|
+
for (const ev of applied) {
|
|
928
|
+
const meta = ev.meta;
|
|
929
|
+
const permissions = meta?.permissions ?? [];
|
|
930
|
+
for (const p of permissions) {
|
|
931
|
+
if (p.detail)
|
|
932
|
+
files.add(p.detail);
|
|
933
|
+
if (p.edits && p.edits.length > 0) {
|
|
934
|
+
for (const e of p.edits) {
|
|
935
|
+
linesRemoved += lineCount(e.oldStr);
|
|
936
|
+
linesAdded += lineCount(e.newStr);
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
linesRemoved += lineCount(p.oldStr);
|
|
940
|
+
linesAdded += lineCount(p.newStr);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { linesAdded, linesRemoved, filesTouched: files.size };
|
|
945
|
+
}
|
|
946
|
+
var init_diff_stats = __esm(() => {
|
|
947
|
+
init_events();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// src/lib/timing.ts
|
|
951
|
+
function getClaudeId(row) {
|
|
952
|
+
if (row.conversationId)
|
|
953
|
+
return row.conversationId;
|
|
954
|
+
const meta = row.meta;
|
|
955
|
+
return meta?.claude_id ?? undefined;
|
|
956
|
+
}
|
|
957
|
+
function tsMs(iso) {
|
|
958
|
+
return new Date(iso).getTime();
|
|
959
|
+
}
|
|
960
|
+
function pushSegment(segments, type, startIso, endIso, claudeId) {
|
|
961
|
+
const durationMs = tsMs(endIso) - tsMs(startIso);
|
|
962
|
+
if (durationMs <= 0)
|
|
963
|
+
return;
|
|
964
|
+
segments.push({ start: startIso, end: endIso, durationMs, type, claudeId });
|
|
965
|
+
}
|
|
966
|
+
function computeTimings(events2) {
|
|
967
|
+
const segments = [];
|
|
968
|
+
let state = "idle";
|
|
969
|
+
let periodStart = null;
|
|
970
|
+
let currentClaudeId;
|
|
971
|
+
for (const ev of events2) {
|
|
972
|
+
switch (ev.event) {
|
|
973
|
+
case "claude.started": {
|
|
974
|
+
if (state === "active" && periodStart) {
|
|
975
|
+
pushSegment(segments, "claude_work", periodStart, ev.createdAt, currentClaudeId);
|
|
976
|
+
}
|
|
977
|
+
if (state === "waiting" && periodStart) {
|
|
978
|
+
pushSegment(segments, "user_wait", periodStart, ev.createdAt, currentClaudeId);
|
|
979
|
+
}
|
|
980
|
+
state = "active";
|
|
981
|
+
periodStart = ev.createdAt;
|
|
982
|
+
currentClaudeId = getClaudeId(ev);
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
case "session.waiting": {
|
|
986
|
+
if (state === "active" && periodStart) {
|
|
987
|
+
pushSegment(segments, "claude_work", periodStart, ev.createdAt, currentClaudeId);
|
|
988
|
+
}
|
|
989
|
+
state = "waiting";
|
|
990
|
+
periodStart = ev.createdAt;
|
|
991
|
+
currentClaudeId = getClaudeId(ev) ?? currentClaudeId;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
case "session.answered": {
|
|
995
|
+
if (state === "waiting" && periodStart) {
|
|
996
|
+
pushSegment(segments, "user_wait", periodStart, ev.createdAt, currentClaudeId);
|
|
997
|
+
}
|
|
998
|
+
state = "active";
|
|
999
|
+
periodStart = ev.createdAt;
|
|
1000
|
+
currentClaudeId = getClaudeId(ev) ?? currentClaudeId;
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
case "claude.ended": {
|
|
1004
|
+
if (state === "active" && periodStart) {
|
|
1005
|
+
pushSegment(segments, "claude_work", periodStart, ev.createdAt, currentClaudeId);
|
|
1006
|
+
}
|
|
1007
|
+
if (state === "waiting" && periodStart) {
|
|
1008
|
+
pushSegment(segments, "user_wait", periodStart, ev.createdAt, currentClaudeId);
|
|
1009
|
+
}
|
|
1010
|
+
state = "idle";
|
|
1011
|
+
periodStart = null;
|
|
1012
|
+
currentClaudeId = undefined;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const lastEvent = events2[events2.length - 1];
|
|
1018
|
+
if (state !== "idle" && periodStart && lastEvent) {
|
|
1019
|
+
if (state === "active") {
|
|
1020
|
+
pushSegment(segments, "claude_work", periodStart, lastEvent.createdAt, currentClaudeId);
|
|
1021
|
+
} else if (state === "waiting") {
|
|
1022
|
+
pushSegment(segments, "user_wait", periodStart, lastEvent.createdAt, currentClaudeId);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const totalClaudeWorkMs = segments.filter((s) => s.type === "claude_work").reduce((sum, s) => sum + s.durationMs, 0);
|
|
1026
|
+
const totalUserWaitMs = segments.filter((s) => s.type === "user_wait").reduce((sum, s) => sum + s.durationMs, 0);
|
|
1027
|
+
const totalTracked = totalClaudeWorkMs + totalUserWaitMs;
|
|
1028
|
+
const activePct = totalTracked > 0 ? Math.round(totalClaudeWorkMs / totalTracked * 100) : 0;
|
|
1029
|
+
let durationS = 0;
|
|
1030
|
+
if (events2.length >= 2) {
|
|
1031
|
+
const first = tsMs(events2[0].createdAt);
|
|
1032
|
+
const last = tsMs(events2[events2.length - 1].createdAt);
|
|
1033
|
+
durationS = Math.round((last - first) / 1000);
|
|
1034
|
+
}
|
|
1035
|
+
return { totalClaudeWorkMs, totalUserWaitMs, activePct, durationS, segments };
|
|
1036
|
+
}
|
|
1037
|
+
function computeSessionStats(sessionId) {
|
|
1038
|
+
const events2 = getEventsBySession(sessionId);
|
|
1039
|
+
const summary = computeTimings(events2);
|
|
1040
|
+
const prEvents = getEventsByType(sessionId, "gh.pr.created");
|
|
1041
|
+
const conversationIds = new Set(events2.filter((e) => e.conversationId).map((e) => e.conversationId));
|
|
1042
|
+
const interactionCount = events2.filter((e) => e.event === "session.waiting" || e.event === "session.answered").length;
|
|
1043
|
+
const diff = computeDiffStats(sessionId);
|
|
1044
|
+
return {
|
|
1045
|
+
eventCount: events2.length,
|
|
1046
|
+
conversationCount: conversationIds.size,
|
|
1047
|
+
interactionCount,
|
|
1048
|
+
prCount: prEvents.length,
|
|
1049
|
+
claudeWorkS: Math.round(summary.totalClaudeWorkMs / 1000),
|
|
1050
|
+
userWaitS: Math.round(summary.totalUserWaitMs / 1000),
|
|
1051
|
+
activePct: summary.activePct,
|
|
1052
|
+
durationS: summary.durationS,
|
|
1053
|
+
linesAdded: diff.linesAdded,
|
|
1054
|
+
linesRemoved: diff.linesRemoved,
|
|
1055
|
+
filesTouched: diff.filesTouched
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function computeAndPersist(sessionId) {
|
|
1059
|
+
const data = computeSessionStats(sessionId);
|
|
1060
|
+
upsertSessionStats(sessionId, data);
|
|
1061
|
+
return data;
|
|
1062
|
+
}
|
|
1063
|
+
function computeTimingsLive(sessionId) {
|
|
1064
|
+
return computeTimings(getEventsBySession(sessionId));
|
|
1065
|
+
}
|
|
1066
|
+
var init_timing = __esm(() => {
|
|
1067
|
+
init_events();
|
|
1068
|
+
init_stats();
|
|
1069
|
+
init_diff_stats();
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// src/lib/engagement_stats.ts
|
|
1073
|
+
import { eq as eq5, sql as sql5 } from "drizzle-orm";
|
|
1074
|
+
function aggregateToolUsage(sessionId) {
|
|
1075
|
+
const counts = {};
|
|
1076
|
+
const applied = getEventsByType(sessionId, "tool.applied");
|
|
1077
|
+
for (const ev of applied) {
|
|
1078
|
+
const meta = ev.meta;
|
|
1079
|
+
const permissions = meta?.permissions ?? [];
|
|
1080
|
+
for (const p of permissions) {
|
|
1081
|
+
if (!p.tool)
|
|
1082
|
+
continue;
|
|
1083
|
+
counts[p.tool] = (counts[p.tool] ?? 0) + (p.count ?? 1);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const resolves = getEventsByType(sessionId, "permission.resolve");
|
|
1087
|
+
for (const ev of resolves) {
|
|
1088
|
+
const meta = ev.meta;
|
|
1089
|
+
const tool = meta?.tool;
|
|
1090
|
+
if (!tool)
|
|
1091
|
+
continue;
|
|
1092
|
+
counts[tool] = (counts[tool] ?? 0) + 1;
|
|
1093
|
+
}
|
|
1094
|
+
return counts;
|
|
1095
|
+
}
|
|
1096
|
+
function contextTokenStats(sessionId) {
|
|
1097
|
+
const snapshots = getEventsByType(sessionId, "context.snapshot");
|
|
1098
|
+
const samples = [];
|
|
1099
|
+
for (const ev of snapshots) {
|
|
1100
|
+
const meta = ev.meta;
|
|
1101
|
+
const total = parseInt(meta?.context_window_tokens ?? "0", 10);
|
|
1102
|
+
if (Number.isFinite(total) && total > 0)
|
|
1103
|
+
samples.push(total);
|
|
1104
|
+
}
|
|
1105
|
+
if (samples.length === 0)
|
|
1106
|
+
return { avg: 0, max: 0, latest: 0 };
|
|
1107
|
+
let sum = 0;
|
|
1108
|
+
let max = 0;
|
|
1109
|
+
for (const n of samples) {
|
|
1110
|
+
sum += n;
|
|
1111
|
+
if (n > max)
|
|
1112
|
+
max = n;
|
|
1113
|
+
}
|
|
1114
|
+
const avg = Math.round(sum / samples.length);
|
|
1115
|
+
const latest = samples[samples.length - 1] ?? 0;
|
|
1116
|
+
return { avg, max, latest };
|
|
1117
|
+
}
|
|
1118
|
+
function permissionDenialCount(sessionId) {
|
|
1119
|
+
const all = getEventsBySession(sessionId);
|
|
1120
|
+
let requests = 0;
|
|
1121
|
+
let resolves = 0;
|
|
1122
|
+
for (const ev of all) {
|
|
1123
|
+
if (ev.event === "permission.request")
|
|
1124
|
+
requests++;
|
|
1125
|
+
else if (ev.event === "permission.resolve")
|
|
1126
|
+
resolves++;
|
|
1127
|
+
}
|
|
1128
|
+
return Math.max(0, requests - resolves);
|
|
1129
|
+
}
|
|
1130
|
+
function discardRate(sessionId) {
|
|
1131
|
+
const row = getDb().select({
|
|
1132
|
+
total: sql5`count(*)`,
|
|
1133
|
+
discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
|
|
1134
|
+
}).from(conversations).where(eq5(conversations.sessionId, sessionId)).get();
|
|
1135
|
+
return {
|
|
1136
|
+
total: row?.total ?? 0,
|
|
1137
|
+
discarded: row?.discarded ?? 0
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
function computeEngagementStats(sessionId) {
|
|
1141
|
+
return {
|
|
1142
|
+
toolUsage: aggregateToolUsage(sessionId),
|
|
1143
|
+
contextTokens: contextTokenStats(sessionId),
|
|
1144
|
+
permissionDenials: permissionDenialCount(sessionId),
|
|
1145
|
+
discardRate: discardRate(sessionId)
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
var init_engagement_stats = __esm(() => {
|
|
1149
|
+
init_client();
|
|
1150
|
+
init_schema();
|
|
1151
|
+
init_events();
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
// src/server/index.ts
|
|
1155
|
+
import { execFile } from "child_process";
|
|
1156
|
+
async function handleOpen(req) {
|
|
1157
|
+
let body;
|
|
1158
|
+
try {
|
|
1159
|
+
body = await req.json();
|
|
1160
|
+
} catch {
|
|
1161
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
1162
|
+
}
|
|
1163
|
+
const path = body.path;
|
|
1164
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
1165
|
+
return Response.json({ error: "path must be an absolute string" }, { status: 400 });
|
|
1166
|
+
}
|
|
1167
|
+
return new Promise((resolve) => {
|
|
1168
|
+
execFile("open", [path], (err) => {
|
|
1169
|
+
if (err) {
|
|
1170
|
+
resolve(Response.json({ error: err.message }, { status: 500 }));
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
resolve(Response.json({ ok: true }));
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
function match(pathname, url) {
|
|
1178
|
+
for (const [pattern, handler] of routes) {
|
|
1179
|
+
const m = pattern.exec(pathname);
|
|
1180
|
+
if (!m)
|
|
1181
|
+
continue;
|
|
1182
|
+
try {
|
|
1183
|
+
const result = handler(m.groups ?? {}, url);
|
|
1184
|
+
return Response.json(result ?? null);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.error(`[server] ${pathname} failed:`, err);
|
|
1187
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
1188
|
+
return Response.json({ error: message }, { status: 500 });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
1192
|
+
}
|
|
1193
|
+
function startServer(port = PORT) {
|
|
1194
|
+
const server = Bun.serve({
|
|
1195
|
+
port,
|
|
1196
|
+
fetch(req) {
|
|
1197
|
+
const url = new URL(req.url);
|
|
1198
|
+
if (req.method === "OPTIONS") {
|
|
1199
|
+
return new Response(null, {
|
|
1200
|
+
headers: {
|
|
1201
|
+
"Access-Control-Allow-Origin": "*",
|
|
1202
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
1203
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (req.method === "POST" && url.pathname === "/api/open") {
|
|
1208
|
+
return handleOpen(req).then((r) => {
|
|
1209
|
+
r.headers.set("Access-Control-Allow-Origin", "*");
|
|
1210
|
+
return r;
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
const response = match(url.pathname, url);
|
|
1214
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
1215
|
+
return response;
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
console.log(`bertrand API server listening on http://localhost:${server.port}`);
|
|
1219
|
+
return server;
|
|
1220
|
+
}
|
|
1221
|
+
var PORT, routes;
|
|
1222
|
+
var init_server = __esm(() => {
|
|
1223
|
+
init_sessions();
|
|
1224
|
+
init_events();
|
|
1225
|
+
init_stats();
|
|
1226
|
+
init_timing();
|
|
1227
|
+
init_engagement_stats();
|
|
1228
|
+
PORT = Number(process.env.BERTRAND_PORT ?? 5200);
|
|
1229
|
+
routes = [
|
|
1230
|
+
[/^\/api\/sessions$/, (_params, url) => {
|
|
1231
|
+
const excludeArchived = url.searchParams.get("excludeArchived") !== "false";
|
|
1232
|
+
return getAllSessions({ excludeArchived });
|
|
1233
|
+
}],
|
|
1234
|
+
[/^\/api\/sessions\/(?<id>[^/]+)$/, ({ id }) => {
|
|
1235
|
+
return getSession(id);
|
|
1236
|
+
}],
|
|
1237
|
+
[/^\/api\/events\/(?<sessionId>[^/]+)$/, ({ sessionId }, url) => {
|
|
1238
|
+
const eventType = url.searchParams.get("type");
|
|
1239
|
+
if (eventType)
|
|
1240
|
+
return getEventsByType(sessionId, eventType);
|
|
1241
|
+
return getEventsBySession(sessionId);
|
|
1242
|
+
}],
|
|
1243
|
+
[/^\/api\/stats$/, () => {
|
|
1244
|
+
const all = getAllSessions();
|
|
1245
|
+
const now = new Date().toISOString();
|
|
1246
|
+
const result = {};
|
|
1247
|
+
for (const { session } of all) {
|
|
1248
|
+
const isLive = session.status === "active" || session.status === "waiting";
|
|
1249
|
+
if (isLive) {
|
|
1250
|
+
result[session.id] = { sessionId: session.id, ...computeSessionStats(session.id), updatedAt: now };
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const stored = getSessionStats(session.id);
|
|
1254
|
+
result[session.id] = stored ?? { sessionId: session.id, ...computeSessionStats(session.id), updatedAt: now };
|
|
1255
|
+
}
|
|
1256
|
+
return result;
|
|
1257
|
+
}],
|
|
1258
|
+
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, ({ sessionId }) => {
|
|
1259
|
+
const session = getSession(sessionId);
|
|
1260
|
+
if (!session)
|
|
1261
|
+
return null;
|
|
1262
|
+
const isLive = session.status === "active" || session.status === "waiting";
|
|
1263
|
+
if (isLive) {
|
|
1264
|
+
return { sessionId, ...computeSessionStats(sessionId), updatedAt: new Date().toISOString() };
|
|
1265
|
+
}
|
|
1266
|
+
const stored = getSessionStats(sessionId);
|
|
1267
|
+
if (stored)
|
|
1268
|
+
return stored;
|
|
1269
|
+
return { sessionId, ...computeSessionStats(sessionId), updatedAt: new Date().toISOString() };
|
|
1270
|
+
}],
|
|
1271
|
+
[/^\/api\/engagement\/(?<sessionId>[^/]+)$/, ({ sessionId }) => {
|
|
1272
|
+
return computeEngagementStats(sessionId);
|
|
1273
|
+
}],
|
|
1274
|
+
[/^\/api\/recaps$/, () => {
|
|
1275
|
+
return getLatestRecaps();
|
|
1276
|
+
}]
|
|
1277
|
+
];
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// src/cli/commands/serve.ts
|
|
1281
|
+
var exports_serve = {};
|
|
1282
|
+
var init_serve = __esm(() => {
|
|
1283
|
+
init_router();
|
|
1284
|
+
init_server();
|
|
1285
|
+
register("serve", async () => {
|
|
1286
|
+
startServer();
|
|
1287
|
+
await new Promise(() => {});
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
// src/db/queries/groups.ts
|
|
1292
|
+
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
1293
|
+
function createGroup(opts) {
|
|
1294
|
+
const db = getDb();
|
|
1295
|
+
const id = createId();
|
|
1296
|
+
let path = opts.slug;
|
|
1297
|
+
let depth = 0;
|
|
1298
|
+
if (opts.parentId) {
|
|
1299
|
+
const parent = db.select().from(groups).where(eq6(groups.id, opts.parentId)).get();
|
|
1300
|
+
if (!parent)
|
|
1301
|
+
throw new Error(`Parent group ${opts.parentId} not found`);
|
|
1302
|
+
path = `${parent.path}/${opts.slug}`;
|
|
1303
|
+
depth = parent.depth + 1;
|
|
1304
|
+
}
|
|
1305
|
+
return db.insert(groups).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
1306
|
+
}
|
|
1307
|
+
function getGroup(id) {
|
|
1308
|
+
return getDb().select().from(groups).where(eq6(groups.id, id)).get();
|
|
1309
|
+
}
|
|
1310
|
+
function getGroupByPath(path) {
|
|
1311
|
+
return getDb().select().from(groups).where(eq6(groups.path, path)).get();
|
|
1312
|
+
}
|
|
1313
|
+
function getOrCreateGroupPath(path) {
|
|
1314
|
+
const existing = getGroupByPath(path);
|
|
1315
|
+
if (existing)
|
|
1316
|
+
return existing.id;
|
|
1317
|
+
const segments = path.split("/");
|
|
1318
|
+
let parentId;
|
|
1319
|
+
for (let i = 0;i < segments.length; i++) {
|
|
1320
|
+
const partialPath = segments.slice(0, i + 1).join("/");
|
|
1321
|
+
const group = getGroupByPath(partialPath);
|
|
1322
|
+
if (group) {
|
|
1323
|
+
parentId = group.id;
|
|
1324
|
+
} else {
|
|
1325
|
+
const slug = segments[i];
|
|
1326
|
+
const created = createGroup({ slug, name: slug, parentId });
|
|
1327
|
+
parentId = created.id;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return parentId;
|
|
1331
|
+
}
|
|
1332
|
+
var init_groups = __esm(() => {
|
|
1333
|
+
init_client();
|
|
1334
|
+
init_schema();
|
|
1335
|
+
init_id();
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
// src/db/queries/labels.ts
|
|
1339
|
+
import { eq as eq7, and as and4 } from "drizzle-orm";
|
|
1340
|
+
function createLabel(opts) {
|
|
1341
|
+
return getDb().insert(labels).values({ id: createId(), ...opts }).returning().get();
|
|
1342
|
+
}
|
|
1343
|
+
function getLabelByName(name) {
|
|
1344
|
+
return getDb().select().from(labels).where(eq7(labels.name, name)).get();
|
|
1345
|
+
}
|
|
1346
|
+
function getOrCreateLabelByName(name) {
|
|
1347
|
+
const existing = getLabelByName(name);
|
|
1348
|
+
if (existing)
|
|
1349
|
+
return existing;
|
|
1350
|
+
return createLabel({ name });
|
|
1351
|
+
}
|
|
1352
|
+
function addLabelToSession(sessionId, labelId) {
|
|
1353
|
+
return getDb().insert(sessionLabels).values({ sessionId, labelId }).onConflictDoNothing().returning().get();
|
|
1354
|
+
}
|
|
1355
|
+
var init_labels = __esm(() => {
|
|
1356
|
+
init_client();
|
|
1357
|
+
init_schema();
|
|
1358
|
+
init_id();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// src/contract/template.md
|
|
1362
|
+
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1363
|
+
|
|
1364
|
+
At session start, run: ToolSearch with query "select:AskUserQuestion" to load the tool.
|
|
1365
|
+
|
|
1366
|
+
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include a "Done for now" option so the user can exit the loop when ready. The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
|
|
1367
|
+
|
|
1368
|
+
If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
|
|
1369
|
+
|
|
1370
|
+
Every option must be a concrete, actionable next step. No filler like "Have questions?" or "Want to learn more?" \u2014 if clarification is needed, phrase it as a specific action: "Discuss tradeoffs of X vs Y".
|
|
1371
|
+
|
|
1372
|
+
Every AskUserQuestion call MUST use multiSelect: true. No exceptions. Single-select fires on Enter with no confirmation, which causes accidental selections when a block gains focus. multiSelect requires explicit confirmation before submitting.
|
|
1373
|
+
|
|
1374
|
+
Before each AskUserQuestion call, emit a \`<recap>...</recap>\` block in your text output. Use markdown \u2014 a short bullet list is usually the most scannable shape; a single short paragraph is fine when the turn was one cohesive thing. Keep it concise. The recap covers what happened since the previous AskUserQuestion (or session start) \u2014 what you found, decided, or did. Write the gist for someone reading the session timeline, not a process log. The dashboard renders these between AskUserQuestion events; do not use this tag for any other purpose.
|
|
1375
|
+
`;
|
|
1376
|
+
var init_template = () => {};
|
|
1377
|
+
|
|
1378
|
+
// src/contract/template.ts
|
|
1379
|
+
function buildContract(sessionName, ...contextLayers) {
|
|
1380
|
+
const base = template_default.replace("{sessionName}", sessionName);
|
|
1381
|
+
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
1382
|
+
if (layers.length === 0)
|
|
1383
|
+
return base;
|
|
1384
|
+
return base + `
|
|
1385
|
+
|
|
1386
|
+
` + layers.join(`
|
|
1387
|
+
|
|
1388
|
+
`);
|
|
1389
|
+
}
|
|
1390
|
+
var init_template2 = __esm(() => {
|
|
1391
|
+
init_template();
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
// src/lib/format.ts
|
|
1395
|
+
function formatDuration(ms) {
|
|
1396
|
+
if (ms < MINUTE)
|
|
1397
|
+
return `${Math.round(ms / SECOND)}s`;
|
|
1398
|
+
const days = Math.floor(ms / DAY);
|
|
1399
|
+
const hours = Math.floor(ms % DAY / HOUR);
|
|
1400
|
+
const minutes = Math.floor(ms % HOUR / MINUTE);
|
|
1401
|
+
if (days > 0)
|
|
1402
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
1403
|
+
if (hours > 0)
|
|
1404
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1405
|
+
return `${minutes}m`;
|
|
1406
|
+
}
|
|
1407
|
+
function formatAgo(isoOrDate) {
|
|
1408
|
+
const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
1409
|
+
const ms = Date.now() - date.getTime();
|
|
1410
|
+
if (ms < MINUTE)
|
|
1411
|
+
return "just now";
|
|
1412
|
+
if (ms < HOUR)
|
|
1413
|
+
return `${Math.floor(ms / MINUTE)}m ago`;
|
|
1414
|
+
if (ms < DAY)
|
|
1415
|
+
return `${Math.floor(ms / HOUR)}h ago`;
|
|
1416
|
+
if (ms < 2 * DAY)
|
|
1417
|
+
return "yesterday";
|
|
1418
|
+
if (ms < 7 * DAY)
|
|
1419
|
+
return `${Math.floor(ms / DAY)}d ago`;
|
|
1420
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
1421
|
+
}
|
|
1422
|
+
function truncate(text2, maxLen) {
|
|
1423
|
+
if (text2.length <= maxLen)
|
|
1424
|
+
return text2;
|
|
1425
|
+
return text2.slice(0, maxLen - 1) + "\u2026";
|
|
1426
|
+
}
|
|
1427
|
+
function formatTime(iso, includeDate = false) {
|
|
1428
|
+
const date = new Date(iso);
|
|
1429
|
+
const time = date.toLocaleTimeString("en-US", {
|
|
1430
|
+
hour: "numeric",
|
|
1431
|
+
minute: "2-digit"
|
|
1432
|
+
});
|
|
1433
|
+
if (!includeDate)
|
|
1434
|
+
return time;
|
|
1435
|
+
const day = date.toLocaleDateString("en-US", {
|
|
1436
|
+
month: "short",
|
|
1437
|
+
day: "numeric"
|
|
1438
|
+
});
|
|
1439
|
+
return `${day} ${time}`;
|
|
1440
|
+
}
|
|
1441
|
+
var SECOND = 1000, MINUTE, HOUR, DAY;
|
|
1442
|
+
var init_format = __esm(() => {
|
|
1443
|
+
MINUTE = 60 * SECOND;
|
|
1444
|
+
HOUR = 60 * MINUTE;
|
|
1445
|
+
DAY = 24 * HOUR;
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// src/contract/context.ts
|
|
1449
|
+
function buildSiblingContext(groupId, groupPath, currentSessionId) {
|
|
1450
|
+
const siblings = getSessionsByGroup(groupId).filter((s) => s.id !== currentSessionId);
|
|
1451
|
+
if (siblings.length === 0)
|
|
1452
|
+
return "";
|
|
1453
|
+
const lines = siblings.map((s) => {
|
|
1454
|
+
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
1455
|
+
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
1456
|
+
return `- ${groupPath}/${s.slug}: ${s.status}${summary} (${ago})`;
|
|
1457
|
+
});
|
|
1458
|
+
const guidance = [
|
|
1459
|
+
"",
|
|
1460
|
+
"To inspect any sibling session's full record, run:",
|
|
1461
|
+
" bertrand log <group>/<slug> --json",
|
|
1462
|
+
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
1463
|
+
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
1464
|
+
].join(`
|
|
1465
|
+
`);
|
|
1466
|
+
return `## Sibling Sessions
|
|
1467
|
+
${lines.join(`
|
|
1468
|
+
`)}
|
|
1469
|
+
${guidance}`;
|
|
1470
|
+
}
|
|
1471
|
+
var init_context = __esm(() => {
|
|
1472
|
+
init_sessions();
|
|
1473
|
+
init_format();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
// src/engine/process.ts
|
|
1477
|
+
import { spawn } from "child_process";
|
|
1478
|
+
function launchClaude(opts) {
|
|
1479
|
+
const args = [];
|
|
1480
|
+
if (opts.resume) {
|
|
1481
|
+
args.push("--resume", opts.claudeId);
|
|
1482
|
+
} else {
|
|
1483
|
+
args.push("--session-id", opts.claudeId);
|
|
1484
|
+
}
|
|
1485
|
+
args.push("--append-system-prompt", opts.contract);
|
|
1486
|
+
const env = {
|
|
1487
|
+
...process.env,
|
|
1488
|
+
BERTRAND_PID: String(process.pid),
|
|
1489
|
+
BERTRAND_CLAUDE_ID: opts.claudeId,
|
|
1490
|
+
BERTRAND_SESSION: opts.sessionId,
|
|
1491
|
+
BERTRAND_SESSION_NAME: opts.sessionName,
|
|
1492
|
+
BERTRAND_SESSION_SLUG: opts.sessionSlug
|
|
1493
|
+
};
|
|
1494
|
+
return new Promise((resolve, reject) => {
|
|
1495
|
+
const child = spawn("claude", args, {
|
|
1496
|
+
env,
|
|
1497
|
+
stdio: "inherit",
|
|
1498
|
+
shell: false
|
|
1499
|
+
});
|
|
1500
|
+
activeChild = child;
|
|
1501
|
+
const onSignal = (signal) => {
|
|
1502
|
+
if (child.pid && !child.killed) {
|
|
1503
|
+
child.kill(signal);
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
process.on("SIGINT", onSignal);
|
|
1507
|
+
process.on("SIGTERM", onSignal);
|
|
1508
|
+
child.on("error", (err) => {
|
|
1509
|
+
activeChild = null;
|
|
1510
|
+
process.removeListener("SIGINT", onSignal);
|
|
1511
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1512
|
+
reject(new Error(`Failed to launch claude: ${err.message}`));
|
|
1513
|
+
});
|
|
1514
|
+
child.on("exit", (code) => {
|
|
1515
|
+
activeChild = null;
|
|
1516
|
+
process.removeListener("SIGINT", onSignal);
|
|
1517
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1518
|
+
resolve(code ?? 0);
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
var activeChild = null;
|
|
1523
|
+
var init_process = () => {};
|
|
1524
|
+
|
|
1525
|
+
// src/engine/spawn-context.ts
|
|
1526
|
+
import { execFile as execFile2 } from "child_process";
|
|
1527
|
+
import { promisify } from "util";
|
|
1528
|
+
function captureModel() {
|
|
1529
|
+
return process.env.BERTRAND_MODEL || process.env.CLAUDE_MODEL || undefined;
|
|
1530
|
+
}
|
|
1531
|
+
async function captureClaudeVersion() {
|
|
1532
|
+
if (cachedClaudeVersion !== null)
|
|
1533
|
+
return cachedClaudeVersion;
|
|
1534
|
+
try {
|
|
1535
|
+
const { stdout } = await execFileAsync("claude", ["--version"], {
|
|
1536
|
+
timeout: 5000
|
|
1537
|
+
});
|
|
1538
|
+
const match2 = stdout.trim().match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
|
|
1539
|
+
cachedClaudeVersion = match2 ? match2[1] : stdout.trim() || undefined;
|
|
1540
|
+
return cachedClaudeVersion;
|
|
1541
|
+
} catch {
|
|
1542
|
+
cachedClaudeVersion = undefined;
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
async function captureGit() {
|
|
1547
|
+
try {
|
|
1548
|
+
const [statusRes, shaRes] = await Promise.all([
|
|
1549
|
+
execFileAsync("git", ["status", "--porcelain=v2", "--branch"], {
|
|
1550
|
+
timeout: 5000
|
|
1551
|
+
}),
|
|
1552
|
+
execFileAsync("git", ["rev-parse", "HEAD"], { timeout: 5000 })
|
|
1553
|
+
]);
|
|
1554
|
+
let branch;
|
|
1555
|
+
let dirty = false;
|
|
1556
|
+
for (const line of statusRes.stdout.split(`
|
|
1557
|
+
`)) {
|
|
1558
|
+
if (line.startsWith("# branch.head ")) {
|
|
1559
|
+
branch = line.slice("# branch.head ".length).trim();
|
|
1560
|
+
} else if (line && !line.startsWith("#")) {
|
|
1561
|
+
dirty = true;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (!branch)
|
|
1565
|
+
return;
|
|
1566
|
+
return {
|
|
1567
|
+
branch,
|
|
1568
|
+
sha: shaRes.stdout.trim(),
|
|
1569
|
+
dirty
|
|
1570
|
+
};
|
|
1571
|
+
} catch {
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
async function captureSpawnContext() {
|
|
1576
|
+
const [claudeVersion, git] = await Promise.all([
|
|
1577
|
+
captureClaudeVersion(),
|
|
1578
|
+
captureGit()
|
|
1579
|
+
]);
|
|
1580
|
+
return {
|
|
1581
|
+
model: captureModel(),
|
|
1582
|
+
claudeVersion,
|
|
1583
|
+
git,
|
|
1584
|
+
cwd: process.cwd()
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
var execFileAsync, cachedClaudeVersion = null;
|
|
1588
|
+
var init_spawn_context = __esm(() => {
|
|
1589
|
+
execFileAsync = promisify(execFile2);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
// src/engine/session.ts
|
|
1593
|
+
import { randomUUID } from "crypto";
|
|
1594
|
+
async function launch(opts) {
|
|
1595
|
+
const existingGroup = getGroupByPath(opts.groupPath);
|
|
1596
|
+
if (existingGroup) {
|
|
1597
|
+
const existing = getSessionByGroupSlug(existingGroup.id, opts.slug);
|
|
1598
|
+
if (existing) {
|
|
1599
|
+
throw new Error(`Session "${opts.slug}" already exists in group "${opts.groupPath}"`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const groupId = getOrCreateGroupPath(opts.groupPath);
|
|
1603
|
+
const session = createSession({
|
|
1604
|
+
groupId,
|
|
1605
|
+
slug: opts.slug,
|
|
1606
|
+
name: opts.name ?? opts.slug
|
|
1607
|
+
});
|
|
1608
|
+
for (const name of opts.labelNames ?? []) {
|
|
1609
|
+
const label = getOrCreateLabelByName(name);
|
|
1610
|
+
addLabelToSession(session.id, label.id);
|
|
1611
|
+
}
|
|
1612
|
+
const claudeId = randomUUID();
|
|
1613
|
+
const conversation = createConversation({
|
|
1614
|
+
id: claudeId,
|
|
1615
|
+
sessionId: session.id
|
|
1616
|
+
});
|
|
1617
|
+
updateSession(session.id, { status: "active", pid: process.pid });
|
|
1618
|
+
const spawnContext = await captureSpawnContext();
|
|
1619
|
+
const sessionName = `${opts.groupPath}/${opts.slug}`;
|
|
1620
|
+
insertEvent({
|
|
1621
|
+
sessionId: session.id,
|
|
1622
|
+
conversationId: claudeId,
|
|
1623
|
+
event: "session.started",
|
|
1624
|
+
meta: {
|
|
1625
|
+
group_path: opts.groupPath,
|
|
1626
|
+
session_name: opts.name ?? opts.slug,
|
|
1627
|
+
session_slug: opts.slug,
|
|
1628
|
+
labels: opts.labelNames ?? [],
|
|
1629
|
+
summary: session.summary ?? null
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
insertEvent({
|
|
1633
|
+
sessionId: session.id,
|
|
1634
|
+
conversationId: claudeId,
|
|
1635
|
+
event: "claude.started",
|
|
1636
|
+
meta: {
|
|
1637
|
+
claude_id: claudeId,
|
|
1638
|
+
model: spawnContext.model,
|
|
1639
|
+
claude_version: spawnContext.claudeVersion,
|
|
1640
|
+
git: spawnContext.git,
|
|
1641
|
+
cwd: spawnContext.cwd
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
const siblingContext = buildSiblingContext(groupId, opts.groupPath, session.id);
|
|
1645
|
+
const contract = buildContract(sessionName, siblingContext);
|
|
1646
|
+
const exitCode = await launchClaude({
|
|
1647
|
+
sessionId: session.id,
|
|
1648
|
+
claudeId,
|
|
1649
|
+
sessionName,
|
|
1650
|
+
sessionSlug: opts.slug,
|
|
1651
|
+
contract
|
|
1652
|
+
});
|
|
1653
|
+
finalizeSession(session.id, claudeId, exitCode);
|
|
1654
|
+
return session.id;
|
|
1655
|
+
}
|
|
1656
|
+
async function resume(opts) {
|
|
1657
|
+
const session = getSession(opts.sessionId);
|
|
1658
|
+
if (!session)
|
|
1659
|
+
throw new Error(`Session not found: ${opts.sessionId}`);
|
|
1660
|
+
const group = getGroup(session.groupId);
|
|
1661
|
+
const sessionName = group ? `${group.path}/${session.slug}` : session.name;
|
|
1662
|
+
updateSession(session.id, { status: "active", pid: process.pid });
|
|
1663
|
+
insertEvent({
|
|
1664
|
+
sessionId: session.id,
|
|
1665
|
+
conversationId: opts.conversationId,
|
|
1666
|
+
event: "session.resumed",
|
|
1667
|
+
meta: { claude_id: opts.conversationId }
|
|
1668
|
+
});
|
|
1669
|
+
const groupPath = group?.path ?? "";
|
|
1670
|
+
const siblingContext = buildSiblingContext(session.groupId, groupPath, session.id);
|
|
1671
|
+
const contract = buildContract(sessionName, siblingContext);
|
|
1672
|
+
const exitCode = await launchClaude({
|
|
1673
|
+
sessionId: session.id,
|
|
1674
|
+
claudeId: opts.conversationId,
|
|
1675
|
+
sessionName,
|
|
1676
|
+
sessionSlug: session.slug,
|
|
1677
|
+
contract,
|
|
1678
|
+
resume: true
|
|
1679
|
+
});
|
|
1680
|
+
finalizeSession(session.id, opts.conversationId, exitCode);
|
|
1681
|
+
return session.id;
|
|
1682
|
+
}
|
|
1683
|
+
function finalizeSession(sessionId, conversationId, exitCode) {
|
|
1684
|
+
if (!getSession(sessionId))
|
|
1685
|
+
return;
|
|
1686
|
+
const conversationExists = !!getConversation(conversationId);
|
|
1687
|
+
const safeConversationId = conversationExists ? conversationId : undefined;
|
|
1688
|
+
if (conversationExists) {
|
|
1689
|
+
endConversation(conversationId);
|
|
1690
|
+
}
|
|
1691
|
+
insertEvent({
|
|
1692
|
+
sessionId,
|
|
1693
|
+
conversationId: safeConversationId,
|
|
1694
|
+
event: "claude.ended",
|
|
1695
|
+
meta: { claude_id: conversationId, exit_code: exitCode }
|
|
1696
|
+
});
|
|
1697
|
+
updateSession(sessionId, {
|
|
1698
|
+
status: "paused",
|
|
1699
|
+
pid: null,
|
|
1700
|
+
endedAt: new Date().toISOString()
|
|
1701
|
+
});
|
|
1702
|
+
insertEvent({
|
|
1703
|
+
sessionId,
|
|
1704
|
+
event: "session.end"
|
|
1705
|
+
});
|
|
1706
|
+
computeAndPersist(sessionId);
|
|
1707
|
+
}
|
|
1708
|
+
var init_session = __esm(() => {
|
|
1709
|
+
init_sessions();
|
|
1710
|
+
init_conversations();
|
|
1711
|
+
init_events();
|
|
1712
|
+
init_groups();
|
|
1713
|
+
init_labels();
|
|
1714
|
+
init_template2();
|
|
1715
|
+
init_context();
|
|
1716
|
+
init_process();
|
|
1717
|
+
init_spawn_context();
|
|
1718
|
+
init_timing();
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// src/tui/app.tsx
|
|
1722
|
+
import { spawn as spawn2 } from "child_process";
|
|
1723
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, unlinkSync } from "fs";
|
|
1724
|
+
import { join as join3 } from "path";
|
|
1725
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1726
|
+
async function runScreen(screen, ...args) {
|
|
1727
|
+
const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
|
|
1728
|
+
const child = spawn2("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
|
|
1729
|
+
stdio: "inherit"
|
|
1730
|
+
});
|
|
1731
|
+
const exitCode = await new Promise((resolve) => {
|
|
1732
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
1733
|
+
});
|
|
1734
|
+
if (exitCode !== 0) {
|
|
1735
|
+
throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
|
|
1736
|
+
}
|
|
1737
|
+
const result = JSON.parse(readFileSync3(tmpFile, "utf-8"));
|
|
1738
|
+
unlinkSync(tmpFile);
|
|
1739
|
+
return result;
|
|
1740
|
+
}
|
|
1741
|
+
async function startLaunchTui() {
|
|
1742
|
+
return runScreen("launch");
|
|
1743
|
+
}
|
|
1744
|
+
async function startExitTui(sessionId) {
|
|
1745
|
+
return runScreen("exit", sessionId);
|
|
1746
|
+
}
|
|
1747
|
+
async function startResumeTui(sessionId) {
|
|
1748
|
+
const conversations2 = getConversationsBySession(sessionId);
|
|
1749
|
+
if (conversations2.length === 1) {
|
|
1750
|
+
return { type: "conversation", conversationId: conversations2[0].id };
|
|
1751
|
+
}
|
|
1752
|
+
if (conversations2.length === 0) {
|
|
1753
|
+
return { type: "new" };
|
|
1754
|
+
}
|
|
1755
|
+
return runScreen("resume", sessionId);
|
|
1756
|
+
}
|
|
1757
|
+
async function resolveConversationForResume(sessionId) {
|
|
1758
|
+
const selection = await startResumeTui(sessionId);
|
|
1759
|
+
switch (selection.type) {
|
|
1760
|
+
case "conversation":
|
|
1761
|
+
return selection.conversationId;
|
|
1762
|
+
case "new": {
|
|
1763
|
+
const id = randomUUID2();
|
|
1764
|
+
createConversation({ id, sessionId });
|
|
1765
|
+
return id;
|
|
1766
|
+
}
|
|
1767
|
+
case "back":
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async function runSessionLoop(sessionId) {
|
|
1772
|
+
const action = await startExitTui(sessionId);
|
|
1773
|
+
switch (action) {
|
|
1774
|
+
case "save":
|
|
1775
|
+
break;
|
|
1776
|
+
case "archive":
|
|
1777
|
+
updateSessionStatus(sessionId, "archived");
|
|
1778
|
+
break;
|
|
1779
|
+
case "discard":
|
|
1780
|
+
deleteSession(sessionId);
|
|
1781
|
+
break;
|
|
1782
|
+
case "resume": {
|
|
1783
|
+
const conversationId = await resolveConversationForResume(sessionId);
|
|
1784
|
+
if (!conversationId)
|
|
1785
|
+
break;
|
|
1786
|
+
await resume({ sessionId, conversationId });
|
|
1787
|
+
await runSessionLoop(sessionId);
|
|
1788
|
+
break;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
async function startTui() {
|
|
1793
|
+
const selection = await startLaunchTui();
|
|
1794
|
+
switch (selection.type) {
|
|
1795
|
+
case "quit":
|
|
1796
|
+
break;
|
|
1797
|
+
case "create": {
|
|
1798
|
+
const sessionId = await launch(selection);
|
|
1799
|
+
await runSessionLoop(sessionId);
|
|
1800
|
+
break;
|
|
1801
|
+
}
|
|
1802
|
+
case "resume": {
|
|
1803
|
+
const sessionId = await resume(selection);
|
|
1804
|
+
await runSessionLoop(sessionId);
|
|
1805
|
+
break;
|
|
1806
|
+
}
|
|
1807
|
+
case "pick": {
|
|
1808
|
+
const conversationId = await resolveConversationForResume(selection.sessionId);
|
|
1809
|
+
if (!conversationId)
|
|
1810
|
+
break;
|
|
1811
|
+
const sessionId = await resume({
|
|
1812
|
+
sessionId: selection.sessionId,
|
|
1813
|
+
conversationId
|
|
1814
|
+
});
|
|
1815
|
+
await runSessionLoop(sessionId);
|
|
1816
|
+
break;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
var SCREEN_ENTRY;
|
|
1821
|
+
var init_app = __esm(() => {
|
|
1822
|
+
init_sessions();
|
|
1823
|
+
init_conversations();
|
|
1824
|
+
init_session();
|
|
1825
|
+
SCREEN_ENTRY = (() => {
|
|
1826
|
+
const built = join3(import.meta.dir, "run-screen.js");
|
|
1827
|
+
return existsSync3(built) ? built : join3(import.meta.dir, "run-screen.tsx");
|
|
1828
|
+
})();
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
// src/lib/parse-session-name.ts
|
|
1832
|
+
function parseSessionName(input) {
|
|
1833
|
+
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
1834
|
+
if (!trimmed) {
|
|
1835
|
+
throw new Error("Session name cannot be empty");
|
|
1836
|
+
}
|
|
1837
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
1838
|
+
if (segments.length < 2) {
|
|
1839
|
+
throw new Error(`Session name must include at least one group: "group/session" (got "${trimmed}")`);
|
|
1840
|
+
}
|
|
1841
|
+
for (const segment of segments) {
|
|
1842
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
|
|
1843
|
+
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
const slug = segments[segments.length - 1];
|
|
1847
|
+
const groupPath = segments.slice(0, -1).join("/");
|
|
1848
|
+
return { groupPath, slug };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// src/engine/recovery.ts
|
|
1852
|
+
function isProcessAlive(pid) {
|
|
1853
|
+
try {
|
|
1854
|
+
process.kill(pid, 0);
|
|
1855
|
+
return true;
|
|
1856
|
+
} catch {
|
|
1857
|
+
return false;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
function recoverStaleSessions() {
|
|
1861
|
+
const active = getActiveSessions();
|
|
1862
|
+
let recovered = 0;
|
|
1863
|
+
for (const { session } of active) {
|
|
1864
|
+
if (session.pid && !isProcessAlive(session.pid)) {
|
|
1865
|
+
updateSession(session.id, {
|
|
1866
|
+
status: "paused",
|
|
1867
|
+
pid: null
|
|
1868
|
+
});
|
|
1869
|
+
insertEvent({
|
|
1870
|
+
sessionId: session.id,
|
|
1871
|
+
event: "session.paused",
|
|
1872
|
+
summary: "Recovered from stale state (process not found)",
|
|
1873
|
+
meta: { stale_pid: session.pid }
|
|
1874
|
+
});
|
|
1875
|
+
recovered++;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
return recovered;
|
|
1879
|
+
}
|
|
1880
|
+
var init_recovery = __esm(() => {
|
|
1881
|
+
init_sessions();
|
|
1882
|
+
init_events();
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
// src/cli/commands/launch.ts
|
|
1886
|
+
var exports_launch = {};
|
|
1887
|
+
var init_launch = __esm(() => {
|
|
1888
|
+
init_router();
|
|
1889
|
+
init_app();
|
|
1890
|
+
init_session();
|
|
1891
|
+
init_recovery();
|
|
1892
|
+
register("launch", async (args) => {
|
|
1893
|
+
recoverStaleSessions();
|
|
1894
|
+
const sessionName = args[0];
|
|
1895
|
+
if (sessionName) {
|
|
1896
|
+
const { groupPath, slug } = parseSessionName(sessionName);
|
|
1897
|
+
const sessionId = await launch({ groupPath, slug });
|
|
1898
|
+
await runSessionLoop(sessionId);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
await startTui();
|
|
1902
|
+
});
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
// src/db/migrate.ts
|
|
1906
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
1907
|
+
import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
|
|
1908
|
+
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
|
1909
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
1910
|
+
import { dirname as dirname2 } from "path";
|
|
1911
|
+
function runMigrations() {
|
|
1912
|
+
mkdirSync2(dirname2(paths.db), { recursive: true });
|
|
1913
|
+
const sqlite = new Database2(paths.db);
|
|
1914
|
+
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
1915
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
1916
|
+
const db = drizzle2(sqlite);
|
|
1917
|
+
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
1918
|
+
sqlite.close();
|
|
1919
|
+
}
|
|
1920
|
+
var MIGRATIONS_FOLDER;
|
|
1921
|
+
var init_migrate = __esm(() => {
|
|
1922
|
+
init_paths();
|
|
1923
|
+
MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
|
|
1924
|
+
if (false) {}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
// src/hooks/scripts.ts
|
|
1928
|
+
function waitingScript(bin) {
|
|
1929
|
+
const BIN = bin;
|
|
1930
|
+
return `#!/usr/bin/env bash
|
|
1931
|
+
# Hook: PreToolUse AskUserQuestion \u2192 mark session as waiting
|
|
1932
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
1933
|
+
[ -z "$sid" ] && exit 0
|
|
1934
|
+
|
|
1935
|
+
input="$(cat)"
|
|
1936
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
1937
|
+
|
|
1938
|
+
# Extract question \u2014 grep for simple field extraction (~1ms vs jq ~15ms)
|
|
1939
|
+
question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-2000)"
|
|
1940
|
+
[ -z "$question" ] && question="Waiting for input"
|
|
1941
|
+
|
|
1942
|
+
# Clear working debounce marker so next resume\u2192working transition fires
|
|
1943
|
+
rm -f "/tmp/bertrand-working-$sid"
|
|
1944
|
+
|
|
1945
|
+
${BIN} update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
|
|
1946
|
+
|
|
1947
|
+
# Context snapshot \u2014 extract transcript path and capture token usage
|
|
1948
|
+
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
1949
|
+
if [ -n "$tpath" ]; then
|
|
1950
|
+
${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
1951
|
+
${BIN} recap-thinking --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
1952
|
+
fi
|
|
1953
|
+
|
|
1954
|
+
# Badge + notify in background \u2014 terminal UI doesn't need to block Claude
|
|
1955
|
+
${BIN} badge message-question --color '#e0b956' --priority 20 --beep &
|
|
1956
|
+
${BIN} notify bertrand "$question" &
|
|
1957
|
+
wait
|
|
1958
|
+
`;
|
|
1959
|
+
}
|
|
1960
|
+
function answeredScript(bin) {
|
|
1961
|
+
const BIN = bin;
|
|
1962
|
+
return `#!/usr/bin/env bash
|
|
1963
|
+
# Hook: PostToolUse AskUserQuestion \u2192 mark session as active
|
|
1964
|
+
#
|
|
1965
|
+
# If the user's answer contains "Done for now", emit {"continue": false} to
|
|
1966
|
+
# Claude Code so the agent halts immediately instead of taking another turn.
|
|
1967
|
+
# This is the mechanical enforcement of the contract's loop-exit rule \u2014 the
|
|
1968
|
+
# contract prose is a soft hint, this JSON is the guarantee.
|
|
1969
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
1970
|
+
[ -z "$sid" ] && exit 0
|
|
1971
|
+
|
|
1972
|
+
input="$(cat)"
|
|
1973
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
1974
|
+
|
|
1975
|
+
# Capture the full AskUserQuestion payload so the UI can render picked vs
|
|
1976
|
+
# unpicked options alongside the user's answer. tool_input.questions carries
|
|
1977
|
+
# the question definitions (label/description/multiSelect) the agent passed.
|
|
1978
|
+
meta="$(printf '%s' "$input" | jq --arg cid "$cid" '
|
|
1979
|
+
{
|
|
1980
|
+
answers: ((.tool_input.answers // .tool_response.answers) // {}),
|
|
1981
|
+
annotations: ((.tool_input.annotations // .tool_response.annotations) // {}),
|
|
1982
|
+
questions: (.tool_input.questions // []),
|
|
1983
|
+
claude_id: $cid
|
|
1984
|
+
}
|
|
1985
|
+
' 2>/dev/null)"
|
|
1986
|
+
|
|
1987
|
+
# Concatenate all answer values into a single string for the Done-for-now check.
|
|
1988
|
+
done_check="$(printf '%s' "$meta" | jq -r '.answers | to_entries | map(.value | tostring) | join(" ")' 2>/dev/null)"
|
|
1989
|
+
|
|
1990
|
+
${BIN} update --session-id "$sid" --event session.answered --meta "$meta"
|
|
1991
|
+
|
|
1992
|
+
${BIN} badge --clear &
|
|
1993
|
+
|
|
1994
|
+
# Halt the agent loop if the user signaled Done for now. The Stop hook
|
|
1995
|
+
# (on-done.sh) will fire afterwards and mark the session as paused.
|
|
1996
|
+
if printf '%s' "$done_check" | grep -q "Done for now"; then
|
|
1997
|
+
# Promote the picked Done-for-now option's description into a session.recap
|
|
1998
|
+
# event so the timeline has a dedicated end-of-session summary row. Bertrand
|
|
1999
|
+
# forces session exit before Claude can write a closing message, so this
|
|
2000
|
+
# reuses the agent-authored recap that already lives on the option.
|
|
2001
|
+
recap="$(printf '%s' "$meta" | jq -r '
|
|
2002
|
+
[.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
|
|
2003
|
+
' 2>/dev/null)"
|
|
2004
|
+
if [ -n "$recap" ]; then
|
|
2005
|
+
${BIN} update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
|
|
2006
|
+
fi
|
|
2007
|
+
|
|
2008
|
+
printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
|
|
2009
|
+
fi
|
|
2010
|
+
|
|
2011
|
+
wait
|
|
2012
|
+
`;
|
|
2013
|
+
}
|
|
2014
|
+
function activeScript(bin) {
|
|
2015
|
+
const BIN = bin;
|
|
2016
|
+
return `#!/usr/bin/env bash
|
|
2017
|
+
# Hook: PreToolUse (catch-all) \u2192 flip waiting to active
|
|
2018
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
2019
|
+
[ -z "$sid" ] && exit 0
|
|
2020
|
+
|
|
2021
|
+
# Debounce: skip if we already sent session.active within the last 5 seconds.
|
|
2022
|
+
# This avoids spawning bertrand (~31ms) on every tool call during rapid sequences.
|
|
2023
|
+
marker="/tmp/bertrand-working-$sid"
|
|
2024
|
+
if [ -f "$marker" ]; then
|
|
2025
|
+
age=$(( $(date +%s) - $(stat -f%m "$marker" 2>/dev/null || echo 0) ))
|
|
2026
|
+
[ "$age" -lt 5 ] && exit 0
|
|
2027
|
+
fi
|
|
2028
|
+
|
|
2029
|
+
input="$(cat)"
|
|
2030
|
+
${EXTRACT_TOOL}
|
|
2031
|
+
|
|
2032
|
+
# AskUserQuestion has its own PreToolUse hook
|
|
2033
|
+
[ "$tool" = "AskUserQuestion" ] && exit 0
|
|
2034
|
+
|
|
2035
|
+
touch "$marker"
|
|
2036
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
2037
|
+
${BIN} update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
|
|
2038
|
+
`;
|
|
2039
|
+
}
|
|
2040
|
+
function permissionWaitScript(bin) {
|
|
2041
|
+
const BIN = bin;
|
|
2042
|
+
return `#!/usr/bin/env bash
|
|
2043
|
+
# Hook: PermissionRequest \u2192 mark pending, emit permission.request
|
|
2044
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
2045
|
+
[ -z "$sid" ] && exit 0
|
|
2046
|
+
|
|
2047
|
+
input="$(cat)"
|
|
2048
|
+
${EXTRACT_TOOL}
|
|
2049
|
+
[ "$tool" = "AskUserQuestion" ] && exit 0
|
|
2050
|
+
|
|
2051
|
+
# Write marker so PostToolUse knows this was a real permission prompt (not auto-approved)
|
|
2052
|
+
touch "/tmp/bertrand-perm-pending-$sid"
|
|
2053
|
+
|
|
2054
|
+
# Extract detail from tool_input via grep (avoid jq for simple fields)
|
|
2055
|
+
detail=""
|
|
2056
|
+
case "$tool" in
|
|
2057
|
+
Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
|
|
2058
|
+
Edit|Write|Read) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
|
|
2059
|
+
esac
|
|
2060
|
+
|
|
2061
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
2062
|
+
${BIN} update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
|
|
2063
|
+
|
|
2064
|
+
# Badge + notify in background
|
|
2065
|
+
${BIN} badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
|
|
2066
|
+
${BIN} notify bertrand "Needs permission: $tool" &
|
|
2067
|
+
wait
|
|
2068
|
+
`;
|
|
2069
|
+
}
|
|
2070
|
+
function permissionDoneScript(bin) {
|
|
2071
|
+
const BIN = bin;
|
|
2072
|
+
return `#!/usr/bin/env bash
|
|
2073
|
+
# Hook: PostToolUse (catch-all)
|
|
2074
|
+
#
|
|
2075
|
+
# Two flows:
|
|
2076
|
+
# 1. Edit/Write/MultiEdit: ALWAYS emit tool.applied with diff, regardless of permission
|
|
2077
|
+
# flow. This is the only way to capture diffs for auto-approved edits \u2014 bertrand
|
|
2078
|
+
# must never require disabling auto-approve to gather data.
|
|
2079
|
+
# 2. Other tools: emit permission.resolve only if a PermissionRequest preceded this
|
|
2080
|
+
# (marker exists). Rejected tools never reach PostToolUse, so a request without a
|
|
2081
|
+
# resolve = rejected.
|
|
2082
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
2083
|
+
[ -z "$sid" ] && exit 0
|
|
2084
|
+
|
|
2085
|
+
input="$(cat)"
|
|
2086
|
+
${EXTRACT_TOOL}
|
|
2087
|
+
|
|
2088
|
+
marker="/tmp/bertrand-perm-pending-$sid"
|
|
2089
|
+
had_marker=0
|
|
2090
|
+
if [ -f "$marker" ]; then
|
|
2091
|
+
had_marker=1
|
|
2092
|
+
rm -f "$marker"
|
|
2093
|
+
${BIN} badge --clear &
|
|
2094
|
+
fi
|
|
2095
|
+
|
|
2096
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
2097
|
+
|
|
2098
|
+
case "$tool" in
|
|
2099
|
+
Edit|Write|MultiEdit)
|
|
2100
|
+
detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)"
|
|
2101
|
+
case "$tool" in
|
|
2102
|
+
Write) summary="wrote a file" ;;
|
|
2103
|
+
*) summary="edited a file" ;;
|
|
2104
|
+
esac
|
|
2105
|
+
# Single jq pass: build meta.permissions[] with diff data so the dashboard renders
|
|
2106
|
+
# via the existing WorkContent path (same shape as collapsed permission events).
|
|
2107
|
+
# Emit camelCase keys (oldStr/newStr/edits) directly so WorkContent reads
|
|
2108
|
+
# meta.permissions[] without going through transforms.ts's snake\u2192camel adapter.
|
|
2109
|
+
meta="$(printf '%s' "$input" | jq --arg t "$tool" --arg d "$detail" --arg cid "$cid" '
|
|
2110
|
+
{
|
|
2111
|
+
permissions: [
|
|
2112
|
+
{tool:$t, detail:$d, outcome:"applied", count:1}
|
|
2113
|
+
+ (.tool_input.old_string | if type == "string" and . != "" then {oldStr: .[:4096]} else {} end)
|
|
2114
|
+
+ ((.tool_input.new_string // .tool_input.content) | if type == "string" and . != "" then {newStr: .[:4096]} else {} end)
|
|
2115
|
+
+ (.tool_input.edits | if type == "array" and length > 0 then {edits: [.[] | {oldStr: ((.old_string // "")[:4096]), newStr: ((.new_string // "")[:4096])}]} else {} end)
|
|
2116
|
+
],
|
|
2117
|
+
outcome: "applied",
|
|
2118
|
+
claude_id: $cid
|
|
2119
|
+
}
|
|
2120
|
+
')"
|
|
2121
|
+
${BIN} update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
|
|
2122
|
+
wait
|
|
2123
|
+
exit 0
|
|
2124
|
+
;;
|
|
2125
|
+
esac
|
|
2126
|
+
|
|
2127
|
+
# Other tools: only emit permission.resolve if there was a real prompt
|
|
2128
|
+
[ "$had_marker" = "0" ] && exit 0
|
|
2129
|
+
|
|
2130
|
+
detail=""
|
|
2131
|
+
case "$tool" in
|
|
2132
|
+
Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
|
|
2133
|
+
esac
|
|
2134
|
+
|
|
2135
|
+
${BIN} update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
|
|
2136
|
+
wait
|
|
2137
|
+
`;
|
|
2138
|
+
}
|
|
2139
|
+
function userPromptScript(bin) {
|
|
2140
|
+
const BIN = bin;
|
|
2141
|
+
return `#!/usr/bin/env bash
|
|
2142
|
+
# Hook: UserPromptSubmit \u2192 record user free-text prompt
|
|
2143
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
2144
|
+
[ -z "$sid" ] && exit 0
|
|
2145
|
+
|
|
2146
|
+
input="$(cat)"
|
|
2147
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
2148
|
+
|
|
2149
|
+
meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
|
|
2150
|
+
[ -z "$meta" ] && exit 0
|
|
2151
|
+
|
|
2152
|
+
${BIN} update --session-id "$sid" --event user.prompt --meta "$meta"
|
|
2153
|
+
`;
|
|
2154
|
+
}
|
|
2155
|
+
function doneScript(bin) {
|
|
2156
|
+
const BIN = bin;
|
|
2157
|
+
return `#!/usr/bin/env bash
|
|
2158
|
+
# Hook: Stop \u2192 mark session as paused
|
|
2159
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
2160
|
+
[ -z "$sid" ] && exit 0
|
|
2161
|
+
|
|
2162
|
+
input="$(cat)"
|
|
2163
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
2164
|
+
${BIN} update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
|
|
2165
|
+
|
|
2166
|
+
# Final context snapshot \u2014 capture token usage at session end
|
|
2167
|
+
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
2168
|
+
if [ -n "$tpath" ]; then
|
|
2169
|
+
${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
2170
|
+
${BIN} assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
2171
|
+
fi
|
|
2172
|
+
|
|
2173
|
+
${BIN} badge check --color '#58c142' --priority 10
|
|
2174
|
+
`;
|
|
2175
|
+
}
|
|
2176
|
+
var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
|
|
2177
|
+
var init_scripts = __esm(() => {
|
|
2178
|
+
HOOK_SCRIPTS = {
|
|
2179
|
+
"on-waiting.sh": waitingScript,
|
|
2180
|
+
"on-answered.sh": answeredScript,
|
|
2181
|
+
"on-active.sh": activeScript,
|
|
2182
|
+
"on-permission-wait.sh": permissionWaitScript,
|
|
2183
|
+
"on-permission-done.sh": permissionDoneScript,
|
|
2184
|
+
"on-user-prompt.sh": userPromptScript,
|
|
2185
|
+
"on-done.sh": doneScript
|
|
2186
|
+
};
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
// src/hooks/install.ts
|
|
2190
|
+
import { mkdirSync as mkdirSync3, writeFileSync, chmodSync } from "fs";
|
|
2191
|
+
import { join as join4 } from "path";
|
|
2192
|
+
function installHookScripts(bin) {
|
|
2193
|
+
mkdirSync3(paths.hooks, { recursive: true });
|
|
2194
|
+
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
2195
|
+
const filePath = join4(paths.hooks, filename);
|
|
2196
|
+
writeFileSync(filePath, scriptFn(bin));
|
|
2197
|
+
chmodSync(filePath, 493);
|
|
2198
|
+
}
|
|
2199
|
+
console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
|
|
2200
|
+
}
|
|
2201
|
+
var init_install = __esm(() => {
|
|
2202
|
+
init_paths();
|
|
2203
|
+
init_scripts();
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
// src/hooks/settings.ts
|
|
2207
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
2208
|
+
import { join as join5, dirname as dirname3 } from "path";
|
|
2209
|
+
import { homedir as homedir2 } from "os";
|
|
2210
|
+
function isBertrandGroup(group) {
|
|
2211
|
+
return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
|
|
2212
|
+
}
|
|
2213
|
+
function installHookSettings() {
|
|
2214
|
+
let settings = {};
|
|
2215
|
+
try {
|
|
2216
|
+
settings = JSON.parse(readFileSync4(SETTINGS_PATH, "utf-8"));
|
|
2217
|
+
} catch {}
|
|
2218
|
+
const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
|
|
2219
|
+
const merged = { ...existingHooks };
|
|
2220
|
+
for (const [eventType, bertrandGroups] of Object.entries(BERTRAND_HOOKS)) {
|
|
2221
|
+
const existing = (merged[eventType] ?? []).filter((g) => !isBertrandGroup(g));
|
|
2222
|
+
merged[eventType] = [...existing, ...bertrandGroups];
|
|
2223
|
+
}
|
|
2224
|
+
settings.hooks = merged;
|
|
2225
|
+
mkdirSync4(dirname3(SETTINGS_PATH), { recursive: true });
|
|
2226
|
+
writeFileSync2(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
|
|
2227
|
+
`);
|
|
2228
|
+
console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
|
|
2229
|
+
}
|
|
2230
|
+
var SETTINGS_PATH, BERTRAND_HOOKS;
|
|
2231
|
+
var init_settings = __esm(() => {
|
|
2232
|
+
init_paths();
|
|
2233
|
+
SETTINGS_PATH = join5(homedir2(), ".claude", "settings.json");
|
|
2234
|
+
BERTRAND_HOOKS = {
|
|
2235
|
+
PreToolUse: [
|
|
2236
|
+
{
|
|
2237
|
+
matcher: "AskUserQuestion",
|
|
2238
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-waiting.sh` }]
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
matcher: "",
|
|
2242
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-active.sh` }]
|
|
2243
|
+
}
|
|
2244
|
+
],
|
|
2245
|
+
PostToolUse: [
|
|
2246
|
+
{
|
|
2247
|
+
matcher: "AskUserQuestion",
|
|
2248
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
|
|
2249
|
+
},
|
|
2250
|
+
{
|
|
2251
|
+
matcher: "",
|
|
2252
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
|
|
2253
|
+
}
|
|
2254
|
+
],
|
|
2255
|
+
PermissionRequest: [
|
|
2256
|
+
{
|
|
2257
|
+
matcher: "",
|
|
2258
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-permission-wait.sh` }]
|
|
2259
|
+
}
|
|
2260
|
+
],
|
|
2261
|
+
Stop: [
|
|
2262
|
+
{
|
|
2263
|
+
matcher: "",
|
|
2264
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-done.sh` }]
|
|
2265
|
+
}
|
|
2266
|
+
],
|
|
2267
|
+
UserPromptSubmit: [
|
|
2268
|
+
{
|
|
2269
|
+
matcher: "",
|
|
2270
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-user-prompt.sh` }]
|
|
2271
|
+
}
|
|
2272
|
+
]
|
|
2273
|
+
};
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
// src/lib/completions.ts
|
|
2277
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync5 } from "fs";
|
|
2278
|
+
import { join as join6 } from "path";
|
|
2279
|
+
function bashCompletion() {
|
|
2280
|
+
return `# bertrand bash completion
|
|
2281
|
+
_bertrand() {
|
|
2282
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
2283
|
+
COMPREPLY=( $(compgen -W "${COMMANDS.join(" ")}" -- "$cur") )
|
|
2284
|
+
}
|
|
2285
|
+
complete -F _bertrand bertrand
|
|
2286
|
+
`;
|
|
2287
|
+
}
|
|
2288
|
+
function zshCompletion() {
|
|
2289
|
+
return `#compdef bertrand
|
|
2290
|
+
# bertrand zsh completion
|
|
2291
|
+
_bertrand() {
|
|
2292
|
+
local -a commands
|
|
2293
|
+
commands=(
|
|
2294
|
+
${COMMANDS.map((c) => ` '${c}:${c} command'`).join(`
|
|
2295
|
+
`)}
|
|
2296
|
+
)
|
|
2297
|
+
_describe 'command' commands
|
|
2298
|
+
}
|
|
2299
|
+
_bertrand "$@"
|
|
2300
|
+
`;
|
|
2301
|
+
}
|
|
2302
|
+
function fishCompletion() {
|
|
2303
|
+
return COMMANDS.map((c) => `complete -c bertrand -n '__fish_use_subcommand' -a '${c}' -d '${c} command'`).join(`
|
|
2304
|
+
`) + `
|
|
2305
|
+
`;
|
|
2306
|
+
}
|
|
2307
|
+
function generateCompletions() {
|
|
2308
|
+
const dir = join6(paths.root, "completions");
|
|
2309
|
+
mkdirSync5(dir, { recursive: true });
|
|
2310
|
+
writeFileSync3(join6(dir, "bertrand.bash"), bashCompletion());
|
|
2311
|
+
writeFileSync3(join6(dir, "_bertrand"), zshCompletion());
|
|
2312
|
+
writeFileSync3(join6(dir, "bertrand.fish"), fishCompletion());
|
|
2313
|
+
console.log(`Shell completions written to ${dir}`);
|
|
2314
|
+
console.log(" Add to your shell config:");
|
|
2315
|
+
console.log(` bash: source ${dir}/bertrand.bash`);
|
|
2316
|
+
console.log(` zsh: fpath=(${dir} $fpath) && compinit`);
|
|
2317
|
+
console.log(` fish: cp ${dir}/bertrand.fish ~/.config/fish/completions/`);
|
|
2318
|
+
}
|
|
2319
|
+
var COMMANDS;
|
|
2320
|
+
var init_completions = __esm(() => {
|
|
2321
|
+
init_paths();
|
|
2322
|
+
COMMANDS = [
|
|
2323
|
+
"init",
|
|
2324
|
+
"list",
|
|
2325
|
+
"log",
|
|
2326
|
+
"stats",
|
|
2327
|
+
"archive",
|
|
2328
|
+
"update",
|
|
2329
|
+
"serve",
|
|
2330
|
+
"import",
|
|
2331
|
+
"completion",
|
|
2332
|
+
"badge",
|
|
2333
|
+
"notify"
|
|
2334
|
+
];
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
// src/cli/commands/init.ts
|
|
2338
|
+
var exports_init = {};
|
|
2339
|
+
import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, chmodSync as chmodSync2 } from "fs";
|
|
2340
|
+
import { execSync as execSync2 } from "child_process";
|
|
2341
|
+
import { join as join7 } from "path";
|
|
2342
|
+
function detectTerminal() {
|
|
2343
|
+
try {
|
|
2344
|
+
execSync2("which wsh", { stdio: "ignore" });
|
|
2345
|
+
return "wave";
|
|
2346
|
+
} catch {
|
|
2347
|
+
return "other";
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
function writeConfig(config) {
|
|
2351
|
+
writeFileSync4(CONFIG_PATH, JSON.stringify(config, null, 2) + `
|
|
2352
|
+
`);
|
|
2353
|
+
}
|
|
2354
|
+
function readConfig() {
|
|
2355
|
+
try {
|
|
2356
|
+
return JSON.parse(readFileSync5(CONFIG_PATH, "utf-8"));
|
|
2357
|
+
} catch {
|
|
2358
|
+
return null;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
function resolveBin() {
|
|
2362
|
+
const onPath = Bun.which("bertrand");
|
|
2363
|
+
if (onPath)
|
|
2364
|
+
return onPath;
|
|
2365
|
+
const entry = process.argv[1];
|
|
2366
|
+
if (entry && SOURCE_ENTRY.test(entry)) {
|
|
2367
|
+
const launcherDir = join7(paths.root, "bin");
|
|
2368
|
+
const launcher = join7(launcherDir, "bertrand-dev");
|
|
2369
|
+
mkdirSync6(launcherDir, { recursive: true });
|
|
2370
|
+
writeFileSync4(launcher, `#!/usr/bin/env bash
|
|
2371
|
+
exec ${process.execPath} ${JSON.stringify(entry)} "$@"
|
|
2372
|
+
`);
|
|
2373
|
+
chmodSync2(launcher, 493);
|
|
2374
|
+
return launcher;
|
|
2375
|
+
}
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
var CONFIG_PATH, SOURCE_ENTRY;
|
|
2379
|
+
var init_init = __esm(() => {
|
|
2380
|
+
init_router();
|
|
2381
|
+
init_migrate();
|
|
2382
|
+
init_install();
|
|
2383
|
+
init_settings();
|
|
2384
|
+
init_paths();
|
|
2385
|
+
init_completions();
|
|
2386
|
+
CONFIG_PATH = join7(paths.root, "config.json");
|
|
2387
|
+
SOURCE_ENTRY = /\/src\/index\.tsx?$/;
|
|
2388
|
+
register("init", async () => {
|
|
2389
|
+
console.log(`bertrand init
|
|
2390
|
+
`);
|
|
2391
|
+
mkdirSync6(paths.root, { recursive: true });
|
|
2392
|
+
mkdirSync6(paths.hooks, { recursive: true });
|
|
2393
|
+
runMigrations();
|
|
2394
|
+
console.log(` Database: ${paths.db}`);
|
|
2395
|
+
const bin = resolveBin();
|
|
2396
|
+
if (!bin) {
|
|
2397
|
+
console.error(`
|
|
2398
|
+
Error: couldn't locate the bertrand binary on PATH.
|
|
2399
|
+
` + `Install it globally first:
|
|
2400
|
+
` + ` bun i -g bertrand # or: npm i -g bertrand
|
|
2401
|
+
` + "Then re-run `bertrand init`.");
|
|
2402
|
+
process.exit(1);
|
|
2403
|
+
}
|
|
2404
|
+
console.log(` Bin: ${bin}`);
|
|
2405
|
+
const existing = readConfig();
|
|
2406
|
+
const terminal = existing?.terminal ?? detectTerminal();
|
|
2407
|
+
const config = {
|
|
2408
|
+
...existing,
|
|
2409
|
+
terminal,
|
|
2410
|
+
bin,
|
|
2411
|
+
version: 1
|
|
2412
|
+
};
|
|
2413
|
+
writeConfig(config);
|
|
2414
|
+
console.log(` Terminal: ${terminal}${existing?.terminal ? "" : " (auto-detected)"}`);
|
|
2415
|
+
installHookScripts(bin);
|
|
2416
|
+
installHookSettings();
|
|
2417
|
+
generateCompletions();
|
|
2418
|
+
console.log("\nDone. Run `bertrand` to get started.");
|
|
2419
|
+
});
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
// src/cli/commands/list.ts
|
|
2423
|
+
var exports_list = {};
|
|
2424
|
+
function buildRows(sessions2) {
|
|
2425
|
+
return sessions2.sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime()).map((row) => {
|
|
2426
|
+
const stats = getSessionStats(row.session.id);
|
|
2427
|
+
return {
|
|
2428
|
+
name: `${row.groupPath}/${row.session.slug}`,
|
|
2429
|
+
status: row.session.status,
|
|
2430
|
+
updatedAt: row.session.updatedAt,
|
|
2431
|
+
conversations: stats?.conversationCount ?? 0,
|
|
2432
|
+
duration: stats?.durationS ? formatDuration(stats.durationS * 1000) : "-"
|
|
2433
|
+
};
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
function renderTable(rows) {
|
|
2437
|
+
if (rows.length === 0) {
|
|
2438
|
+
console.log("No sessions found.");
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const maxName = Math.max(...rows.map((r) => r.name.length), 4);
|
|
2442
|
+
const dim = "\x1B[2m";
|
|
2443
|
+
const reset = "\x1B[0m";
|
|
2444
|
+
console.log(`${dim}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"DURATION".padEnd(8)} ${"CONVOS".padEnd(6)} LAST ACTIVE${reset}`);
|
|
2445
|
+
for (const row of rows) {
|
|
2446
|
+
const dot = STATUS_DOTS[row.status] ?? "?";
|
|
2447
|
+
const statusText = row.status.padEnd(10);
|
|
2448
|
+
const dur = row.duration.padEnd(8);
|
|
2449
|
+
const convos = String(row.conversations).padEnd(6);
|
|
2450
|
+
const ago = formatAgo(row.updatedAt);
|
|
2451
|
+
console.log(`${dot} ${row.name.padEnd(maxName)} ${statusText} ${dur} ${convos} ${ago}`);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
function renderJson(rows) {
|
|
2455
|
+
const data = rows.map((r) => ({
|
|
2456
|
+
name: r.name,
|
|
2457
|
+
status: r.status,
|
|
2458
|
+
duration: r.duration,
|
|
2459
|
+
conversations: r.conversations,
|
|
2460
|
+
updatedAt: r.updatedAt
|
|
2461
|
+
}));
|
|
2462
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2463
|
+
}
|
|
2464
|
+
var STATUS_DOTS;
|
|
2465
|
+
var init_list = __esm(() => {
|
|
2466
|
+
init_router();
|
|
2467
|
+
init_sessions();
|
|
2468
|
+
init_groups();
|
|
2469
|
+
init_stats();
|
|
2470
|
+
init_format();
|
|
2471
|
+
STATUS_DOTS = {
|
|
2472
|
+
active: "\x1B[32m\u25CF\x1B[0m",
|
|
2473
|
+
waiting: "\x1B[33m\u25CF\x1B[0m",
|
|
2474
|
+
paused: "\x1B[90m\u25CF\x1B[0m",
|
|
2475
|
+
archived: "\x1B[90m\u25CB\x1B[0m"
|
|
2476
|
+
};
|
|
2477
|
+
alias("ls", "list");
|
|
2478
|
+
register("list", async (args) => {
|
|
2479
|
+
const isJson = args.includes("--json");
|
|
2480
|
+
const showAll = args.includes("--all") || args.includes("-a");
|
|
2481
|
+
const groupFlag = args.indexOf("--group");
|
|
2482
|
+
const groupPath = groupFlag !== -1 ? args[groupFlag + 1] : undefined;
|
|
2483
|
+
let sessionRows;
|
|
2484
|
+
if (groupPath) {
|
|
2485
|
+
const group = getGroupByPath(groupPath);
|
|
2486
|
+
if (!group) {
|
|
2487
|
+
console.error(`Group not found: ${groupPath}`);
|
|
2488
|
+
process.exit(1);
|
|
2489
|
+
}
|
|
2490
|
+
const groupSessions = getSessionsByGroup(group.id);
|
|
2491
|
+
sessionRows = groupSessions.map((s) => ({ session: s, groupPath: group.path }));
|
|
2492
|
+
if (!showAll) {
|
|
2493
|
+
sessionRows = sessionRows.filter((r) => r.session.status !== "archived");
|
|
2494
|
+
}
|
|
2495
|
+
} else {
|
|
2496
|
+
sessionRows = getAllSessions(showAll ? undefined : { excludeArchived: true });
|
|
2497
|
+
}
|
|
2498
|
+
const rows = buildRows(sessionRows);
|
|
2499
|
+
if (isJson) {
|
|
2500
|
+
renderJson(rows);
|
|
2501
|
+
} else {
|
|
2502
|
+
renderTable(rows);
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
// src/lib/catalog.ts
|
|
2508
|
+
function lookup(eventType) {
|
|
2509
|
+
return catalog[eventType] ?? DEFAULT_INFO;
|
|
2510
|
+
}
|
|
2511
|
+
function extractClaudeId(row) {
|
|
2512
|
+
if (row.conversationId)
|
|
2513
|
+
return row.conversationId;
|
|
2514
|
+
const meta = row.meta;
|
|
2515
|
+
return meta?.claude_id ?? undefined;
|
|
2516
|
+
}
|
|
2517
|
+
function extractSummary(row) {
|
|
2518
|
+
if (row.summary)
|
|
2519
|
+
return row.summary;
|
|
2520
|
+
const meta = row.meta;
|
|
2521
|
+
if (!meta)
|
|
2522
|
+
return "";
|
|
2523
|
+
switch (row.event) {
|
|
2524
|
+
case "session.waiting":
|
|
2525
|
+
return meta.question ?? "";
|
|
2526
|
+
case "session.answered": {
|
|
2527
|
+
const answers = meta.answers;
|
|
2528
|
+
return answers ? Object.values(answers).join(", ") : "";
|
|
2529
|
+
}
|
|
2530
|
+
case "permission.request":
|
|
2531
|
+
case "permission.resolve": {
|
|
2532
|
+
const tool = meta.tool ?? "";
|
|
2533
|
+
const detail = meta.detail ?? "";
|
|
2534
|
+
return detail ? `${tool}: ${detail}` : tool;
|
|
2535
|
+
}
|
|
2536
|
+
case "gh.pr.created": {
|
|
2537
|
+
const title = meta.pr_title ?? "";
|
|
2538
|
+
const url = meta.pr_url ?? "";
|
|
2539
|
+
return title || url;
|
|
2540
|
+
}
|
|
2541
|
+
case "gh.pr.merged":
|
|
2542
|
+
return meta.branch ?? "";
|
|
2543
|
+
case "worktree.entered":
|
|
2544
|
+
return meta.branch ?? "";
|
|
2545
|
+
case "linear.issue.read":
|
|
2546
|
+
return meta.issue_title ?? "";
|
|
2547
|
+
case "notion.page.read":
|
|
2548
|
+
return meta.page_title ?? "";
|
|
2549
|
+
case "vercel.deploy":
|
|
2550
|
+
return meta.project_name ?? "";
|
|
2551
|
+
case "user.prompt":
|
|
2552
|
+
return meta.prompt ?? "";
|
|
2553
|
+
case "context.snapshot":
|
|
2554
|
+
return meta.remaining_pct ? `${meta.remaining_pct}% remaining` : "";
|
|
2555
|
+
case "session.recap":
|
|
2556
|
+
return meta.recap ?? "";
|
|
2557
|
+
default:
|
|
2558
|
+
return "";
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
function enrich(row) {
|
|
2562
|
+
const info = lookup(row.event);
|
|
2563
|
+
return {
|
|
2564
|
+
id: row.id,
|
|
2565
|
+
sessionId: row.sessionId,
|
|
2566
|
+
conversationId: row.conversationId,
|
|
2567
|
+
event: row.event,
|
|
2568
|
+
createdAt: row.createdAt,
|
|
2569
|
+
meta: row.meta,
|
|
2570
|
+
label: info.label,
|
|
2571
|
+
category: info.category,
|
|
2572
|
+
color: info.color,
|
|
2573
|
+
detailColor: info.detailColor,
|
|
2574
|
+
summary: extractSummary(row),
|
|
2575
|
+
claudeId: extractClaudeId(row)
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
function enrichAll(rows) {
|
|
2579
|
+
return rows.map(enrich);
|
|
2580
|
+
}
|
|
2581
|
+
var catalog, DEFAULT_INFO;
|
|
2582
|
+
var init_catalog = __esm(() => {
|
|
2583
|
+
catalog = {
|
|
2584
|
+
"session.started": { label: "started", category: "lifecycle", color: 34, detailColor: 245, skip: false },
|
|
2585
|
+
"session.resumed": { label: "resumed", category: "lifecycle", color: 34, detailColor: 245, skip: false },
|
|
2586
|
+
"session.end": { label: "ended", category: "lifecycle", color: 245, detailColor: 245, skip: false },
|
|
2587
|
+
"claude.started": { label: "claude started", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
2588
|
+
"claude.ended": { label: "claude ended", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
2589
|
+
"claude.discarded": { label: "discarded", category: "lifecycle", color: 245, detailColor: 245, skip: false },
|
|
2590
|
+
"session.waiting": { label: "waiting", category: "interaction", color: 33, detailColor: 245, skip: false },
|
|
2591
|
+
"session.answered": { label: "answered", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
2592
|
+
"permission.request": { label: "permission", category: "work", color: 214, detailColor: 245, skip: false },
|
|
2593
|
+
"permission.resolve": { label: "allowed", category: "work", color: 214, detailColor: 245, skip: false },
|
|
2594
|
+
"worktree.entered": { label: "worktree", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
2595
|
+
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
2596
|
+
"gh.pr.created": { label: "PR created", category: "integration", color: 32, detailColor: 32, skip: false },
|
|
2597
|
+
"gh.pr.merged": { label: "PR merged", category: "integration", color: 35, detailColor: 35, skip: false },
|
|
2598
|
+
"linear.issue.read": { label: "Linear issue", category: "integration", color: 33, detailColor: 33, skip: false },
|
|
2599
|
+
"notion.page.read": { label: "Notion page", category: "integration", color: 245, detailColor: 245, skip: false },
|
|
2600
|
+
"vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
|
|
2601
|
+
"user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
2602
|
+
"context.snapshot": { label: "context", category: "context", color: 245, detailColor: 245, skip: true },
|
|
2603
|
+
"tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
|
|
2604
|
+
"session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false }
|
|
2605
|
+
};
|
|
2606
|
+
DEFAULT_INFO = {
|
|
2607
|
+
label: "unknown",
|
|
2608
|
+
category: "lifecycle",
|
|
2609
|
+
color: 245,
|
|
2610
|
+
detailColor: 245,
|
|
2611
|
+
skip: false
|
|
2612
|
+
};
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
// src/lib/compact.ts
|
|
2616
|
+
function repairQAPairs(events2) {
|
|
2617
|
+
const result = [...events2];
|
|
2618
|
+
for (let i = 0;i < result.length; i++) {
|
|
2619
|
+
const ev = result[i];
|
|
2620
|
+
if (ev.event !== "session.answered")
|
|
2621
|
+
continue;
|
|
2622
|
+
let blockIdx = -1;
|
|
2623
|
+
for (let j = i - 1;j >= 0; j--) {
|
|
2624
|
+
const candidate = result[j];
|
|
2625
|
+
if (candidate.event === "session.waiting" && candidate.claudeId === ev.claudeId) {
|
|
2626
|
+
blockIdx = j;
|
|
2627
|
+
break;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (blockIdx === -1)
|
|
2631
|
+
continue;
|
|
2632
|
+
if (blockIdx === i - 1)
|
|
2633
|
+
continue;
|
|
2634
|
+
result.splice(i, 1);
|
|
2635
|
+
result.splice(blockIdx + 1, 0, ev);
|
|
2636
|
+
}
|
|
2637
|
+
return result;
|
|
2638
|
+
}
|
|
2639
|
+
function collapsePermissions(events2) {
|
|
2640
|
+
const result = [];
|
|
2641
|
+
let i = 0;
|
|
2642
|
+
while (i < events2.length) {
|
|
2643
|
+
const ev = events2[i];
|
|
2644
|
+
if (ev.event !== "permission.request" && ev.event !== "permission.resolve") {
|
|
2645
|
+
result.push(ev);
|
|
2646
|
+
i++;
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
const batch = [];
|
|
2650
|
+
while (i < events2.length) {
|
|
2651
|
+
const current = events2[i];
|
|
2652
|
+
if (current.event !== "permission.request" && current.event !== "permission.resolve")
|
|
2653
|
+
break;
|
|
2654
|
+
batch.push(current);
|
|
2655
|
+
i++;
|
|
2656
|
+
}
|
|
2657
|
+
if (batch.length === 0)
|
|
2658
|
+
continue;
|
|
2659
|
+
const toolCounts = new Map;
|
|
2660
|
+
for (const pev of batch) {
|
|
2661
|
+
if (pev.event !== "permission.request")
|
|
2662
|
+
continue;
|
|
2663
|
+
const meta = pev.meta;
|
|
2664
|
+
const tool = meta?.tool ?? "unknown";
|
|
2665
|
+
toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
|
|
2666
|
+
}
|
|
2667
|
+
const sorted = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
2668
|
+
const summary = batch.length === 1 ? formatSinglePermission(batch[0]) : sorted.map(([tool, count]) => `${count}\xD7 ${tool}`).join(", ");
|
|
2669
|
+
const first = batch[0];
|
|
2670
|
+
const last = batch[batch.length - 1];
|
|
2671
|
+
const midMs = (new Date(first.createdAt).getTime() + new Date(last.createdAt).getTime()) / 2;
|
|
2672
|
+
result.push({
|
|
2673
|
+
...first,
|
|
2674
|
+
event: "tool.work",
|
|
2675
|
+
label: "tool work",
|
|
2676
|
+
category: "work",
|
|
2677
|
+
summary,
|
|
2678
|
+
createdAt: new Date(midMs).toISOString()
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
return result;
|
|
2682
|
+
}
|
|
2683
|
+
function formatSinglePermission(ev) {
|
|
2684
|
+
const meta = ev.meta;
|
|
2685
|
+
const tool = meta?.tool ?? "";
|
|
2686
|
+
const detail = meta?.detail ?? "";
|
|
2687
|
+
if (!detail)
|
|
2688
|
+
return tool;
|
|
2689
|
+
switch (tool) {
|
|
2690
|
+
case "Bash":
|
|
2691
|
+
return `ran \`${detail}\``;
|
|
2692
|
+
case "Edit":
|
|
2693
|
+
case "Write":
|
|
2694
|
+
return `${tool.toLowerCase()} ${detail}`;
|
|
2695
|
+
default:
|
|
2696
|
+
return `${tool}: ${detail}`;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
function deduplicate(events2) {
|
|
2700
|
+
if (events2.length === 0)
|
|
2701
|
+
return [];
|
|
2702
|
+
const result = [events2[0]];
|
|
2703
|
+
for (let i = 1;i < events2.length; i++) {
|
|
2704
|
+
const current = events2[i];
|
|
2705
|
+
const prev = result[result.length - 1];
|
|
2706
|
+
if (current.event === prev.event && current.summary === prev.summary) {
|
|
2707
|
+
result[result.length - 1] = current;
|
|
2708
|
+
} else {
|
|
2709
|
+
result.push(current);
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
return result;
|
|
2713
|
+
}
|
|
2714
|
+
function filterSkipped(events2) {
|
|
2715
|
+
return events2.filter((ev) => !lookup(ev.event).skip);
|
|
2716
|
+
}
|
|
2717
|
+
function compact(events2) {
|
|
2718
|
+
return deduplicate(collapsePermissions(repairQAPairs(filterSkipped(events2))));
|
|
2719
|
+
}
|
|
2720
|
+
var init_compact = __esm(() => {
|
|
2721
|
+
init_catalog();
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
// src/cli/commands/log.ts
|
|
2725
|
+
var exports_log = {};
|
|
2726
|
+
function ansi(code, text2) {
|
|
2727
|
+
return `\x1B[${code}m${text2}${RESET}`;
|
|
2728
|
+
}
|
|
2729
|
+
function ansi256(code, text2) {
|
|
2730
|
+
return `\x1B[38;5;${code}m${text2}${RESET}`;
|
|
2731
|
+
}
|
|
2732
|
+
function showAllSessions() {
|
|
2733
|
+
const rows = getAllSessions().sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime());
|
|
2734
|
+
if (rows.length === 0) {
|
|
2735
|
+
console.log("No sessions.");
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
const maxName = Math.max(...rows.map((r) => `${r.groupPath}/${r.session.slug}`.length), 4);
|
|
2739
|
+
console.log(`${DIM}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"EVENTS".padEnd(6)} LAST ACTIVE${RESET}`);
|
|
2740
|
+
for (const row of rows) {
|
|
2741
|
+
const name = `${row.groupPath}/${row.session.slug}`;
|
|
2742
|
+
const dot = STATUS_DOTS2[row.session.status] ?? "?";
|
|
2743
|
+
const stats = getSessionStats(row.session.id);
|
|
2744
|
+
const eventCount = String(stats?.eventCount ?? 0).padEnd(6);
|
|
2745
|
+
const ago = formatAgo(row.session.updatedAt);
|
|
2746
|
+
console.log(`${dot} ${name.padEnd(maxName)} ${row.session.status.padEnd(10)} ${eventCount} ${ago}`);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
function segmentByConversation(events2) {
|
|
2750
|
+
const segments = [];
|
|
2751
|
+
let current = null;
|
|
2752
|
+
for (const ev of events2) {
|
|
2753
|
+
if (ev.event === "claude.started") {
|
|
2754
|
+
current = { claudeId: ev.claudeId ?? "unknown", events: [ev] };
|
|
2755
|
+
segments.push(current);
|
|
2756
|
+
} else if (current) {
|
|
2757
|
+
current.events.push(ev);
|
|
2758
|
+
} else {
|
|
2759
|
+
if (segments.length === 0) {
|
|
2760
|
+
current = { claudeId: ev.claudeId ?? "unknown", events: [ev] };
|
|
2761
|
+
segments.push(current);
|
|
2762
|
+
} else {
|
|
2763
|
+
segments[segments.length - 1].events.push(ev);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return segments;
|
|
2768
|
+
}
|
|
2769
|
+
function renderConnector(idx, total) {
|
|
2770
|
+
if (total === 1)
|
|
2771
|
+
return `${PIPE_COLOR}\u2500${RESET}`;
|
|
2772
|
+
if (idx === 0)
|
|
2773
|
+
return `${PIPE_COLOR}\u250C${RESET}`;
|
|
2774
|
+
if (idx === total - 1)
|
|
2775
|
+
return `${PIPE_COLOR}\u2514${RESET}`;
|
|
2776
|
+
return `${PIPE_COLOR}\u251C${RESET}`;
|
|
2777
|
+
}
|
|
2778
|
+
function renderGap(prevTime, currTime) {
|
|
2779
|
+
const gap = new Date(currTime).getTime() - new Date(prevTime).getTime();
|
|
2780
|
+
if (gap < GAP_THRESHOLD_MS)
|
|
2781
|
+
return null;
|
|
2782
|
+
return `${PIPE_COLOR}\u2502${RESET} ${DIM} \xB7\xB7\xB7 ${formatDuration(gap)} \xB7\xB7\xB7${RESET}`;
|
|
2783
|
+
}
|
|
2784
|
+
function renderEventLine(ev) {
|
|
2785
|
+
const time = formatTime(ev.createdAt);
|
|
2786
|
+
const label = ansi256(ev.color, ev.label);
|
|
2787
|
+
const detail = ev.summary ? `${DIM}${truncate(ev.summary, 60)}${RESET}` : "";
|
|
2788
|
+
return `${DIM}${time}${RESET} ${label}${detail ? ` ${detail}` : ""}`;
|
|
2789
|
+
}
|
|
2790
|
+
function renderQAPair(block, resume2) {
|
|
2791
|
+
const lines = [];
|
|
2792
|
+
const time = formatTime(block.createdAt);
|
|
2793
|
+
const question = block.summary ? truncate(block.summary, 60) : "";
|
|
2794
|
+
lines.push(`${DIM}${time}${RESET} ${ansi256(block.color, block.label)}${question ? ` ${DIM}${question}${RESET}` : ""}`);
|
|
2795
|
+
if (resume2) {
|
|
2796
|
+
const answer = resume2.summary ? truncate(resume2.summary, 60) : "";
|
|
2797
|
+
lines.push(`${PIPE_COLOR}\u2502${RESET} ${ansi256(resume2.color, "\u2514 " + resume2.label)}${answer ? ` ${DIM}${answer}${RESET}` : ""}`);
|
|
2798
|
+
}
|
|
2799
|
+
return lines;
|
|
2800
|
+
}
|
|
2801
|
+
function renderSegment(seg, segIdx, totalSegs) {
|
|
2802
|
+
const lines = [];
|
|
2803
|
+
if (totalSegs > 1) {
|
|
2804
|
+
const header = `${BOLD}Conversation ${segIdx + 1}${RESET}`;
|
|
2805
|
+
if (segIdx > 0)
|
|
2806
|
+
lines.push("");
|
|
2807
|
+
lines.push(header);
|
|
2808
|
+
}
|
|
2809
|
+
for (let i = 0;i < seg.events.length; i++) {
|
|
2810
|
+
const ev = seg.events[i];
|
|
2811
|
+
const connector = renderConnector(i, seg.events.length);
|
|
2812
|
+
if (i > 0) {
|
|
2813
|
+
const gap = renderGap(seg.events[i - 1].createdAt, ev.createdAt);
|
|
2814
|
+
if (gap)
|
|
2815
|
+
lines.push(gap);
|
|
2816
|
+
}
|
|
2817
|
+
if (ev.event === "session.waiting") {
|
|
2818
|
+
const next = seg.events[i + 1];
|
|
2819
|
+
const resume2 = next?.event === "session.answered" ? next : undefined;
|
|
2820
|
+
const qaLines = renderQAPair(ev, resume2);
|
|
2821
|
+
lines.push(`${connector} ${qaLines[0]}`);
|
|
2822
|
+
for (let j = 1;j < qaLines.length; j++) {
|
|
2823
|
+
lines.push(` ${qaLines[j]}`);
|
|
2824
|
+
}
|
|
2825
|
+
if (resume2)
|
|
2826
|
+
i++;
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
lines.push(`${connector} ${renderEventLine(ev)}`);
|
|
2830
|
+
}
|
|
2831
|
+
return lines;
|
|
2832
|
+
}
|
|
2833
|
+
function renderTimingFooter(sessionId) {
|
|
2834
|
+
const stats = getSessionStats(sessionId);
|
|
2835
|
+
const lines = [];
|
|
2836
|
+
let claudeWorkS;
|
|
2837
|
+
let userWaitS;
|
|
2838
|
+
let activePctVal;
|
|
2839
|
+
let durationS;
|
|
2840
|
+
if (stats) {
|
|
2841
|
+
claudeWorkS = stats.claudeWorkS;
|
|
2842
|
+
userWaitS = stats.userWaitS;
|
|
2843
|
+
activePctVal = stats.activePct;
|
|
2844
|
+
durationS = stats.durationS;
|
|
2845
|
+
} else {
|
|
2846
|
+
const timing = computeTimingsLive(sessionId);
|
|
2847
|
+
claudeWorkS = Math.round(timing.totalClaudeWorkMs / 1000);
|
|
2848
|
+
userWaitS = Math.round(timing.totalUserWaitMs / 1000);
|
|
2849
|
+
activePctVal = timing.activePct;
|
|
2850
|
+
durationS = timing.durationS;
|
|
2851
|
+
}
|
|
2852
|
+
if (durationS === 0)
|
|
2853
|
+
return lines;
|
|
2854
|
+
lines.push("");
|
|
2855
|
+
lines.push(`${DIM}Duration: ${formatDuration(durationS * 1000)} \xB7 Claude: ${formatDuration(claudeWorkS * 1000)} (${activePctVal}%) \xB7 Wait: ${formatDuration(userWaitS * 1000)} (${100 - activePctVal}%)${RESET}`);
|
|
2856
|
+
return lines;
|
|
2857
|
+
}
|
|
2858
|
+
function showSessionLog(session, sessionName, isJson) {
|
|
2859
|
+
const sessionId = session.id;
|
|
2860
|
+
const rawEvents = getEventsBySession(sessionId);
|
|
2861
|
+
const enriched = enrichAll(rawEvents);
|
|
2862
|
+
const compacted = compact(enriched);
|
|
2863
|
+
if (isJson) {
|
|
2864
|
+
const stats = getSessionStats(sessionId);
|
|
2865
|
+
const conversations2 = getConversationsBySession(sessionId);
|
|
2866
|
+
console.log(JSON.stringify({
|
|
2867
|
+
session: { ...session, name: sessionName },
|
|
2868
|
+
stats,
|
|
2869
|
+
conversations: conversations2,
|
|
2870
|
+
events: compacted.map((e) => ({
|
|
2871
|
+
event: e.event,
|
|
2872
|
+
label: e.label,
|
|
2873
|
+
category: e.category,
|
|
2874
|
+
summary: e.summary,
|
|
2875
|
+
meta: e.meta,
|
|
2876
|
+
createdAt: e.createdAt,
|
|
2877
|
+
claudeId: e.claudeId
|
|
2878
|
+
}))
|
|
2879
|
+
}, null, 2));
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
const segments = segmentByConversation(compacted);
|
|
2883
|
+
const allLines = [];
|
|
2884
|
+
for (let i = 0;i < segments.length; i++) {
|
|
2885
|
+
const segLines = renderSegment(segments[i], i, segments.length);
|
|
2886
|
+
allLines.push(...segLines);
|
|
2887
|
+
}
|
|
2888
|
+
allLines.push(...renderTimingFooter(sessionId));
|
|
2889
|
+
for (const line of allLines) {
|
|
2890
|
+
console.log(line);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
var DIM = "\x1B[2m", BOLD = "\x1B[1m", RESET = "\x1B[0m", PIPE_COLOR = "\x1B[38;5;238m", STATUS_DOTS2, GAP_THRESHOLD_MS = 5000;
|
|
2894
|
+
var init_log = __esm(() => {
|
|
2895
|
+
init_router();
|
|
2896
|
+
init_sessions();
|
|
2897
|
+
init_events();
|
|
2898
|
+
init_groups();
|
|
2899
|
+
init_stats();
|
|
2900
|
+
init_conversations();
|
|
2901
|
+
init_catalog();
|
|
2902
|
+
init_compact();
|
|
2903
|
+
init_timing();
|
|
2904
|
+
init_format();
|
|
2905
|
+
STATUS_DOTS2 = {
|
|
2906
|
+
active: ansi(32, "\u25CF"),
|
|
2907
|
+
waiting: ansi(33, "\u25CF"),
|
|
2908
|
+
paused: `${DIM}\u25CF${RESET}`,
|
|
2909
|
+
archived: `${DIM}\u25CB${RESET}`
|
|
2910
|
+
};
|
|
2911
|
+
register("log", async (args) => {
|
|
2912
|
+
const isJson = args.includes("--json");
|
|
2913
|
+
const filteredArgs = args.filter((a) => !a.startsWith("--"));
|
|
2914
|
+
const target = filteredArgs[0];
|
|
2915
|
+
if (!target) {
|
|
2916
|
+
showAllSessions();
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const { groupPath, slug } = parseSessionName(target);
|
|
2920
|
+
const group = getGroupByPath(groupPath);
|
|
2921
|
+
if (!group) {
|
|
2922
|
+
console.error(`Group not found: ${groupPath}`);
|
|
2923
|
+
process.exit(1);
|
|
2924
|
+
}
|
|
2925
|
+
const session = getSessionByGroupSlug(group.id, slug);
|
|
2926
|
+
if (!session) {
|
|
2927
|
+
console.error(`Session not found: ${target}`);
|
|
2928
|
+
process.exit(1);
|
|
2929
|
+
}
|
|
2930
|
+
showSessionLog(session, `${groupPath}/${slug}`, isJson);
|
|
2931
|
+
});
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
// src/cli/commands/stats.ts
|
|
2935
|
+
var exports_stats = {};
|
|
2936
|
+
function getMetrics(sessionId, name, status) {
|
|
2937
|
+
const stats = getSessionStats(sessionId);
|
|
2938
|
+
if (stats) {
|
|
2939
|
+
return { name, status, ...stats };
|
|
2940
|
+
}
|
|
2941
|
+
const timing = computeTimingsLive(sessionId);
|
|
2942
|
+
const allEvents = getEventsBySession(sessionId);
|
|
2943
|
+
const conversations2 = new Set;
|
|
2944
|
+
let interactionCount = 0;
|
|
2945
|
+
let prCount = 0;
|
|
2946
|
+
for (const ev of allEvents) {
|
|
2947
|
+
if (ev.conversationId)
|
|
2948
|
+
conversations2.add(ev.conversationId);
|
|
2949
|
+
if (ev.event === "session.waiting" || ev.event === "session.answered")
|
|
2950
|
+
interactionCount++;
|
|
2951
|
+
if (ev.event === "gh.pr.created")
|
|
2952
|
+
prCount++;
|
|
2953
|
+
}
|
|
2954
|
+
return {
|
|
2955
|
+
name,
|
|
2956
|
+
status,
|
|
2957
|
+
eventCount: allEvents.length,
|
|
2958
|
+
conversationCount: conversations2.size,
|
|
2959
|
+
interactionCount,
|
|
2960
|
+
prCount,
|
|
2961
|
+
claudeWorkS: Math.round(timing.totalClaudeWorkMs / 1000),
|
|
2962
|
+
userWaitS: Math.round(timing.totalUserWaitMs / 1000),
|
|
2963
|
+
activePct: timing.activePct,
|
|
2964
|
+
durationS: timing.durationS
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
function pct(n) {
|
|
2968
|
+
return `${n}%`;
|
|
2969
|
+
}
|
|
2970
|
+
function dur(seconds) {
|
|
2971
|
+
return seconds > 0 ? formatDuration(seconds * 1000) : "-";
|
|
2972
|
+
}
|
|
2973
|
+
function renderGlobal(metrics, isJson) {
|
|
2974
|
+
const totals = metrics.reduce((acc, m) => ({
|
|
2975
|
+
sessions: acc.sessions + 1,
|
|
2976
|
+
active: acc.active + (ACTIVE_STATUSES.includes(m.status) ? 1 : 0),
|
|
2977
|
+
durationS: acc.durationS + m.durationS,
|
|
2978
|
+
claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
|
|
2979
|
+
userWaitS: acc.userWaitS + m.userWaitS,
|
|
2980
|
+
conversations: acc.conversations + m.conversationCount,
|
|
2981
|
+
prs: acc.prs + m.prCount,
|
|
2982
|
+
interactions: acc.interactions + m.interactionCount
|
|
2983
|
+
}), { sessions: 0, active: 0, durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, prs: 0, interactions: 0 });
|
|
2984
|
+
const totalTracked = totals.claudeWorkS + totals.userWaitS;
|
|
2985
|
+
const globalActivePct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
|
|
2986
|
+
if (isJson) {
|
|
2987
|
+
console.log(JSON.stringify({ ...totals, activePct: globalActivePct }, null, 2));
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
const dim = "\x1B[2m";
|
|
2991
|
+
const bold = "\x1B[1m";
|
|
2992
|
+
const reset = "\x1B[0m";
|
|
2993
|
+
console.log(`${bold}Global Statistics${reset}
|
|
2994
|
+
`);
|
|
2995
|
+
console.log(` Sessions: ${totals.sessions} (${totals.active} active)`);
|
|
2996
|
+
console.log(` Total time: ${dur(totals.durationS)}`);
|
|
2997
|
+
console.log(` Claude work: ${dur(totals.claudeWorkS)} ${dim}(${pct(globalActivePct)})${reset}`);
|
|
2998
|
+
console.log(` User wait: ${dur(totals.userWaitS)} ${dim}(${pct(100 - globalActivePct)})${reset}`);
|
|
2999
|
+
console.log(` Conversations: ${totals.conversations}`);
|
|
3000
|
+
console.log(` PRs: ${totals.prs}`);
|
|
3001
|
+
console.log(` Interactions: ${totals.interactions}`);
|
|
3002
|
+
}
|
|
3003
|
+
function renderGroup(metrics, groupPath, isJson) {
|
|
3004
|
+
if (isJson) {
|
|
3005
|
+
console.log(JSON.stringify(metrics, null, 2));
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
const dim = "\x1B[2m";
|
|
3009
|
+
const bold = "\x1B[1m";
|
|
3010
|
+
const reset = "\x1B[0m";
|
|
3011
|
+
const sorted = [...metrics].sort((a, b) => b.durationS - a.durationS);
|
|
3012
|
+
const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
|
|
3013
|
+
console.log(`${bold}${groupPath}${reset}
|
|
3014
|
+
`);
|
|
3015
|
+
console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
|
|
3016
|
+
for (const m of sorted) {
|
|
3017
|
+
console.log(`${m.name.padEnd(maxName)} ${dur(m.durationS).padEnd(8)} ${dur(m.claudeWorkS).padEnd(8)} ${dur(m.userWaitS).padEnd(8)} ${pct(m.activePct).padEnd(5)} ${String(m.conversationCount).padEnd(6)} ${m.prCount}`);
|
|
3018
|
+
}
|
|
3019
|
+
const totals = sorted.reduce((acc, m) => ({
|
|
3020
|
+
durationS: acc.durationS + m.durationS,
|
|
3021
|
+
claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
|
|
3022
|
+
userWaitS: acc.userWaitS + m.userWaitS,
|
|
3023
|
+
conversations: acc.conversations + m.conversationCount,
|
|
3024
|
+
prs: acc.prs + m.prCount
|
|
3025
|
+
}), { durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, prs: 0 });
|
|
3026
|
+
const totalTracked = totals.claudeWorkS + totals.userWaitS;
|
|
3027
|
+
const totalPct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
|
|
3028
|
+
console.log(`${dim}${"\u2500".repeat(maxName + 50)}${reset}`);
|
|
3029
|
+
console.log(`${"TOTAL".padEnd(maxName)} ${dur(totals.durationS).padEnd(8)} ${dur(totals.claudeWorkS).padEnd(8)} ${dur(totals.userWaitS).padEnd(8)} ${pct(totalPct).padEnd(5)} ${String(totals.conversations).padEnd(6)} ${totals.prs}`);
|
|
3030
|
+
}
|
|
3031
|
+
function renderSession(m, isJson) {
|
|
3032
|
+
if (isJson) {
|
|
3033
|
+
console.log(JSON.stringify(m, null, 2));
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
const dim = "\x1B[2m";
|
|
3037
|
+
const bold = "\x1B[1m";
|
|
3038
|
+
const reset = "\x1B[0m";
|
|
3039
|
+
console.log(`${bold}${m.name}${reset} ${dim}(${m.status})${reset}
|
|
3040
|
+
`);
|
|
3041
|
+
console.log(` Duration: ${dur(m.durationS)}`);
|
|
3042
|
+
console.log(` Claude work: ${dur(m.claudeWorkS)} ${dim}(${pct(m.activePct)})${reset}`);
|
|
3043
|
+
console.log(` User wait: ${dur(m.userWaitS)} ${dim}(${pct(100 - m.activePct)})${reset}`);
|
|
3044
|
+
console.log(` Events: ${m.eventCount}`);
|
|
3045
|
+
console.log(` Conversations: ${m.conversationCount}`);
|
|
3046
|
+
console.log(` Interactions: ${m.interactionCount}`);
|
|
3047
|
+
console.log(` PRs: ${m.prCount}`);
|
|
3048
|
+
}
|
|
3049
|
+
var ACTIVE_STATUSES;
|
|
3050
|
+
var init_stats2 = __esm(() => {
|
|
3051
|
+
init_router();
|
|
3052
|
+
init_sessions();
|
|
3053
|
+
init_stats();
|
|
3054
|
+
init_groups();
|
|
3055
|
+
init_events();
|
|
3056
|
+
init_timing();
|
|
3057
|
+
init_format();
|
|
3058
|
+
ACTIVE_STATUSES = ["active", "waiting"];
|
|
3059
|
+
register("stats", async (args) => {
|
|
3060
|
+
const isJson = args.includes("--json");
|
|
3061
|
+
const filteredArgs = args.filter((a) => !a.startsWith("--"));
|
|
3062
|
+
const target = filteredArgs[0];
|
|
3063
|
+
if (!target) {
|
|
3064
|
+
const rows = getAllSessions();
|
|
3065
|
+
const metrics = rows.map((r) => getMetrics(r.session.id, `${r.groupPath}/${r.session.slug}`, r.session.status));
|
|
3066
|
+
renderGlobal(metrics, isJson);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
if (target.endsWith("/")) {
|
|
3070
|
+
const groupPath2 = target.replace(/\/+$/, "");
|
|
3071
|
+
const group2 = getGroupByPath(groupPath2);
|
|
3072
|
+
if (!group2) {
|
|
3073
|
+
console.error(`Group not found: ${groupPath2}`);
|
|
3074
|
+
process.exit(1);
|
|
3075
|
+
}
|
|
3076
|
+
const groupSessions = getSessionsByGroup(group2.id);
|
|
3077
|
+
const metrics = groupSessions.map((s) => getMetrics(s.id, s.slug, s.status));
|
|
3078
|
+
renderGroup(metrics, groupPath2, isJson);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const { groupPath, slug } = parseSessionName(target);
|
|
3082
|
+
const group = getGroupByPath(groupPath);
|
|
3083
|
+
if (!group) {
|
|
3084
|
+
console.error(`Group not found: ${groupPath}`);
|
|
3085
|
+
process.exit(1);
|
|
3086
|
+
}
|
|
3087
|
+
const session = getSessionByGroupSlug(group.id, slug);
|
|
3088
|
+
if (!session) {
|
|
3089
|
+
console.error(`Session not found: ${target}`);
|
|
3090
|
+
process.exit(1);
|
|
3091
|
+
}
|
|
3092
|
+
const m = getMetrics(session.id, `${groupPath}/${slug}`, session.status);
|
|
3093
|
+
renderSession(m, isJson);
|
|
3094
|
+
});
|
|
3095
|
+
});
|
|
3096
|
+
|
|
3097
|
+
// src/cli/commands/backfill-stats.ts
|
|
3098
|
+
var exports_backfill_stats = {};
|
|
3099
|
+
var init_backfill_stats = __esm(() => {
|
|
3100
|
+
init_router();
|
|
3101
|
+
init_sessions();
|
|
3102
|
+
init_timing();
|
|
3103
|
+
register("backfill-stats", async (args) => {
|
|
3104
|
+
const includeArchived = args.includes("--include-archived");
|
|
3105
|
+
const rows = getAllSessions({ excludeArchived: !includeArchived });
|
|
3106
|
+
console.log(`Backfilling stats for ${rows.length} session(s)${includeArchived ? "" : " (excluding archived)"}...`);
|
|
3107
|
+
for (const { session, groupPath } of rows) {
|
|
3108
|
+
computeAndPersist(session.id);
|
|
3109
|
+
console.log(` \u2713 ${groupPath}/${session.slug}`);
|
|
3110
|
+
}
|
|
3111
|
+
console.log("Done.");
|
|
3112
|
+
});
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
// src/cli/commands/archive.ts
|
|
3116
|
+
var exports_archive = {};
|
|
3117
|
+
function resolveSession(name) {
|
|
3118
|
+
const { groupPath, slug } = parseSessionName(name);
|
|
3119
|
+
const group = getGroupByPath(groupPath);
|
|
3120
|
+
if (!group) {
|
|
3121
|
+
console.error(`Group not found: ${groupPath}`);
|
|
3122
|
+
process.exit(1);
|
|
3123
|
+
}
|
|
3124
|
+
const session = getSessionByGroupSlug(group.id, slug);
|
|
3125
|
+
if (!session) {
|
|
3126
|
+
console.error(`Session not found: ${name}`);
|
|
3127
|
+
process.exit(1);
|
|
3128
|
+
}
|
|
3129
|
+
return { session, groupPath };
|
|
3130
|
+
}
|
|
3131
|
+
var ACTIVE_STATUSES2;
|
|
3132
|
+
var init_archive = __esm(() => {
|
|
3133
|
+
init_router();
|
|
3134
|
+
init_sessions();
|
|
3135
|
+
init_groups();
|
|
3136
|
+
ACTIVE_STATUSES2 = ["active", "waiting"];
|
|
3137
|
+
register("archive", async (args) => {
|
|
3138
|
+
const isUndo = args.includes("--undo");
|
|
3139
|
+
const isAllPaused = args.includes("--all-paused");
|
|
3140
|
+
const filteredArgs = args.filter((a) => !a.startsWith("--"));
|
|
3141
|
+
const sessionName = filteredArgs[0];
|
|
3142
|
+
if (isAllPaused) {
|
|
3143
|
+
const rows = getAllSessions({ excludeArchived: true });
|
|
3144
|
+
const paused = rows.filter((r) => r.session.status === "paused");
|
|
3145
|
+
if (paused.length === 0) {
|
|
3146
|
+
console.log("No paused sessions to archive.");
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
let archived = 0;
|
|
3150
|
+
for (const row of paused) {
|
|
3151
|
+
updateSessionStatus(row.session.id, "archived");
|
|
3152
|
+
console.log(` archived ${row.groupPath}/${row.session.slug}`);
|
|
3153
|
+
archived++;
|
|
3154
|
+
}
|
|
3155
|
+
console.log(`
|
|
3156
|
+
Archived ${archived} session${archived === 1 ? "" : "s"}.`);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (!sessionName) {
|
|
3160
|
+
console.error("Usage: bertrand archive <session>");
|
|
3161
|
+
console.error(" bertrand archive --undo <session>");
|
|
3162
|
+
console.error(" bertrand archive --all-paused");
|
|
3163
|
+
process.exit(1);
|
|
3164
|
+
}
|
|
3165
|
+
const { session, groupPath } = resolveSession(sessionName);
|
|
3166
|
+
const fullName = `${groupPath}/${session.slug}`;
|
|
3167
|
+
if (isUndo) {
|
|
3168
|
+
if (session.status !== "archived") {
|
|
3169
|
+
console.error(`${fullName} is not archived (status: ${session.status})`);
|
|
3170
|
+
process.exit(1);
|
|
3171
|
+
}
|
|
3172
|
+
updateSessionStatus(session.id, "paused");
|
|
3173
|
+
console.log(`Unarchived ${fullName}`);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if (ACTIVE_STATUSES2.includes(session.status)) {
|
|
3177
|
+
console.error(`Cannot archive active session ${fullName} (status: ${session.status})`);
|
|
3178
|
+
process.exit(1);
|
|
3179
|
+
}
|
|
3180
|
+
if (session.status === "archived") {
|
|
3181
|
+
console.error(`${fullName} is already archived`);
|
|
3182
|
+
process.exit(1);
|
|
3183
|
+
}
|
|
3184
|
+
updateSessionStatus(session.id, "archived");
|
|
3185
|
+
console.log(`Archived ${fullName}`);
|
|
3186
|
+
});
|
|
3187
|
+
});
|
|
3188
|
+
|
|
3189
|
+
// src/index.ts
|
|
3190
|
+
init_router();
|
|
3191
|
+
var command = process.argv[2];
|
|
3192
|
+
var hotPath = {
|
|
3193
|
+
update: () => Promise.resolve().then(() => (init_update(), exports_update)),
|
|
3194
|
+
snapshot: () => Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
|
|
3195
|
+
"assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
3196
|
+
"recap-thinking": () => Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
|
|
3197
|
+
badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
3198
|
+
notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
3199
|
+
serve: () => Promise.resolve().then(() => (init_serve(), exports_serve))
|
|
3200
|
+
};
|
|
3201
|
+
if (command && command in hotPath) {
|
|
3202
|
+
await hotPath[command]();
|
|
3203
|
+
} else {
|
|
3204
|
+
await Promise.all([
|
|
3205
|
+
Promise.resolve().then(() => (init_launch(), exports_launch)),
|
|
3206
|
+
Promise.resolve().then(() => (init_init(), exports_init)),
|
|
3207
|
+
Promise.resolve().then(() => (init_list(), exports_list)),
|
|
3208
|
+
Promise.resolve().then(() => (init_log(), exports_log)),
|
|
3209
|
+
Promise.resolve().then(() => (init_stats2(), exports_stats)),
|
|
3210
|
+
Promise.resolve().then(() => (init_backfill_stats(), exports_backfill_stats)),
|
|
3211
|
+
Promise.resolve().then(() => (init_archive(), exports_archive)),
|
|
3212
|
+
Promise.resolve().then(() => (init_update(), exports_update)),
|
|
3213
|
+
Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
|
|
3214
|
+
Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
3215
|
+
Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
|
|
3216
|
+
Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
3217
|
+
Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
3218
|
+
Promise.resolve().then(() => (init_notify(), exports_notify))
|
|
3219
|
+
]);
|
|
3220
|
+
}
|
|
3221
|
+
await route(process.argv);
|
|
3222
|
+
process.exit(0);
|