bertrand 0.22.2 → 0.24.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/README.md +1 -1
- package/dist/bertrand.js +687 -491
- package/dist/dashboard/assets/index-BXNZ0sJ0.css +1 -0
- package/dist/dashboard/assets/index-DM4zHgCh.js +694 -0
- package/dist/dashboard/index.html +2 -2
- 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 +7 -0
- package/dist/run-screen.js +33 -28
- package/package.json +1 -1
- package/dist/dashboard/assets/index-CCHVGoKS.js +0 -654
- package/dist/dashboard/assets/index-D_l3EPhC.css +0 -1
package/dist/bertrand.js
CHANGED
|
@@ -504,6 +504,48 @@ var init_trigger = __esm(() => {
|
|
|
504
504
|
init_config2();
|
|
505
505
|
});
|
|
506
506
|
|
|
507
|
+
// src/cli/help.ts
|
|
508
|
+
function helpText(opts = {}) {
|
|
509
|
+
const header = opts.agent ? AGENT_HEADER : HUMAN_HEADER;
|
|
510
|
+
return `${header}
|
|
511
|
+
|
|
512
|
+
${COMMAND_REFERENCE}`;
|
|
513
|
+
}
|
|
514
|
+
var COMMAND_REFERENCE = `Usage:
|
|
515
|
+
bertrand Launch the interactive TUI; start or resume a session.
|
|
516
|
+
bertrand init First-time setup: install hooks, settings, completions.
|
|
517
|
+
|
|
518
|
+
Inspect sessions (read-only):
|
|
519
|
+
bertrand list [--json] List sessions in the active project with status + activity.
|
|
520
|
+
bertrand log <session> [--json]
|
|
521
|
+
Full record for one session: metadata, stats,
|
|
522
|
+
conversations, and the complete event timeline.
|
|
523
|
+
<session> is "<category>/<slug>" (see \`list\`). Use this
|
|
524
|
+
to see what was decided or tried in another session.
|
|
525
|
+
bertrand stats <session> [--json]
|
|
526
|
+
Aggregate statistics (durations, interactions, diff metrics).
|
|
527
|
+
|
|
528
|
+
Manage sessions & projects:
|
|
529
|
+
bertrand archive <session> Archive or unarchive a session.
|
|
530
|
+
bertrand project <op> list | create | switch | current | rename | remove | import
|
|
531
|
+
(bertrand project --help)
|
|
532
|
+
bertrand sync <op> onboard | push | pull | status | invite | enable | disable
|
|
533
|
+
(bertrand sync --help)
|
|
534
|
+
bertrand serve Start the local dashboard HTTP server.
|
|
535
|
+
|
|
536
|
+
Add --json to list/log/stats for machine-readable output. Most commands accept
|
|
537
|
+
--project <slug> to target a project other than the active one.`, HUMAN_HEADER = `bertrand \u2014 multi-session workflow manager for Claude Code
|
|
538
|
+
|
|
539
|
+
bertrand wraps each Claude Code conversation in a tracked "session": it records the
|
|
540
|
+
full event timeline (prompts, answers, tool use, PRs, deploys), groups sessions by
|
|
541
|
+
project, and can replicate that history across machines.`, AGENT_HEADER = `## bertrand CLI
|
|
542
|
+
|
|
543
|
+
You are running inside a bertrand session. bertrand wraps each Claude Code
|
|
544
|
+
conversation in a tracked "session" and records the full event timeline (prompts,
|
|
545
|
+
answers, tool use, PRs, deploys), grouped by project and replicable across machines.
|
|
546
|
+
The subcommands below inspect and manage that data \u2014 reach for them (e.g.
|
|
547
|
+
\`bertrand log <session>\`) instead of assuming sessions are isolated.`;
|
|
548
|
+
|
|
507
549
|
// src/cli/router.ts
|
|
508
550
|
import { existsSync as existsSync4 } from "fs";
|
|
509
551
|
function register(name, handler) {
|
|
@@ -544,6 +586,10 @@ async function autoInitIfFirstRun() {
|
|
|
544
586
|
async function route(argv) {
|
|
545
587
|
const args = argv.slice(2);
|
|
546
588
|
const command = args[0];
|
|
589
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
590
|
+
console.log(helpText({ agent: args.includes("--agent") }));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
547
593
|
if (!command || !HOOK_COMMANDS.has(command)) {
|
|
548
594
|
migrateOrAbort();
|
|
549
595
|
}
|
|
@@ -573,21 +619,7 @@ function resolveCommand(name) {
|
|
|
573
619
|
return;
|
|
574
620
|
}
|
|
575
621
|
function printUsage() {
|
|
576
|
-
console.log(
|
|
577
|
-
bertrand \u2014 multi-session workflow manager for Claude Code
|
|
578
|
-
|
|
579
|
-
Usage:
|
|
580
|
-
bertrand Launch TUI (or resume named session)
|
|
581
|
-
bertrand init Setup wizard
|
|
582
|
-
bertrand list Session picker
|
|
583
|
-
bertrand log <session> View session log
|
|
584
|
-
bertrand stats <session> Session statistics
|
|
585
|
-
bertrand archive <name> Archive/unarchive a session
|
|
586
|
-
bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
|
|
587
|
-
bertrand update Hook-facing state writer (internal)
|
|
588
|
-
bertrand serve Start dashboard HTTP server
|
|
589
|
-
bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
|
|
590
|
-
`.trim());
|
|
622
|
+
console.log(helpText());
|
|
591
623
|
}
|
|
592
624
|
var commands, aliases, HOOK_COMMANDS;
|
|
593
625
|
var init_router = __esm(() => {
|
|
@@ -600,8 +632,10 @@ var init_router = __esm(() => {
|
|
|
600
632
|
HOOK_COMMANDS = new Set([
|
|
601
633
|
"update",
|
|
602
634
|
"assistant-message",
|
|
635
|
+
"contract",
|
|
603
636
|
"notify",
|
|
604
|
-
"badge"
|
|
637
|
+
"badge",
|
|
638
|
+
"ensure-server"
|
|
605
639
|
]);
|
|
606
640
|
});
|
|
607
641
|
|
|
@@ -654,6 +688,8 @@ var init_schema = __esm(() => {
|
|
|
654
688
|
pid: integer("pid"),
|
|
655
689
|
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
|
656
690
|
endedAt: text("ended_at"),
|
|
691
|
+
worktreePath: text("worktree_path"),
|
|
692
|
+
worktreeBranch: text("worktree_branch"),
|
|
657
693
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
|
658
694
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
|
|
659
695
|
}, (t) => [
|
|
@@ -782,28 +818,122 @@ function createId(size = 12) {
|
|
|
782
818
|
}
|
|
783
819
|
var init_id = () => {};
|
|
784
820
|
|
|
821
|
+
// src/db/queries/categories.ts
|
|
822
|
+
import { eq, like, or, isNull } from "drizzle-orm";
|
|
823
|
+
function createCategory(opts) {
|
|
824
|
+
const db = getDb();
|
|
825
|
+
const id = createId();
|
|
826
|
+
let path = opts.slug;
|
|
827
|
+
let depth = 0;
|
|
828
|
+
if (opts.parentId) {
|
|
829
|
+
const parent = db.select().from(categories).where(eq(categories.id, opts.parentId)).get();
|
|
830
|
+
if (!parent)
|
|
831
|
+
throw new Error(`Parent category ${opts.parentId} not found`);
|
|
832
|
+
path = `${parent.path}/${opts.slug}`;
|
|
833
|
+
depth = parent.depth + 1;
|
|
834
|
+
}
|
|
835
|
+
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
836
|
+
}
|
|
837
|
+
function getCategory(id) {
|
|
838
|
+
return getDb().select().from(categories).where(eq(categories.id, id)).get();
|
|
839
|
+
}
|
|
840
|
+
function getCategoryByPath(path) {
|
|
841
|
+
return getDb().select().from(categories).where(eq(categories.path, path)).get();
|
|
842
|
+
}
|
|
843
|
+
function getOrCreateCategoryPath(path) {
|
|
844
|
+
const existing = getCategoryByPath(path);
|
|
845
|
+
if (existing)
|
|
846
|
+
return existing.id;
|
|
847
|
+
const segments = path.split("/");
|
|
848
|
+
let parentId;
|
|
849
|
+
for (let i = 0;i < segments.length; i++) {
|
|
850
|
+
const partialPath = segments.slice(0, i + 1).join("/");
|
|
851
|
+
const category = getCategoryByPath(partialPath);
|
|
852
|
+
if (category) {
|
|
853
|
+
parentId = category.id;
|
|
854
|
+
} else {
|
|
855
|
+
const slug = segments[i];
|
|
856
|
+
const created = createCategory({ slug, name: slug, parentId });
|
|
857
|
+
parentId = created.id;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return parentId;
|
|
861
|
+
}
|
|
862
|
+
var init_categories = __esm(() => {
|
|
863
|
+
init_client();
|
|
864
|
+
init_schema();
|
|
865
|
+
init_id();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// src/lib/parse-session-name.ts
|
|
869
|
+
function parseSessionName(input) {
|
|
870
|
+
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
871
|
+
if (!trimmed) {
|
|
872
|
+
throw new Error("Session name cannot be empty");
|
|
873
|
+
}
|
|
874
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
875
|
+
if (segments.length < 2) {
|
|
876
|
+
throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
|
|
877
|
+
}
|
|
878
|
+
for (const segment of segments) {
|
|
879
|
+
if (!SEGMENT_PATTERN.test(segment)) {
|
|
880
|
+
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const categoryPath = segments[0];
|
|
884
|
+
const slug = segments.slice(1).join("/");
|
|
885
|
+
return { categoryPath, slug };
|
|
886
|
+
}
|
|
887
|
+
var SEGMENT_PATTERN;
|
|
888
|
+
var init_parse_session_name = __esm(() => {
|
|
889
|
+
SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
890
|
+
});
|
|
891
|
+
|
|
785
892
|
// src/db/queries/sessions.ts
|
|
786
|
-
import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
893
|
+
import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
894
|
+
function resolveSessionByName(name) {
|
|
895
|
+
const flat = parseSessionName(name);
|
|
896
|
+
const flatCategory = getCategoryByPath(flat.categoryPath);
|
|
897
|
+
if (flatCategory) {
|
|
898
|
+
const session = getSessionByCategorySlug(flatCategory.id, flat.slug);
|
|
899
|
+
if (session) {
|
|
900
|
+
return { session, categoryPath: flat.categoryPath, slug: flat.slug };
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const segments = name.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
904
|
+
if (segments.length >= 3) {
|
|
905
|
+
const legacyCategoryPath = segments.slice(0, -1).join("/");
|
|
906
|
+
const legacySlug = segments[segments.length - 1];
|
|
907
|
+
const legacyCategory = getCategoryByPath(legacyCategoryPath);
|
|
908
|
+
if (legacyCategory) {
|
|
909
|
+
const session = getSessionByCategorySlug(legacyCategory.id, legacySlug);
|
|
910
|
+
if (session) {
|
|
911
|
+
return { session, categoryPath: legacyCategoryPath, slug: legacySlug };
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
787
917
|
function createSession(opts) {
|
|
788
918
|
const db = getDb();
|
|
789
919
|
const id = createId();
|
|
790
920
|
return db.insert(sessions).values({ id, ...opts }).returning().get();
|
|
791
921
|
}
|
|
792
922
|
function getSession(id) {
|
|
793
|
-
return getDb().select().from(sessions).where(
|
|
923
|
+
return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
|
|
794
924
|
}
|
|
795
925
|
function getSessionByCategorySlug(categoryId, slug) {
|
|
796
|
-
return getDb().select().from(sessions).where(and(
|
|
926
|
+
return getDb().select().from(sessions).where(and(eq2(sessions.categoryId, categoryId), eq2(sessions.slug, slug))).get();
|
|
797
927
|
}
|
|
798
928
|
function getSessionsByCategory(categoryId) {
|
|
799
|
-
return getDb().select().from(sessions).where(
|
|
929
|
+
return getDb().select().from(sessions).where(eq2(sessions.categoryId, categoryId)).all();
|
|
800
930
|
}
|
|
801
931
|
function getActiveSessions() {
|
|
802
|
-
return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories,
|
|
932
|
+
return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
|
|
803
933
|
}
|
|
804
934
|
function getAllSessions(opts) {
|
|
805
935
|
const db = getDb();
|
|
806
|
-
const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories,
|
|
936
|
+
const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id));
|
|
807
937
|
if (opts?.excludeArchived) {
|
|
808
938
|
return query.where(inArray(sessions.status, [
|
|
809
939
|
"active",
|
|
@@ -814,33 +944,35 @@ function getAllSessions(opts) {
|
|
|
814
944
|
return query.all();
|
|
815
945
|
}
|
|
816
946
|
function updateSessionStatus(id, status) {
|
|
817
|
-
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(
|
|
947
|
+
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
|
|
818
948
|
}
|
|
819
949
|
function updateSession(id, data) {
|
|
820
|
-
return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(
|
|
950
|
+
return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
|
|
821
951
|
}
|
|
822
952
|
function deleteSession(id) {
|
|
823
|
-
return getDb().delete(sessions).where(
|
|
953
|
+
return getDb().delete(sessions).where(eq2(sessions.id, id)).run();
|
|
824
954
|
}
|
|
825
955
|
var init_sessions = __esm(() => {
|
|
826
956
|
init_client();
|
|
827
957
|
init_schema();
|
|
828
958
|
init_id();
|
|
959
|
+
init_categories();
|
|
960
|
+
init_parse_session_name();
|
|
829
961
|
});
|
|
830
962
|
|
|
831
963
|
// src/db/queries/conversations.ts
|
|
832
|
-
import { eq as
|
|
964
|
+
import { eq as eq3, and as and2, desc, sql as sql3 } from "drizzle-orm";
|
|
833
965
|
function createConversation(opts) {
|
|
834
966
|
return getDb().insert(conversations).values(opts).returning().get();
|
|
835
967
|
}
|
|
836
968
|
function getConversation(id) {
|
|
837
|
-
return getDb().select().from(conversations).where(
|
|
969
|
+
return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
|
|
838
970
|
}
|
|
839
971
|
function getConversationsBySession(sessionId) {
|
|
840
|
-
return getDb().select().from(conversations).where(and2(
|
|
972
|
+
return getDb().select().from(conversations).where(and2(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
|
|
841
973
|
}
|
|
842
974
|
function endConversation(id) {
|
|
843
|
-
return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(
|
|
975
|
+
return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
|
|
844
976
|
}
|
|
845
977
|
var init_conversations = __esm(() => {
|
|
846
978
|
init_client();
|
|
@@ -856,8 +988,6 @@ function normalizeEventMeta(eventName, meta) {
|
|
|
856
988
|
if (!meta)
|
|
857
989
|
return meta;
|
|
858
990
|
switch (eventName) {
|
|
859
|
-
case "session.recap":
|
|
860
|
-
return mapStringField(meta, "recap", normalizeMarkdown);
|
|
861
991
|
case "assistant.message":
|
|
862
992
|
return mapStringField(meta, "text", normalizeMarkdown);
|
|
863
993
|
case "session.answered":
|
|
@@ -896,7 +1026,7 @@ function normalizeAnsweredMeta(meta) {
|
|
|
896
1026
|
}
|
|
897
1027
|
|
|
898
1028
|
// src/db/queries/events.ts
|
|
899
|
-
import { eq as
|
|
1029
|
+
import { eq as eq4, and as and3, desc as desc2 } from "drizzle-orm";
|
|
900
1030
|
function insertEvent(opts) {
|
|
901
1031
|
return getDb().insert(events).values({
|
|
902
1032
|
sessionId: opts.sessionId,
|
|
@@ -907,39 +1037,21 @@ function insertEvent(opts) {
|
|
|
907
1037
|
}).returning().get();
|
|
908
1038
|
}
|
|
909
1039
|
function getEventsBySession(sessionId) {
|
|
910
|
-
return getDb().select().from(events).where(
|
|
1040
|
+
return getDb().select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
|
|
911
1041
|
}
|
|
912
1042
|
function getEventsByType(sessionId, eventType) {
|
|
913
|
-
return getDb().select().from(events).where(and3(
|
|
1043
|
+
return getDb().select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
|
|
914
1044
|
}
|
|
915
1045
|
function getLatestEventOfType(sessionId, eventType, conversationId) {
|
|
916
1046
|
const conditions = [
|
|
917
|
-
|
|
918
|
-
|
|
1047
|
+
eq4(events.sessionId, sessionId),
|
|
1048
|
+
eq4(events.event, eventType)
|
|
919
1049
|
];
|
|
920
1050
|
if (conversationId) {
|
|
921
|
-
conditions.push(
|
|
1051
|
+
conditions.push(eq4(events.conversationId, conversationId));
|
|
922
1052
|
}
|
|
923
1053
|
return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
|
|
924
1054
|
}
|
|
925
|
-
function getLatestRecaps() {
|
|
926
|
-
const rows = getDb().select({
|
|
927
|
-
sessionId: events.sessionId,
|
|
928
|
-
meta: events.meta,
|
|
929
|
-
createdAt: events.createdAt
|
|
930
|
-
}).from(events).where(eq3(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
|
|
931
|
-
const result = {};
|
|
932
|
-
for (const row of rows) {
|
|
933
|
-
if (result[row.sessionId])
|
|
934
|
-
continue;
|
|
935
|
-
const meta = row.meta;
|
|
936
|
-
const recap = typeof meta?.recap === "string" ? meta.recap : null;
|
|
937
|
-
if (!recap)
|
|
938
|
-
continue;
|
|
939
|
-
result[row.sessionId] = { recap, createdAt: row.createdAt };
|
|
940
|
-
}
|
|
941
|
-
return result;
|
|
942
|
-
}
|
|
943
1055
|
var init_events = __esm(() => {
|
|
944
1056
|
init_client();
|
|
945
1057
|
init_schema();
|
|
@@ -997,15 +1109,6 @@ function emitSessionAnswered(args) {
|
|
|
997
1109
|
}
|
|
998
1110
|
});
|
|
999
1111
|
}
|
|
1000
|
-
function emitSessionRecap(args) {
|
|
1001
|
-
return insertEvent({
|
|
1002
|
-
sessionId: args.sessionId,
|
|
1003
|
-
conversationId: args.conversationId,
|
|
1004
|
-
event: "session.recap",
|
|
1005
|
-
summary: args.recap.slice(0, 200),
|
|
1006
|
-
meta: { recap: args.recap, claude_id: args.conversationId }
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
1112
|
function emitToolUsed(args) {
|
|
1010
1113
|
const summary = formatToolSummary(args.tool, args.detail);
|
|
1011
1114
|
return insertEvent({
|
|
@@ -1070,13 +1173,22 @@ function emitAssistantMessage(args) {
|
|
|
1070
1173
|
}
|
|
1071
1174
|
});
|
|
1072
1175
|
}
|
|
1073
|
-
function
|
|
1176
|
+
function emitWorktreeEntered(args) {
|
|
1177
|
+
return insertEvent({
|
|
1178
|
+
sessionId: args.sessionId,
|
|
1179
|
+
conversationId: args.conversationId,
|
|
1180
|
+
event: "worktree.entered",
|
|
1181
|
+
summary: args.branch ? `entered worktree ${args.branch}` : "entered worktree",
|
|
1182
|
+
meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
function emitWorktreeExited(args) {
|
|
1074
1186
|
return insertEvent({
|
|
1075
1187
|
sessionId: args.sessionId,
|
|
1076
1188
|
conversationId: args.conversationId,
|
|
1077
|
-
event: "
|
|
1078
|
-
summary: args.
|
|
1079
|
-
meta: {
|
|
1189
|
+
event: "worktree.exited",
|
|
1190
|
+
summary: args.branch ? `exited worktree ${args.branch}` : "exited worktree",
|
|
1191
|
+
meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
|
|
1080
1192
|
});
|
|
1081
1193
|
}
|
|
1082
1194
|
var init_emit = __esm(() => {
|
|
@@ -1086,7 +1198,8 @@ var init_emit = __esm(() => {
|
|
|
1086
1198
|
// src/cli/commands/update.ts
|
|
1087
1199
|
var exports_update = {};
|
|
1088
1200
|
__export(exports_update, {
|
|
1089
|
-
shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
|
|
1201
|
+
shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip,
|
|
1202
|
+
dispatchHookEvent: () => dispatchHookEvent
|
|
1090
1203
|
});
|
|
1091
1204
|
function shouldIgnoreStatusFlip(newStatus, sessionPid) {
|
|
1092
1205
|
if (!newStatus)
|
|
@@ -1121,13 +1234,6 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1121
1234
|
questions: meta.questions ?? []
|
|
1122
1235
|
});
|
|
1123
1236
|
return true;
|
|
1124
|
-
case "session.recap":
|
|
1125
|
-
emitSessionRecap({
|
|
1126
|
-
sessionId,
|
|
1127
|
-
conversationId,
|
|
1128
|
-
recap: String(meta.recap ?? "")
|
|
1129
|
-
});
|
|
1130
|
-
return true;
|
|
1131
1237
|
case "tool.applied":
|
|
1132
1238
|
emitToolApplied({
|
|
1133
1239
|
sessionId,
|
|
@@ -1145,6 +1251,22 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1145
1251
|
outcome: meta.outcome === "approved" ? "approved" : "auto"
|
|
1146
1252
|
});
|
|
1147
1253
|
return true;
|
|
1254
|
+
case "worktree.entered": {
|
|
1255
|
+
const path = String(meta.path ?? "");
|
|
1256
|
+
const branch = meta.branch ? String(meta.branch) : undefined;
|
|
1257
|
+
emitWorktreeEntered({ sessionId, conversationId, path, branch });
|
|
1258
|
+
updateSession(sessionId, {
|
|
1259
|
+
worktreePath: path || null,
|
|
1260
|
+
worktreeBranch: branch ?? null
|
|
1261
|
+
});
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
case "worktree.exited": {
|
|
1265
|
+
const path = meta.path ? String(meta.path) : undefined;
|
|
1266
|
+
emitWorktreeExited({ sessionId, conversationId, path });
|
|
1267
|
+
updateSession(sessionId, { worktreePath: null, worktreeBranch: null });
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1148
1270
|
default:
|
|
1149
1271
|
return false;
|
|
1150
1272
|
}
|
|
@@ -1300,7 +1422,6 @@ function summarize(text2) {
|
|
|
1300
1422
|
const trimmed = firstLine.trim();
|
|
1301
1423
|
return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
1302
1424
|
}
|
|
1303
|
-
var RECAP_RE, RECAP_TAG_GLOBAL;
|
|
1304
1425
|
var init_assistant_message = __esm(() => {
|
|
1305
1426
|
init_router();
|
|
1306
1427
|
init_sessions();
|
|
@@ -1308,8 +1429,6 @@ var init_assistant_message = __esm(() => {
|
|
|
1308
1429
|
init_events();
|
|
1309
1430
|
init_emit();
|
|
1310
1431
|
init_transcript();
|
|
1311
|
-
RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
|
|
1312
|
-
RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
|
|
1313
1432
|
register("assistant-message", async (args) => {
|
|
1314
1433
|
let sessionId = "";
|
|
1315
1434
|
let transcriptPath = "";
|
|
@@ -1340,37 +1459,171 @@ var init_assistant_message = __esm(() => {
|
|
|
1340
1459
|
const turn = getLatestAssistantTurn(transcriptPath);
|
|
1341
1460
|
if (!turn)
|
|
1342
1461
|
return;
|
|
1343
|
-
const
|
|
1344
|
-
const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
|
|
1462
|
+
const text2 = turn.text.trim();
|
|
1345
1463
|
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
1346
|
-
if (
|
|
1464
|
+
if (text2 || turn.thinkingBlocks > 0) {
|
|
1347
1465
|
const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
|
|
1348
1466
|
const latestText = latestMsg?.meta?.text;
|
|
1349
|
-
if (latestText !==
|
|
1467
|
+
if (latestText !== text2) {
|
|
1350
1468
|
emitAssistantMessage({
|
|
1351
1469
|
sessionId,
|
|
1352
1470
|
conversationId: convoId,
|
|
1353
|
-
text:
|
|
1471
|
+
text: text2,
|
|
1354
1472
|
model: turn.model,
|
|
1355
1473
|
thinkingBlocks: turn.thinkingBlocks,
|
|
1356
1474
|
thinkingBytes: turn.thinkingBytes,
|
|
1357
|
-
summary:
|
|
1475
|
+
summary: text2 ? summarize(text2) : "thinking only"
|
|
1358
1476
|
});
|
|
1359
1477
|
}
|
|
1360
1478
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
// src/contract/template.md
|
|
1483
|
+
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1484
|
+
|
|
1485
|
+
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).
|
|
1486
|
+
|
|
1487
|
+
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.
|
|
1488
|
+
`;
|
|
1489
|
+
var init_template = () => {};
|
|
1490
|
+
|
|
1491
|
+
// src/contract/template.ts
|
|
1492
|
+
function buildContract(sessionName, ...contextLayers) {
|
|
1493
|
+
const base = template_default.replace("{sessionName}", sessionName);
|
|
1494
|
+
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
1495
|
+
if (layers.length === 0)
|
|
1496
|
+
return base;
|
|
1497
|
+
return base + `
|
|
1498
|
+
|
|
1499
|
+
` + layers.join(`
|
|
1500
|
+
|
|
1501
|
+
`);
|
|
1502
|
+
}
|
|
1503
|
+
var init_template2 = __esm(() => {
|
|
1504
|
+
init_template();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// src/lib/format.ts
|
|
1508
|
+
function formatDuration(ms) {
|
|
1509
|
+
if (ms < MINUTE)
|
|
1510
|
+
return `${Math.round(ms / SECOND)}s`;
|
|
1511
|
+
const days = Math.floor(ms / DAY);
|
|
1512
|
+
const hours = Math.floor(ms % DAY / HOUR);
|
|
1513
|
+
const minutes = Math.floor(ms % HOUR / MINUTE);
|
|
1514
|
+
if (days > 0)
|
|
1515
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
1516
|
+
if (hours > 0)
|
|
1517
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1518
|
+
return `${minutes}m`;
|
|
1519
|
+
}
|
|
1520
|
+
function formatAgo(isoOrDate) {
|
|
1521
|
+
const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
1522
|
+
const ms = Date.now() - date.getTime();
|
|
1523
|
+
if (ms < MINUTE)
|
|
1524
|
+
return "just now";
|
|
1525
|
+
if (ms < HOUR)
|
|
1526
|
+
return `${Math.floor(ms / MINUTE)}m ago`;
|
|
1527
|
+
if (ms < DAY)
|
|
1528
|
+
return `${Math.floor(ms / HOUR)}h ago`;
|
|
1529
|
+
if (ms < 2 * DAY)
|
|
1530
|
+
return "yesterday";
|
|
1531
|
+
if (ms < 7 * DAY)
|
|
1532
|
+
return `${Math.floor(ms / DAY)}d ago`;
|
|
1533
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
1534
|
+
}
|
|
1535
|
+
function truncate(text2, maxLen) {
|
|
1536
|
+
if (text2.length <= maxLen)
|
|
1537
|
+
return text2;
|
|
1538
|
+
return text2.slice(0, maxLen - 1) + "\u2026";
|
|
1539
|
+
}
|
|
1540
|
+
function formatTime(iso, includeDate = false) {
|
|
1541
|
+
const date = new Date(iso);
|
|
1542
|
+
const time = date.toLocaleTimeString("en-US", {
|
|
1543
|
+
hour: "numeric",
|
|
1544
|
+
minute: "2-digit"
|
|
1545
|
+
});
|
|
1546
|
+
if (!includeDate)
|
|
1547
|
+
return time;
|
|
1548
|
+
const day = date.toLocaleDateString("en-US", {
|
|
1549
|
+
month: "short",
|
|
1550
|
+
day: "numeric"
|
|
1551
|
+
});
|
|
1552
|
+
return `${day} ${time}`;
|
|
1553
|
+
}
|
|
1554
|
+
var SECOND = 1000, MINUTE, HOUR, DAY;
|
|
1555
|
+
var init_format = __esm(() => {
|
|
1556
|
+
MINUTE = 60 * SECOND;
|
|
1557
|
+
HOUR = 60 * MINUTE;
|
|
1558
|
+
DAY = 24 * HOUR;
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// src/contract/context.ts
|
|
1562
|
+
function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
|
|
1563
|
+
const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
|
|
1564
|
+
if (siblings.length === 0)
|
|
1565
|
+
return "";
|
|
1566
|
+
const lines = siblings.map((s) => {
|
|
1567
|
+
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
1568
|
+
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
1569
|
+
const worktree = s.worktreeBranch ? ` [worktree: ${s.worktreeBranch}]` : "";
|
|
1570
|
+
return `- ${categoryPath}/${s.slug}: ${s.status}${worktree}${summary} (${ago})`;
|
|
1571
|
+
});
|
|
1572
|
+
const guidance = [
|
|
1573
|
+
"",
|
|
1574
|
+
"To inspect any sibling session's full record, run:",
|
|
1575
|
+
" bertrand log <category>/<slug> --json",
|
|
1576
|
+
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
1577
|
+
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
1578
|
+
].join(`
|
|
1579
|
+
`);
|
|
1580
|
+
return `## Sibling Sessions
|
|
1581
|
+
${lines.join(`
|
|
1582
|
+
`)}
|
|
1583
|
+
${guidance}`;
|
|
1584
|
+
}
|
|
1585
|
+
var init_context = __esm(() => {
|
|
1586
|
+
init_sessions();
|
|
1587
|
+
init_format();
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
// src/cli/commands/contract.ts
|
|
1591
|
+
var exports_contract = {};
|
|
1592
|
+
var init_contract = __esm(() => {
|
|
1593
|
+
init_router();
|
|
1594
|
+
init_sessions();
|
|
1595
|
+
init_categories();
|
|
1596
|
+
init_template2();
|
|
1597
|
+
init_context();
|
|
1598
|
+
register("contract", async (args) => {
|
|
1599
|
+
let sessionId = "";
|
|
1600
|
+
let short = false;
|
|
1601
|
+
for (let i = 0;i < args.length; i++) {
|
|
1602
|
+
const arg = args[i];
|
|
1603
|
+
const next = args[i + 1];
|
|
1604
|
+
if (arg === "--session-id" && next) {
|
|
1605
|
+
sessionId = next;
|
|
1606
|
+
i++;
|
|
1607
|
+
} else if (arg === "--short") {
|
|
1608
|
+
short = true;
|
|
1372
1609
|
}
|
|
1373
1610
|
}
|
|
1611
|
+
if (!sessionId) {
|
|
1612
|
+
console.error("Usage: bertrand contract --session-id <id> [--short]");
|
|
1613
|
+
process.exit(1);
|
|
1614
|
+
}
|
|
1615
|
+
const session = getSession(sessionId);
|
|
1616
|
+
if (!session)
|
|
1617
|
+
return;
|
|
1618
|
+
const category = getCategory(session.categoryId);
|
|
1619
|
+
const categoryPath = category?.path ?? "";
|
|
1620
|
+
const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
|
|
1621
|
+
if (short) {
|
|
1622
|
+
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).`);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
1626
|
+
process.stdout.write(buildContract(sessionName, helpText({ agent: true }), siblingContext));
|
|
1374
1627
|
});
|
|
1375
1628
|
});
|
|
1376
1629
|
|
|
@@ -1491,9 +1744,9 @@ var init_notify = __esm(() => {
|
|
|
1491
1744
|
});
|
|
1492
1745
|
|
|
1493
1746
|
// src/db/queries/stats.ts
|
|
1494
|
-
import { eq as
|
|
1747
|
+
import { eq as eq5, sql as sql4 } from "drizzle-orm";
|
|
1495
1748
|
function getSessionStats(sessionId) {
|
|
1496
|
-
return getDb().select().from(sessionStats).where(
|
|
1749
|
+
return getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
|
|
1497
1750
|
}
|
|
1498
1751
|
function upsertSessionStats(sessionId, data) {
|
|
1499
1752
|
return getDb().insert(sessionStats).values({
|
|
@@ -1666,7 +1919,7 @@ var init_timing = __esm(() => {
|
|
|
1666
1919
|
});
|
|
1667
1920
|
|
|
1668
1921
|
// src/lib/engagement_stats.ts
|
|
1669
|
-
import { eq as
|
|
1922
|
+
import { eq as eq6, sql as sql5 } from "drizzle-orm";
|
|
1670
1923
|
function aggregateToolUsage(sessionId) {
|
|
1671
1924
|
const counts = {};
|
|
1672
1925
|
const applied = getEventsByType(sessionId, "tool.applied");
|
|
@@ -1693,7 +1946,7 @@ function discardRate(sessionId) {
|
|
|
1693
1946
|
const row = getDb().select({
|
|
1694
1947
|
total: sql5`count(*)`,
|
|
1695
1948
|
discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
|
|
1696
|
-
}).from(conversations).where(
|
|
1949
|
+
}).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
|
|
1697
1950
|
return {
|
|
1698
1951
|
total: row?.total ?? 0,
|
|
1699
1952
|
discarded: row?.discarded ?? 0
|
|
@@ -1934,7 +2187,7 @@ var PORT, listSessions = (_params, url) => {
|
|
|
1934
2187
|
return getSessionStats(sessionId) ?? liveStats(sessionId);
|
|
1935
2188
|
}, getEngagement = ({
|
|
1936
2189
|
sessionId
|
|
1937
|
-
}) => computeEngagementStats(sessionId),
|
|
2190
|
+
}) => computeEngagementStats(sessionId), listAllProjects = () => {
|
|
1938
2191
|
const active = resolveActiveProject();
|
|
1939
2192
|
return listProjects().map((p) => ({
|
|
1940
2193
|
slug: p.slug,
|
|
@@ -1945,7 +2198,7 @@ var PORT, listSessions = (_params, url) => {
|
|
|
1945
2198
|
}, getActiveProjectMeta = () => {
|
|
1946
2199
|
const active = resolveActiveProject();
|
|
1947
2200
|
return { slug: active.slug, name: active.name };
|
|
1948
|
-
}, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
|
|
2201
|
+
}, listWorktreeSessions = () => getAllSessions({ excludeArchived: true }).filter(({ session }) => session.worktreePath != null), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
|
|
1949
2202
|
var init_server = __esm(() => {
|
|
1950
2203
|
init_sessions();
|
|
1951
2204
|
init_events();
|
|
@@ -1960,11 +2213,11 @@ var init_server = __esm(() => {
|
|
|
1960
2213
|
routes = [
|
|
1961
2214
|
[/^\/api\/sessions$/, listSessions],
|
|
1962
2215
|
[/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
|
|
2216
|
+
[/^\/api\/worktrees$/, listWorktreeSessions],
|
|
1963
2217
|
[/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
|
|
1964
2218
|
[/^\/api\/stats$/, listAllStats],
|
|
1965
2219
|
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
|
|
1966
2220
|
[/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
|
|
1967
|
-
[/^\/api\/recaps$/, listRecaps],
|
|
1968
2221
|
[/^\/api\/projects$/, listAllProjects],
|
|
1969
2222
|
[/^\/api\/active-project$/, getActiveProjectMeta]
|
|
1970
2223
|
];
|
|
@@ -1988,9 +2241,110 @@ var init_serve = __esm(() => {
|
|
|
1988
2241
|
});
|
|
1989
2242
|
});
|
|
1990
2243
|
|
|
2244
|
+
// src/lib/server-lifecycle.ts
|
|
2245
|
+
import { spawn as spawn2 } from "child_process";
|
|
2246
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
2247
|
+
import { join as join9 } from "path";
|
|
2248
|
+
function readPidFile() {
|
|
2249
|
+
try {
|
|
2250
|
+
const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
|
|
2251
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function isProcessAlive(pid) {
|
|
2257
|
+
try {
|
|
2258
|
+
process.kill(pid, 0);
|
|
2259
|
+
return true;
|
|
2260
|
+
} catch {
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
async function isPortListening(port) {
|
|
2265
|
+
try {
|
|
2266
|
+
await fetch(`http://localhost:${port}/api/sessions`, {
|
|
2267
|
+
signal: AbortSignal.timeout(500)
|
|
2268
|
+
});
|
|
2269
|
+
return true;
|
|
2270
|
+
} catch {
|
|
2271
|
+
return false;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
function removePidFile() {
|
|
2275
|
+
try {
|
|
2276
|
+
unlinkSync(deps.pidFile);
|
|
2277
|
+
} catch {}
|
|
2278
|
+
}
|
|
2279
|
+
async function ensureServerStarted() {
|
|
2280
|
+
const existingPid = readPidFile();
|
|
2281
|
+
if (existingPid && isProcessAlive(existingPid))
|
|
2282
|
+
return;
|
|
2283
|
+
if (existingPid)
|
|
2284
|
+
removePidFile();
|
|
2285
|
+
if (await isPortListening(deps.port))
|
|
2286
|
+
return;
|
|
2287
|
+
const bin = deps.resolveBin();
|
|
2288
|
+
if (!bin)
|
|
2289
|
+
return;
|
|
2290
|
+
const child = spawn2(bin, ["serve"], {
|
|
2291
|
+
detached: true,
|
|
2292
|
+
stdio: "ignore",
|
|
2293
|
+
env: { ...process.env, BERTRAND_PORT: String(deps.port) }
|
|
2294
|
+
});
|
|
2295
|
+
child.unref();
|
|
2296
|
+
if (child.pid)
|
|
2297
|
+
writeFileSync4(deps.pidFile, String(child.pid));
|
|
2298
|
+
}
|
|
2299
|
+
async function ensureServerForActiveSessions() {
|
|
2300
|
+
if (deps.getActiveCount() === 0)
|
|
2301
|
+
return;
|
|
2302
|
+
await ensureServerStarted();
|
|
2303
|
+
}
|
|
2304
|
+
function stopServerIfIdle() {
|
|
2305
|
+
if (deps.getActiveCount() > 0)
|
|
2306
|
+
return;
|
|
2307
|
+
const pid = readPidFile();
|
|
2308
|
+
if (!pid)
|
|
2309
|
+
return;
|
|
2310
|
+
try {
|
|
2311
|
+
process.kill(pid, "SIGTERM");
|
|
2312
|
+
} catch {}
|
|
2313
|
+
removePidFile();
|
|
2314
|
+
}
|
|
2315
|
+
var defaultDeps, deps;
|
|
2316
|
+
var init_server_lifecycle = __esm(() => {
|
|
2317
|
+
init_paths();
|
|
2318
|
+
init_sessions();
|
|
2319
|
+
defaultDeps = {
|
|
2320
|
+
pidFile: join9(paths.root, "server.pid"),
|
|
2321
|
+
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
2322
|
+
resolveBin() {
|
|
2323
|
+
try {
|
|
2324
|
+
const config = JSON.parse(readFileSync6(join9(paths.root, "config.json"), "utf-8"));
|
|
2325
|
+
return typeof config?.bin === "string" ? config.bin : null;
|
|
2326
|
+
} catch {
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
getActiveCount: () => getActiveSessions().length
|
|
2331
|
+
};
|
|
2332
|
+
deps = defaultDeps;
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
// src/cli/commands/ensure-server.ts
|
|
2336
|
+
var exports_ensure_server = {};
|
|
2337
|
+
var init_ensure_server = __esm(() => {
|
|
2338
|
+
init_router();
|
|
2339
|
+
init_server_lifecycle();
|
|
2340
|
+
register("ensure-server", async () => {
|
|
2341
|
+
await ensureServerForActiveSessions();
|
|
2342
|
+
});
|
|
2343
|
+
});
|
|
2344
|
+
|
|
1991
2345
|
// src/sync/snapshot.ts
|
|
1992
2346
|
import { Database as Database2 } from "bun:sqlite";
|
|
1993
|
-
import { existsSync as existsSync7, unlinkSync } from "fs";
|
|
2347
|
+
import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "fs";
|
|
1994
2348
|
function snapshotPathFor(dbPath) {
|
|
1995
2349
|
return `${dbPath}.sync-snapshot`;
|
|
1996
2350
|
}
|
|
@@ -2012,7 +2366,7 @@ function cleanupSnapshot() {
|
|
|
2012
2366
|
const p = base + suffix;
|
|
2013
2367
|
if (existsSync7(p)) {
|
|
2014
2368
|
try {
|
|
2015
|
-
|
|
2369
|
+
unlinkSync2(p);
|
|
2016
2370
|
} catch {}
|
|
2017
2371
|
}
|
|
2018
2372
|
}
|
|
@@ -2065,7 +2419,7 @@ var init_crypto = __esm(() => {
|
|
|
2065
2419
|
|
|
2066
2420
|
// src/sync/engine.ts
|
|
2067
2421
|
import { createClient } from "@supabase/supabase-js";
|
|
2068
|
-
import { readFileSync as
|
|
2422
|
+
import { readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
|
|
2069
2423
|
import { dirname as dirname2 } from "path";
|
|
2070
2424
|
function client(cfg) {
|
|
2071
2425
|
if (!cfg)
|
|
@@ -2095,7 +2449,7 @@ async function push() {
|
|
|
2095
2449
|
error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
|
|
2096
2450
|
};
|
|
2097
2451
|
}
|
|
2098
|
-
const plaintext =
|
|
2452
|
+
const plaintext = readFileSync7(snapshotPath);
|
|
2099
2453
|
const ciphertext = encrypt(plaintext, cfg.encryptionKey);
|
|
2100
2454
|
const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
|
|
2101
2455
|
contentType: "application/octet-stream",
|
|
@@ -2375,60 +2729,6 @@ var init_bootstrap = __esm(() => {
|
|
|
2375
2729
|
init_config2();
|
|
2376
2730
|
});
|
|
2377
2731
|
|
|
2378
|
-
// src/lib/format.ts
|
|
2379
|
-
function formatDuration(ms) {
|
|
2380
|
-
if (ms < MINUTE)
|
|
2381
|
-
return `${Math.round(ms / SECOND)}s`;
|
|
2382
|
-
const days = Math.floor(ms / DAY);
|
|
2383
|
-
const hours = Math.floor(ms % DAY / HOUR);
|
|
2384
|
-
const minutes = Math.floor(ms % HOUR / MINUTE);
|
|
2385
|
-
if (days > 0)
|
|
2386
|
-
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
2387
|
-
if (hours > 0)
|
|
2388
|
-
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
2389
|
-
return `${minutes}m`;
|
|
2390
|
-
}
|
|
2391
|
-
function formatAgo(isoOrDate) {
|
|
2392
|
-
const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
2393
|
-
const ms = Date.now() - date.getTime();
|
|
2394
|
-
if (ms < MINUTE)
|
|
2395
|
-
return "just now";
|
|
2396
|
-
if (ms < HOUR)
|
|
2397
|
-
return `${Math.floor(ms / MINUTE)}m ago`;
|
|
2398
|
-
if (ms < DAY)
|
|
2399
|
-
return `${Math.floor(ms / HOUR)}h ago`;
|
|
2400
|
-
if (ms < 2 * DAY)
|
|
2401
|
-
return "yesterday";
|
|
2402
|
-
if (ms < 7 * DAY)
|
|
2403
|
-
return `${Math.floor(ms / DAY)}d ago`;
|
|
2404
|
-
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
2405
|
-
}
|
|
2406
|
-
function truncate(text2, maxLen) {
|
|
2407
|
-
if (text2.length <= maxLen)
|
|
2408
|
-
return text2;
|
|
2409
|
-
return text2.slice(0, maxLen - 1) + "\u2026";
|
|
2410
|
-
}
|
|
2411
|
-
function formatTime(iso, includeDate = false) {
|
|
2412
|
-
const date = new Date(iso);
|
|
2413
|
-
const time = date.toLocaleTimeString("en-US", {
|
|
2414
|
-
hour: "numeric",
|
|
2415
|
-
minute: "2-digit"
|
|
2416
|
-
});
|
|
2417
|
-
if (!includeDate)
|
|
2418
|
-
return time;
|
|
2419
|
-
const day = date.toLocaleDateString("en-US", {
|
|
2420
|
-
month: "short",
|
|
2421
|
-
day: "numeric"
|
|
2422
|
-
});
|
|
2423
|
-
return `${day} ${time}`;
|
|
2424
|
-
}
|
|
2425
|
-
var SECOND = 1000, MINUTE, HOUR, DAY;
|
|
2426
|
-
var init_format = __esm(() => {
|
|
2427
|
-
MINUTE = 60 * SECOND;
|
|
2428
|
-
HOUR = 60 * MINUTE;
|
|
2429
|
-
DAY = 24 * HOUR;
|
|
2430
|
-
});
|
|
2431
|
-
|
|
2432
2732
|
// src/cli/commands/sync.ts
|
|
2433
2733
|
var exports_sync = {};
|
|
2434
2734
|
import { hostname as hostname2 } from "os";
|
|
@@ -2723,81 +3023,34 @@ var init_sync = __esm(() => {
|
|
|
2723
3023
|
await runPush();
|
|
2724
3024
|
return;
|
|
2725
3025
|
case "pull":
|
|
2726
|
-
await runPull(rest);
|
|
2727
|
-
return;
|
|
2728
|
-
case "status":
|
|
2729
|
-
await runStatus();
|
|
2730
|
-
return;
|
|
2731
|
-
case "onboard":
|
|
2732
|
-
await runOnboard();
|
|
2733
|
-
return;
|
|
2734
|
-
case "invite":
|
|
2735
|
-
runInvite();
|
|
2736
|
-
return;
|
|
2737
|
-
case "enable":
|
|
2738
|
-
runEnable();
|
|
2739
|
-
return;
|
|
2740
|
-
case "disable":
|
|
2741
|
-
runDisable();
|
|
2742
|
-
return;
|
|
2743
|
-
case undefined:
|
|
2744
|
-
case "--help":
|
|
2745
|
-
case "-h":
|
|
2746
|
-
printUsage2();
|
|
2747
|
-
return;
|
|
2748
|
-
default:
|
|
2749
|
-
console.error(`Unknown subcommand: ${sub}`);
|
|
2750
|
-
printUsage2();
|
|
2751
|
-
process.exit(1);
|
|
2752
|
-
}
|
|
2753
|
-
});
|
|
2754
|
-
});
|
|
2755
|
-
|
|
2756
|
-
// src/db/queries/categories.ts
|
|
2757
|
-
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
2758
|
-
function createCategory(opts) {
|
|
2759
|
-
const db = getDb();
|
|
2760
|
-
const id = createId();
|
|
2761
|
-
let path = opts.slug;
|
|
2762
|
-
let depth = 0;
|
|
2763
|
-
if (opts.parentId) {
|
|
2764
|
-
const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
|
|
2765
|
-
if (!parent)
|
|
2766
|
-
throw new Error(`Parent category ${opts.parentId} not found`);
|
|
2767
|
-
path = `${parent.path}/${opts.slug}`;
|
|
2768
|
-
depth = parent.depth + 1;
|
|
2769
|
-
}
|
|
2770
|
-
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
2771
|
-
}
|
|
2772
|
-
function getCategory(id) {
|
|
2773
|
-
return getDb().select().from(categories).where(eq6(categories.id, id)).get();
|
|
2774
|
-
}
|
|
2775
|
-
function getCategoryByPath(path) {
|
|
2776
|
-
return getDb().select().from(categories).where(eq6(categories.path, path)).get();
|
|
2777
|
-
}
|
|
2778
|
-
function getOrCreateCategoryPath(path) {
|
|
2779
|
-
const existing = getCategoryByPath(path);
|
|
2780
|
-
if (existing)
|
|
2781
|
-
return existing.id;
|
|
2782
|
-
const segments = path.split("/");
|
|
2783
|
-
let parentId;
|
|
2784
|
-
for (let i = 0;i < segments.length; i++) {
|
|
2785
|
-
const partialPath = segments.slice(0, i + 1).join("/");
|
|
2786
|
-
const category = getCategoryByPath(partialPath);
|
|
2787
|
-
if (category) {
|
|
2788
|
-
parentId = category.id;
|
|
2789
|
-
} else {
|
|
2790
|
-
const slug = segments[i];
|
|
2791
|
-
const created = createCategory({ slug, name: slug, parentId });
|
|
2792
|
-
parentId = created.id;
|
|
3026
|
+
await runPull(rest);
|
|
3027
|
+
return;
|
|
3028
|
+
case "status":
|
|
3029
|
+
await runStatus();
|
|
3030
|
+
return;
|
|
3031
|
+
case "onboard":
|
|
3032
|
+
await runOnboard();
|
|
3033
|
+
return;
|
|
3034
|
+
case "invite":
|
|
3035
|
+
runInvite();
|
|
3036
|
+
return;
|
|
3037
|
+
case "enable":
|
|
3038
|
+
runEnable();
|
|
3039
|
+
return;
|
|
3040
|
+
case "disable":
|
|
3041
|
+
runDisable();
|
|
3042
|
+
return;
|
|
3043
|
+
case undefined:
|
|
3044
|
+
case "--help":
|
|
3045
|
+
case "-h":
|
|
3046
|
+
printUsage2();
|
|
3047
|
+
return;
|
|
3048
|
+
default:
|
|
3049
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
3050
|
+
printUsage2();
|
|
3051
|
+
process.exit(1);
|
|
2793
3052
|
}
|
|
2794
|
-
}
|
|
2795
|
-
return parentId;
|
|
2796
|
-
}
|
|
2797
|
-
var init_categories = __esm(() => {
|
|
2798
|
-
init_client();
|
|
2799
|
-
init_schema();
|
|
2800
|
-
init_id();
|
|
3053
|
+
});
|
|
2801
3054
|
});
|
|
2802
3055
|
|
|
2803
3056
|
// src/db/queries/labels.ts
|
|
@@ -2823,63 +3076,8 @@ var init_labels = __esm(() => {
|
|
|
2823
3076
|
init_id();
|
|
2824
3077
|
});
|
|
2825
3078
|
|
|
2826
|
-
// src/contract/template.md
|
|
2827
|
-
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
2828
|
-
|
|
2829
|
-
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.
|
|
2830
|
-
|
|
2831
|
-
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.
|
|
2832
|
-
|
|
2833
|
-
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.
|
|
2834
|
-
`;
|
|
2835
|
-
var init_template = () => {};
|
|
2836
|
-
|
|
2837
|
-
// src/contract/template.ts
|
|
2838
|
-
function buildContract(sessionName, ...contextLayers) {
|
|
2839
|
-
const base = template_default.replace("{sessionName}", sessionName);
|
|
2840
|
-
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
2841
|
-
if (layers.length === 0)
|
|
2842
|
-
return base;
|
|
2843
|
-
return base + `
|
|
2844
|
-
|
|
2845
|
-
` + layers.join(`
|
|
2846
|
-
|
|
2847
|
-
`);
|
|
2848
|
-
}
|
|
2849
|
-
var init_template2 = __esm(() => {
|
|
2850
|
-
init_template();
|
|
2851
|
-
});
|
|
2852
|
-
|
|
2853
|
-
// src/contract/context.ts
|
|
2854
|
-
function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
|
|
2855
|
-
const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
|
|
2856
|
-
if (siblings.length === 0)
|
|
2857
|
-
return "";
|
|
2858
|
-
const lines = siblings.map((s) => {
|
|
2859
|
-
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
2860
|
-
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
2861
|
-
return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
|
|
2862
|
-
});
|
|
2863
|
-
const guidance = [
|
|
2864
|
-
"",
|
|
2865
|
-
"To inspect any sibling session's full record, run:",
|
|
2866
|
-
" bertrand log <category>/<slug> --json",
|
|
2867
|
-
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
2868
|
-
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
2869
|
-
].join(`
|
|
2870
|
-
`);
|
|
2871
|
-
return `## Sibling Sessions
|
|
2872
|
-
${lines.join(`
|
|
2873
|
-
`)}
|
|
2874
|
-
${guidance}`;
|
|
2875
|
-
}
|
|
2876
|
-
var init_context = __esm(() => {
|
|
2877
|
-
init_sessions();
|
|
2878
|
-
init_format();
|
|
2879
|
-
});
|
|
2880
|
-
|
|
2881
3079
|
// src/engine/process.ts
|
|
2882
|
-
import { spawn as
|
|
3080
|
+
import { spawn as spawn3 } from "child_process";
|
|
2883
3081
|
function launchClaude(opts) {
|
|
2884
3082
|
const args = [];
|
|
2885
3083
|
if (opts.resume) {
|
|
@@ -2900,7 +3098,7 @@ function launchClaude(opts) {
|
|
|
2900
3098
|
BERTRAND_PROJECT_DB: active.db
|
|
2901
3099
|
};
|
|
2902
3100
|
return new Promise((resolve, reject) => {
|
|
2903
|
-
const child =
|
|
3101
|
+
const child = spawn3("claude", args, {
|
|
2904
3102
|
env,
|
|
2905
3103
|
stdio: "inherit",
|
|
2906
3104
|
shell: false
|
|
@@ -2935,90 +3133,42 @@ var init_process = __esm(() => {
|
|
|
2935
3133
|
init_resolve();
|
|
2936
3134
|
});
|
|
2937
3135
|
|
|
2938
|
-
// src/
|
|
2939
|
-
import {
|
|
2940
|
-
import {
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
|
|
3136
|
+
// src/hooks/runtime.ts
|
|
3137
|
+
import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
|
|
3138
|
+
import { join as join10 } from "path";
|
|
3139
|
+
function rmMarker(name) {
|
|
3140
|
+
rmSync(join10(runtimeDir, name), { force: true });
|
|
3141
|
+
}
|
|
3142
|
+
function pruneSessionMarkers(sessionId, conversationId) {
|
|
3143
|
+
rmMarker(`done-${sessionId}`);
|
|
3144
|
+
rmMarker(`auq-nudge-${sessionId}`);
|
|
3145
|
+
rmMarker(`working-${sessionId}`);
|
|
3146
|
+
rmMarker(`worktree-${sessionId}`);
|
|
3147
|
+
if (conversationId)
|
|
3148
|
+
rmMarker(`${CONTRACT_MARKER_PREFIX}${conversationId}`);
|
|
3149
|
+
}
|
|
3150
|
+
function pruneStaleContractMarkers(maxAgeMs = STALE_MS) {
|
|
3151
|
+
let entries;
|
|
2951
3152
|
try {
|
|
2952
|
-
|
|
2953
|
-
return true;
|
|
3153
|
+
entries = readdirSync2(runtimeDir);
|
|
2954
3154
|
} catch {
|
|
2955
|
-
return
|
|
3155
|
+
return;
|
|
2956
3156
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
return false;
|
|
3157
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
3158
|
+
for (const name of entries) {
|
|
3159
|
+
if (!name.startsWith(CONTRACT_MARKER_PREFIX))
|
|
3160
|
+
continue;
|
|
3161
|
+
try {
|
|
3162
|
+
if (statSync4(join10(runtimeDir, name)).mtimeMs < cutoff)
|
|
3163
|
+
rmMarker(name);
|
|
3164
|
+
} catch {}
|
|
2966
3165
|
}
|
|
2967
3166
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
unlinkSync2(deps.pidFile);
|
|
2971
|
-
} catch {}
|
|
2972
|
-
}
|
|
2973
|
-
async function ensureServerStarted() {
|
|
2974
|
-
const existingPid = readPidFile();
|
|
2975
|
-
if (existingPid && isProcessAlive(existingPid))
|
|
2976
|
-
return;
|
|
2977
|
-
if (existingPid)
|
|
2978
|
-
removePidFile();
|
|
2979
|
-
if (await isPortListening(deps.port))
|
|
2980
|
-
return;
|
|
2981
|
-
const bin = deps.resolveBin();
|
|
2982
|
-
if (!bin)
|
|
2983
|
-
return;
|
|
2984
|
-
const child = spawn3(bin, ["serve"], {
|
|
2985
|
-
detached: true,
|
|
2986
|
-
stdio: "ignore",
|
|
2987
|
-
env: { ...process.env, BERTRAND_PORT: String(deps.port) }
|
|
2988
|
-
});
|
|
2989
|
-
child.unref();
|
|
2990
|
-
if (child.pid)
|
|
2991
|
-
writeFileSync4(deps.pidFile, String(child.pid));
|
|
2992
|
-
}
|
|
2993
|
-
function stopServerIfIdle() {
|
|
2994
|
-
if (deps.getActiveCount() > 0)
|
|
2995
|
-
return;
|
|
2996
|
-
const pid = readPidFile();
|
|
2997
|
-
if (!pid)
|
|
2998
|
-
return;
|
|
2999
|
-
try {
|
|
3000
|
-
process.kill(pid, "SIGTERM");
|
|
3001
|
-
} catch {}
|
|
3002
|
-
removePidFile();
|
|
3003
|
-
}
|
|
3004
|
-
var defaultDeps, deps;
|
|
3005
|
-
var init_server_lifecycle = __esm(() => {
|
|
3167
|
+
var CONTRACT_MARKER_PREFIX = "contract-sent-", STALE_MS, runtimeDir;
|
|
3168
|
+
var init_runtime = __esm(() => {
|
|
3006
3169
|
init_paths();
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
pidFile: join9(paths.root, "server.pid"),
|
|
3010
|
-
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
3011
|
-
resolveBin() {
|
|
3012
|
-
try {
|
|
3013
|
-
const config = JSON.parse(readFileSync7(join9(paths.root, "config.json"), "utf-8"));
|
|
3014
|
-
return typeof config?.bin === "string" ? config.bin : null;
|
|
3015
|
-
} catch {
|
|
3016
|
-
return null;
|
|
3017
|
-
}
|
|
3018
|
-
},
|
|
3019
|
-
getActiveCount: () => getActiveSessions().length
|
|
3020
|
-
};
|
|
3021
|
-
deps = defaultDeps;
|
|
3170
|
+
STALE_MS = 24 * 60 * 60 * 1000;
|
|
3171
|
+
runtimeDir = paths.runtime;
|
|
3022
3172
|
});
|
|
3023
3173
|
|
|
3024
3174
|
// src/engine/session.ts
|
|
@@ -3057,6 +3207,7 @@ function installExitHandlers() {
|
|
|
3057
3207
|
process.on("SIGHUP", onSignal);
|
|
3058
3208
|
}
|
|
3059
3209
|
async function launch(opts) {
|
|
3210
|
+
pruneStaleContractMarkers();
|
|
3060
3211
|
const existingCategory = getCategoryByPath(opts.categoryPath);
|
|
3061
3212
|
if (existingCategory) {
|
|
3062
3213
|
const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
|
|
@@ -3090,7 +3241,7 @@ async function launch(opts) {
|
|
|
3090
3241
|
cwd: process.cwd()
|
|
3091
3242
|
});
|
|
3092
3243
|
const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
|
|
3093
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3244
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3094
3245
|
const exitCode = await launchClaude({
|
|
3095
3246
|
sessionId: session.id,
|
|
3096
3247
|
claudeId,
|
|
@@ -3102,6 +3253,7 @@ async function launch(opts) {
|
|
|
3102
3253
|
return session.id;
|
|
3103
3254
|
}
|
|
3104
3255
|
async function resume(opts) {
|
|
3256
|
+
pruneStaleContractMarkers();
|
|
3105
3257
|
const session = getSession(opts.sessionId);
|
|
3106
3258
|
if (!session)
|
|
3107
3259
|
throw new Error(`Session not found: ${opts.sessionId}`);
|
|
@@ -3121,7 +3273,7 @@ async function resume(opts) {
|
|
|
3121
3273
|
}
|
|
3122
3274
|
const categoryPath = category?.path ?? "";
|
|
3123
3275
|
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
3124
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3276
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3125
3277
|
const exitCode = await launchClaude({
|
|
3126
3278
|
sessionId: session.id,
|
|
3127
3279
|
claudeId: opts.conversationId,
|
|
@@ -3153,6 +3305,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
|
|
|
3153
3305
|
});
|
|
3154
3306
|
if (liveSession?.sessionId === sessionId)
|
|
3155
3307
|
liveSession = null;
|
|
3308
|
+
pruneSessionMarkers(sessionId, safeConversationId);
|
|
3156
3309
|
computeAndPersist(sessionId);
|
|
3157
3310
|
stopServerIfIdle();
|
|
3158
3311
|
triggerBackgroundPush();
|
|
@@ -3171,16 +3324,17 @@ var init_session = __esm(() => {
|
|
|
3171
3324
|
init_server_lifecycle();
|
|
3172
3325
|
init_trigger();
|
|
3173
3326
|
init_transcript();
|
|
3327
|
+
init_runtime();
|
|
3174
3328
|
});
|
|
3175
3329
|
|
|
3176
3330
|
// src/tui/app.tsx
|
|
3177
3331
|
import { spawn as spawn4 } from "child_process";
|
|
3178
3332
|
import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
|
|
3179
3333
|
import { tmpdir } from "os";
|
|
3180
|
-
import { join as
|
|
3334
|
+
import { join as join11 } from "path";
|
|
3181
3335
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3182
3336
|
async function runScreen(screen, ...args) {
|
|
3183
|
-
const tmpFile =
|
|
3337
|
+
const tmpFile = join11(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
|
|
3184
3338
|
if (process.env.BERTRAND_DEBUG_TUI) {
|
|
3185
3339
|
try {
|
|
3186
3340
|
const { appendFileSync } = await import("fs");
|
|
@@ -3190,7 +3344,7 @@ async function runScreen(screen, ...args) {
|
|
|
3190
3344
|
}
|
|
3191
3345
|
const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
|
|
3192
3346
|
stdio: "inherit",
|
|
3193
|
-
env: process.env
|
|
3347
|
+
env: { ...process.env, BERTRAND_PROJECT: resolveActiveProject().slug }
|
|
3194
3348
|
});
|
|
3195
3349
|
const noopSignal = () => {};
|
|
3196
3350
|
process.on("SIGINT", noopSignal);
|
|
@@ -3337,35 +3491,11 @@ var init_app = __esm(() => {
|
|
|
3337
3491
|
init_create();
|
|
3338
3492
|
init_resolve();
|
|
3339
3493
|
SCREEN_ENTRY = (() => {
|
|
3340
|
-
const built =
|
|
3341
|
-
return existsSync8(built) ? built :
|
|
3494
|
+
const built = join11(import.meta.dir, "run-screen.js");
|
|
3495
|
+
return existsSync8(built) ? built : join11(import.meta.dir, "run-screen.tsx");
|
|
3342
3496
|
})();
|
|
3343
3497
|
});
|
|
3344
3498
|
|
|
3345
|
-
// src/lib/parse-session-name.ts
|
|
3346
|
-
function parseSessionName(input) {
|
|
3347
|
-
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
3348
|
-
if (!trimmed) {
|
|
3349
|
-
throw new Error("Session name cannot be empty");
|
|
3350
|
-
}
|
|
3351
|
-
const segments = trimmed.split("/").filter(Boolean);
|
|
3352
|
-
if (segments.length < 2) {
|
|
3353
|
-
throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
|
|
3354
|
-
}
|
|
3355
|
-
for (const segment of segments) {
|
|
3356
|
-
if (!SEGMENT_PATTERN.test(segment)) {
|
|
3357
|
-
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
3358
|
-
}
|
|
3359
|
-
}
|
|
3360
|
-
const categoryPath = segments[0];
|
|
3361
|
-
const slug = segments.slice(1).join("/");
|
|
3362
|
-
return { categoryPath, slug };
|
|
3363
|
-
}
|
|
3364
|
-
var SEGMENT_PATTERN;
|
|
3365
|
-
var init_parse_session_name = __esm(() => {
|
|
3366
|
-
SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
3367
|
-
});
|
|
3368
|
-
|
|
3369
3499
|
// src/engine/recovery.ts
|
|
3370
3500
|
function isProcessAlive2(pid) {
|
|
3371
3501
|
try {
|
|
@@ -3430,7 +3560,7 @@ var init_launch = __esm(() => {
|
|
|
3430
3560
|
function quietHelper(bin) {
|
|
3431
3561
|
return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
|
|
3432
3562
|
}
|
|
3433
|
-
function waitingScript(bin,
|
|
3563
|
+
function waitingScript(bin, runtimeDir2) {
|
|
3434
3564
|
return `#!/usr/bin/env bash
|
|
3435
3565
|
# Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
|
|
3436
3566
|
${quietHelper(bin)}
|
|
@@ -3455,11 +3585,11 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
|
|
|
3455
3585
|
[ -z "$question" ] && question="Waiting for input"
|
|
3456
3586
|
|
|
3457
3587
|
# Clear working debounce marker so next resume\u2192working transition fires
|
|
3458
|
-
rm -f "${
|
|
3588
|
+
rm -f "${runtimeDir2}/working-$sid"
|
|
3459
3589
|
|
|
3460
3590
|
bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
|
|
3461
3591
|
|
|
3462
|
-
# Capture the latest assistant turn's text
|
|
3592
|
+
# Capture the latest assistant turn's text. Dedup inside the
|
|
3463
3593
|
# command makes it idempotent vs the matching Stop-time capture so the same
|
|
3464
3594
|
# turn never lands twice.
|
|
3465
3595
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
@@ -3473,7 +3603,7 @@ bq notify bertrand "$question" &
|
|
|
3473
3603
|
wait
|
|
3474
3604
|
`;
|
|
3475
3605
|
}
|
|
3476
|
-
function answeredScript(bin,
|
|
3606
|
+
function answeredScript(bin, runtimeDir2) {
|
|
3477
3607
|
return `#!/usr/bin/env bash
|
|
3478
3608
|
# Hook: PostToolUse AskUserQuestion \u2192 mark session as active
|
|
3479
3609
|
#
|
|
@@ -3507,19 +3637,17 @@ bq update --session-id "$sid" --event session.answered --meta "$meta"
|
|
|
3507
3637
|
|
|
3508
3638
|
bq badge --clear &
|
|
3509
3639
|
|
|
3640
|
+
# The loop is healthy \u2014 the agent ended its turn on AskUserQuestion and the
|
|
3641
|
+
# user answered. Reset the Stop-hook nudge counter so its cap applies per
|
|
3642
|
+
# run of consecutive contract violations, not cumulatively across the session.
|
|
3643
|
+
rm -f "${runtimeDir2}/auq-nudge-$sid"
|
|
3644
|
+
|
|
3510
3645
|
# Halt the agent loop if the user signaled Done for now. The Stop hook
|
|
3511
3646
|
# (on-done.sh) will fire afterwards and mark the session as paused.
|
|
3512
3647
|
if printf '%s' "$done_check" | grep -q "Done for now"; then
|
|
3513
|
-
#
|
|
3514
|
-
#
|
|
3515
|
-
|
|
3516
|
-
# reuses the agent-authored recap that already lives on the option.
|
|
3517
|
-
recap="$(printf '%s' "$meta" | jq -r '
|
|
3518
|
-
[.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
|
|
3519
|
-
' 2>/dev/null)"
|
|
3520
|
-
if [ -n "$recap" ]; then
|
|
3521
|
-
bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
|
|
3522
|
-
fi
|
|
3648
|
+
# Tell on-done.sh this Stop is a legitimate exit, not a dropped AUQ call \u2014
|
|
3649
|
+
# so it pauses normally instead of forcing the loop to continue.
|
|
3650
|
+
touch "${runtimeDir2}/done-$sid"
|
|
3523
3651
|
|
|
3524
3652
|
printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
|
|
3525
3653
|
fi
|
|
@@ -3527,7 +3655,7 @@ fi
|
|
|
3527
3655
|
wait
|
|
3528
3656
|
`;
|
|
3529
3657
|
}
|
|
3530
|
-
function permissionWaitScript(bin,
|
|
3658
|
+
function permissionWaitScript(bin, runtimeDir2) {
|
|
3531
3659
|
return `#!/usr/bin/env bash
|
|
3532
3660
|
# Hook: PermissionRequest \u2192 mark pending, badge + notify
|
|
3533
3661
|
${quietHelper(bin)}
|
|
@@ -3541,7 +3669,7 @@ ${EXTRACT_TOOL}
|
|
|
3541
3669
|
# Marker tells the PostToolUse hook to emit tool.used with outcome:approved
|
|
3542
3670
|
# instead of outcome:auto. Without it, every prompted-then-approved tool call
|
|
3543
3671
|
# would look identical to an auto-approved one.
|
|
3544
|
-
touch "${
|
|
3672
|
+
touch "${runtimeDir2}/perm-pending-$sid"
|
|
3545
3673
|
|
|
3546
3674
|
# Badge + notify in background
|
|
3547
3675
|
bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
|
|
@@ -3549,7 +3677,7 @@ bq notify bertrand "Needs permission: $tool" &
|
|
|
3549
3677
|
wait
|
|
3550
3678
|
`;
|
|
3551
3679
|
}
|
|
3552
|
-
function permissionDoneScript(bin,
|
|
3680
|
+
function permissionDoneScript(bin, runtimeDir2) {
|
|
3553
3681
|
return `#!/usr/bin/env bash
|
|
3554
3682
|
# Hook: PostToolUse (catch-all)
|
|
3555
3683
|
#
|
|
@@ -3569,10 +3697,11 @@ sid="\${BERTRAND_SESSION:-}"
|
|
|
3569
3697
|
input="$(cat)"
|
|
3570
3698
|
${EXTRACT_TOOL}
|
|
3571
3699
|
|
|
3572
|
-
# Don't double-log: AskUserQuestion has its own waiting/answered events
|
|
3573
|
-
|
|
3700
|
+
# Don't double-log: AskUserQuestion has its own waiting/answered events, and
|
|
3701
|
+
# EnterWorktree/ExitWorktree have their own worktree.entered/exited hooks.
|
|
3702
|
+
case "$tool" in AskUserQuestion|EnterWorktree|ExitWorktree) exit 0 ;; esac
|
|
3574
3703
|
|
|
3575
|
-
marker="${
|
|
3704
|
+
marker="${runtimeDir2}/perm-pending-$sid"
|
|
3576
3705
|
had_marker=0
|
|
3577
3706
|
if [ -f "$marker" ]; then
|
|
3578
3707
|
had_marker=1
|
|
@@ -3626,9 +3755,17 @@ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool"
|
|
|
3626
3755
|
wait
|
|
3627
3756
|
`;
|
|
3628
3757
|
}
|
|
3629
|
-
function userPromptScript(bin,
|
|
3758
|
+
function userPromptScript(bin, runtimeDir2) {
|
|
3630
3759
|
return `#!/usr/bin/env bash
|
|
3631
|
-
# Hook: UserPromptSubmit \u2192 record user
|
|
3760
|
+
# Hook: UserPromptSubmit \u2192 record user prompt + re-inject the session contract.
|
|
3761
|
+
#
|
|
3762
|
+
# The contract normally arrives via --append-system-prompt on bertrand's own
|
|
3763
|
+
# claude spawn, which reaches only that one process. Sessions that inherit the
|
|
3764
|
+
# BERTRAND_* env vars without going through launchClaude (background jobs,
|
|
3765
|
+
# nested \`claude\`, the Warp plugin's own launcher) never receive it. Re-
|
|
3766
|
+
# injecting here \u2014 through the durable env/hook channel \u2014 closes that gap.
|
|
3767
|
+
# Full contract on the first prompt of each conversation, a one-line reminder
|
|
3768
|
+
# thereafter, to keep the per-turn token cost low.
|
|
3632
3769
|
${quietHelper(bin)}
|
|
3633
3770
|
sid="\${BERTRAND_SESSION:-}"
|
|
3634
3771
|
[ -z "$sid" ] && exit 0
|
|
@@ -3636,34 +3773,102 @@ sid="\${BERTRAND_SESSION:-}"
|
|
|
3636
3773
|
input="$(cat)"
|
|
3637
3774
|
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3638
3775
|
|
|
3776
|
+
# Record the prompt event. Stdout muted so only the context JSON below reaches
|
|
3777
|
+
# the hook's stdout (UserPromptSubmit parses stdout as a hook decision).
|
|
3639
3778
|
meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
|
|
3640
|
-
[ -
|
|
3779
|
+
[ -n "$meta" ] && bq update --session-id "$sid" --event user.prompt --meta "$meta" >/dev/null
|
|
3780
|
+
|
|
3781
|
+
# Re-deliver the contract as additional context.
|
|
3782
|
+
marker="${runtimeDir2}/contract-sent-\${cid:-$sid}"
|
|
3783
|
+
if [ -f "$marker" ]; then
|
|
3784
|
+
contract="$(bq contract --session-id "$sid" --short)"
|
|
3785
|
+
else
|
|
3786
|
+
contract="$(bq contract --session-id "$sid")"
|
|
3787
|
+
: > "$marker"
|
|
3788
|
+
fi
|
|
3641
3789
|
|
|
3642
|
-
|
|
3790
|
+
[ -n "$contract" ] && jq -n --arg c "$contract" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
|
3643
3791
|
`;
|
|
3644
3792
|
}
|
|
3645
|
-
function doneScript(bin,
|
|
3793
|
+
function doneScript(bin, runtimeDir2) {
|
|
3646
3794
|
return `#!/usr/bin/env bash
|
|
3647
|
-
# Hook: Stop \u2192 flip session status to paused
|
|
3648
|
-
# The status flip is driven by EVENT_STATUS_MAP[session.paused]; dispatchHookEvent
|
|
3649
|
-
# no longer has a session.paused case, so update.ts flips status without inserting.
|
|
3795
|
+
# Hook: Stop \u2192 enforce AUQ loop, else flip session status to paused.
|
|
3650
3796
|
${quietHelper(bin)}
|
|
3651
3797
|
sid="\${BERTRAND_SESSION:-}"
|
|
3652
3798
|
[ -z "$sid" ] && exit 0
|
|
3653
3799
|
|
|
3654
3800
|
input="$(cat)"
|
|
3655
3801
|
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3656
|
-
bq update --session-id "$sid" --event session.paused
|
|
3657
3802
|
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3803
|
+
done_marker="${runtimeDir2}/done-$sid"
|
|
3804
|
+
nudge_marker="${runtimeDir2}/auq-nudge-$sid"
|
|
3805
|
+
|
|
3806
|
+
# Capture the assistant turn either way. Stdout is muted so it can never corrupt
|
|
3807
|
+
# a decision-JSON payload. Dedups against the most-recent AskUQ-time capture, so
|
|
3808
|
+
# a Done-for-now exit lands zero new events; a dropped-AUQ Stop records the
|
|
3809
|
+
# stray turn; intermediate Stops with fresh output land normally.
|
|
3661
3810
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
3662
3811
|
if [ -n "$tpath" ]; then
|
|
3663
|
-
bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
|
|
3812
|
+
bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" >/dev/null &
|
|
3813
|
+
fi
|
|
3814
|
+
|
|
3815
|
+
if [ ! -f "$done_marker" ]; then
|
|
3816
|
+
# Not a Done-for-now exit \u2192 the turn ended without AskUserQuestion. Force the
|
|
3817
|
+
# loop to continue, up to a small cap so a context where AUQ is genuinely
|
|
3818
|
+
# unavailable can't wedge the session in an endless block/stop cycle. The
|
|
3819
|
+
# counter is reset on every answered AUQ (on-answered.sh), so the cap bounds
|
|
3820
|
+
# consecutive violations, not the whole session.
|
|
3821
|
+
count="$(cat "$nudge_marker" 2>/dev/null)"
|
|
3822
|
+
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
|
3823
|
+
if [ "$count" -lt 3 ]; then
|
|
3824
|
+
printf '%s' "$((count + 1))" > "$nudge_marker"
|
|
3825
|
+
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. 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.'
|
|
3826
|
+
wait
|
|
3827
|
+
jq -n --arg r "$reason" '{decision:"block", reason:$r}'
|
|
3828
|
+
exit 0
|
|
3829
|
+
fi
|
|
3830
|
+
# Cap reached \u2014 stop nudging and let the session pause normally.
|
|
3664
3831
|
fi
|
|
3665
3832
|
|
|
3833
|
+
# Terminal path: legitimate Done-for-now exit, or nudge cap exhausted.
|
|
3834
|
+
rm -f "$done_marker" "$nudge_marker"
|
|
3835
|
+
bq update --session-id "$sid" --event session.paused
|
|
3666
3836
|
bq badge check --color '#58c142' --priority 10
|
|
3837
|
+
wait
|
|
3838
|
+
`;
|
|
3839
|
+
}
|
|
3840
|
+
function enterWorktreeScript(bin, runtimeDir2) {
|
|
3841
|
+
return `#!/usr/bin/env bash
|
|
3842
|
+
# Hook: PostToolUse EnterWorktree \u2192 record worktree path + branch on the session.
|
|
3843
|
+
${quietHelper(bin)}
|
|
3844
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
3845
|
+
[ -z "$sid" ] && exit 0
|
|
3846
|
+
|
|
3847
|
+
input="$(cat)"
|
|
3848
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3849
|
+
|
|
3850
|
+
path="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
|
|
3851
|
+
[ -z "$path" ] && exit 0
|
|
3852
|
+
branch="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
|
3853
|
+
|
|
3854
|
+
printf '%s' "$path" > "${runtimeDir2}/worktree-$sid"
|
|
3855
|
+
|
|
3856
|
+
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}')"
|
|
3857
|
+
`;
|
|
3858
|
+
}
|
|
3859
|
+
function exitWorktreeScript(bin, runtimeDir2) {
|
|
3860
|
+
return `#!/usr/bin/env bash
|
|
3861
|
+
# Hook: PostToolUse ExitWorktree \u2192 clear the session's worktree state.
|
|
3862
|
+
${quietHelper(bin)}
|
|
3863
|
+
sid="\${BERTRAND_SESSION:-}"
|
|
3864
|
+
[ -z "$sid" ] && exit 0
|
|
3865
|
+
|
|
3866
|
+
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
3867
|
+
marker="${runtimeDir2}/worktree-$sid"
|
|
3868
|
+
path="$(cat "$marker" 2>/dev/null)"
|
|
3869
|
+
rm -f "$marker"
|
|
3870
|
+
|
|
3871
|
+
bq update --session-id "$sid" --event worktree.exited --meta "$(jq -n --arg p "$path" --arg cid "$cid" '{path:$p, claude_id:$cid}')"
|
|
3667
3872
|
`;
|
|
3668
3873
|
}
|
|
3669
3874
|
var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
|
|
@@ -3674,18 +3879,20 @@ var init_scripts = __esm(() => {
|
|
|
3674
3879
|
"on-permission-wait.sh": permissionWaitScript,
|
|
3675
3880
|
"on-permission-done.sh": permissionDoneScript,
|
|
3676
3881
|
"on-user-prompt.sh": userPromptScript,
|
|
3677
|
-
"on-done.sh": doneScript
|
|
3882
|
+
"on-done.sh": doneScript,
|
|
3883
|
+
"on-enter-worktree.sh": enterWorktreeScript,
|
|
3884
|
+
"on-exit-worktree.sh": exitWorktreeScript
|
|
3678
3885
|
};
|
|
3679
3886
|
});
|
|
3680
3887
|
|
|
3681
3888
|
// src/hooks/install.ts
|
|
3682
3889
|
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
|
|
3683
|
-
import { join as
|
|
3890
|
+
import { join as join12 } from "path";
|
|
3684
3891
|
function installHookScripts(bin) {
|
|
3685
3892
|
mkdirSync7(paths.hooks, { recursive: true });
|
|
3686
3893
|
mkdirSync7(paths.runtime, { recursive: true });
|
|
3687
3894
|
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
3688
|
-
const filePath =
|
|
3895
|
+
const filePath = join12(paths.hooks, filename);
|
|
3689
3896
|
writeFileSync5(filePath, scriptFn(bin, paths.runtime));
|
|
3690
3897
|
chmodSync2(filePath, 493);
|
|
3691
3898
|
}
|
|
@@ -3698,7 +3905,7 @@ var init_install = __esm(() => {
|
|
|
3698
3905
|
|
|
3699
3906
|
// src/hooks/settings.ts
|
|
3700
3907
|
import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
|
|
3701
|
-
import { join as
|
|
3908
|
+
import { join as join13, dirname as dirname4 } from "path";
|
|
3702
3909
|
import { homedir as homedir3 } from "os";
|
|
3703
3910
|
function isBertrandGroup(group) {
|
|
3704
3911
|
return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
|
|
@@ -3723,7 +3930,7 @@ function installHookSettings() {
|
|
|
3723
3930
|
var SETTINGS_PATH, BERTRAND_HOOKS;
|
|
3724
3931
|
var init_settings = __esm(() => {
|
|
3725
3932
|
init_paths();
|
|
3726
|
-
SETTINGS_PATH =
|
|
3933
|
+
SETTINGS_PATH = join13(homedir3(), ".claude", "settings.json");
|
|
3727
3934
|
BERTRAND_HOOKS = {
|
|
3728
3935
|
PreToolUse: [
|
|
3729
3936
|
{
|
|
@@ -3736,6 +3943,14 @@ var init_settings = __esm(() => {
|
|
|
3736
3943
|
matcher: "AskUserQuestion",
|
|
3737
3944
|
hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
|
|
3738
3945
|
},
|
|
3946
|
+
{
|
|
3947
|
+
matcher: "EnterWorktree",
|
|
3948
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-enter-worktree.sh` }]
|
|
3949
|
+
},
|
|
3950
|
+
{
|
|
3951
|
+
matcher: "ExitWorktree",
|
|
3952
|
+
hooks: [{ type: "command", command: `${paths.hooks}/on-exit-worktree.sh` }]
|
|
3953
|
+
},
|
|
3739
3954
|
{
|
|
3740
3955
|
matcher: "",
|
|
3741
3956
|
hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
|
|
@@ -3764,7 +3979,7 @@ var init_settings = __esm(() => {
|
|
|
3764
3979
|
|
|
3765
3980
|
// src/lib/completions.ts
|
|
3766
3981
|
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
|
|
3767
|
-
import { join as
|
|
3982
|
+
import { join as join14 } from "path";
|
|
3768
3983
|
function bashCompletion() {
|
|
3769
3984
|
return `# bertrand bash completion
|
|
3770
3985
|
_bertrand() {
|
|
@@ -3794,11 +4009,11 @@ function fishCompletion() {
|
|
|
3794
4009
|
`;
|
|
3795
4010
|
}
|
|
3796
4011
|
function generateCompletions() {
|
|
3797
|
-
const dir =
|
|
4012
|
+
const dir = join14(paths.root, "completions");
|
|
3798
4013
|
mkdirSync9(dir, { recursive: true });
|
|
3799
|
-
writeFileSync7(
|
|
3800
|
-
writeFileSync7(
|
|
3801
|
-
writeFileSync7(
|
|
4014
|
+
writeFileSync7(join14(dir, "bertrand.bash"), bashCompletion());
|
|
4015
|
+
writeFileSync7(join14(dir, "_bertrand"), zshCompletion());
|
|
4016
|
+
writeFileSync7(join14(dir, "bertrand.fish"), fishCompletion());
|
|
3802
4017
|
console.log(`Shell completions written to ${dir}`);
|
|
3803
4018
|
console.log(" Add to your shell config:");
|
|
3804
4019
|
console.log(` bash: source ${dir}/bertrand.bash`);
|
|
@@ -3829,7 +4044,7 @@ var init_completions = __esm(() => {
|
|
|
3829
4044
|
var exports_init = {};
|
|
3830
4045
|
import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
|
|
3831
4046
|
import { execSync as execSync2 } from "child_process";
|
|
3832
|
-
import { join as
|
|
4047
|
+
import { join as join15 } from "path";
|
|
3833
4048
|
function detectTerminal() {
|
|
3834
4049
|
try {
|
|
3835
4050
|
execSync2("which wsh", { stdio: "ignore" });
|
|
@@ -3844,8 +4059,8 @@ function resolveBin() {
|
|
|
3844
4059
|
return onPath;
|
|
3845
4060
|
const entry = process.argv[1];
|
|
3846
4061
|
if (entry && SOURCE_ENTRY.test(entry)) {
|
|
3847
|
-
const launcherDir =
|
|
3848
|
-
const launcher =
|
|
4062
|
+
const launcherDir = join15(paths.root, "bin");
|
|
4063
|
+
const launcher = join15(launcherDir, "bertrand-dev");
|
|
3849
4064
|
mkdirSync10(launcherDir, { recursive: true });
|
|
3850
4065
|
writeFileSync8(launcher, `#!/usr/bin/env bash
|
|
3851
4066
|
exec ${process.execPath} ${JSON.stringify(entry)} "$@"
|
|
@@ -4054,8 +4269,9 @@ function extractSummary(row) {
|
|
|
4054
4269
|
}
|
|
4055
4270
|
case "user.prompt":
|
|
4056
4271
|
return meta.prompt ?? "";
|
|
4057
|
-
case "
|
|
4058
|
-
|
|
4272
|
+
case "worktree.entered":
|
|
4273
|
+
case "worktree.exited":
|
|
4274
|
+
return meta.branch ?? meta.path ?? "";
|
|
4059
4275
|
default:
|
|
4060
4276
|
return "";
|
|
4061
4277
|
}
|
|
@@ -4091,9 +4307,9 @@ var init_catalog = __esm(() => {
|
|
|
4091
4307
|
"user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
4092
4308
|
"tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4093
4309
|
"tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4094
|
-
"session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
|
|
4095
4310
|
"assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
|
|
4096
|
-
"
|
|
4311
|
+
"worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
|
|
4312
|
+
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
|
|
4097
4313
|
};
|
|
4098
4314
|
DEFAULT_INFO = {
|
|
4099
4315
|
label: "unknown",
|
|
@@ -4400,13 +4616,11 @@ var init_log = __esm(() => {
|
|
|
4400
4616
|
init_router();
|
|
4401
4617
|
init_sessions();
|
|
4402
4618
|
init_events();
|
|
4403
|
-
init_categories();
|
|
4404
4619
|
init_stats();
|
|
4405
4620
|
init_conversations();
|
|
4406
4621
|
init_catalog();
|
|
4407
4622
|
init_compact();
|
|
4408
4623
|
init_timing();
|
|
4409
|
-
init_parse_session_name();
|
|
4410
4624
|
init_format();
|
|
4411
4625
|
init_resolve();
|
|
4412
4626
|
init_cli_flag();
|
|
@@ -4426,18 +4640,12 @@ var init_log = __esm(() => {
|
|
|
4426
4640
|
showAllSessions();
|
|
4427
4641
|
return;
|
|
4428
4642
|
}
|
|
4429
|
-
const
|
|
4430
|
-
|
|
4431
|
-
if (!category) {
|
|
4432
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
4433
|
-
process.exit(1);
|
|
4434
|
-
}
|
|
4435
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
4436
|
-
if (!session) {
|
|
4643
|
+
const resolved = resolveSessionByName(target);
|
|
4644
|
+
if (!resolved) {
|
|
4437
4645
|
console.error(`Session not found: ${target}`);
|
|
4438
4646
|
process.exit(1);
|
|
4439
4647
|
}
|
|
4440
|
-
showSessionLog(session, `${categoryPath}/${slug}`, isJson);
|
|
4648
|
+
showSessionLog(resolved.session, `${resolved.categoryPath}/${resolved.slug}`, isJson);
|
|
4441
4649
|
});
|
|
4442
4650
|
});
|
|
4443
4651
|
|
|
@@ -4557,7 +4765,6 @@ var init_stats2 = __esm(() => {
|
|
|
4557
4765
|
init_events();
|
|
4558
4766
|
init_timing();
|
|
4559
4767
|
init_format();
|
|
4560
|
-
init_parse_session_name();
|
|
4561
4768
|
ACTIVE_STATUSES2 = ["active", "waiting"];
|
|
4562
4769
|
register("stats", async (args) => {
|
|
4563
4770
|
const isJson = args.includes("--json");
|
|
@@ -4570,29 +4777,23 @@ var init_stats2 = __esm(() => {
|
|
|
4570
4777
|
return;
|
|
4571
4778
|
}
|
|
4572
4779
|
if (target.endsWith("/")) {
|
|
4573
|
-
const
|
|
4574
|
-
const
|
|
4575
|
-
if (!
|
|
4576
|
-
console.error(`Category not found: ${
|
|
4780
|
+
const categoryPath = target.replace(/\/+$/, "");
|
|
4781
|
+
const category = getCategoryByPath(categoryPath);
|
|
4782
|
+
if (!category) {
|
|
4783
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
4577
4784
|
process.exit(1);
|
|
4578
4785
|
}
|
|
4579
|
-
const categorySessions = getSessionsByCategory(
|
|
4786
|
+
const categorySessions = getSessionsByCategory(category.id);
|
|
4580
4787
|
const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
|
|
4581
|
-
renderCategory(metrics,
|
|
4788
|
+
renderCategory(metrics, categoryPath, isJson);
|
|
4582
4789
|
return;
|
|
4583
4790
|
}
|
|
4584
|
-
const
|
|
4585
|
-
|
|
4586
|
-
if (!category) {
|
|
4587
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
4588
|
-
process.exit(1);
|
|
4589
|
-
}
|
|
4590
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
4591
|
-
if (!session) {
|
|
4791
|
+
const resolved = resolveSessionByName(target);
|
|
4792
|
+
if (!resolved) {
|
|
4592
4793
|
console.error(`Session not found: ${target}`);
|
|
4593
4794
|
process.exit(1);
|
|
4594
4795
|
}
|
|
4595
|
-
const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
|
|
4796
|
+
const m = getMetrics(resolved.session.id, `${resolved.categoryPath}/${resolved.slug}`, resolved.session.status);
|
|
4596
4797
|
renderSession(m, isJson);
|
|
4597
4798
|
});
|
|
4598
4799
|
});
|
|
@@ -4618,24 +4819,16 @@ var init_backfill_stats = __esm(() => {
|
|
|
4618
4819
|
// src/cli/commands/archive.ts
|
|
4619
4820
|
var exports_archive = {};
|
|
4620
4821
|
function resolveSession(name) {
|
|
4621
|
-
const
|
|
4622
|
-
|
|
4623
|
-
if (!category) {
|
|
4624
|
-
console.error(`Category not found: ${categoryPath}`);
|
|
4625
|
-
process.exit(1);
|
|
4626
|
-
}
|
|
4627
|
-
const session = getSessionByCategorySlug(category.id, slug);
|
|
4628
|
-
if (!session) {
|
|
4822
|
+
const resolved = resolveSessionByName(name);
|
|
4823
|
+
if (!resolved) {
|
|
4629
4824
|
console.error(`Session not found: ${name}`);
|
|
4630
4825
|
process.exit(1);
|
|
4631
4826
|
}
|
|
4632
|
-
return { session, categoryPath };
|
|
4827
|
+
return { session: resolved.session, categoryPath: resolved.categoryPath };
|
|
4633
4828
|
}
|
|
4634
4829
|
var init_archive = __esm(() => {
|
|
4635
4830
|
init_router();
|
|
4636
4831
|
init_sessions();
|
|
4637
|
-
init_categories();
|
|
4638
|
-
init_parse_session_name();
|
|
4639
4832
|
init_session_archive();
|
|
4640
4833
|
register("archive", async (args) => {
|
|
4641
4834
|
const isUndo = args.includes("--undo");
|
|
@@ -4707,7 +4900,7 @@ __export(exports_project, {
|
|
|
4707
4900
|
createSubcommand: () => createSubcommand,
|
|
4708
4901
|
_UsageError: () => _UsageError
|
|
4709
4902
|
});
|
|
4710
|
-
import { existsSync as existsSync9, rmSync } from "fs";
|
|
4903
|
+
import { existsSync as existsSync9, rmSync as rmSync2 } from "fs";
|
|
4711
4904
|
function countSessions(slug) {
|
|
4712
4905
|
const dbFile = projectPaths(slug).db;
|
|
4713
4906
|
if (!existsSync9(dbFile))
|
|
@@ -4887,7 +5080,7 @@ function removeSubcommand(args) {
|
|
|
4887
5080
|
removeProject(slug);
|
|
4888
5081
|
invalidateDbCache(slug);
|
|
4889
5082
|
if (purge) {
|
|
4890
|
-
|
|
5083
|
+
rmSync2(projectPaths(slug).root, { recursive: true, force: true });
|
|
4891
5084
|
}
|
|
4892
5085
|
console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
|
|
4893
5086
|
}
|
|
@@ -4997,9 +5190,11 @@ var command = process.argv[2];
|
|
|
4997
5190
|
var hotPath = {
|
|
4998
5191
|
update: () => Promise.resolve().then(() => (init_update(), exports_update)),
|
|
4999
5192
|
"assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
5193
|
+
contract: () => Promise.resolve().then(() => (init_contract(), exports_contract)),
|
|
5000
5194
|
badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5001
5195
|
notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
5002
5196
|
serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
5197
|
+
"ensure-server": () => Promise.resolve().then(() => (init_ensure_server(), exports_ensure_server)),
|
|
5003
5198
|
sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
|
|
5004
5199
|
};
|
|
5005
5200
|
if (command && command in hotPath) {
|
|
@@ -5015,6 +5210,7 @@ if (command && command in hotPath) {
|
|
|
5015
5210
|
Promise.resolve().then(() => (init_archive(), exports_archive)),
|
|
5016
5211
|
Promise.resolve().then(() => (init_update(), exports_update)),
|
|
5017
5212
|
Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
|
|
5213
|
+
Promise.resolve().then(() => (init_contract(), exports_contract)),
|
|
5018
5214
|
Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
5019
5215
|
Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5020
5216
|
Promise.resolve().then(() => (init_notify(), exports_notify)),
|