bertrand 0.22.1 → 0.23.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/dist/bertrand.js +548 -863
- package/dist/dashboard/assets/index-DW_dgQQt.js +659 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/migrations/0005_drop_pr_count.sql +1 -0
- package/dist/migrations/0006_drop_worktrees_last_question.sql +2 -0
- package/dist/migrations/0007_awesome_justin_hammer.sql +2 -0
- package/dist/migrations/meta/0007_snapshot.json +693 -0
- package/dist/migrations/meta/_journal.json +21 -0
- package/dist/run-screen.js +33 -43
- package/package.json +1 -1
- package/dist/dashboard/assets/index-SB5vH7Ec.js +0 -684
package/dist/bertrand.js
CHANGED
|
@@ -599,9 +599,8 @@ var init_router = __esm(() => {
|
|
|
599
599
|
aliases = new Map;
|
|
600
600
|
HOOK_COMMANDS = new Set([
|
|
601
601
|
"update",
|
|
602
|
-
"snapshot",
|
|
603
|
-
"recap-thinking",
|
|
604
602
|
"assistant-message",
|
|
603
|
+
"contract",
|
|
605
604
|
"notify",
|
|
606
605
|
"badge"
|
|
607
606
|
]);
|
|
@@ -610,7 +609,6 @@ var init_router = __esm(() => {
|
|
|
610
609
|
// src/db/schema.ts
|
|
611
610
|
var exports_schema = {};
|
|
612
611
|
__export(exports_schema, {
|
|
613
|
-
worktreeAssociations: () => worktreeAssociations,
|
|
614
612
|
sessions: () => sessions,
|
|
615
613
|
sessionStats: () => sessionStats,
|
|
616
614
|
sessionLabels: () => sessionLabels,
|
|
@@ -621,7 +619,7 @@ __export(exports_schema, {
|
|
|
621
619
|
});
|
|
622
620
|
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
623
621
|
import { sql } from "drizzle-orm";
|
|
624
|
-
var categories, labels, sessions, sessionLabels, conversations, events,
|
|
622
|
+
var categories, labels, sessions, sessionLabels, conversations, events, sessionStats;
|
|
625
623
|
var init_schema = __esm(() => {
|
|
626
624
|
categories = sqliteTable("categories", {
|
|
627
625
|
id: text("id").primaryKey(),
|
|
@@ -657,6 +655,8 @@ var init_schema = __esm(() => {
|
|
|
657
655
|
pid: integer("pid"),
|
|
658
656
|
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
|
659
657
|
endedAt: text("ended_at"),
|
|
658
|
+
worktreePath: text("worktree_path"),
|
|
659
|
+
worktreeBranch: text("worktree_branch"),
|
|
660
660
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
|
661
661
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
|
|
662
662
|
}, (t) => [
|
|
@@ -677,7 +677,6 @@ var init_schema = __esm(() => {
|
|
|
677
677
|
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
|
678
678
|
endedAt: text("ended_at"),
|
|
679
679
|
discarded: integer("discarded", { mode: "boolean" }).notNull().default(false),
|
|
680
|
-
lastQuestion: text("last_question"),
|
|
681
680
|
eventCount: integer("event_count").notNull().default(0)
|
|
682
681
|
}, (t) => [index("conv_session").on(t.sessionId)]);
|
|
683
682
|
events = sqliteTable("events", {
|
|
@@ -694,24 +693,11 @@ var init_schema = __esm(() => {
|
|
|
694
693
|
index("ev_event_created").on(t.event, t.createdAt),
|
|
695
694
|
index("ev_conversation").on(t.conversationId)
|
|
696
695
|
]);
|
|
697
|
-
worktreeAssociations = sqliteTable("worktree_associations", {
|
|
698
|
-
id: text("id").primaryKey(),
|
|
699
|
-
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
700
|
-
branch: text("branch").notNull(),
|
|
701
|
-
worktreePath: text("worktree_path"),
|
|
702
|
-
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
|
703
|
-
enteredAt: text("entered_at").notNull().default(sql`(datetime('now'))`),
|
|
704
|
-
exitedAt: text("exited_at")
|
|
705
|
-
}, (t) => [
|
|
706
|
-
index("wt_session").on(t.sessionId),
|
|
707
|
-
index("wt_active").on(t.active)
|
|
708
|
-
]);
|
|
709
696
|
sessionStats = sqliteTable("session_stats", {
|
|
710
697
|
sessionId: text("session_id").primaryKey().references(() => sessions.id, { onDelete: "cascade" }),
|
|
711
698
|
eventCount: integer("event_count").notNull().default(0),
|
|
712
699
|
conversationCount: integer("conversation_count").notNull().default(0),
|
|
713
700
|
interactionCount: integer("interaction_count").notNull().default(0),
|
|
714
|
-
prCount: integer("pr_count").notNull().default(0),
|
|
715
701
|
claudeWorkS: integer("claude_work_s").notNull().default(0),
|
|
716
702
|
userWaitS: integer("user_wait_s").notNull().default(0),
|
|
717
703
|
activePct: integer("active_pct").notNull().default(0),
|
|
@@ -799,28 +785,122 @@ function createId(size = 12) {
|
|
|
799
785
|
}
|
|
800
786
|
var init_id = () => {};
|
|
801
787
|
|
|
788
|
+
// src/db/queries/categories.ts
|
|
789
|
+
import { eq, like, or, isNull } from "drizzle-orm";
|
|
790
|
+
function createCategory(opts) {
|
|
791
|
+
const db = getDb();
|
|
792
|
+
const id = createId();
|
|
793
|
+
let path = opts.slug;
|
|
794
|
+
let depth = 0;
|
|
795
|
+
if (opts.parentId) {
|
|
796
|
+
const parent = db.select().from(categories).where(eq(categories.id, opts.parentId)).get();
|
|
797
|
+
if (!parent)
|
|
798
|
+
throw new Error(`Parent category ${opts.parentId} not found`);
|
|
799
|
+
path = `${parent.path}/${opts.slug}`;
|
|
800
|
+
depth = parent.depth + 1;
|
|
801
|
+
}
|
|
802
|
+
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
803
|
+
}
|
|
804
|
+
function getCategory(id) {
|
|
805
|
+
return getDb().select().from(categories).where(eq(categories.id, id)).get();
|
|
806
|
+
}
|
|
807
|
+
function getCategoryByPath(path) {
|
|
808
|
+
return getDb().select().from(categories).where(eq(categories.path, path)).get();
|
|
809
|
+
}
|
|
810
|
+
function getOrCreateCategoryPath(path) {
|
|
811
|
+
const existing = getCategoryByPath(path);
|
|
812
|
+
if (existing)
|
|
813
|
+
return existing.id;
|
|
814
|
+
const segments = path.split("/");
|
|
815
|
+
let parentId;
|
|
816
|
+
for (let i = 0;i < segments.length; i++) {
|
|
817
|
+
const partialPath = segments.slice(0, i + 1).join("/");
|
|
818
|
+
const category = getCategoryByPath(partialPath);
|
|
819
|
+
if (category) {
|
|
820
|
+
parentId = category.id;
|
|
821
|
+
} else {
|
|
822
|
+
const slug = segments[i];
|
|
823
|
+
const created = createCategory({ slug, name: slug, parentId });
|
|
824
|
+
parentId = created.id;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return parentId;
|
|
828
|
+
}
|
|
829
|
+
var init_categories = __esm(() => {
|
|
830
|
+
init_client();
|
|
831
|
+
init_schema();
|
|
832
|
+
init_id();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// src/lib/parse-session-name.ts
|
|
836
|
+
function parseSessionName(input) {
|
|
837
|
+
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
838
|
+
if (!trimmed) {
|
|
839
|
+
throw new Error("Session name cannot be empty");
|
|
840
|
+
}
|
|
841
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
842
|
+
if (segments.length < 2) {
|
|
843
|
+
throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
|
|
844
|
+
}
|
|
845
|
+
for (const segment of segments) {
|
|
846
|
+
if (!SEGMENT_PATTERN.test(segment)) {
|
|
847
|
+
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const categoryPath = segments[0];
|
|
851
|
+
const slug = segments.slice(1).join("/");
|
|
852
|
+
return { categoryPath, slug };
|
|
853
|
+
}
|
|
854
|
+
var SEGMENT_PATTERN;
|
|
855
|
+
var init_parse_session_name = __esm(() => {
|
|
856
|
+
SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
857
|
+
});
|
|
858
|
+
|
|
802
859
|
// src/db/queries/sessions.ts
|
|
803
|
-
import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
860
|
+
import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
861
|
+
function resolveSessionByName(name) {
|
|
862
|
+
const flat = parseSessionName(name);
|
|
863
|
+
const flatCategory = getCategoryByPath(flat.categoryPath);
|
|
864
|
+
if (flatCategory) {
|
|
865
|
+
const session = getSessionByCategorySlug(flatCategory.id, flat.slug);
|
|
866
|
+
if (session) {
|
|
867
|
+
return { session, categoryPath: flat.categoryPath, slug: flat.slug };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const segments = name.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
871
|
+
if (segments.length >= 3) {
|
|
872
|
+
const legacyCategoryPath = segments.slice(0, -1).join("/");
|
|
873
|
+
const legacySlug = segments[segments.length - 1];
|
|
874
|
+
const legacyCategory = getCategoryByPath(legacyCategoryPath);
|
|
875
|
+
if (legacyCategory) {
|
|
876
|
+
const session = getSessionByCategorySlug(legacyCategory.id, legacySlug);
|
|
877
|
+
if (session) {
|
|
878
|
+
return { session, categoryPath: legacyCategoryPath, slug: legacySlug };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
804
884
|
function createSession(opts) {
|
|
805
885
|
const db = getDb();
|
|
806
886
|
const id = createId();
|
|
807
887
|
return db.insert(sessions).values({ id, ...opts }).returning().get();
|
|
808
888
|
}
|
|
809
889
|
function getSession(id) {
|
|
810
|
-
return getDb().select().from(sessions).where(
|
|
890
|
+
return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
|
|
811
891
|
}
|
|
812
892
|
function getSessionByCategorySlug(categoryId, slug) {
|
|
813
|
-
return getDb().select().from(sessions).where(and(
|
|
893
|
+
return getDb().select().from(sessions).where(and(eq2(sessions.categoryId, categoryId), eq2(sessions.slug, slug))).get();
|
|
814
894
|
}
|
|
815
895
|
function getSessionsByCategory(categoryId) {
|
|
816
|
-
return getDb().select().from(sessions).where(
|
|
896
|
+
return getDb().select().from(sessions).where(eq2(sessions.categoryId, categoryId)).all();
|
|
817
897
|
}
|
|
818
898
|
function getActiveSessions() {
|
|
819
|
-
return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories,
|
|
899
|
+
return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
|
|
820
900
|
}
|
|
821
901
|
function getAllSessions(opts) {
|
|
822
902
|
const db = getDb();
|
|
823
|
-
const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories,
|
|
903
|
+
const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id));
|
|
824
904
|
if (opts?.excludeArchived) {
|
|
825
905
|
return query.where(inArray(sessions.status, [
|
|
826
906
|
"active",
|
|
@@ -831,36 +911,35 @@ function getAllSessions(opts) {
|
|
|
831
911
|
return query.all();
|
|
832
912
|
}
|
|
833
913
|
function updateSessionStatus(id, status) {
|
|
834
|
-
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(
|
|
914
|
+
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
|
|
835
915
|
}
|
|
836
916
|
function updateSession(id, data) {
|
|
837
|
-
return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(
|
|
917
|
+
return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
|
|
838
918
|
}
|
|
839
919
|
function deleteSession(id) {
|
|
840
|
-
return getDb().delete(sessions).where(
|
|
920
|
+
return getDb().delete(sessions).where(eq2(sessions.id, id)).run();
|
|
841
921
|
}
|
|
842
922
|
var init_sessions = __esm(() => {
|
|
843
923
|
init_client();
|
|
844
924
|
init_schema();
|
|
845
925
|
init_id();
|
|
926
|
+
init_categories();
|
|
927
|
+
init_parse_session_name();
|
|
846
928
|
});
|
|
847
929
|
|
|
848
930
|
// src/db/queries/conversations.ts
|
|
849
|
-
import { eq as
|
|
931
|
+
import { eq as eq3, and as and2, desc, sql as sql3 } from "drizzle-orm";
|
|
850
932
|
function createConversation(opts) {
|
|
851
933
|
return getDb().insert(conversations).values(opts).returning().get();
|
|
852
934
|
}
|
|
853
935
|
function getConversation(id) {
|
|
854
|
-
return getDb().select().from(conversations).where(
|
|
936
|
+
return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
|
|
855
937
|
}
|
|
856
938
|
function getConversationsBySession(sessionId) {
|
|
857
|
-
return getDb().select().from(conversations).where(and2(
|
|
939
|
+
return getDb().select().from(conversations).where(and2(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
|
|
858
940
|
}
|
|
859
941
|
function endConversation(id) {
|
|
860
|
-
return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(
|
|
861
|
-
}
|
|
862
|
-
function updateLastQuestion(id, question) {
|
|
863
|
-
return getDb().update(conversations).set({ lastQuestion: question }).where(eq2(conversations.id, id)).returning().get();
|
|
942
|
+
return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
|
|
864
943
|
}
|
|
865
944
|
var init_conversations = __esm(() => {
|
|
866
945
|
init_client();
|
|
@@ -916,7 +995,7 @@ function normalizeAnsweredMeta(meta) {
|
|
|
916
995
|
}
|
|
917
996
|
|
|
918
997
|
// src/db/queries/events.ts
|
|
919
|
-
import { eq as
|
|
998
|
+
import { eq as eq4, and as and3, desc as desc2 } from "drizzle-orm";
|
|
920
999
|
function insertEvent(opts) {
|
|
921
1000
|
return getDb().insert(events).values({
|
|
922
1001
|
sessionId: opts.sessionId,
|
|
@@ -927,18 +1006,18 @@ function insertEvent(opts) {
|
|
|
927
1006
|
}).returning().get();
|
|
928
1007
|
}
|
|
929
1008
|
function getEventsBySession(sessionId) {
|
|
930
|
-
return getDb().select().from(events).where(
|
|
1009
|
+
return getDb().select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
|
|
931
1010
|
}
|
|
932
1011
|
function getEventsByType(sessionId, eventType) {
|
|
933
|
-
return getDb().select().from(events).where(and3(
|
|
1012
|
+
return getDb().select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
|
|
934
1013
|
}
|
|
935
1014
|
function getLatestEventOfType(sessionId, eventType, conversationId) {
|
|
936
1015
|
const conditions = [
|
|
937
|
-
|
|
938
|
-
|
|
1016
|
+
eq4(events.sessionId, sessionId),
|
|
1017
|
+
eq4(events.event, eventType)
|
|
939
1018
|
];
|
|
940
1019
|
if (conversationId) {
|
|
941
|
-
conditions.push(
|
|
1020
|
+
conditions.push(eq4(events.conversationId, conversationId));
|
|
942
1021
|
}
|
|
943
1022
|
return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
|
|
944
1023
|
}
|
|
@@ -947,7 +1026,7 @@ function getLatestRecaps() {
|
|
|
947
1026
|
sessionId: events.sessionId,
|
|
948
1027
|
meta: events.meta,
|
|
949
1028
|
createdAt: events.createdAt
|
|
950
|
-
}).from(events).where(
|
|
1029
|
+
}).from(events).where(eq4(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
|
|
951
1030
|
const result = {};
|
|
952
1031
|
for (const row of rows) {
|
|
953
1032
|
if (result[row.sessionId])
|
|
@@ -966,50 +1045,6 @@ var init_events = __esm(() => {
|
|
|
966
1045
|
});
|
|
967
1046
|
|
|
968
1047
|
// src/db/events/emit.ts
|
|
969
|
-
function emitSessionStarted(args) {
|
|
970
|
-
return insertEvent({
|
|
971
|
-
sessionId: args.sessionId,
|
|
972
|
-
conversationId: args.conversationId,
|
|
973
|
-
event: "session.started",
|
|
974
|
-
meta: {
|
|
975
|
-
category_path: args.categoryPath,
|
|
976
|
-
session_name: args.sessionName,
|
|
977
|
-
session_slug: args.sessionSlug,
|
|
978
|
-
labels: args.labels,
|
|
979
|
-
summary: args.summary ?? null
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
function emitSessionResumed(args) {
|
|
984
|
-
return insertEvent({
|
|
985
|
-
sessionId: args.sessionId,
|
|
986
|
-
conversationId: args.conversationId,
|
|
987
|
-
event: "session.resumed",
|
|
988
|
-
meta: { claude_id: args.conversationId }
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
function emitSessionPaused(args) {
|
|
992
|
-
return insertEvent({
|
|
993
|
-
sessionId: args.sessionId,
|
|
994
|
-
conversationId: args.conversationId,
|
|
995
|
-
event: "session.paused",
|
|
996
|
-
meta: { claude_id: args.conversationId }
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
function emitSessionPausedByRecovery(args) {
|
|
1000
|
-
return insertEvent({
|
|
1001
|
-
sessionId: args.sessionId,
|
|
1002
|
-
event: "session.paused",
|
|
1003
|
-
summary: "Recovered from stale state (process not found)",
|
|
1004
|
-
meta: { stale_pid: args.stalePid }
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
function emitSessionEnded(args) {
|
|
1008
|
-
return insertEvent({
|
|
1009
|
-
sessionId: args.sessionId,
|
|
1010
|
-
event: "session.end"
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
1048
|
function emitClaudeStarted(args) {
|
|
1014
1049
|
return insertEvent({
|
|
1015
1050
|
sessionId: args.sessionId,
|
|
@@ -1017,9 +1052,6 @@ function emitClaudeStarted(args) {
|
|
|
1017
1052
|
event: "claude.started",
|
|
1018
1053
|
meta: {
|
|
1019
1054
|
claude_id: args.conversationId,
|
|
1020
|
-
model: args.model,
|
|
1021
|
-
claude_version: args.claudeVersion,
|
|
1022
|
-
git: args.git,
|
|
1023
1055
|
cwd: args.cwd
|
|
1024
1056
|
}
|
|
1025
1057
|
});
|
|
@@ -1073,31 +1105,6 @@ function emitSessionRecap(args) {
|
|
|
1073
1105
|
meta: { recap: args.recap, claude_id: args.conversationId }
|
|
1074
1106
|
});
|
|
1075
1107
|
}
|
|
1076
|
-
function emitPermissionRequested(args) {
|
|
1077
|
-
return insertEvent({
|
|
1078
|
-
sessionId: args.sessionId,
|
|
1079
|
-
conversationId: args.conversationId,
|
|
1080
|
-
event: "permission.request",
|
|
1081
|
-
meta: {
|
|
1082
|
-
tool: args.tool,
|
|
1083
|
-
detail: args.detail,
|
|
1084
|
-
claude_id: args.conversationId
|
|
1085
|
-
}
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
function emitPermissionResolved(args) {
|
|
1089
|
-
return insertEvent({
|
|
1090
|
-
sessionId: args.sessionId,
|
|
1091
|
-
conversationId: args.conversationId,
|
|
1092
|
-
event: "permission.resolve",
|
|
1093
|
-
meta: {
|
|
1094
|
-
tool: args.tool,
|
|
1095
|
-
detail: args.detail,
|
|
1096
|
-
outcome: args.outcome,
|
|
1097
|
-
claude_id: args.conversationId
|
|
1098
|
-
}
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
1108
|
function emitToolUsed(args) {
|
|
1102
1109
|
const summary = formatToolSummary(args.tool, args.detail);
|
|
1103
1110
|
return insertEvent({
|
|
@@ -1171,21 +1178,22 @@ function emitAssistantRecap(args) {
|
|
|
1171
1178
|
meta: { recap: args.recap, claude_id: args.conversationId }
|
|
1172
1179
|
});
|
|
1173
1180
|
}
|
|
1174
|
-
function
|
|
1181
|
+
function emitWorktreeEntered(args) {
|
|
1175
1182
|
return insertEvent({
|
|
1176
1183
|
sessionId: args.sessionId,
|
|
1177
1184
|
conversationId: args.conversationId,
|
|
1178
|
-
event: "
|
|
1179
|
-
summary:
|
|
1180
|
-
meta: {
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
}
|
|
1185
|
+
event: "worktree.entered",
|
|
1186
|
+
summary: args.branch ? `entered worktree ${args.branch}` : "entered worktree",
|
|
1187
|
+
meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
function emitWorktreeExited(args) {
|
|
1191
|
+
return insertEvent({
|
|
1192
|
+
sessionId: args.sessionId,
|
|
1193
|
+
conversationId: args.conversationId,
|
|
1194
|
+
event: "worktree.exited",
|
|
1195
|
+
summary: args.branch ? `exited worktree ${args.branch}` : "exited worktree",
|
|
1196
|
+
meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
|
|
1189
1197
|
});
|
|
1190
1198
|
}
|
|
1191
1199
|
var init_emit = __esm(() => {
|
|
@@ -1195,7 +1203,8 @@ var init_emit = __esm(() => {
|
|
|
1195
1203
|
// src/cli/commands/update.ts
|
|
1196
1204
|
var exports_update = {};
|
|
1197
1205
|
__export(exports_update, {
|
|
1198
|
-
shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
|
|
1206
|
+
shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip,
|
|
1207
|
+
dispatchHookEvent: () => dispatchHookEvent
|
|
1199
1208
|
});
|
|
1200
1209
|
function shouldIgnoreStatusFlip(newStatus, sessionPid) {
|
|
1201
1210
|
if (!newStatus)
|
|
@@ -1237,26 +1246,6 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1237
1246
|
recap: String(meta.recap ?? "")
|
|
1238
1247
|
});
|
|
1239
1248
|
return true;
|
|
1240
|
-
case "session.paused":
|
|
1241
|
-
emitSessionPaused({ sessionId, conversationId });
|
|
1242
|
-
return true;
|
|
1243
|
-
case "permission.request":
|
|
1244
|
-
emitPermissionRequested({
|
|
1245
|
-
sessionId,
|
|
1246
|
-
conversationId,
|
|
1247
|
-
tool: String(meta.tool ?? ""),
|
|
1248
|
-
detail: String(meta.detail ?? "")
|
|
1249
|
-
});
|
|
1250
|
-
return true;
|
|
1251
|
-
case "permission.resolve":
|
|
1252
|
-
emitPermissionResolved({
|
|
1253
|
-
sessionId,
|
|
1254
|
-
conversationId,
|
|
1255
|
-
tool: String(meta.tool ?? ""),
|
|
1256
|
-
detail: String(meta.detail ?? ""),
|
|
1257
|
-
outcome: meta.outcome === "denied" ? "denied" : "approved"
|
|
1258
|
-
});
|
|
1259
|
-
return true;
|
|
1260
1249
|
case "tool.applied":
|
|
1261
1250
|
emitToolApplied({
|
|
1262
1251
|
sessionId,
|
|
@@ -1274,6 +1263,22 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1274
1263
|
outcome: meta.outcome === "approved" ? "approved" : "auto"
|
|
1275
1264
|
});
|
|
1276
1265
|
return true;
|
|
1266
|
+
case "worktree.entered": {
|
|
1267
|
+
const path = String(meta.path ?? "");
|
|
1268
|
+
const branch = meta.branch ? String(meta.branch) : undefined;
|
|
1269
|
+
emitWorktreeEntered({ sessionId, conversationId, path, branch });
|
|
1270
|
+
updateSession(sessionId, {
|
|
1271
|
+
worktreePath: path || null,
|
|
1272
|
+
worktreeBranch: branch ?? null
|
|
1273
|
+
});
|
|
1274
|
+
return true;
|
|
1275
|
+
}
|
|
1276
|
+
case "worktree.exited": {
|
|
1277
|
+
const path = meta.path ? String(meta.path) : undefined;
|
|
1278
|
+
emitWorktreeExited({ sessionId, conversationId, path });
|
|
1279
|
+
updateSession(sessionId, { worktreePath: null, worktreeBranch: null });
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1277
1282
|
default:
|
|
1278
1283
|
return false;
|
|
1279
1284
|
}
|
|
@@ -1283,15 +1288,11 @@ var init_update = __esm(() => {
|
|
|
1283
1288
|
init_router();
|
|
1284
1289
|
init_sessions();
|
|
1285
1290
|
init_conversations();
|
|
1286
|
-
init_trigger();
|
|
1287
1291
|
init_emit();
|
|
1288
1292
|
EVENT_STATUS_MAP = {
|
|
1289
1293
|
"session.waiting": "waiting",
|
|
1290
1294
|
"session.answered": "active",
|
|
1291
|
-
"session.
|
|
1292
|
-
"session.paused": "paused",
|
|
1293
|
-
"session.started": "active",
|
|
1294
|
-
"session.end": "paused"
|
|
1295
|
+
"session.paused": "paused"
|
|
1295
1296
|
};
|
|
1296
1297
|
register("update", async (args) => {
|
|
1297
1298
|
let sessionId = "";
|
|
@@ -1340,24 +1341,15 @@ var init_update = __esm(() => {
|
|
|
1340
1341
|
}
|
|
1341
1342
|
const rawConvoId = meta?.claude_id || process.env.BERTRAND_CLAUDE_ID || undefined;
|
|
1342
1343
|
const conversationId = rawConvoId && getConversation(rawConvoId) ? rawConvoId : undefined;
|
|
1343
|
-
|
|
1344
|
+
dispatchHookEvent(event, {
|
|
1344
1345
|
sessionId,
|
|
1345
1346
|
conversationId,
|
|
1346
1347
|
meta,
|
|
1347
1348
|
summary: summaryArg
|
|
1348
1349
|
});
|
|
1349
|
-
if (!dispatched) {
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
1350
|
if (newStatus && !ignoreStatusFlip) {
|
|
1353
1351
|
updateSessionStatus(sessionId, newStatus);
|
|
1354
1352
|
}
|
|
1355
|
-
if (event === "session.waiting" && conversationId && meta?.question) {
|
|
1356
|
-
updateLastQuestion(conversationId, meta.question);
|
|
1357
|
-
}
|
|
1358
|
-
if (event === "session.end") {
|
|
1359
|
-
triggerBackgroundPush();
|
|
1360
|
-
}
|
|
1361
1353
|
});
|
|
1362
1354
|
});
|
|
1363
1355
|
|
|
@@ -1365,13 +1357,6 @@ var init_update = __esm(() => {
|
|
|
1365
1357
|
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
1366
1358
|
import { homedir as homedir2 } from "os";
|
|
1367
1359
|
import { join as join6 } from "path";
|
|
1368
|
-
function getContextWindowSize(model) {
|
|
1369
|
-
for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
|
|
1370
|
-
if (model.startsWith(prefix))
|
|
1371
|
-
return size;
|
|
1372
|
-
}
|
|
1373
|
-
return 200000;
|
|
1374
|
-
}
|
|
1375
1360
|
function claudeTranscriptPath(sessionId, cwd) {
|
|
1376
1361
|
const dir = (cwd ?? process.cwd()).replace(/\//g, "-");
|
|
1377
1362
|
return join6(homedir2(), ".claude", "projects", dir, `${sessionId}.jsonl`);
|
|
@@ -1439,108 +1424,7 @@ function getLatestAssistantTurn(filePath) {
|
|
|
1439
1424
|
thinkingBytes
|
|
1440
1425
|
};
|
|
1441
1426
|
}
|
|
1442
|
-
|
|
1443
|
-
if (!existsSync5(filePath))
|
|
1444
|
-
return null;
|
|
1445
|
-
const text2 = readFileSync4(filePath, "utf-8");
|
|
1446
|
-
const lines = text2.split(`
|
|
1447
|
-
`);
|
|
1448
|
-
for (let i = lines.length - 1;i >= 0; i--) {
|
|
1449
|
-
const line = lines[i];
|
|
1450
|
-
if (!line)
|
|
1451
|
-
continue;
|
|
1452
|
-
let entry;
|
|
1453
|
-
try {
|
|
1454
|
-
entry = JSON.parse(line);
|
|
1455
|
-
} catch {
|
|
1456
|
-
continue;
|
|
1457
|
-
}
|
|
1458
|
-
if (entry.type !== "assistant")
|
|
1459
|
-
continue;
|
|
1460
|
-
const message = entry.message;
|
|
1461
|
-
const usage = message?.usage;
|
|
1462
|
-
if (!usage)
|
|
1463
|
-
continue;
|
|
1464
|
-
const model = message?.model ?? "";
|
|
1465
|
-
const inputTokens = usage.input_tokens ?? 0;
|
|
1466
|
-
const outputTokens = usage.output_tokens ?? 0;
|
|
1467
|
-
const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
|
|
1468
|
-
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
1469
|
-
const totalContextTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
1470
|
-
const windowSize = getContextWindowSize(model);
|
|
1471
|
-
const remainingPct = Math.max(0, Math.min(100, Math.round(100 - totalContextTokens * 100 / windowSize)));
|
|
1472
|
-
return {
|
|
1473
|
-
model,
|
|
1474
|
-
inputTokens,
|
|
1475
|
-
outputTokens,
|
|
1476
|
-
cacheCreationTokens,
|
|
1477
|
-
cacheReadTokens,
|
|
1478
|
-
totalContextTokens,
|
|
1479
|
-
remainingPct
|
|
1480
|
-
};
|
|
1481
|
-
}
|
|
1482
|
-
return null;
|
|
1483
|
-
}
|
|
1484
|
-
var CONTEXT_WINDOW_SIZES;
|
|
1485
|
-
var init_transcript = __esm(() => {
|
|
1486
|
-
CONTEXT_WINDOW_SIZES = {
|
|
1487
|
-
"claude-opus-4": 1e6,
|
|
1488
|
-
"claude-sonnet-4": 200000,
|
|
1489
|
-
"claude-haiku-4": 200000
|
|
1490
|
-
};
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
// src/cli/commands/snapshot.ts
|
|
1494
|
-
var exports_snapshot = {};
|
|
1495
|
-
var init_snapshot = __esm(() => {
|
|
1496
|
-
init_router();
|
|
1497
|
-
init_sessions();
|
|
1498
|
-
init_conversations();
|
|
1499
|
-
init_emit();
|
|
1500
|
-
init_transcript();
|
|
1501
|
-
register("snapshot", async (args) => {
|
|
1502
|
-
let sessionId = "";
|
|
1503
|
-
let transcriptPath = "";
|
|
1504
|
-
let conversationId = "";
|
|
1505
|
-
for (let i = 0;i < args.length; i++) {
|
|
1506
|
-
const arg = args[i];
|
|
1507
|
-
const next = args[i + 1];
|
|
1508
|
-
if (arg === "--session-id" && next) {
|
|
1509
|
-
sessionId = next;
|
|
1510
|
-
i++;
|
|
1511
|
-
} else if (arg === "--transcript-path" && next) {
|
|
1512
|
-
transcriptPath = next;
|
|
1513
|
-
i++;
|
|
1514
|
-
} else if (arg === "--conversation-id" && next) {
|
|
1515
|
-
conversationId = next;
|
|
1516
|
-
i++;
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
if (!sessionId || !transcriptPath) {
|
|
1520
|
-
console.error("Usage: bertrand snapshot --session-id <id> --transcript-path <path> [--conversation-id <id>]");
|
|
1521
|
-
process.exit(1);
|
|
1522
|
-
}
|
|
1523
|
-
const session = getSession(sessionId);
|
|
1524
|
-
if (!session) {
|
|
1525
|
-
console.error(`Session not found: ${sessionId}`);
|
|
1526
|
-
process.exit(1);
|
|
1527
|
-
}
|
|
1528
|
-
const snapshot = getContextSnapshot(transcriptPath);
|
|
1529
|
-
if (!snapshot)
|
|
1530
|
-
return;
|
|
1531
|
-
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
1532
|
-
emitContextSnapshot({
|
|
1533
|
-
sessionId,
|
|
1534
|
-
conversationId: convoId,
|
|
1535
|
-
model: snapshot.model,
|
|
1536
|
-
inputTokens: snapshot.inputTokens,
|
|
1537
|
-
cacheCreationTokens: snapshot.cacheCreationTokens,
|
|
1538
|
-
cacheReadTokens: snapshot.cacheReadTokens,
|
|
1539
|
-
totalContextTokens: snapshot.totalContextTokens,
|
|
1540
|
-
remainingPct: snapshot.remainingPct
|
|
1541
|
-
});
|
|
1542
|
-
});
|
|
1543
|
-
});
|
|
1427
|
+
var init_transcript = () => {};
|
|
1544
1428
|
|
|
1545
1429
|
// src/cli/commands/assistant-message.ts
|
|
1546
1430
|
var exports_assistant_message = {};
|
|
@@ -1624,53 +1508,153 @@ var init_assistant_message = __esm(() => {
|
|
|
1624
1508
|
});
|
|
1625
1509
|
});
|
|
1626
1510
|
|
|
1627
|
-
// src/
|
|
1628
|
-
var
|
|
1629
|
-
|
|
1630
|
-
|
|
1511
|
+
// src/contract/template.md
|
|
1512
|
+
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1513
|
+
|
|
1514
|
+
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it). 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.
|
|
1515
|
+
|
|
1516
|
+
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.
|
|
1517
|
+
|
|
1518
|
+
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.
|
|
1519
|
+
`;
|
|
1520
|
+
var init_template = () => {};
|
|
1521
|
+
|
|
1522
|
+
// src/contract/template.ts
|
|
1523
|
+
function buildContract(sessionName, ...contextLayers) {
|
|
1524
|
+
const base = template_default.replace("{sessionName}", sessionName);
|
|
1525
|
+
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
1526
|
+
if (layers.length === 0)
|
|
1527
|
+
return base;
|
|
1528
|
+
return base + `
|
|
1529
|
+
|
|
1530
|
+
` + layers.join(`
|
|
1531
|
+
|
|
1532
|
+
`);
|
|
1533
|
+
}
|
|
1534
|
+
var init_template2 = __esm(() => {
|
|
1535
|
+
init_template();
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// src/lib/format.ts
|
|
1539
|
+
function formatDuration(ms) {
|
|
1540
|
+
if (ms < MINUTE)
|
|
1541
|
+
return `${Math.round(ms / SECOND)}s`;
|
|
1542
|
+
const days = Math.floor(ms / DAY);
|
|
1543
|
+
const hours = Math.floor(ms % DAY / HOUR);
|
|
1544
|
+
const minutes = Math.floor(ms % HOUR / MINUTE);
|
|
1545
|
+
if (days > 0)
|
|
1546
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
1547
|
+
if (hours > 0)
|
|
1548
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1549
|
+
return `${minutes}m`;
|
|
1550
|
+
}
|
|
1551
|
+
function formatAgo(isoOrDate) {
|
|
1552
|
+
const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
1553
|
+
const ms = Date.now() - date.getTime();
|
|
1554
|
+
if (ms < MINUTE)
|
|
1555
|
+
return "just now";
|
|
1556
|
+
if (ms < HOUR)
|
|
1557
|
+
return `${Math.floor(ms / MINUTE)}m ago`;
|
|
1558
|
+
if (ms < DAY)
|
|
1559
|
+
return `${Math.floor(ms / HOUR)}h ago`;
|
|
1560
|
+
if (ms < 2 * DAY)
|
|
1561
|
+
return "yesterday";
|
|
1562
|
+
if (ms < 7 * DAY)
|
|
1563
|
+
return `${Math.floor(ms / DAY)}d ago`;
|
|
1564
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
1565
|
+
}
|
|
1566
|
+
function truncate(text2, maxLen) {
|
|
1567
|
+
if (text2.length <= maxLen)
|
|
1568
|
+
return text2;
|
|
1569
|
+
return text2.slice(0, maxLen - 1) + "\u2026";
|
|
1570
|
+
}
|
|
1571
|
+
function formatTime(iso, includeDate = false) {
|
|
1572
|
+
const date = new Date(iso);
|
|
1573
|
+
const time = date.toLocaleTimeString("en-US", {
|
|
1574
|
+
hour: "numeric",
|
|
1575
|
+
minute: "2-digit"
|
|
1576
|
+
});
|
|
1577
|
+
if (!includeDate)
|
|
1578
|
+
return time;
|
|
1579
|
+
const day = date.toLocaleDateString("en-US", {
|
|
1580
|
+
month: "short",
|
|
1581
|
+
day: "numeric"
|
|
1582
|
+
});
|
|
1583
|
+
return `${day} ${time}`;
|
|
1584
|
+
}
|
|
1585
|
+
var SECOND = 1000, MINUTE, HOUR, DAY;
|
|
1586
|
+
var init_format = __esm(() => {
|
|
1587
|
+
MINUTE = 60 * SECOND;
|
|
1588
|
+
HOUR = 60 * MINUTE;
|
|
1589
|
+
DAY = 24 * HOUR;
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
// src/contract/context.ts
|
|
1593
|
+
function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
|
|
1594
|
+
const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
|
|
1595
|
+
if (siblings.length === 0)
|
|
1596
|
+
return "";
|
|
1597
|
+
const lines = siblings.map((s) => {
|
|
1598
|
+
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
1599
|
+
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
1600
|
+
const worktree = s.worktreeBranch ? ` [worktree: ${s.worktreeBranch}]` : "";
|
|
1601
|
+
return `- ${categoryPath}/${s.slug}: ${s.status}${worktree}${summary} (${ago})`;
|
|
1602
|
+
});
|
|
1603
|
+
const guidance = [
|
|
1604
|
+
"",
|
|
1605
|
+
"To inspect any sibling session's full record, run:",
|
|
1606
|
+
" bertrand log <category>/<slug> --json",
|
|
1607
|
+
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
1608
|
+
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
1609
|
+
].join(`
|
|
1610
|
+
`);
|
|
1611
|
+
return `## Sibling Sessions
|
|
1612
|
+
${lines.join(`
|
|
1613
|
+
`)}
|
|
1614
|
+
${guidance}`;
|
|
1615
|
+
}
|
|
1616
|
+
var init_context = __esm(() => {
|
|
1617
|
+
init_sessions();
|
|
1618
|
+
init_format();
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
// src/cli/commands/contract.ts
|
|
1622
|
+
var exports_contract = {};
|
|
1623
|
+
var init_contract = __esm(() => {
|
|
1631
1624
|
init_router();
|
|
1632
1625
|
init_sessions();
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
register("recap-thinking", async (args) => {
|
|
1626
|
+
init_categories();
|
|
1627
|
+
init_template2();
|
|
1628
|
+
init_context();
|
|
1629
|
+
register("contract", async (args) => {
|
|
1638
1630
|
let sessionId = "";
|
|
1639
|
-
let
|
|
1640
|
-
let conversationId = "";
|
|
1631
|
+
let short = false;
|
|
1641
1632
|
for (let i = 0;i < args.length; i++) {
|
|
1642
1633
|
const arg = args[i];
|
|
1643
1634
|
const next = args[i + 1];
|
|
1644
1635
|
if (arg === "--session-id" && next) {
|
|
1645
1636
|
sessionId = next;
|
|
1646
1637
|
i++;
|
|
1647
|
-
} else if (arg === "--
|
|
1648
|
-
|
|
1649
|
-
i++;
|
|
1650
|
-
} else if (arg === "--conversation-id" && next) {
|
|
1651
|
-
conversationId = next;
|
|
1652
|
-
i++;
|
|
1638
|
+
} else if (arg === "--short") {
|
|
1639
|
+
short = true;
|
|
1653
1640
|
}
|
|
1654
1641
|
}
|
|
1655
|
-
if (!sessionId
|
|
1656
|
-
console.error("Usage: bertrand
|
|
1642
|
+
if (!sessionId) {
|
|
1643
|
+
console.error("Usage: bertrand contract --session-id <id> [--short]");
|
|
1657
1644
|
process.exit(1);
|
|
1658
1645
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
const turn = getLatestAssistantTurn(transcriptPath);
|
|
1662
|
-
if (!turn?.text)
|
|
1646
|
+
const session = getSession(sessionId);
|
|
1647
|
+
if (!session)
|
|
1663
1648
|
return;
|
|
1664
|
-
const
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1649
|
+
const category = getCategory(session.categoryId);
|
|
1650
|
+
const categoryPath = category?.path ?? "";
|
|
1651
|
+
const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
|
|
1652
|
+
if (short) {
|
|
1653
|
+
process.stdout.write(`Reminder \u2014 you are in bertrand session ${sessionName}: end this turn with an AskUserQuestion call (multiSelect:true on every question, plus a "Done for now" option) preceded by a <recap> block.`);
|
|
1667
1654
|
return;
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
conversationId: convoId,
|
|
1672
|
-
recap
|
|
1673
|
-
});
|
|
1655
|
+
}
|
|
1656
|
+
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
1657
|
+
process.stdout.write(buildContract(sessionName, siblingContext));
|
|
1674
1658
|
});
|
|
1675
1659
|
});
|
|
1676
1660
|
|
|
@@ -1791,9 +1775,9 @@ var init_notify = __esm(() => {
|
|
|
1791
1775
|
});
|
|
1792
1776
|
|
|
1793
1777
|
// src/db/queries/stats.ts
|
|
1794
|
-
import { eq as
|
|
1778
|
+
import { eq as eq5, sql as sql4 } from "drizzle-orm";
|
|
1795
1779
|
function getSessionStats(sessionId) {
|
|
1796
|
-
return getDb().select().from(sessionStats).where(
|
|
1780
|
+
return getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
|
|
1797
1781
|
}
|
|
1798
1782
|
function upsertSessionStats(sessionId, data) {
|
|
1799
1783
|
return getDb().insert(sessionStats).values({
|
|
@@ -1935,7 +1919,6 @@ function computeTimings(events2) {
|
|
|
1935
1919
|
function computeSessionStats(sessionId) {
|
|
1936
1920
|
const events2 = getEventsBySession(sessionId);
|
|
1937
1921
|
const summary = computeTimings(events2);
|
|
1938
|
-
const prEvents = getEventsByType(sessionId, "gh.pr.created");
|
|
1939
1922
|
const conversationIds = new Set(events2.filter((e) => e.conversationId).map((e) => e.conversationId));
|
|
1940
1923
|
const interactionCount = events2.filter((e) => e.event === "session.waiting" || e.event === "session.answered").length;
|
|
1941
1924
|
const diff = computeDiffStats(sessionId);
|
|
@@ -1943,7 +1926,6 @@ function computeSessionStats(sessionId) {
|
|
|
1943
1926
|
eventCount: events2.length,
|
|
1944
1927
|
conversationCount: conversationIds.size,
|
|
1945
1928
|
interactionCount,
|
|
1946
|
-
prCount: prEvents.length,
|
|
1947
1929
|
claudeWorkS: Math.round(summary.totalClaudeWorkMs / 1000),
|
|
1948
1930
|
userWaitS: Math.round(summary.totalUserWaitMs / 1000),
|
|
1949
1931
|
activePct: summary.activePct,
|
|
@@ -1968,7 +1950,7 @@ var init_timing = __esm(() => {
|
|
|
1968
1950
|
});
|
|
1969
1951
|
|
|
1970
1952
|
// src/lib/engagement_stats.ts
|
|
1971
|
-
import { eq as
|
|
1953
|
+
import { eq as eq6, sql as sql5 } from "drizzle-orm";
|
|
1972
1954
|
function aggregateToolUsage(sessionId) {
|
|
1973
1955
|
const counts = {};
|
|
1974
1956
|
const applied = getEventsByType(sessionId, "tool.applied");
|
|
@@ -1981,14 +1963,6 @@ function aggregateToolUsage(sessionId) {
|
|
|
1981
1963
|
counts[p.tool] = (counts[p.tool] ?? 0) + (p.count ?? 1);
|
|
1982
1964
|
}
|
|
1983
1965
|
}
|
|
1984
|
-
const resolves = getEventsByType(sessionId, "permission.resolve");
|
|
1985
|
-
for (const ev of resolves) {
|
|
1986
|
-
const meta = ev.meta;
|
|
1987
|
-
const tool = meta?.tool;
|
|
1988
|
-
if (!tool)
|
|
1989
|
-
continue;
|
|
1990
|
-
counts[tool] = (counts[tool] ?? 0) + 1;
|
|
1991
|
-
}
|
|
1992
1966
|
const used = getEventsByType(sessionId, "tool.used");
|
|
1993
1967
|
for (const ev of used) {
|
|
1994
1968
|
const meta = ev.meta;
|
|
@@ -1999,45 +1973,11 @@ function aggregateToolUsage(sessionId) {
|
|
|
1999
1973
|
}
|
|
2000
1974
|
return counts;
|
|
2001
1975
|
}
|
|
2002
|
-
function contextTokenStats(sessionId) {
|
|
2003
|
-
const snapshots = getEventsByType(sessionId, "context.snapshot");
|
|
2004
|
-
const samples = [];
|
|
2005
|
-
for (const ev of snapshots) {
|
|
2006
|
-
const meta = ev.meta;
|
|
2007
|
-
const total = parseInt(meta?.context_window_tokens ?? "0", 10);
|
|
2008
|
-
if (Number.isFinite(total) && total > 0)
|
|
2009
|
-
samples.push(total);
|
|
2010
|
-
}
|
|
2011
|
-
if (samples.length === 0)
|
|
2012
|
-
return { avg: 0, max: 0, latest: 0 };
|
|
2013
|
-
let sum = 0;
|
|
2014
|
-
let max = 0;
|
|
2015
|
-
for (const n of samples) {
|
|
2016
|
-
sum += n;
|
|
2017
|
-
if (n > max)
|
|
2018
|
-
max = n;
|
|
2019
|
-
}
|
|
2020
|
-
const avg = Math.round(sum / samples.length);
|
|
2021
|
-
const latest = samples[samples.length - 1] ?? 0;
|
|
2022
|
-
return { avg, max, latest };
|
|
2023
|
-
}
|
|
2024
|
-
function permissionDenialCount(sessionId) {
|
|
2025
|
-
const all = getEventsBySession(sessionId);
|
|
2026
|
-
let requests = 0;
|
|
2027
|
-
let resolves = 0;
|
|
2028
|
-
for (const ev of all) {
|
|
2029
|
-
if (ev.event === "permission.request")
|
|
2030
|
-
requests++;
|
|
2031
|
-
else if (ev.event === "permission.resolve")
|
|
2032
|
-
resolves++;
|
|
2033
|
-
}
|
|
2034
|
-
return Math.max(0, requests - resolves);
|
|
2035
|
-
}
|
|
2036
1976
|
function discardRate(sessionId) {
|
|
2037
1977
|
const row = getDb().select({
|
|
2038
1978
|
total: sql5`count(*)`,
|
|
2039
1979
|
discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
|
|
2040
|
-
}).from(conversations).where(
|
|
1980
|
+
}).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
|
|
2041
1981
|
return {
|
|
2042
1982
|
total: row?.total ?? 0,
|
|
2043
1983
|
discarded: row?.discarded ?? 0
|
|
@@ -2046,8 +1986,6 @@ function discardRate(sessionId) {
|
|
|
2046
1986
|
function computeEngagementStats(sessionId) {
|
|
2047
1987
|
return {
|
|
2048
1988
|
toolUsage: aggregateToolUsage(sessionId),
|
|
2049
|
-
contextTokens: contextTokenStats(sessionId),
|
|
2050
|
-
permissionDenials: permissionDenialCount(sessionId),
|
|
2051
1989
|
discardRate: discardRate(sessionId)
|
|
2052
1990
|
};
|
|
2053
1991
|
}
|
|
@@ -2291,7 +2229,7 @@ var PORT, listSessions = (_params, url) => {
|
|
|
2291
2229
|
}, getActiveProjectMeta = () => {
|
|
2292
2230
|
const active = resolveActiveProject();
|
|
2293
2231
|
return { slug: active.slug, name: active.name };
|
|
2294
|
-
}, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
|
|
2232
|
+
}, listWorktreeSessions = () => getAllSessions({ excludeArchived: true }).filter(({ session }) => session.worktreePath != null), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
|
|
2295
2233
|
var init_server = __esm(() => {
|
|
2296
2234
|
init_sessions();
|
|
2297
2235
|
init_events();
|
|
@@ -2306,6 +2244,7 @@ var init_server = __esm(() => {
|
|
|
2306
2244
|
routes = [
|
|
2307
2245
|
[/^\/api\/sessions$/, listSessions],
|
|
2308
2246
|
[/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
|
|
2247
|
+
[/^\/api\/worktrees$/, listWorktreeSessions],
|
|
2309
2248
|
[/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
|
|
2310
2249
|
[/^\/api\/stats$/, listAllStats],
|
|
2311
2250
|
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
|
|
@@ -2364,7 +2303,7 @@ function cleanupSnapshot() {
|
|
|
2364
2303
|
}
|
|
2365
2304
|
}
|
|
2366
2305
|
var SIDECAR_SUFFIXES;
|
|
2367
|
-
var
|
|
2306
|
+
var init_snapshot = __esm(() => {
|
|
2368
2307
|
init_resolve();
|
|
2369
2308
|
SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
|
|
2370
2309
|
});
|
|
@@ -2563,7 +2502,7 @@ var init_engine = __esm(() => {
|
|
|
2563
2502
|
init_resolve();
|
|
2564
2503
|
init_lsof();
|
|
2565
2504
|
init_config();
|
|
2566
|
-
|
|
2505
|
+
init_snapshot();
|
|
2567
2506
|
init_crypto();
|
|
2568
2507
|
});
|
|
2569
2508
|
|
|
@@ -2721,60 +2660,6 @@ var init_bootstrap = __esm(() => {
|
|
|
2721
2660
|
init_config2();
|
|
2722
2661
|
});
|
|
2723
2662
|
|
|
2724
|
-
// src/lib/format.ts
|
|
2725
|
-
function formatDuration(ms) {
|
|
2726
|
-
if (ms < MINUTE)
|
|
2727
|
-
return `${Math.round(ms / SECOND)}s`;
|
|
2728
|
-
const days = Math.floor(ms / DAY);
|
|
2729
|
-
const hours = Math.floor(ms % DAY / HOUR);
|
|
2730
|
-
const minutes = Math.floor(ms % HOUR / MINUTE);
|
|
2731
|
-
if (days > 0)
|
|
2732
|
-
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
2733
|
-
if (hours > 0)
|
|
2734
|
-
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
2735
|
-
return `${minutes}m`;
|
|
2736
|
-
}
|
|
2737
|
-
function formatAgo(isoOrDate) {
|
|
2738
|
-
const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
2739
|
-
const ms = Date.now() - date.getTime();
|
|
2740
|
-
if (ms < MINUTE)
|
|
2741
|
-
return "just now";
|
|
2742
|
-
if (ms < HOUR)
|
|
2743
|
-
return `${Math.floor(ms / MINUTE)}m ago`;
|
|
2744
|
-
if (ms < DAY)
|
|
2745
|
-
return `${Math.floor(ms / HOUR)}h ago`;
|
|
2746
|
-
if (ms < 2 * DAY)
|
|
2747
|
-
return "yesterday";
|
|
2748
|
-
if (ms < 7 * DAY)
|
|
2749
|
-
return `${Math.floor(ms / DAY)}d ago`;
|
|
2750
|
-
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
2751
|
-
}
|
|
2752
|
-
function truncate(text2, maxLen) {
|
|
2753
|
-
if (text2.length <= maxLen)
|
|
2754
|
-
return text2;
|
|
2755
|
-
return text2.slice(0, maxLen - 1) + "\u2026";
|
|
2756
|
-
}
|
|
2757
|
-
function formatTime(iso, includeDate = false) {
|
|
2758
|
-
const date = new Date(iso);
|
|
2759
|
-
const time = date.toLocaleTimeString("en-US", {
|
|
2760
|
-
hour: "numeric",
|
|
2761
|
-
minute: "2-digit"
|
|
2762
|
-
});
|
|
2763
|
-
if (!includeDate)
|
|
2764
|
-
return time;
|
|
2765
|
-
const day = date.toLocaleDateString("en-US", {
|
|
2766
|
-
month: "short",
|
|
2767
|
-
day: "numeric"
|
|
2768
|
-
});
|
|
2769
|
-
return `${day} ${time}`;
|
|
2770
|
-
}
|
|
2771
|
-
var SECOND = 1000, MINUTE, HOUR, DAY;
|
|
2772
|
-
var init_format = __esm(() => {
|
|
2773
|
-
MINUTE = 60 * SECOND;
|
|
2774
|
-
HOUR = 60 * MINUTE;
|
|
2775
|
-
DAY = 24 * HOUR;
|
|
2776
|
-
});
|
|
2777
|
-
|
|
2778
2663
|
// src/cli/commands/sync.ts
|
|
2779
2664
|
var exports_sync = {};
|
|
2780
2665
|
import { hostname as hostname2 } from "os";
|
|
@@ -3070,80 +2955,33 @@ var init_sync = __esm(() => {
|
|
|
3070
2955
|
return;
|
|
3071
2956
|
case "pull":
|
|
3072
2957
|
await runPull(rest);
|
|
3073
|
-
return;
|
|
3074
|
-
case "status":
|
|
3075
|
-
await runStatus();
|
|
3076
|
-
return;
|
|
3077
|
-
case "onboard":
|
|
3078
|
-
await runOnboard();
|
|
3079
|
-
return;
|
|
3080
|
-
case "invite":
|
|
3081
|
-
runInvite();
|
|
3082
|
-
return;
|
|
3083
|
-
case "enable":
|
|
3084
|
-
runEnable();
|
|
3085
|
-
return;
|
|
3086
|
-
case "disable":
|
|
3087
|
-
runDisable();
|
|
3088
|
-
return;
|
|
3089
|
-
case undefined:
|
|
3090
|
-
case "--help":
|
|
3091
|
-
case "-h":
|
|
3092
|
-
printUsage2();
|
|
3093
|
-
return;
|
|
3094
|
-
default:
|
|
3095
|
-
console.error(`Unknown subcommand: ${sub}`);
|
|
3096
|
-
printUsage2();
|
|
3097
|
-
process.exit(1);
|
|
3098
|
-
}
|
|
3099
|
-
});
|
|
3100
|
-
});
|
|
3101
|
-
|
|
3102
|
-
// src/db/queries/categories.ts
|
|
3103
|
-
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
3104
|
-
function createCategory(opts) {
|
|
3105
|
-
const db = getDb();
|
|
3106
|
-
const id = createId();
|
|
3107
|
-
let path = opts.slug;
|
|
3108
|
-
let depth = 0;
|
|
3109
|
-
if (opts.parentId) {
|
|
3110
|
-
const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
|
|
3111
|
-
if (!parent)
|
|
3112
|
-
throw new Error(`Parent category ${opts.parentId} not found`);
|
|
3113
|
-
path = `${parent.path}/${opts.slug}`;
|
|
3114
|
-
depth = parent.depth + 1;
|
|
3115
|
-
}
|
|
3116
|
-
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
3117
|
-
}
|
|
3118
|
-
function getCategory(id) {
|
|
3119
|
-
return getDb().select().from(categories).where(eq6(categories.id, id)).get();
|
|
3120
|
-
}
|
|
3121
|
-
function getCategoryByPath(path) {
|
|
3122
|
-
return getDb().select().from(categories).where(eq6(categories.path, path)).get();
|
|
3123
|
-
}
|
|
3124
|
-
function getOrCreateCategoryPath(path) {
|
|
3125
|
-
const existing = getCategoryByPath(path);
|
|
3126
|
-
if (existing)
|
|
3127
|
-
return existing.id;
|
|
3128
|
-
const segments = path.split("/");
|
|
3129
|
-
let parentId;
|
|
3130
|
-
for (let i = 0;i < segments.length; i++) {
|
|
3131
|
-
const partialPath = segments.slice(0, i + 1).join("/");
|
|
3132
|
-
const category = getCategoryByPath(partialPath);
|
|
3133
|
-
if (category) {
|
|
3134
|
-
parentId = category.id;
|
|
3135
|
-
} else {
|
|
3136
|
-
const slug = segments[i];
|
|
3137
|
-
const created = createCategory({ slug, name: slug, parentId });
|
|
3138
|
-
parentId = created.id;
|
|
2958
|
+
return;
|
|
2959
|
+
case "status":
|
|
2960
|
+
await runStatus();
|
|
2961
|
+
return;
|
|
2962
|
+
case "onboard":
|
|
2963
|
+
await runOnboard();
|
|
2964
|
+
return;
|
|
2965
|
+
case "invite":
|
|
2966
|
+
runInvite();
|
|
2967
|
+
return;
|
|
2968
|
+
case "enable":
|
|
2969
|
+
runEnable();
|
|
2970
|
+
return;
|
|
2971
|
+
case "disable":
|
|
2972
|
+
runDisable();
|
|
2973
|
+
return;
|
|
2974
|
+
case undefined:
|
|
2975
|
+
case "--help":
|
|
2976
|
+
case "-h":
|
|
2977
|
+
printUsage2();
|
|
2978
|
+
return;
|
|
2979
|
+
default:
|
|
2980
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
2981
|
+
printUsage2();
|
|
2982
|
+
process.exit(1);
|
|
3139
2983
|
}
|
|
3140
|
-
}
|
|
3141
|
-
return parentId;
|
|
3142
|
-
}
|
|
3143
|
-
var init_categories = __esm(() => {
|
|
3144
|
-
init_client();
|
|
3145
|
-
init_schema();
|
|
3146
|
-
init_id();
|
|
2984
|
+
});
|
|
3147
2985
|
});
|
|
3148
2986
|
|
|
3149
2987
|
// src/db/queries/labels.ts
|
|
@@ -3169,61 +3007,6 @@ var init_labels = __esm(() => {
|
|
|
3169
3007
|
init_id();
|
|
3170
3008
|
});
|
|
3171
3009
|
|
|
3172
|
-
// src/contract/template.md
|
|
3173
|
-
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
3174
|
-
|
|
3175
|
-
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it). 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.
|
|
3176
|
-
|
|
3177
|
-
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.
|
|
3178
|
-
|
|
3179
|
-
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.
|
|
3180
|
-
`;
|
|
3181
|
-
var init_template = () => {};
|
|
3182
|
-
|
|
3183
|
-
// src/contract/template.ts
|
|
3184
|
-
function buildContract(sessionName, ...contextLayers) {
|
|
3185
|
-
const base = template_default.replace("{sessionName}", sessionName);
|
|
3186
|
-
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
3187
|
-
if (layers.length === 0)
|
|
3188
|
-
return base;
|
|
3189
|
-
return base + `
|
|
3190
|
-
|
|
3191
|
-
` + layers.join(`
|
|
3192
|
-
|
|
3193
|
-
`);
|
|
3194
|
-
}
|
|
3195
|
-
var init_template2 = __esm(() => {
|
|
3196
|
-
init_template();
|
|
3197
|
-
});
|
|
3198
|
-
|
|
3199
|
-
// src/contract/context.ts
|
|
3200
|
-
function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
|
|
3201
|
-
const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
|
|
3202
|
-
if (siblings.length === 0)
|
|
3203
|
-
return "";
|
|
3204
|
-
const lines = siblings.map((s) => {
|
|
3205
|
-
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
3206
|
-
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
3207
|
-
return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
|
|
3208
|
-
});
|
|
3209
|
-
const guidance = [
|
|
3210
|
-
"",
|
|
3211
|
-
"To inspect any sibling session's full record, run:",
|
|
3212
|
-
" bertrand log <category>/<slug> --json",
|
|
3213
|
-
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
3214
|
-
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
3215
|
-
].join(`
|
|
3216
|
-
`);
|
|
3217
|
-
return `## Sibling Sessions
|
|
3218
|
-
${lines.join(`
|
|
3219
|
-
`)}
|
|
3220
|
-
${guidance}`;
|
|
3221
|
-
}
|
|
3222
|
-
var init_context = __esm(() => {
|
|
3223
|
-
init_sessions();
|
|
3224
|
-
init_format();
|
|
3225
|
-
});
|
|
3226
|
-
|
|
3227
3010
|
// src/engine/process.ts
|
|
3228
3011
|
import { spawn as spawn2 } from "child_process";
|
|
3229
3012
|
function launchClaude(opts) {
|
|
@@ -3281,73 +3064,6 @@ var init_process = __esm(() => {
|
|
|
3281
3064
|
init_resolve();
|
|
3282
3065
|
});
|
|
3283
3066
|
|
|
3284
|
-
// src/engine/spawn-context.ts
|
|
3285
|
-
import { execFile as execFile2 } from "child_process";
|
|
3286
|
-
import { promisify } from "util";
|
|
3287
|
-
function captureModel() {
|
|
3288
|
-
return process.env.BERTRAND_MODEL || process.env.CLAUDE_MODEL || undefined;
|
|
3289
|
-
}
|
|
3290
|
-
async function captureClaudeVersion() {
|
|
3291
|
-
if (cachedClaudeVersion !== null)
|
|
3292
|
-
return cachedClaudeVersion;
|
|
3293
|
-
try {
|
|
3294
|
-
const { stdout } = await execFileAsync("claude", ["--version"], {
|
|
3295
|
-
timeout: 5000
|
|
3296
|
-
});
|
|
3297
|
-
const match2 = stdout.trim().match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
|
|
3298
|
-
cachedClaudeVersion = match2 ? match2[1] : stdout.trim() || undefined;
|
|
3299
|
-
return cachedClaudeVersion;
|
|
3300
|
-
} catch {
|
|
3301
|
-
cachedClaudeVersion = undefined;
|
|
3302
|
-
return;
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
async function captureGit() {
|
|
3306
|
-
try {
|
|
3307
|
-
const [statusRes, shaRes] = await Promise.all([
|
|
3308
|
-
execFileAsync("git", ["status", "--porcelain=v2", "--branch"], {
|
|
3309
|
-
timeout: 5000
|
|
3310
|
-
}),
|
|
3311
|
-
execFileAsync("git", ["rev-parse", "HEAD"], { timeout: 5000 })
|
|
3312
|
-
]);
|
|
3313
|
-
let branch;
|
|
3314
|
-
let dirty = false;
|
|
3315
|
-
for (const line of statusRes.stdout.split(`
|
|
3316
|
-
`)) {
|
|
3317
|
-
if (line.startsWith("# branch.head ")) {
|
|
3318
|
-
branch = line.slice("# branch.head ".length).trim();
|
|
3319
|
-
} else if (line && !line.startsWith("#")) {
|
|
3320
|
-
dirty = true;
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
3323
|
-
if (!branch)
|
|
3324
|
-
return;
|
|
3325
|
-
return {
|
|
3326
|
-
branch,
|
|
3327
|
-
sha: shaRes.stdout.trim(),
|
|
3328
|
-
dirty
|
|
3329
|
-
};
|
|
3330
|
-
} catch {
|
|
3331
|
-
return;
|
|
3332
|
-
}
|
|
3333
|
-
}
|
|
3334
|
-
async function captureSpawnContext() {
|
|
3335
|
-
const [claudeVersion, git] = await Promise.all([
|
|
3336
|
-
captureClaudeVersion(),
|
|
3337
|
-
captureGit()
|
|
3338
|
-
]);
|
|
3339
|
-
return {
|
|
3340
|
-
model: captureModel(),
|
|
3341
|
-
claudeVersion,
|
|
3342
|
-
git,
|
|
3343
|
-
cwd: process.cwd()
|
|
3344
|
-
};
|
|
3345
|
-
}
|
|
3346
|
-
var execFileAsync, cachedClaudeVersion = null;
|
|
3347
|
-
var init_spawn_context = __esm(() => {
|
|
3348
|
-
execFileAsync = promisify(execFile2);
|
|
3349
|
-
});
|
|
3350
|
-
|
|
3351
3067
|
// src/lib/server-lifecycle.ts
|
|
3352
3068
|
import { spawn as spawn3 } from "child_process";
|
|
3353
3069
|
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
@@ -3434,6 +3150,44 @@ var init_server_lifecycle = __esm(() => {
|
|
|
3434
3150
|
deps = defaultDeps;
|
|
3435
3151
|
});
|
|
3436
3152
|
|
|
3153
|
+
// src/hooks/runtime.ts
|
|
3154
|
+
import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
|
|
3155
|
+
import { join as join10 } from "path";
|
|
3156
|
+
function rmMarker(name) {
|
|
3157
|
+
rmSync(join10(runtimeDir, name), { force: true });
|
|
3158
|
+
}
|
|
3159
|
+
function pruneSessionMarkers(sessionId, conversationId) {
|
|
3160
|
+
rmMarker(`done-${sessionId}`);
|
|
3161
|
+
rmMarker(`auq-nudge-${sessionId}`);
|
|
3162
|
+
rmMarker(`working-${sessionId}`);
|
|
3163
|
+
rmMarker(`worktree-${sessionId}`);
|
|
3164
|
+
if (conversationId)
|
|
3165
|
+
rmMarker(`${CONTRACT_MARKER_PREFIX}${conversationId}`);
|
|
3166
|
+
}
|
|
3167
|
+
function pruneStaleContractMarkers(maxAgeMs = STALE_MS) {
|
|
3168
|
+
let entries;
|
|
3169
|
+
try {
|
|
3170
|
+
entries = readdirSync2(runtimeDir);
|
|
3171
|
+
} catch {
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
3175
|
+
for (const name of entries) {
|
|
3176
|
+
if (!name.startsWith(CONTRACT_MARKER_PREFIX))
|
|
3177
|
+
continue;
|
|
3178
|
+
try {
|
|
3179
|
+
if (statSync4(join10(runtimeDir, name)).mtimeMs < cutoff)
|
|
3180
|
+
rmMarker(name);
|
|
3181
|
+
} catch {}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
var CONTRACT_MARKER_PREFIX = "contract-sent-", STALE_MS, runtimeDir;
|
|
3185
|
+
var init_runtime = __esm(() => {
|
|
3186
|
+
init_paths();
|
|
3187
|
+
STALE_MS = 24 * 60 * 60 * 1000;
|
|
3188
|
+
runtimeDir = paths.runtime;
|
|
3189
|
+
});
|
|
3190
|
+
|
|
3437
3191
|
// src/engine/session.ts
|
|
3438
3192
|
import { randomUUID } from "crypto";
|
|
3439
3193
|
function forceFinalizeLive() {
|
|
@@ -3470,6 +3224,7 @@ function installExitHandlers() {
|
|
|
3470
3224
|
process.on("SIGHUP", onSignal);
|
|
3471
3225
|
}
|
|
3472
3226
|
async function launch(opts) {
|
|
3227
|
+
pruneStaleContractMarkers();
|
|
3473
3228
|
const existingCategory = getCategoryByPath(opts.categoryPath);
|
|
3474
3229
|
if (existingCategory) {
|
|
3475
3230
|
const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
|
|
@@ -3496,24 +3251,11 @@ async function launch(opts) {
|
|
|
3496
3251
|
liveSession = { sessionId: session.id, claudeId };
|
|
3497
3252
|
installExitHandlers();
|
|
3498
3253
|
await ensureServerStarted();
|
|
3499
|
-
const spawnContext = await captureSpawnContext();
|
|
3500
3254
|
const sessionName = `${opts.categoryPath}/${opts.slug}`;
|
|
3501
|
-
emitSessionStarted({
|
|
3502
|
-
sessionId: session.id,
|
|
3503
|
-
conversationId: claudeId,
|
|
3504
|
-
categoryPath: opts.categoryPath,
|
|
3505
|
-
sessionName: opts.name ?? opts.slug,
|
|
3506
|
-
sessionSlug: opts.slug,
|
|
3507
|
-
labels: opts.labelNames ?? [],
|
|
3508
|
-
summary: session.summary ?? null
|
|
3509
|
-
});
|
|
3510
3255
|
emitClaudeStarted({
|
|
3511
3256
|
sessionId: session.id,
|
|
3512
3257
|
conversationId: claudeId,
|
|
3513
|
-
|
|
3514
|
-
claudeVersion: spawnContext.claudeVersion,
|
|
3515
|
-
git: spawnContext.git,
|
|
3516
|
-
cwd: spawnContext.cwd
|
|
3258
|
+
cwd: process.cwd()
|
|
3517
3259
|
});
|
|
3518
3260
|
const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
|
|
3519
3261
|
const contract = buildContract(sessionName, siblingContext);
|
|
@@ -3528,6 +3270,7 @@ async function launch(opts) {
|
|
|
3528
3270
|
return session.id;
|
|
3529
3271
|
}
|
|
3530
3272
|
async function resume(opts) {
|
|
3273
|
+
pruneStaleContractMarkers();
|
|
3531
3274
|
const session = getSession(opts.sessionId);
|
|
3532
3275
|
if (!session)
|
|
3533
3276
|
throw new Error(`Session not found: ${opts.sessionId}`);
|
|
@@ -3538,19 +3281,11 @@ async function resume(opts) {
|
|
|
3538
3281
|
liveSession = { sessionId: session.id, claudeId: opts.conversationId };
|
|
3539
3282
|
installExitHandlers();
|
|
3540
3283
|
await ensureServerStarted();
|
|
3541
|
-
emitSessionResumed({
|
|
3542
|
-
sessionId: session.id,
|
|
3543
|
-
conversationId: opts.conversationId
|
|
3544
|
-
});
|
|
3545
3284
|
if (isFreshClaudeSession) {
|
|
3546
|
-
const spawnContext = await captureSpawnContext();
|
|
3547
3285
|
emitClaudeStarted({
|
|
3548
3286
|
sessionId: session.id,
|
|
3549
3287
|
conversationId: opts.conversationId,
|
|
3550
|
-
|
|
3551
|
-
claudeVersion: spawnContext.claudeVersion,
|
|
3552
|
-
git: spawnContext.git,
|
|
3553
|
-
cwd: spawnContext.cwd
|
|
3288
|
+
cwd: process.cwd()
|
|
3554
3289
|
});
|
|
3555
3290
|
}
|
|
3556
3291
|
const categoryPath = category?.path ?? "";
|
|
@@ -3587,7 +3322,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
|
|
|
3587
3322
|
});
|
|
3588
3323
|
if (liveSession?.sessionId === sessionId)
|
|
3589
3324
|
liveSession = null;
|
|
3590
|
-
|
|
3325
|
+
pruneSessionMarkers(sessionId, safeConversationId);
|
|
3591
3326
|
computeAndPersist(sessionId);
|
|
3592
3327
|
stopServerIfIdle();
|
|
3593
3328
|
triggerBackgroundPush();
|
|
@@ -3602,21 +3337,21 @@ var init_session = __esm(() => {
|
|
|
3602
3337
|
init_template2();
|
|
3603
3338
|
init_context();
|
|
3604
3339
|
init_process();
|
|
3605
|
-
init_spawn_context();
|
|
3606
3340
|
init_timing();
|
|
3607
3341
|
init_server_lifecycle();
|
|
3608
3342
|
init_trigger();
|
|
3609
3343
|
init_transcript();
|
|
3344
|
+
init_runtime();
|
|
3610
3345
|
});
|
|
3611
3346
|
|
|
3612
3347
|
// src/tui/app.tsx
|
|
3613
3348
|
import { spawn as spawn4 } from "child_process";
|
|
3614
3349
|
import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
|
|
3615
3350
|
import { tmpdir } from "os";
|
|
3616
|
-
import { join as
|
|
3351
|
+
import { join as join11 } from "path";
|
|
3617
3352
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3618
3353
|
async function runScreen(screen, ...args) {
|
|
3619
|
-
const tmpFile =
|
|
3354
|
+
const tmpFile = join11(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
|
|
3620
3355
|
if (process.env.BERTRAND_DEBUG_TUI) {
|
|
3621
3356
|
try {
|
|
3622
3357
|
const { appendFileSync } = await import("fs");
|
|
@@ -3626,7 +3361,7 @@ async function runScreen(screen, ...args) {
|
|
|
3626
3361
|
}
|
|
3627
3362
|
const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
|
|
3628
3363
|
stdio: "inherit",
|
|
3629
|
-
env: process.env
|
|
3364
|
+
env: { ...process.env, BERTRAND_PROJECT: resolveActiveProject().slug }
|
|
3630
3365
|
});
|
|
3631
3366
|
const noopSignal = () => {};
|
|
3632
3367
|
process.on("SIGINT", noopSignal);
|
|
@@ -3773,35 +3508,11 @@ var init_app = __esm(() => {
|
|
|
3773
3508
|
init_create();
|
|
3774
3509
|
init_resolve();
|
|
3775
3510
|
SCREEN_ENTRY = (() => {
|
|
3776
|
-
const built =
|
|
3777
|
-
return existsSync8(built) ? built :
|
|
3511
|
+
const built = join11(import.meta.dir, "run-screen.js");
|
|
3512
|
+
return existsSync8(built) ? built : join11(import.meta.dir, "run-screen.tsx");
|
|
3778
3513
|
})();
|
|
3779
3514
|
});
|
|
3780
3515
|
|
|
3781
|
-
// src/lib/parse-session-name.ts
|
|
3782
|
-
function parseSessionName(input) {
|
|
3783
|
-
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
3784
|
-
if (!trimmed) {
|
|
3785
|
-
throw new Error("Session name cannot be empty");
|
|
3786
|
-
}
|
|
3787
|
-
const segments = trimmed.split("/").filter(Boolean);
|
|
3788
|
-
if (segments.length < 2) {
|
|
3789
|
-
throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
|
|
3790
|
-
}
|
|
3791
|
-
for (const segment of segments) {
|
|
3792
|
-
if (!SEGMENT_PATTERN.test(segment)) {
|
|
3793
|
-
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
3794
|
-
}
|
|
3795
|
-
}
|
|
3796
|
-
const categoryPath = segments[0];
|
|
3797
|
-
const slug = segments.slice(1).join("/");
|
|
3798
|
-
return { categoryPath, slug };
|
|
3799
|
-
}
|
|
3800
|
-
var SEGMENT_PATTERN;
|
|
3801
|
-
var init_parse_session_name = __esm(() => {
|
|
3802
|
-
SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
3803
|
-
});
|
|
3804
|
-
|
|
3805
3516
|
// src/engine/recovery.ts
|
|
3806
3517
|
function isProcessAlive2(pid) {
|
|
3807
3518
|
try {
|
|
@@ -3820,10 +3531,6 @@ function recoverStaleSessions() {
|
|
|
3820
3531
|
status: "paused",
|
|
3821
3532
|
pid: null
|
|
3822
3533
|
});
|
|
3823
|
-
emitSessionPausedByRecovery({
|
|
3824
|
-
sessionId: session.id,
|
|
3825
|
-
stalePid: session.pid
|
|
3826
|
-
});
|
|
3827
3534
|
recovered++;
|
|
3828
3535
|
}
|
|
3829
3536
|
}
|
|
@@ -3831,7 +3538,6 @@ function recoverStaleSessions() {
|
|
|
3831
3538
|
}
|
|
3832
3539
|
var init_recovery = __esm(() => {
|
|
3833
3540
|
init_sessions();
|
|
3834
|
-
init_emit();
|
|
3835
3541
|
});
|
|
3836
3542
|
|
|
3837
3543
|
// src/cli/commands/launch.ts
|
|
@@ -3871,7 +3577,7 @@ var init_launch = __esm(() => {
|
|
|
3871
3577
|
function quietHelper(bin) {
|
|
3872
3578
|
return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
|
|
3873
3579
|
}
|
|
3874
|
-
function waitingScript(bin,
|
|
3580
|
+
function waitingScript(bin, runtimeDir2) {
|
|
3875
3581
|
return `#!/usr/bin/env bash
|
|
3876
3582
|
# Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
|
|
3877
3583
|
${quietHelper(bin)}
|
|
@@ -3896,18 +3602,15 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
|
|
|
3896
3602
|
[ -z "$question" ] && question="Waiting for input"
|
|
3897
3603
|
|
|
3898
3604
|
# Clear working debounce marker so next resume\u2192working transition fires
|
|
3899
|
-
rm -f "${
|
|
3605
|
+
rm -f "${runtimeDir2}/working-$sid"
|
|
3900
3606
|
|
|
3901
3607
|
bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
|
|
3902
3608
|
|
|
3903
|
-
#
|
|
3904
|
-
#
|
|
3905
|
-
#
|
|
3906
|
-
# inside the command makes it idempotent vs the matching Stop-time capture,
|
|
3907
|
-
# so the same turn never lands twice.
|
|
3609
|
+
# Capture the latest assistant turn's text + recap tag. Dedup inside the
|
|
3610
|
+
# command makes it idempotent vs the matching Stop-time capture so the same
|
|
3611
|
+
# turn never lands twice.
|
|
3908
3612
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
3909
3613
|
if [ -n "$tpath" ]; then
|
|
3910
|
-
bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
3911
3614
|
bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
3912
3615
|
fi
|
|
3913
3616
|
|
|
@@ -3917,7 +3620,7 @@ bq notify bertrand "$question" &
|
|
|
3917
3620
|
wait
|
|
3918
3621
|
`;
|
|
3919
3622
|
}
|
|
3920
|
-
function answeredScript(bin,
|
|
3623
|
+
function answeredScript(bin, runtimeDir2) {
|
|
3921
3624
|
return `#!/usr/bin/env bash
|
|
3922
3625
|
# Hook: PostToolUse AskUserQuestion \u2192 mark session as active
|
|
3923
3626
|
#
|
|
@@ -3951,9 +3654,18 @@ bq update --session-id "$sid" --event session.answered --meta "$meta"
|
|
|
3951
3654
|
|
|
3952
3655
|
bq badge --clear &
|
|
3953
3656
|
|
|
3657
|
+
# The loop is healthy \u2014 the agent ended its turn on AskUserQuestion and the
|
|
3658
|
+
# user answered. Reset the Stop-hook nudge counter so its cap applies per
|
|
3659
|
+
# run of consecutive contract violations, not cumulatively across the session.
|
|
3660
|
+
rm -f "${runtimeDir2}/auq-nudge-$sid"
|
|
3661
|
+
|
|
3954
3662
|
# Halt the agent loop if the user signaled Done for now. The Stop hook
|
|
3955
3663
|
# (on-done.sh) will fire afterwards and mark the session as paused.
|
|
3956
3664
|
if printf '%s' "$done_check" | grep -q "Done for now"; then
|
|
3665
|
+
# Tell on-done.sh this Stop is a legitimate exit, not a dropped AUQ call \u2014
|
|
3666
|
+
# so it pauses normally instead of forcing the loop to continue.
|
|
3667
|
+
touch "${runtimeDir2}/done-$sid"
|
|
3668
|
+
|
|
3957
3669
|
# Promote the picked Done-for-now option's description into a session.recap
|
|
3958
3670
|
# event so the timeline has a dedicated end-of-session summary row. Bertrand
|
|
3959
3671
|
# forces session exit before Claude can write a closing message, so this
|
|
@@ -3971,35 +3683,9 @@ fi
|
|
|
3971
3683
|
wait
|
|
3972
3684
|
`;
|
|
3973
3685
|
}
|
|
3974
|
-
function
|
|
3975
|
-
return `#!/usr/bin/env bash
|
|
3976
|
-
# Hook: PreToolUse (catch-all) \u2192 flip waiting to active
|
|
3977
|
-
${quietHelper(bin)}
|
|
3978
|
-
sid="\${BERTRAND_SESSION:-}"
|
|
3979
|
-
[ -z "$sid" ] && exit 0
|
|
3980
|
-
|
|
3981
|
-
# Debounce: skip if we already sent session.active within the last 5 seconds.
|
|
3982
|
-
# This avoids spawning bertrand (~31ms) on every tool call during rapid sequences.
|
|
3983
|
-
marker="${runtimeDir}/working-$sid"
|
|
3984
|
-
if [ -f "$marker" ]; then
|
|
3985
|
-
age=$(( $(date +%s) - $(stat -f%m "$marker" 2>/dev/null || echo 0) ))
|
|
3986
|
-
[ "$age" -lt 5 ] && exit 0
|
|
3987
|
-
fi
|
|
3988
|
-
|
|
3989
|
-
input="$(cat)"
|
|
3990
|
-
${EXTRACT_TOOL}
|
|
3991
|
-
|
|
3992
|
-
# AskUserQuestion has its own PreToolUse hook
|
|
3993
|
-
[ "$tool" = "AskUserQuestion" ] && exit 0
|
|
3994
|
-
|
|
3995
|
-
touch "$marker"
|
|
3996
|
-
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3997
|
-
bq update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
|
|
3998
|
-
`;
|
|
3999
|
-
}
|
|
4000
|
-
function permissionWaitScript(bin, runtimeDir) {
|
|
3686
|
+
function permissionWaitScript(bin, runtimeDir2) {
|
|
4001
3687
|
return `#!/usr/bin/env bash
|
|
4002
|
-
# Hook: PermissionRequest \u2192 mark pending,
|
|
3688
|
+
# Hook: PermissionRequest \u2192 mark pending, badge + notify
|
|
4003
3689
|
${quietHelper(bin)}
|
|
4004
3690
|
sid="\${BERTRAND_SESSION:-}"
|
|
4005
3691
|
[ -z "$sid" ] && exit 0
|
|
@@ -4008,18 +3694,10 @@ input="$(cat)"
|
|
|
4008
3694
|
${EXTRACT_TOOL}
|
|
4009
3695
|
[ "$tool" = "AskUserQuestion" ] && exit 0
|
|
4010
3696
|
|
|
4011
|
-
#
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
detail=""
|
|
4016
|
-
case "$tool" in
|
|
4017
|
-
Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
|
|
4018
|
-
Edit|Write|Read) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
|
|
4019
|
-
esac
|
|
4020
|
-
|
|
4021
|
-
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
4022
|
-
bq 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}')"
|
|
3697
|
+
# Marker tells the PostToolUse hook to emit tool.used with outcome:approved
|
|
3698
|
+
# instead of outcome:auto. Without it, every prompted-then-approved tool call
|
|
3699
|
+
# would look identical to an auto-approved one.
|
|
3700
|
+
touch "${runtimeDir2}/perm-pending-$sid"
|
|
4023
3701
|
|
|
4024
3702
|
# Badge + notify in background
|
|
4025
3703
|
bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
|
|
@@ -4027,21 +3705,19 @@ bq notify bertrand "Needs permission: $tool" &
|
|
|
4027
3705
|
wait
|
|
4028
3706
|
`;
|
|
4029
3707
|
}
|
|
4030
|
-
function permissionDoneScript(bin,
|
|
3708
|
+
function permissionDoneScript(bin, runtimeDir2) {
|
|
4031
3709
|
return `#!/usr/bin/env bash
|
|
4032
3710
|
# Hook: PostToolUse (catch-all)
|
|
4033
3711
|
#
|
|
4034
|
-
# Captures every tool call Claude makes.
|
|
3712
|
+
# Captures every tool call Claude makes. Two event flows:
|
|
4035
3713
|
# 1. Edit/Write/MultiEdit \u2192 tool.applied with diff payload. Keeps the
|
|
4036
3714
|
# existing dashboard diff-renderer happy and is the only place we get
|
|
4037
3715
|
# old_string/new_string on auto-approved edits.
|
|
4038
|
-
# 2.
|
|
4039
|
-
#
|
|
4040
|
-
#
|
|
4041
|
-
#
|
|
4042
|
-
#
|
|
4043
|
-
# with outcome:"auto". Previously this case dropped the call entirely;
|
|
4044
|
-
# now Claude's read-only / shell activity shows up in the timeline.
|
|
3716
|
+
# 2. Everything else \u2192 tool.used. The PermissionRequest hook may have set
|
|
3717
|
+
# a marker; if so the call was prompted-then-approved (outcome:approved),
|
|
3718
|
+
# otherwise it was auto-approved (outcome:auto). Denials never reach
|
|
3719
|
+
# PostToolUse, so absence of a tool.used after a permission.request means
|
|
3720
|
+
# the user said no.
|
|
4045
3721
|
${quietHelper(bin)}
|
|
4046
3722
|
sid="\${BERTRAND_SESSION:-}"
|
|
4047
3723
|
[ -z "$sid" ] && exit 0
|
|
@@ -4049,10 +3725,11 @@ sid="\${BERTRAND_SESSION:-}"
|
|
|
4049
3725
|
input="$(cat)"
|
|
4050
3726
|
${EXTRACT_TOOL}
|
|
4051
3727
|
|
|
4052
|
-
# Don't double-log: AskUserQuestion has its own waiting/answered events
|
|
4053
|
-
|
|
3728
|
+
# Don't double-log: AskUserQuestion has its own waiting/answered events, and
|
|
3729
|
+
# EnterWorktree/ExitWorktree have their own worktree.entered/exited hooks.
|
|
3730
|
+
case "$tool" in AskUserQuestion|EnterWorktree|ExitWorktree) exit 0 ;; esac
|
|
4054
3731
|
|
|
4055
|
-
marker="${
|
|
3732
|
+
marker="${runtimeDir2}/perm-pending-$sid"
|
|
4056
3733
|
had_marker=0
|
|
4057
3734
|
if [ -f "$marker" ]; then
|
|
4058
3735
|
had_marker=1
|
|
@@ -4100,20 +3777,23 @@ case "$tool" in
|
|
|
4100
3777
|
WebSearch) detail="$(printf '%s' "$input" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
|
|
4101
3778
|
esac
|
|
4102
3779
|
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
bq 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}')"
|
|
4107
|
-
else
|
|
4108
|
-
# Auto-approved path. Without tool.used, these calls were invisible.
|
|
4109
|
-
bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"auto", claude_id:$cid}')"
|
|
4110
|
-
fi
|
|
3780
|
+
outcome="auto"
|
|
3781
|
+
[ "$had_marker" = "1" ] && outcome="approved"
|
|
3782
|
+
bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg o "$outcome" --arg cid "$cid" '{tool:$t, detail:$d, outcome:$o, claude_id:$cid}')"
|
|
4111
3783
|
wait
|
|
4112
3784
|
`;
|
|
4113
3785
|
}
|
|
4114
|
-
function userPromptScript(bin,
|
|
3786
|
+
function userPromptScript(bin, runtimeDir2) {
|
|
4115
3787
|
return `#!/usr/bin/env bash
|
|
4116
|
-
# Hook: UserPromptSubmit \u2192 record user
|
|
3788
|
+
# Hook: UserPromptSubmit \u2192 record user prompt + re-inject the session contract.
|
|
3789
|
+
#
|
|
3790
|
+
# The contract normally arrives via --append-system-prompt on bertrand's own
|
|
3791
|
+
# claude spawn, which reaches only that one process. Sessions that inherit the
|
|
3792
|
+
# BERTRAND_* env vars without going through launchClaude (background jobs,
|
|
3793
|
+
# nested \`claude\`, the Warp plugin's own launcher) never receive it. Re-
|
|
3794
|
+
# injecting here \u2014 through the durable env/hook channel \u2014 closes that gap.
|
|
3795
|
+
# Full contract on the first prompt of each conversation, a one-line reminder
|
|
3796
|
+
# thereafter, to keep the per-turn token cost low.
|
|
4117
3797
|
${quietHelper(bin)}
|
|
4118
3798
|
sid="\${BERTRAND_SESSION:-}"
|
|
4119
3799
|
[ -z "$sid" ] && exit 0
|
|
@@ -4121,34 +3801,102 @@ sid="\${BERTRAND_SESSION:-}"
|
|
|
4121
3801
|
input="$(cat)"
|
|
4122
3802
|
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
4123
3803
|
|
|
3804
|
+
# Record the prompt event. Stdout muted so only the context JSON below reaches
|
|
3805
|
+
# the hook's stdout (UserPromptSubmit parses stdout as a hook decision).
|
|
4124
3806
|
meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
|
|
4125
|
-
[ -
|
|
3807
|
+
[ -n "$meta" ] && bq update --session-id "$sid" --event user.prompt --meta "$meta" >/dev/null
|
|
3808
|
+
|
|
3809
|
+
# Re-deliver the contract as additional context.
|
|
3810
|
+
marker="${runtimeDir2}/contract-sent-\${cid:-$sid}"
|
|
3811
|
+
if [ -f "$marker" ]; then
|
|
3812
|
+
contract="$(bq contract --session-id "$sid" --short)"
|
|
3813
|
+
else
|
|
3814
|
+
contract="$(bq contract --session-id "$sid")"
|
|
3815
|
+
: > "$marker"
|
|
3816
|
+
fi
|
|
4126
3817
|
|
|
4127
|
-
|
|
3818
|
+
[ -n "$contract" ] && jq -n --arg c "$contract" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
|
4128
3819
|
`;
|
|
4129
3820
|
}
|
|
4130
|
-
function doneScript(bin,
|
|
3821
|
+
function doneScript(bin, runtimeDir2) {
|
|
4131
3822
|
return `#!/usr/bin/env bash
|
|
4132
|
-
# Hook: Stop \u2192
|
|
3823
|
+
# Hook: Stop \u2192 enforce AUQ loop, else flip session status to paused.
|
|
4133
3824
|
${quietHelper(bin)}
|
|
4134
3825
|
sid="\${BERTRAND_SESSION:-}"
|
|
4135
3826
|
[ -z "$sid" ] && exit 0
|
|
4136
3827
|
|
|
4137
3828
|
input="$(cat)"
|
|
4138
3829
|
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
4139
|
-
bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
|
|
4140
3830
|
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
#
|
|
3831
|
+
done_marker="${runtimeDir2}/done-$sid"
|
|
3832
|
+
nudge_marker="${runtimeDir2}/auq-nudge-$sid"
|
|
3833
|
+
|
|
3834
|
+
# Capture the assistant turn either way. Stdout is muted so it can never corrupt
|
|
3835
|
+
# a decision-JSON payload. Dedups against the most-recent AskUQ-time capture, so
|
|
3836
|
+
# a Done-for-now exit lands zero new events; a dropped-AUQ Stop records the
|
|
3837
|
+
# stray turn; intermediate Stops with fresh output land normally.
|
|
4145
3838
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
4146
3839
|
if [ -n "$tpath" ]; then
|
|
4147
|
-
bq
|
|
4148
|
-
|
|
3840
|
+
bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" >/dev/null &
|
|
3841
|
+
fi
|
|
3842
|
+
|
|
3843
|
+
if [ ! -f "$done_marker" ]; then
|
|
3844
|
+
# Not a Done-for-now exit \u2192 the turn ended without AskUserQuestion. Force the
|
|
3845
|
+
# loop to continue, up to a small cap so a context where AUQ is genuinely
|
|
3846
|
+
# unavailable can't wedge the session in an endless block/stop cycle. The
|
|
3847
|
+
# counter is reset on every answered AUQ (on-answered.sh), so the cap bounds
|
|
3848
|
+
# consecutive violations, not the whole session.
|
|
3849
|
+
count="$(cat "$nudge_marker" 2>/dev/null)"
|
|
3850
|
+
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
|
3851
|
+
if [ "$count" -lt 3 ]; then
|
|
3852
|
+
printf '%s' "$((count + 1))" > "$nudge_marker"
|
|
3853
|
+
reason='This is a bertrand session: every turn must end with an AskUserQuestion call (multiSelect:true on every question) that includes a "Done for now" option, preceded by a <recap> block. You ended a turn without calling AskUserQuestion. Call it now to continue the loop, or \u2014 if the work is finished \u2014 present it so the user can pick "Done for now" to end the session.'
|
|
3854
|
+
wait
|
|
3855
|
+
jq -n --arg r "$reason" '{decision:"block", reason:$r}'
|
|
3856
|
+
exit 0
|
|
3857
|
+
fi
|
|
3858
|
+
# Cap reached \u2014 stop nudging and let the session pause normally.
|
|
4149
3859
|
fi
|
|
4150
3860
|
|
|
3861
|
+
# Terminal path: legitimate Done-for-now exit, or nudge cap exhausted.
|
|
3862
|
+
rm -f "$done_marker" "$nudge_marker"
|
|
3863
|
+
bq update --session-id "$sid" --event session.paused
|
|
4151
3864
|
bq badge check --color '#58c142' --priority 10
|
|
3865
|
+
wait
|
|
3866
|
+
`;
|
|
3867
|
+
}
|
|
3868
|
+
function enterWorktreeScript(bin, runtimeDir2) {
|
|
3869
|
+
return `#!/usr/bin/env bash
|
|
3870
|
+
# Hook: PostToolUse EnterWorktree \u2192 record worktree path + branch on the session.
|
|
3871
|
+
${quietHelper(bin)}
|
|
3872
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
3873
|
+
[ -z "$sid" ] && exit 0
|
|
3874
|
+
|
|
3875
|
+
input="$(cat)"
|
|
3876
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3877
|
+
|
|
3878
|
+
path="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
|
|
3879
|
+
[ -z "$path" ] && exit 0
|
|
3880
|
+
branch="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
|
3881
|
+
|
|
3882
|
+
printf '%s' "$path" > "${runtimeDir2}/worktree-$sid"
|
|
3883
|
+
|
|
3884
|
+
bq update --session-id "$sid" --event worktree.entered --meta "$(jq -n --arg p "$path" --arg b "$branch" --arg cid "$cid" '{path:$p, branch:$b, claude_id:$cid}')"
|
|
3885
|
+
`;
|
|
3886
|
+
}
|
|
3887
|
+
function exitWorktreeScript(bin, runtimeDir2) {
|
|
3888
|
+
return `#!/usr/bin/env bash
|
|
3889
|
+
# Hook: PostToolUse ExitWorktree \u2192 clear the session's worktree state.
|
|
3890
|
+
${quietHelper(bin)}
|
|
3891
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
3892
|
+
[ -z "$sid" ] && exit 0
|
|
3893
|
+
|
|
3894
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3895
|
+
marker="${runtimeDir2}/worktree-$sid"
|
|
3896
|
+
path="$(cat "$marker" 2>/dev/null)"
|
|
3897
|
+
rm -f "$marker"
|
|
3898
|
+
|
|
3899
|
+
bq update --session-id "$sid" --event worktree.exited --meta "$(jq -n --arg p "$path" --arg cid "$cid" '{path:$p, claude_id:$cid}')"
|
|
4152
3900
|
`;
|
|
4153
3901
|
}
|
|
4154
3902
|
var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
|
|
@@ -4156,22 +3904,23 @@ var init_scripts = __esm(() => {
|
|
|
4156
3904
|
HOOK_SCRIPTS = {
|
|
4157
3905
|
"on-waiting.sh": waitingScript,
|
|
4158
3906
|
"on-answered.sh": answeredScript,
|
|
4159
|
-
"on-active.sh": activeScript,
|
|
4160
3907
|
"on-permission-wait.sh": permissionWaitScript,
|
|
4161
3908
|
"on-permission-done.sh": permissionDoneScript,
|
|
4162
3909
|
"on-user-prompt.sh": userPromptScript,
|
|
4163
|
-
"on-done.sh": doneScript
|
|
3910
|
+
"on-done.sh": doneScript,
|
|
3911
|
+
"on-enter-worktree.sh": enterWorktreeScript,
|
|
3912
|
+
"on-exit-worktree.sh": exitWorktreeScript
|
|
4164
3913
|
};
|
|
4165
3914
|
});
|
|
4166
3915
|
|
|
4167
3916
|
// src/hooks/install.ts
|
|
4168
3917
|
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
|
|
4169
|
-
import { join as
|
|
3918
|
+
import { join as join12 } from "path";
|
|
4170
3919
|
function installHookScripts(bin) {
|
|
4171
3920
|
mkdirSync7(paths.hooks, { recursive: true });
|
|
4172
3921
|
mkdirSync7(paths.runtime, { recursive: true });
|
|
4173
3922
|
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
4174
|
-
const filePath =
|
|
3923
|
+
const filePath = join12(paths.hooks, filename);
|
|
4175
3924
|
writeFileSync5(filePath, scriptFn(bin, paths.runtime));
|
|
4176
3925
|
chmodSync2(filePath, 493);
|
|
4177
3926
|
}
|
|
@@ -4184,7 +3933,7 @@ var init_install = __esm(() => {
|
|
|
4184
3933
|
|
|
4185
3934
|
// src/hooks/settings.ts
|
|
4186
3935
|
import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
|
|
4187
|
-
import { join as
|
|
3936
|
+
import { join as join13, dirname as dirname4 } from "path";
|
|
4188
3937
|
import { homedir as homedir3 } from "os";
|
|
4189
3938
|
function isBertrandGroup(group) {
|
|
4190
3939
|
return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
|
|
@@ -4209,16 +3958,12 @@ function installHookSettings() {
|
|
|
4209
3958
|
var SETTINGS_PATH, BERTRAND_HOOKS;
|
|
4210
3959
|
var init_settings = __esm(() => {
|
|
4211
3960
|
init_paths();
|
|
4212
|
-
SETTINGS_PATH =
|
|
3961
|
+
SETTINGS_PATH = join13(homedir3(), ".claude", "settings.json");
|
|
4213
3962
|
BERTRAND_HOOKS = {
|
|
4214
3963
|
PreToolUse: [
|
|
4215
3964
|
{
|
|
4216
3965
|
matcher: "AskUserQuestion",
|
|
4217
3966
|
hooks: [{ type: "command", command: `${paths.hooks}/on-waiting.sh` }]
|
|
4218
|
-
},
|
|
4219
|
-
{
|
|
4220
|
-
matcher: "",
|
|
4221
|
-
hooks: [{ type: "command", command: `${paths.hooks}/on-active.sh` }]
|
|
4222
3967
|
}
|
|
4223
3968
|
],
|
|
4224
3969
|
PostToolUse: [
|
|
@@ -4226,6 +3971,14 @@ var init_settings = __esm(() => {
|
|
|
4226
3971
|
matcher: "AskUserQuestion",
|
|
4227
3972
|
hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
|
|
4228
3973
|
},
|
|
3974
|
+
{
|
|
3975
|
+
matcher: "EnterWorktree",
|
|
3976
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-enter-worktree.sh` }]
|
|
3977
|
+
},
|
|
3978
|
+
{
|
|
3979
|
+
matcher: "ExitWorktree",
|
|
3980
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-exit-worktree.sh` }]
|
|
3981
|
+
},
|
|
4229
3982
|
{
|
|
4230
3983
|
matcher: "",
|
|
4231
3984
|
hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
|
|
@@ -4254,7 +4007,7 @@ var init_settings = __esm(() => {
|
|
|
4254
4007
|
|
|
4255
4008
|
// src/lib/completions.ts
|
|
4256
4009
|
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
|
|
4257
|
-
import { join as
|
|
4010
|
+
import { join as join14 } from "path";
|
|
4258
4011
|
function bashCompletion() {
|
|
4259
4012
|
return `# bertrand bash completion
|
|
4260
4013
|
_bertrand() {
|
|
@@ -4284,11 +4037,11 @@ function fishCompletion() {
|
|
|
4284
4037
|
`;
|
|
4285
4038
|
}
|
|
4286
4039
|
function generateCompletions() {
|
|
4287
|
-
const dir =
|
|
4040
|
+
const dir = join14(paths.root, "completions");
|
|
4288
4041
|
mkdirSync9(dir, { recursive: true });
|
|
4289
|
-
writeFileSync7(
|
|
4290
|
-
writeFileSync7(
|
|
4291
|
-
writeFileSync7(
|
|
4042
|
+
writeFileSync7(join14(dir, "bertrand.bash"), bashCompletion());
|
|
4043
|
+
writeFileSync7(join14(dir, "_bertrand"), zshCompletion());
|
|
4044
|
+
writeFileSync7(join14(dir, "bertrand.fish"), fishCompletion());
|
|
4292
4045
|
console.log(`Shell completions written to ${dir}`);
|
|
4293
4046
|
console.log(" Add to your shell config:");
|
|
4294
4047
|
console.log(` bash: source ${dir}/bertrand.bash`);
|
|
@@ -4319,7 +4072,7 @@ var init_completions = __esm(() => {
|
|
|
4319
4072
|
var exports_init = {};
|
|
4320
4073
|
import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
|
|
4321
4074
|
import { execSync as execSync2 } from "child_process";
|
|
4322
|
-
import { join as
|
|
4075
|
+
import { join as join15 } from "path";
|
|
4323
4076
|
function detectTerminal() {
|
|
4324
4077
|
try {
|
|
4325
4078
|
execSync2("which wsh", { stdio: "ignore" });
|
|
@@ -4334,8 +4087,8 @@ function resolveBin() {
|
|
|
4334
4087
|
return onPath;
|
|
4335
4088
|
const entry = process.argv[1];
|
|
4336
4089
|
if (entry && SOURCE_ENTRY.test(entry)) {
|
|
4337
|
-
const launcherDir =
|
|
4338
|
-
const launcher =
|
|
4090
|
+
const launcherDir = join15(paths.root, "bin");
|
|
4091
|
+
const launcher = join15(launcherDir, "bertrand-dev");
|
|
4339
4092
|
mkdirSync10(launcherDir, { recursive: true });
|
|
4340
4093
|
writeFileSync8(launcher, `#!/usr/bin/env bash
|
|
4341
4094
|
exec ${process.execPath} ${JSON.stringify(entry)} "$@"
|
|
@@ -4542,33 +4295,13 @@ function extractSummary(row) {
|
|
|
4542
4295
|
const answers = meta.answers;
|
|
4543
4296
|
return answers ? Object.values(answers).join(", ") : "";
|
|
4544
4297
|
}
|
|
4545
|
-
case "permission.request":
|
|
4546
|
-
case "permission.resolve": {
|
|
4547
|
-
const tool = meta.tool ?? "";
|
|
4548
|
-
const detail = meta.detail ?? "";
|
|
4549
|
-
return detail ? `${tool}: ${detail}` : tool;
|
|
4550
|
-
}
|
|
4551
|
-
case "gh.pr.created": {
|
|
4552
|
-
const title = meta.pr_title ?? "";
|
|
4553
|
-
const url = meta.pr_url ?? "";
|
|
4554
|
-
return title || url;
|
|
4555
|
-
}
|
|
4556
|
-
case "gh.pr.merged":
|
|
4557
|
-
return meta.branch ?? "";
|
|
4558
|
-
case "worktree.entered":
|
|
4559
|
-
return meta.branch ?? "";
|
|
4560
|
-
case "linear.issue.read":
|
|
4561
|
-
return meta.issue_title ?? "";
|
|
4562
|
-
case "notion.page.read":
|
|
4563
|
-
return meta.page_title ?? "";
|
|
4564
|
-
case "vercel.deploy":
|
|
4565
|
-
return meta.project_name ?? "";
|
|
4566
4298
|
case "user.prompt":
|
|
4567
4299
|
return meta.prompt ?? "";
|
|
4568
|
-
case "context.snapshot":
|
|
4569
|
-
return meta.remaining_pct ? `${meta.remaining_pct}% remaining` : "";
|
|
4570
4300
|
case "session.recap":
|
|
4571
4301
|
return meta.recap ?? "";
|
|
4302
|
+
case "worktree.entered":
|
|
4303
|
+
case "worktree.exited":
|
|
4304
|
+
return meta.branch ?? meta.path ?? "";
|
|
4572
4305
|
default:
|
|
4573
4306
|
return "";
|
|
4574
4307
|
}
|
|
@@ -4596,30 +4329,19 @@ function enrichAll(rows) {
|
|
|
4596
4329
|
var catalog, DEFAULT_INFO;
|
|
4597
4330
|
var init_catalog = __esm(() => {
|
|
4598
4331
|
catalog = {
|
|
4599
|
-
"session.started": { label: "started", category: "lifecycle", color: 34, detailColor: 245, skip: false },
|
|
4600
|
-
"session.resumed": { label: "resumed", category: "lifecycle", color: 34, detailColor: 245, skip: false },
|
|
4601
|
-
"session.end": { label: "ended", category: "lifecycle", color: 245, detailColor: 245, skip: false },
|
|
4602
4332
|
"claude.started": { label: "claude started", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
4603
4333
|
"claude.ended": { label: "claude ended", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
4604
4334
|
"claude.discarded": { label: "discarded", category: "lifecycle", color: 245, detailColor: 245, skip: false },
|
|
4605
4335
|
"session.waiting": { label: "waiting", category: "interaction", color: 33, detailColor: 245, skip: false },
|
|
4606
4336
|
"session.answered": { label: "answered", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
4607
|
-
"permission.request": { label: "permission", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4608
|
-
"permission.resolve": { label: "allowed", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4609
|
-
"worktree.entered": { label: "worktree", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
4610
|
-
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 35, detailColor: 245, skip: false },
|
|
4611
|
-
"gh.pr.created": { label: "PR created", category: "integration", color: 32, detailColor: 32, skip: false },
|
|
4612
|
-
"gh.pr.merged": { label: "PR merged", category: "integration", color: 35, detailColor: 35, skip: false },
|
|
4613
|
-
"linear.issue.read": { label: "Linear issue", category: "integration", color: 33, detailColor: 33, skip: false },
|
|
4614
|
-
"notion.page.read": { label: "Notion page", category: "integration", color: 245, detailColor: 245, skip: false },
|
|
4615
|
-
"vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
|
|
4616
4337
|
"user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
4617
|
-
"
|
|
4618
|
-
"tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: true },
|
|
4338
|
+
"tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4619
4339
|
"tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4620
4340
|
"session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
|
|
4621
4341
|
"assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
|
|
4622
|
-
"assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
|
|
4342
|
+
"assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false },
|
|
4343
|
+
"worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
|
|
4344
|
+
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
|
|
4623
4345
|
};
|
|
4624
4346
|
DEFAULT_INFO = {
|
|
4625
4347
|
label: "unknown",
|
|
@@ -4676,8 +4398,6 @@ function collapsePermissions(events2) {
|
|
|
4676
4398
|
continue;
|
|
4677
4399
|
const toolCounts = new Map;
|
|
4678
4400
|
for (const pev of batch) {
|
|
4679
|
-
if (pev.event !== "permission.request" && pev.event !== "tool.used")
|
|
4680
|
-
continue;
|
|
4681
4401
|
const meta = pev.meta;
|
|
4682
4402
|
const tool = meta?.tool ?? "unknown";
|
|
4683
4403
|
toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
|
|
@@ -4739,8 +4459,6 @@ var ROLLUP_EVENTS;
|
|
|
4739
4459
|
var init_compact = __esm(() => {
|
|
4740
4460
|
init_catalog();
|
|
4741
4461
|
ROLLUP_EVENTS = new Set([
|
|
4742
|
-
"permission.request",
|
|
4743
|
-
"permission.resolve",
|
|
4744
4462
|
"tool.used"
|
|
4745
4463
|
]);
|
|
4746
4464
|
});
|
|
@@ -4930,13 +4648,11 @@ var init_log = __esm(() => {
|
|
|
4930
4648
|
init_router();
|
|
4931
4649
|
init_sessions();
|
|
4932
4650
|
init_events();
|
|
4933
|
-
init_categories();
|
|
4934
4651
|
init_stats();
|
|
4935
4652
|
init_conversations();
|
|
4936
4653
|
init_catalog();
|
|
4937
4654
|
init_compact();
|
|
4938
4655
|
init_timing();
|
|
4939
|
-
init_parse_session_name();
|
|
4940
4656
|
init_format();
|
|
4941
4657
|
init_resolve();
|
|
4942
4658
|
init_cli_flag();
|
|
@@ -4956,18 +4672,12 @@ var init_log = __esm(() => {
|
|
|
4956
4672
|
showAllSessions();
|
|
4957
4673
|
return;
|
|
4958
4674
|
}
|
|
4959
|
-
const
|
|
4960
|
-
|
|
4961
|
-
if (!category) {
|
|
4962
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
4963
|
-
process.exit(1);
|
|
4964
|
-
}
|
|
4965
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
4966
|
-
if (!session) {
|
|
4675
|
+
const resolved = resolveSessionByName(target);
|
|
4676
|
+
if (!resolved) {
|
|
4967
4677
|
console.error(`Session not found: ${target}`);
|
|
4968
4678
|
process.exit(1);
|
|
4969
4679
|
}
|
|
4970
|
-
showSessionLog(session, `${categoryPath}/${slug}`, isJson);
|
|
4680
|
+
showSessionLog(resolved.session, `${resolved.categoryPath}/${resolved.slug}`, isJson);
|
|
4971
4681
|
});
|
|
4972
4682
|
});
|
|
4973
4683
|
|
|
@@ -4982,14 +4692,11 @@ function getMetrics(sessionId, name, status2) {
|
|
|
4982
4692
|
const allEvents = getEventsBySession(sessionId);
|
|
4983
4693
|
const conversations2 = new Set;
|
|
4984
4694
|
let interactionCount = 0;
|
|
4985
|
-
let prCount = 0;
|
|
4986
4695
|
for (const ev of allEvents) {
|
|
4987
4696
|
if (ev.conversationId)
|
|
4988
4697
|
conversations2.add(ev.conversationId);
|
|
4989
4698
|
if (ev.event === "session.waiting" || ev.event === "session.answered")
|
|
4990
4699
|
interactionCount++;
|
|
4991
|
-
if (ev.event === "gh.pr.created")
|
|
4992
|
-
prCount++;
|
|
4993
4700
|
}
|
|
4994
4701
|
return {
|
|
4995
4702
|
name,
|
|
@@ -4997,7 +4704,6 @@ function getMetrics(sessionId, name, status2) {
|
|
|
4997
4704
|
eventCount: allEvents.length,
|
|
4998
4705
|
conversationCount: conversations2.size,
|
|
4999
4706
|
interactionCount,
|
|
5000
|
-
prCount,
|
|
5001
4707
|
claudeWorkS: Math.round(timing.totalClaudeWorkMs / 1000),
|
|
5002
4708
|
userWaitS: Math.round(timing.totalUserWaitMs / 1000),
|
|
5003
4709
|
activePct: timing.activePct,
|
|
@@ -5018,9 +4724,8 @@ function renderGlobal(metrics, isJson) {
|
|
|
5018
4724
|
claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
|
|
5019
4725
|
userWaitS: acc.userWaitS + m.userWaitS,
|
|
5020
4726
|
conversations: acc.conversations + m.conversationCount,
|
|
5021
|
-
prs: acc.prs + m.prCount,
|
|
5022
4727
|
interactions: acc.interactions + m.interactionCount
|
|
5023
|
-
}), { sessions: 0, active: 0, durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0,
|
|
4728
|
+
}), { sessions: 0, active: 0, durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, interactions: 0 });
|
|
5024
4729
|
const totalTracked = totals.claudeWorkS + totals.userWaitS;
|
|
5025
4730
|
const globalActivePct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
|
|
5026
4731
|
if (isJson) {
|
|
@@ -5037,7 +4742,6 @@ function renderGlobal(metrics, isJson) {
|
|
|
5037
4742
|
console.log(` Claude work: ${dur(totals.claudeWorkS)} ${dim}(${pct(globalActivePct)})${reset}`);
|
|
5038
4743
|
console.log(` User wait: ${dur(totals.userWaitS)} ${dim}(${pct(100 - globalActivePct)})${reset}`);
|
|
5039
4744
|
console.log(` Conversations: ${totals.conversations}`);
|
|
5040
|
-
console.log(` PRs: ${totals.prs}`);
|
|
5041
4745
|
console.log(` Interactions: ${totals.interactions}`);
|
|
5042
4746
|
}
|
|
5043
4747
|
function renderCategory(metrics, categoryPath, isJson) {
|
|
@@ -5052,21 +4756,20 @@ function renderCategory(metrics, categoryPath, isJson) {
|
|
|
5052
4756
|
const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
|
|
5053
4757
|
console.log(`${bold}${categoryPath}${reset}
|
|
5054
4758
|
`);
|
|
5055
|
-
console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)}
|
|
4759
|
+
console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} CONVOS${reset}`);
|
|
5056
4760
|
for (const m of sorted) {
|
|
5057
|
-
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)} ${
|
|
4761
|
+
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)} ${m.conversationCount}`);
|
|
5058
4762
|
}
|
|
5059
4763
|
const totals = sorted.reduce((acc, m) => ({
|
|
5060
4764
|
durationS: acc.durationS + m.durationS,
|
|
5061
4765
|
claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
|
|
5062
4766
|
userWaitS: acc.userWaitS + m.userWaitS,
|
|
5063
|
-
conversations: acc.conversations + m.conversationCount
|
|
5064
|
-
|
|
5065
|
-
}), { durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, prs: 0 });
|
|
4767
|
+
conversations: acc.conversations + m.conversationCount
|
|
4768
|
+
}), { durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0 });
|
|
5066
4769
|
const totalTracked = totals.claudeWorkS + totals.userWaitS;
|
|
5067
4770
|
const totalPct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
|
|
5068
4771
|
console.log(`${dim}${"\u2500".repeat(maxName + 50)}${reset}`);
|
|
5069
|
-
console.log(`${"TOTAL".padEnd(maxName)} ${dur(totals.durationS).padEnd(8)} ${dur(totals.claudeWorkS).padEnd(8)} ${dur(totals.userWaitS).padEnd(8)} ${pct(totalPct).padEnd(5)} ${
|
|
4772
|
+
console.log(`${"TOTAL".padEnd(maxName)} ${dur(totals.durationS).padEnd(8)} ${dur(totals.claudeWorkS).padEnd(8)} ${dur(totals.userWaitS).padEnd(8)} ${pct(totalPct).padEnd(5)} ${totals.conversations}`);
|
|
5070
4773
|
}
|
|
5071
4774
|
function renderSession(m, isJson) {
|
|
5072
4775
|
if (isJson) {
|
|
@@ -5084,7 +4787,6 @@ function renderSession(m, isJson) {
|
|
|
5084
4787
|
console.log(` Events: ${m.eventCount}`);
|
|
5085
4788
|
console.log(` Conversations: ${m.conversationCount}`);
|
|
5086
4789
|
console.log(` Interactions: ${m.interactionCount}`);
|
|
5087
|
-
console.log(` PRs: ${m.prCount}`);
|
|
5088
4790
|
}
|
|
5089
4791
|
var ACTIVE_STATUSES2;
|
|
5090
4792
|
var init_stats2 = __esm(() => {
|
|
@@ -5095,7 +4797,6 @@ var init_stats2 = __esm(() => {
|
|
|
5095
4797
|
init_events();
|
|
5096
4798
|
init_timing();
|
|
5097
4799
|
init_format();
|
|
5098
|
-
init_parse_session_name();
|
|
5099
4800
|
ACTIVE_STATUSES2 = ["active", "waiting"];
|
|
5100
4801
|
register("stats", async (args) => {
|
|
5101
4802
|
const isJson = args.includes("--json");
|
|
@@ -5108,29 +4809,23 @@ var init_stats2 = __esm(() => {
|
|
|
5108
4809
|
return;
|
|
5109
4810
|
}
|
|
5110
4811
|
if (target.endsWith("/")) {
|
|
5111
|
-
const
|
|
5112
|
-
const
|
|
5113
|
-
if (!
|
|
5114
|
-
console.error(`Category not found: ${
|
|
4812
|
+
const categoryPath = target.replace(/\/+$/, "");
|
|
4813
|
+
const category = getCategoryByPath(categoryPath);
|
|
4814
|
+
if (!category) {
|
|
4815
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
5115
4816
|
process.exit(1);
|
|
5116
4817
|
}
|
|
5117
|
-
const categorySessions = getSessionsByCategory(
|
|
4818
|
+
const categorySessions = getSessionsByCategory(category.id);
|
|
5118
4819
|
const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
|
|
5119
|
-
renderCategory(metrics,
|
|
4820
|
+
renderCategory(metrics, categoryPath, isJson);
|
|
5120
4821
|
return;
|
|
5121
4822
|
}
|
|
5122
|
-
const
|
|
5123
|
-
|
|
5124
|
-
if (!category) {
|
|
5125
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
5126
|
-
process.exit(1);
|
|
5127
|
-
}
|
|
5128
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
5129
|
-
if (!session) {
|
|
4823
|
+
const resolved = resolveSessionByName(target);
|
|
4824
|
+
if (!resolved) {
|
|
5130
4825
|
console.error(`Session not found: ${target}`);
|
|
5131
4826
|
process.exit(1);
|
|
5132
4827
|
}
|
|
5133
|
-
const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
|
|
4828
|
+
const m = getMetrics(resolved.session.id, `${resolved.categoryPath}/${resolved.slug}`, resolved.session.status);
|
|
5134
4829
|
renderSession(m, isJson);
|
|
5135
4830
|
});
|
|
5136
4831
|
});
|
|
@@ -5156,24 +4851,16 @@ var init_backfill_stats = __esm(() => {
|
|
|
5156
4851
|
// src/cli/commands/archive.ts
|
|
5157
4852
|
var exports_archive = {};
|
|
5158
4853
|
function resolveSession(name) {
|
|
5159
|
-
const
|
|
5160
|
-
|
|
5161
|
-
if (!category) {
|
|
5162
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
5163
|
-
process.exit(1);
|
|
5164
|
-
}
|
|
5165
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
5166
|
-
if (!session) {
|
|
4854
|
+
const resolved = resolveSessionByName(name);
|
|
4855
|
+
if (!resolved) {
|
|
5167
4856
|
console.error(`Session not found: ${name}`);
|
|
5168
4857
|
process.exit(1);
|
|
5169
4858
|
}
|
|
5170
|
-
return { session, categoryPath };
|
|
4859
|
+
return { session: resolved.session, categoryPath: resolved.categoryPath };
|
|
5171
4860
|
}
|
|
5172
4861
|
var init_archive = __esm(() => {
|
|
5173
4862
|
init_router();
|
|
5174
4863
|
init_sessions();
|
|
5175
|
-
init_categories();
|
|
5176
|
-
init_parse_session_name();
|
|
5177
4864
|
init_session_archive();
|
|
5178
4865
|
register("archive", async (args) => {
|
|
5179
4866
|
const isUndo = args.includes("--undo");
|
|
@@ -5245,7 +4932,7 @@ __export(exports_project, {
|
|
|
5245
4932
|
createSubcommand: () => createSubcommand,
|
|
5246
4933
|
_UsageError: () => _UsageError
|
|
5247
4934
|
});
|
|
5248
|
-
import { existsSync as existsSync9, rmSync } from "fs";
|
|
4935
|
+
import { existsSync as existsSync9, rmSync as rmSync2 } from "fs";
|
|
5249
4936
|
function countSessions(slug) {
|
|
5250
4937
|
const dbFile = projectPaths(slug).db;
|
|
5251
4938
|
if (!existsSync9(dbFile))
|
|
@@ -5425,7 +5112,7 @@ function removeSubcommand(args) {
|
|
|
5425
5112
|
removeProject(slug);
|
|
5426
5113
|
invalidateDbCache(slug);
|
|
5427
5114
|
if (purge) {
|
|
5428
|
-
|
|
5115
|
+
rmSync2(projectPaths(slug).root, { recursive: true, force: true });
|
|
5429
5116
|
}
|
|
5430
5117
|
console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
|
|
5431
5118
|
}
|
|
@@ -5534,9 +5221,8 @@ init_router();
|
|
|
5534
5221
|
var command = process.argv[2];
|
|
5535
5222
|
var hotPath = {
|
|
5536
5223
|
update: () => Promise.resolve().then(() => (init_update(), exports_update)),
|
|
5537
|
-
snapshot: () => Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
|
|
5538
5224
|
"assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
5539
|
-
|
|
5225
|
+
contract: () => Promise.resolve().then(() => (init_contract(), exports_contract)),
|
|
5540
5226
|
badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5541
5227
|
notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
5542
5228
|
serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
@@ -5554,9 +5240,8 @@ if (command && command in hotPath) {
|
|
|
5554
5240
|
Promise.resolve().then(() => (init_backfill_stats(), exports_backfill_stats)),
|
|
5555
5241
|
Promise.resolve().then(() => (init_archive(), exports_archive)),
|
|
5556
5242
|
Promise.resolve().then(() => (init_update(), exports_update)),
|
|
5557
|
-
Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
|
|
5558
5243
|
Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
5559
|
-
Promise.resolve().then(() => (
|
|
5244
|
+
Promise.resolve().then(() => (init_contract(), exports_contract)),
|
|
5560
5245
|
Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
5561
5246
|
Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5562
5247
|
Promise.resolve().then(() => (init_notify(), exports_notify)),
|